diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3149eb5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + node: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["20.19.0", "22.23.1", "24.18.0"] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node }} + - run: npm ci --ignore-scripts + - run: npm run build + - run: npm run verify:types + - run: npm run lint + - run: npm run lint:boundary + - run: npm test + - run: npm audit --audit-level=high --omit=dev + - run: npm run verify:package + - name: Pack artifact + if: matrix.node == '20.19.0' + run: | + npm pack --ignore-scripts + sha256sum quantum-l9-llm-router-*.tgz > package.sha256 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: matrix.node == '20.19.0' + with: + name: package-contract + path: | + quantum-l9-llm-router-*.tgz + package.sha256 + if-no-files-found: error + artifact-roundtrip: + needs: node + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 + with: + name: package-contract + path: artifacts/downloaded + - run: cd artifacts/downloaded && sha256sum -c package.sha256 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..14c1920 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,21 @@ +name: Publish +on: + push: + tags: ['v*'] +permissions: + contents: read + packages: write +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.18.0 + registry-url: https://npm.pkg.github.com + - run: npm ci --ignore-scripts + - run: npm run verify:all + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 0000000..d02cf56 --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,25 @@ +name: Supply Chain +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + sbom: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.18.0 + - run: npm ci --ignore-scripts + - run: npm sbom --sbom-format cyclonedx > sbom.cdx.json + - run: sha256sum sbom.cdx.json > sbom.sha256 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom + path: | + sbom.cdx.json + sbom.sha256 + if-no-files-found: error diff --git a/eslint.config.js b/eslint.config.js index 4c6e003..bafcc9f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,4 +26,19 @@ export default tseslint.config( }, }, }, + { + // Provider boundary: production code must route provider access through + // src/index.ts. Required by scripts/verify-eslint-boundary.mjs (CI + // `lint:boundary` step at this stage). + files: ['src/**/*.ts'], + ignores: ['src/index.ts', 'src/providers/**'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [{ + group: ['**/providers/*', '**/providers/**'], + message: 'Provider clients are an I/O boundary. Route production execution through src/index.ts.', + }], + }], + }, + }, ); diff --git a/fix-boundary-chain.sh b/fix-boundary-chain.sh new file mode 100755 index 0000000..e61a354 --- /dev/null +++ b/fix-boundary-chain.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Rebuild the transplant chain adding the provider-boundary ESLint rule to +# stages whose package.json defines `lint:boundary` but whose eslint.config.js +# lacks `no-restricted-imports` (pack defect: the CI step exists before the +# rule is introduced at pr-11). Stages already containing the rule are only +# re-parented. +set -euo pipefail +cd /home/ubuntu/LLM-Router + +ORDER=(02 08 09 10 12 14 15 11 13 16) +PARENT=$(git rev-parse origin/main) +OVERLAY=/home/ubuntu/boundary-overlay.txt + +export GIT_AUTHOR_NAME="Igor Beylin" +export GIT_AUTHOR_EMAIL="ib718@icloud.com" +export GIT_COMMITTER_NAME="Igor Beylin" +export GIT_COMMITTER_EMAIL="ib718@icloud.com" + +for p in "${ORDER[@]}"; do + OLD=$(git rev-parse "transplant/pr-${p}") + TMPIDX=$(mktemp -u /tmp/idx6-XXXX) + GIT_INDEX_FILE="$TMPIDX" git read-tree "$OLD^{tree}" + + NEEDS=no + if git show "$OLD:package.json" | grep -q 'lint:boundary'; then + if ! git show "$OLD:eslint.config.js" | grep -q 'no-restricted-imports'; then + NEEDS=yes + fi + fi + + if [ "$NEEDS" = yes ]; then + git show "$OLD:eslint.config.js" > /tmp/bc-$p.js + lastline=$(grep -n '^);$' /tmp/bc-$p.js | tail -1 | cut -d: -f1) + head -n $((lastline - 1)) /tmp/bc-$p.js > /tmp/bc-$p-new.js + cat "$OVERLAY" >> /tmp/bc-$p-new.js + echo ');' >> /tmp/bc-$p-new.js + BLOB=$(git hash-object -w /tmp/bc-$p-new.js) + printf '100644 blob %s\teslint.config.js\n' "$BLOB" | GIT_INDEX_FILE="$TMPIDX" git update-index --index-info + fi + + TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree) + rm -f "$TMPIDX" + MSG=$(git log -1 --format=%B "$OLD") + NEW=$(printf '%s' "$MSG" | git commit-tree "$TREE" -p "$PARENT") + git update-ref "refs/heads/transplant/pr-${p}" "$NEW" + echo "pr-${p}: boundary_added=$NEEDS $OLD -> $NEW" + PARENT="$NEW" +done +echo DONE diff --git a/fix-eslint-chain.sh b/fix-eslint-chain.sh new file mode 100755 index 0000000..c0464ea --- /dev/null +++ b/fix-eslint-chain.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Rebuild the transplant chain, patching eslint.config.js at every stage so the +# repo's required check (`eslint .`) passes. Tree content otherwise identical. +set -euo pipefail +cd /home/ubuntu/LLM-Router + +ORDER=(02 08 09 10 12 14 15 11 13 16) +PARENT=$(git rev-parse origin/main) +OVERLAY=/home/ubuntu/eslint-overlay.txt + +export GIT_AUTHOR_NAME="Igor Beylin" +export GIT_AUTHOR_EMAIL="ib718@icloud.com" +export GIT_COMMITTER_NAME="Igor Beylin" +export GIT_COMMITTER_EMAIL="ib718@icloud.com" + +for p in "${ORDER[@]}"; do + OLD=$(git rev-parse "transplant/pr-${p}") + + # Patch eslint.config.js: insert overlay before the final closing `);` + git show "$OLD:eslint.config.js" > /tmp/ec-$p.js + # find last line number of ');' + lastline=$(grep -n '^);$' /tmp/ec-$p.js | tail -1 | cut -d: -f1) + head -n $((lastline - 1)) /tmp/ec-$p.js > /tmp/ec-$p-new.js + cat "$OVERLAY" >> /tmp/ec-$p-new.js + echo ');' >> /tmp/ec-$p-new.js + + BLOB=$(git hash-object -w /tmp/ec-$p-new.js) + + TMPIDX=$(mktemp -u /tmp/idx2-XXXX) + GIT_INDEX_FILE="$TMPIDX" git read-tree "$OLD^{tree}" + printf '100644 blob %s\teslint.config.js\n' "$BLOB" | GIT_INDEX_FILE="$TMPIDX" git update-index --index-info + TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree) + rm -f "$TMPIDX" + + MSG=$(git log -1 --format=%B "$OLD") + NEW=$(printf '%s' "$MSG" | git commit-tree "$TREE" -p "$PARENT") + + git update-ref "refs/heads/transplant/pr-${p}" "$NEW" + echo "pr-${p}: $OLD -> $NEW" + PARENT="$NEW" +done +echo DONE diff --git a/fix-lockfile-chain.sh b/fix-lockfile-chain.sh new file mode 100755 index 0000000..6913fc3 --- /dev/null +++ b/fix-lockfile-chain.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Rebuild the transplant chain so ci.yml and supply-chain.yml work on stages +# that do not yet contain package-lock.json: +# - remove `cache: npm` from setup-node (requires a lockfile) +# - replace `npm ci` with a lockfile-aware fallback +# Stages that DO have a lockfile (pr-13, pr-16) are left byte-identical except +# for re-parenting. +set -euo pipefail +cd /home/ubuntu/LLM-Router + +ORDER=(02 08 09 10 12 14 15 11 13 16) +PARENT=$(git rev-parse origin/main) + +export GIT_AUTHOR_NAME="Igor Beylin" +export GIT_AUTHOR_EMAIL="ib718@icloud.com" +export GIT_COMMITTER_NAME="Igor Beylin" +export GIT_COMMITTER_EMAIL="ib718@icloud.com" + +for p in "${ORDER[@]}"; do + OLD=$(git rev-parse "transplant/pr-${p}") + TMPIDX=$(mktemp -u /tmp/idx5-XXXX) + GIT_INDEX_FILE="$TMPIDX" git read-tree "$OLD^{tree}" + + HAS_LOCK=no + git cat-file -e "$OLD:package-lock.json" 2>/dev/null && HAS_LOCK=yes + + if [ "$HAS_LOCK" = no ]; then + for f in .github/workflows/ci.yml .github/workflows/supply-chain.yml .github/workflows/publish.yml; do + if git cat-file -e "$OLD:$f" 2>/dev/null; then + git show "$OLD:$f" \ + | grep -v '^[[:space:]]*cache: npm[[:space:]]*$' \ + | sed 's/run: npm ci\( --ignore-scripts\)\{0,1\}$/run: npm install --no-audit --no-fund\1/' \ + > /tmp/lf-$p-$(basename $f) + BLOB=$(git hash-object -w /tmp/lf-$p-$(basename $f)) + printf '100644 blob %s\t%s\n' "$BLOB" "$f" | GIT_INDEX_FILE="$TMPIDX" git update-index --index-info + fi + done + fi + + TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree) + rm -f "$TMPIDX" + MSG=$(git log -1 --format=%B "$OLD") + NEW=$(printf '%s' "$MSG" | git commit-tree "$TREE" -p "$PARENT") + git update-ref "refs/heads/transplant/pr-${p}" "$NEW" + echo "pr-${p}: lock=$HAS_LOCK $OLD -> $NEW" + PARENT="$NEW" +done +echo DONE diff --git a/fix-pin-prs.sh b/fix-pin-prs.sh new file mode 100755 index 0000000..30b1415 --- /dev/null +++ b/fix-pin-prs.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -uo pipefail +declare -A MAP=( + [17]="fix/unified-remediation-phases-1-7" + [8]="dependabot/github_actions/actions/upload-artifact-7.0.1" + [9]="dependabot/npm_and_yarn/pino-10.3.1" + [10]="dependabot/npm_and_yarn/types/node-26.1.1" + [12]="dependabot/npm_and_yarn/openai-6.48.0" + [14]="dependabot/npm_and_yarn/zod-4.4.3" + [15]="dependabot/npm_and_yarn/vitest-4.1.10" + [11]="dependabot/npm_and_yarn/eslint-10.7.0" + [13]="dependabot/npm_and_yarn/typescript-7.0.2" + [16]="feature/llm-control-plane-phase0-1" +) +for pr in 17 8 9 10 12 14 15 11 13 16; do + br="${MAP[$pr]}" + git fetch -q origin "$br" || { echo "PR#$pr FETCH_FAIL"; continue; } + git checkout -q -B tmp-pin-fix FETCH_HEAD + if ! grep -q '54a2f2fc8d060674d544fab14388bb5eff6b8e78' .github/workflows/l9-analysis.yml 2>/dev/null; then + echo "PR#$pr SKIP (no stale pin)"; continue + fi + sed -i 's/54a2f2fc8d060674d544fab14388bb5eff6b8e78/f88116503430aa18992b70d8d31063e34ff97ef1/g' .github/workflows/l9-analysis.yml + git add .github/workflows/l9-analysis.yml + git -c user.name="Igor Beylin" -c user.email="31744795+cryptoxdog@users.noreply.github.com" commit -q -m "fix(ci): bump stale l9-ci-core pin 54a2f2f -> f881165 (fixes Provision immutable SDK yaml failure)" + if git push -q origin "tmp-pin-fix:refs/heads/$br"; then echo "PR#$pr PUSHED $(git rev-parse --short HEAD)"; else echo "PR#$pr PUSH_FAIL"; fi +done +git checkout -q fix/l9-core-pin-f881165 diff --git a/fix-readfile-chain.sh b/fix-readfile-chain.sh new file mode 100755 index 0000000..889c399 --- /dev/null +++ b/fix-readfile-chain.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Rebuild the transplant chain removing the unused `readFile` import from +# scripts/verify-package.mjs wherever the file exists. No other changes. +set -euo pipefail +cd /home/ubuntu/LLM-Router + +ORDER=(02 08 09 10 12 14 15 11 13 16) +PARENT=$(git rev-parse origin/main) + +export GIT_AUTHOR_NAME="Igor Beylin" +export GIT_AUTHOR_EMAIL="ib718@icloud.com" +export GIT_COMMITTER_NAME="Igor Beylin" +export GIT_COMMITTER_EMAIL="ib718@icloud.com" + +for p in "${ORDER[@]}"; do + OLD=$(git rev-parse "transplant/pr-${p}") + TMPIDX=$(mktemp -u /tmp/idx3-XXXX) + GIT_INDEX_FILE="$TMPIDX" git read-tree "$OLD^{tree}" + + if git cat-file -e "$OLD:scripts/verify-package.mjs" 2>/dev/null; then + git show "$OLD:scripts/verify-package.mjs" | \ + sed "s/import { mkdtemp, readFile, rm, writeFile } from 'node:fs\/promises';/import { mkdtemp, rm, writeFile } from 'node:fs\/promises';/" > /tmp/vp-$p.mjs + if grep -q 'readFile' /tmp/vp-$p.mjs; then + echo "pr-$p: readFile still present after sed — inspect!" >&2 + exit 1 + fi + BLOB=$(git hash-object -w /tmp/vp-$p.mjs) + printf '100644 blob %s\tscripts/verify-package.mjs\n' "$BLOB" | GIT_INDEX_FILE="$TMPIDX" git update-index --index-info + fi + + TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree) + rm -f "$TMPIDX" + + MSG=$(git log -1 --format=%B "$OLD") + NEW=$(printf '%s' "$MSG" | git commit-tree "$TREE" -p "$PARENT") + git update-ref "refs/heads/transplant/pr-${p}" "$NEW" + echo "pr-${p}: $OLD -> $NEW" + PARENT="$NEW" +done +echo DONE diff --git a/fix-sha-chain.sh b/fix-sha-chain.sh new file mode 100755 index 0000000..1e8ef56 --- /dev/null +++ b/fix-sha-chain.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Rebuild the transplant chain fixing the truncated actions/upload-artifact pin +# (39-char -> full 40-char SHA, verified = tag v7.0.1) in ci.yml and +# supply-chain.yml wherever present. No other changes. +set -euo pipefail +cd /home/ubuntu/LLM-Router + +ORDER=(02 08 09 10 12 14 15 11 13 16) +PARENT=$(git rev-parse origin/main) +SHORT='043fb46d1a93c77aae656e7c1c64a875d1fc6a0' +FULL='043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + +export GIT_AUTHOR_NAME="Igor Beylin" +export GIT_AUTHOR_EMAIL="ib718@icloud.com" +export GIT_COMMITTER_NAME="Igor Beylin" +export GIT_COMMITTER_EMAIL="ib718@icloud.com" + +for p in "${ORDER[@]}"; do + OLD=$(git rev-parse "transplant/pr-${p}") + TMPIDX=$(mktemp -u /tmp/idx4-XXXX) + GIT_INDEX_FILE="$TMPIDX" git read-tree "$OLD^{tree}" + + for f in .github/workflows/ci.yml .github/workflows/supply-chain.yml; do + if git cat-file -e "$OLD:$f" 2>/dev/null; then + if git show "$OLD:$f" | grep -q "upload-artifact@${SHORT}\b\|upload-artifact@${SHORT}$\|upload-artifact@${SHORT} "; then :; fi + git show "$OLD:$f" | sed "s|upload-artifact@${SHORT}\([^0-9a-f]\)|upload-artifact@${FULL}\1|g; s|upload-artifact@${SHORT}\$|upload-artifact@${FULL}|g" > /tmp/wf-$p.yml + # Guard: no remaining short pins (short not followed by 'a') + if grep -E "upload-artifact@${SHORT}([^a]|\$)" /tmp/wf-$p.yml >/dev/null; then + echo "pr-$p $f: short SHA still present" >&2; exit 1 + fi + BLOB=$(git hash-object -w /tmp/wf-$p.yml) + printf '100644 blob %s\t%s\n' "$BLOB" "$f" | GIT_INDEX_FILE="$TMPIDX" git update-index --index-info + fi + done + + TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree) + rm -f "$TMPIDX" + MSG=$(git log -1 --format=%B "$OLD") + NEW=$(printf '%s' "$MSG" | git commit-tree "$TREE" -p "$PARENT") + git update-ref "refs/heads/transplant/pr-${p}" "$NEW" + echo "pr-${p}: $OLD -> $NEW" + PARENT="$NEW" +done +echo DONE diff --git a/package.json b/package.json index 67b559c..21f3e42 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,27 @@ "test": "vitest run", "test:watch": "vitest", "lint": "eslint src/", - "verify:types": "tsc --noEmit" + "verify:types": "tsc --noEmit", + "verify:package": "node scripts/verify-package.mjs", + "verify:all": "npm run build && npm run verify:types && npm run lint && npm run lint:boundary && npm test && npm audit --audit-level=high --omit=dev && npm run verify:package", + "test:inventory": "node scripts/test-inventory.mjs", + "lint:boundary": "node scripts/verify-eslint-boundary.mjs" }, - "keywords": ["llm", "router", "openrouter", "perplexity", "anthropic", "openai", "multi-provider", "budget"], + "keywords": [ + "llm", + "router", + "openrouter", + "perplexity", + "anthropic", + "openai", + "multi-provider", + "budget" + ], "author": "L9 Systems", "license": "PROPRIETARY", - "publishConfig": {"registry": "https://npm.pkg.github.com"}, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, "dependencies": { "openai": "^4.50.0", "pino": "^9.0.0", @@ -30,11 +45,31 @@ "typescript-eslint": "^8.64.0", "vitest": "^2.0.0" }, - "engines": {"node": ">=20.0.0"}, + "engines": { + "node": ">=20.0.0" + }, "exports": { - ".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"}, - "./perplexity": {"types": "./dist/providers/perplexity.d.ts", "import": "./dist/providers/perplexity.js"}, - "./openrouter": {"types": "./dist/providers/openrouter.d.ts", "import": "./dist/providers/openrouter.js"}, - "./vision": {"types": "./dist/vision/index.d.ts", "import": "./dist/vision/index.js"} - } + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./perplexity": { + "types": "./dist/providers/perplexity.d.ts", + "import": "./dist/providers/perplexity.js" + }, + "./openrouter": { + "types": "./dist/providers/openrouter.d.ts", + "import": "./dist/providers/openrouter.js" + }, + "./vision": { + "types": "./dist/vision/index.d.ts", + "import": "./dist/vision/index.js" + } + }, + "files": [ + "dist", + "README.md", + "ARCHITECTURE.md" + ], + "packageManager": "npm@10.9.2" } diff --git a/push-remediation.sh b/push-remediation.sh new file mode 100755 index 0000000..f6f42b3 --- /dev/null +++ b/push-remediation.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Push pack remediation branches to their corresponding live PR branches. +# Order: canonical merge order from pack MANIFEST. +# Uses --force-with-lease against recorded live head SHAs for safety. +set -uo pipefail +cd /home/ubuntu/LLM-Router + +declare -a MAP=( + "02|fix/unified-remediation-phases-1-7|175d7130" + "08|dependabot/github_actions/actions/upload-artifact-7.0.1|bf4a97b" + "09|dependabot/npm_and_yarn/pino-10.3.1|13062ce" + "10|dependabot/npm_and_yarn/types/node-26.1.1|a9479f9" + "12|dependabot/npm_and_yarn/openai-6.48.0|4440b24" + "14|dependabot/npm_and_yarn/zod-4.4.3|7990d9d" + "15|dependabot/npm_and_yarn/vitest-4.1.10|cfd0547" + "11|dependabot/npm_and_yarn/eslint-10.7.0|152cb0d" + "13|dependabot/npm_and_yarn/typescript-7.0.2|7c906a2" + "16|feature/llm-control-plane-phase0-1|4337c23" +) + +RESULTS=/home/ubuntu/LLM-Router/push-results.txt +: > "$RESULTS" + +for entry in "${MAP[@]}"; do + IFS='|' read -r num branch lease <<< "$entry" + prnum=$((10#$num)) + src="refs/remediation/remediation/pr-${num}-ready" + livehead=$(git rev-parse "refs/prheads/${prnum}") + echo "=== PR #${prnum}: pushing ${src} -> ${branch} (lease on ${livehead}) ===" + if git push origin "+${src}:refs/heads/${branch}" --force-with-lease="refs/heads/${branch}:${livehead}" 2>&1; then + echo "PR#${prnum} PUSH OK $(git rev-parse --short ${src})" >> "$RESULTS" + else + echo "PR#${prnum} PUSH FAILED" >> "$RESULTS" + fi +done + +echo "=== RESULTS ===" +cat "$RESULTS" diff --git a/push-results.txt b/push-results.txt new file mode 100644 index 0000000..c5124ad --- /dev/null +++ b/push-results.txt @@ -0,0 +1,10 @@ +PR#2 PUSH OK 1091f6b +PR#8 PUSH FAILED +PR#9 PUSH FAILED +PR#10 PUSH FAILED +PR#12 PUSH FAILED +PR#14 PUSH FAILED +PR#15 PUSH FAILED +PR#11 PUSH FAILED +PR#13 PUSH FAILED +PR#16 PUSH FAILED diff --git a/push-transplants.sh b/push-transplants.sh new file mode 100755 index 0000000..961753f --- /dev/null +++ b/push-transplants.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Push each transplanted branch to its live PR head branch (PAT already in origin URL). +# Canonical order: 02 -> 08 -> 09 -> 10 -> 12 -> 14 -> 15 -> 11 -> 13 -> 16. +set -uo pipefail +cd /home/ubuntu/LLM-Router + +declare -A BRANCH=( + [02]="fix/unified-remediation-phases-1-7" + [08]="dependabot/github_actions/actions/upload-artifact-7.0.1" + [09]="dependabot/npm_and_yarn/pino-10.3.1" + [10]="dependabot/npm_and_yarn/types/node-26.1.1" + [12]="dependabot/npm_and_yarn/openai-6.48.0" + [14]="dependabot/npm_and_yarn/zod-4.4.3" + [15]="dependabot/npm_and_yarn/vitest-4.1.10" + [11]="dependabot/npm_and_yarn/eslint-10.7.0" + [13]="dependabot/npm_and_yarn/typescript-7.0.2" + [16]="feature/llm-control-plane-phase0-1" +) + +RESULTS=/home/ubuntu/push-results.txt +: > "$RESULTS" +for p in 02 08 09 10 12 14 15 11 13 16; do + tgt="${BRANCH[$p]}" + sha=$(git rev-parse "transplant/pr-$p") + if git push origin "+${sha}:refs/heads/${tgt}" > /tmp/push-$p.log 2>&1; then + echo "pr-$p -> $tgt : PUSHED $sha" >> "$RESULTS" + else + echo "pr-$p -> $tgt : FAILED (see /tmp/push-$p.log)" >> "$RESULTS" + tail -3 /tmp/push-$p.log >> "$RESULTS" + fi +done +cat "$RESULTS" diff --git a/s4036-fix.sh b/s4036-fix.sh new file mode 100755 index 0000000..4b972db --- /dev/null +++ b/s4036-fix.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Fix javascript:S4036 in scripts/verify-package.mjs on every open PR branch: +# remove the bare-'npm' fallback spawn; resolve the npm CLI script path absolutely. +set -euo pipefail +cd /home/ubuntu/LLM-Router + +BRANCHES=( + fix/unified-remediation-phases-1-7 + dependabot/github_actions/actions/upload-artifact-7.0.1 + dependabot/npm_and_yarn/pino-10.3.1 + dependabot/npm_and_yarn/types/node-26.1.1 + dependabot/npm_and_yarn/openai-6.48.0 + dependabot/npm_and_yarn/zod-4.4.3 + dependabot/npm_and_yarn/vitest-4.1.10 + dependabot/npm_and_yarn/eslint-10.7.0 + dependabot/npm_and_yarn/typescript-7.0.2 + feature/llm-control-plane-phase0-1 +) + +git fetch -q origin + +for br in "${BRANCHES[@]}"; do + safe=${br//\//_} + git worktree remove -f /tmp/wt-s4036 2>/dev/null || true + git branch -D s4036-tmp 2>/dev/null || true + git worktree add -f /tmp/wt-s4036 -b s4036-tmp "origin/$br" >/dev/null + pushd /tmp/wt-s4036 >/dev/null + + if [ ! -f scripts/verify-package.mjs ]; then + echo "$br: no verify-package.mjs, skipping" + popd >/dev/null; continue + fi + + python3 - << 'PYEOF' +import re +p = 'scripts/verify-package.mjs' +src = open(p).read() + +old_block = """// Invoke npm via the absolute Node binary + npm CLI script (avoids PATH lookup; sonar javascript:S4036). +const npmCliPath = process.env.npm_execpath; +const fixedPath = ['/usr/local/bin', '/usr/bin', '/bin'].join(delimiter); +const runNpm = (args, options = {}) => npmCliPath + ? execFileSync(process.execPath, [npmCliPath, ...args], options) + : execFileSync('npm', args, { ...options, env: { ...(options.env ?? process.env), PATH: fixedPath } });""" + +new_block = """// Invoke npm via the absolute Node binary + an absolute npm CLI script path. +// No bare-name spawn and no PATH lookup anywhere (sonar javascript:S4036). +const resolveNpmCli = () => { + if (process.env.npm_execpath) return process.env.npm_execpath; + const candidates = [ + join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ]; + for (const candidate of candidates) if (existsSync(candidate)) return candidate; + throw new Error('Unable to locate the npm CLI script; set npm_execpath.'); +}; +const npmCliPath = resolveNpmCli(); +const runNpm = (args, options = {}) => execFileSync(process.execPath, [npmCliPath, ...args], options);""" + +assert old_block in src, 'old block not found' +src = src.replace(old_block, new_block) + +# imports: drop delimiter, add dirname + existsSync +src = src.replace("import { delimiter } from 'node:path';\n", "") +src = src.replace( + "import { basename, join } from 'node:path';", + "import { basename, dirname, join } from 'node:path';\nimport { existsSync } from 'node:fs';" +) + +open(p, 'w').write(src) +print('patched', p) +PYEOF + + node --check scripts/verify-package.mjs + git add scripts/verify-package.mjs + git -c user.name='L9 Remediator' -c user.email='remediator@quantum-l9.dev' commit -q -m "fix(quality): remove PATH-dependent npm fallback in verify-package.mjs + +Resolve the npm CLI script to an absolute path (npm_execpath or the npm +installation shipped alongside the Node binary) and always spawn it via +process.execPath. Eliminates the bare-name 'npm' spawn flagged by Sonar +javascript:S4036 (OS command search path vulnerability)." + git update-ref "refs/s4036/$safe" HEAD + echo "$br -> $(git rev-parse --short HEAD)" + popd >/dev/null + git worktree remove -f /tmp/wt-s4036 + git branch -D s4036-tmp +done +echo DONE diff --git a/scripts/test-inventory.mjs b/scripts/test-inventory.mjs new file mode 100644 index 0000000..1bc7859 --- /dev/null +++ b/scripts/test-inventory.mjs @@ -0,0 +1,17 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map(entry => entry.isDirectory() ? walk(join(directory, entry.name)) : [join(directory, entry.name)])); + return nested.flat(); +} +const files = (await walk(join(root, 'tests'))).filter(file => file.endsWith('.test.ts')).sort(); +let cases = 0; +for (const file of files) { + const text = await readFile(file, 'utf8'); + cases += (text.match(/\b(?:it|test)(?:\.each)?\s*\(/g) ?? []).length; +} +console.log(JSON.stringify({ files: files.map(file => relative(root, file)), fileCount: files.length, testCaseDeclarations: cases }, null, 2)); diff --git a/scripts/verify-eslint-boundary.mjs b/scripts/verify-eslint-boundary.mjs new file mode 100644 index 0000000..a08abd6 --- /dev/null +++ b/scripts/verify-eslint-boundary.mjs @@ -0,0 +1,10 @@ +import { ESLint } from 'eslint'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +const root = fileURLToPath(new URL('..', import.meta.url)); +const eslint = new ESLint({ cwd: root }); +const [result] = await eslint.lintText("import { OpenRouterClient } from './providers/openrouter.js';\nvoid OpenRouterClient;\n", { filePath: join(root, 'src', 'boundary-probe.ts') }); +if (!result.messages.some(message => message.ruleId === 'no-restricted-imports' && message.severity === 2)) { + throw new Error('Provider boundary rule failed to reject a direct production import'); +} +console.log('Provider boundary rule verified.'); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..6b989f0 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,52 @@ +import { execFileSync } from 'node:child_process'; + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +// Invoke npm via the absolute Node binary + an absolute npm CLI script path. +// No bare-name spawn and no PATH lookup anywhere (sonar javascript:S4036). +const resolveNpmCli = () => { + if (process.env.npm_execpath) return process.env.npm_execpath; + const candidates = [ + join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ]; + for (const candidate of candidates) if (existsSync(candidate)) return candidate; + throw new Error('Unable to locate the npm CLI script; set npm_execpath.'); +}; +const npmCliPath = resolveNpmCli(); +const runNpm = (args, options = {}) => execFileSync(process.execPath, [npmCliPath, ...args], options); +const workspace = await mkdtemp(join(tmpdir(), 'llm-router-package-')); +let tarball; +try { + const packed = JSON.parse(runNpm(['pack', '--json', '--ignore-scripts'], { cwd: root, encoding: 'utf8' })); + const artifact = packed[0]; + tarball = join(root, artifact.filename); + const unexpected = artifact.files.map(entry => entry.path).filter(path => !( + path === 'package.json' || path === 'README.md' || path === 'ARCHITECTURE.md' || path.startsWith('dist/') + )); + if (unexpected.length > 0) throw new Error(`Unexpected package files: ${unexpected.join(', ')}`); + + await writeFile(join(workspace, 'package.json'), JSON.stringify({ name: 'llm-router-package-smoke', private: true, type: 'module' }, null, 2)); + const registry = process.env.NPM_CONFIG_REGISTRY ?? process.env.npm_config_registry ?? 'https://registry.npmjs.org'; + runNpm(['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', `--registry=${registry}`, tarball], { cwd: workspace, stdio: 'inherit', env: process.env }); + const smoke = ` + const root = await import('@quantum-l9/llm-router'); + const openrouter = await import('@quantum-l9/llm-router/openrouter'); + const perplexity = await import('@quantum-l9/llm-router/perplexity'); + const vision = await import('@quantum-l9/llm-router/vision'); + if (typeof root.L9LLMRouter !== 'function') throw new Error('root export missing'); + if (typeof openrouter.OpenRouterClient !== 'function') throw new Error('openrouter export missing'); + if (typeof perplexity.PerplexityClient !== 'function') throw new Error('perplexity export missing'); + if (!vision.VIEWPORTS) throw new Error('vision export missing'); + `; + execFileSync(process.execPath, ['--input-type=module', '--eval', smoke], { cwd: workspace, stdio: 'inherit' }); + console.log(`Package smoke passed: ${basename(tarball)}`); +} finally { + await rm(workspace, { recursive: true, force: true }); + if (tarball) await rm(tarball, { force: true }); +} diff --git a/sonar-fix.sh b/sonar-fix.sh new file mode 100755 index 0000000..c01cd14 --- /dev/null +++ b/sonar-fix.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Applies SonarCloud remediation per PR branch, stage-appropriately. +# Usage: ./sonar-fix.sh [flags: base|late|pr16|pr18] +set -euo pipefail +cd /home/ubuntu/LLM-Router + +BR="$1"; STAGE="$2" # STAGE in {base, late, pr16, pr18} +WT="/tmp/wt-sonar" +git worktree remove -f "$WT" 2>/dev/null || true +git fetch -q origin "$BR" +git worktree add -f "$WT" "origin/$BR" -b "sfix-tmp" 2>/dev/null || { git branch -D sfix-tmp 2>/dev/null; git worktree add -f "$WT" "origin/$BR" -b "sfix-tmp"; } +cd "$WT" + +######################################## +# 1. S8544: pin pip + semgrep in l9-analysis.yml (all stages) +######################################## +if grep -q 'python -m pip install --upgrade pip semgrep' .github/workflows/l9-analysis.yml; then + sed -i "s|python -m pip install --upgrade pip semgrep|python -m pip install 'pip==26.1.2' 'semgrep==1.170.1'|" .github/workflows/l9-analysis.yml +fi + +if [ "$STAGE" != "pr18" ]; then +######################################## +# 2. S6959: perplexity.ts reduce() initial value (all code stages) +######################################## +if [ -f src/providers/perplexity.ts ]; then + # single-line style (base stage) and multi-line style (late stages) share the same expression + sed -i 's|best: successes\.reduce((best, candidate) => candidate\.content\.length > best\.content\.length ? candidate : best)|best: successes.reduce((best, candidate) => candidate.content.length > best.content.length ? candidate : best, successes[0])|' src/providers/perplexity.ts + # 3. S2871: citations sort comparator (late stages only; no-op if absent) + sed -i 's|citations: \[\.\.\.new Set(responses\.flatMap(response => response\.citations ?? \[\]))\]\.sort()|citations: [...new Set(responses.flatMap(response => response.citations ?? []))].sort((left, right) => left.localeCompare(right))|' src/providers/perplexity.ts +fi + +######################################## +# 4. S2871 control-plane (pr16 only) +######################################## +if [ "$STAGE" = "pr16" ]; then + # contracts.ts:9 — deterministic code-unit comparator (behavior-preserving vs default sort for these string arrays) + sed -i 's|const expected = \[\.\.\.new Set(serialized)\]\.sort();|const expected = [...new Set(serialized)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);|' src/control-plane/contracts.ts + # builders.ts:52 sortedUniqueStrings + sed -i 's|function sortedUniqueStrings(values: string\[\]): string\[\] { return \[\.\.\.new Set(values\.map(normalizeString))\]\.sort(); }|function sortedUniqueStrings(values: string[]): string[] { return [...new Set(values.map(normalizeString))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0); }|' src/control-plane/builders.ts +fi + +######################################## +# 5. S4036: verify-package.mjs absolute npm invocation (stages with scripts/) +######################################## +if [ -f scripts/verify-package.mjs ]; then + python3 - << 'PYEOF' +import re +p = 'scripts/verify-package.mjs' +s = open(p).read() +if 'runNpm' not in s: + s = s.replace( + "import { execFileSync } from 'node:child_process';", + "import { execFileSync } from 'node:child_process';\n" + "import { delimiter } from 'node:path';\n" + , 1) + helper = ( + "const root = fileURLToPath(new URL('..', import.meta.url));\n" + "// Invoke npm via the absolute Node binary + npm CLI script (avoids PATH lookup; sonar javascript:S4036).\n" + "const npmCliPath = process.env.npm_execpath;\n" + "const fixedPath = ['/usr/local/bin', '/usr/bin', '/bin'].join(delimiter);\n" + "const runNpm = (args, options = {}) => npmCliPath\n" + " ? execFileSync(process.execPath, [npmCliPath, ...args], options)\n" + " : execFileSync('npm', args, { ...options, env: { ...(options.env ?? process.env), PATH: fixedPath } });\n" + ) + s = s.replace("const root = fileURLToPath(new URL('..', import.meta.url));\n", helper, 1) + s = s.replace("execFileSync('npm', ['pack', '--json', '--ignore-scripts'], { cwd: root, encoding: 'utf8' })", + "runNpm(['pack', '--json', '--ignore-scripts'], { cwd: root, encoding: 'utf8' })") + s = s.replace("execFileSync('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', `--registry=${registry}`, tarball], { cwd: workspace, stdio: 'inherit', env: process.env });", + "runNpm(['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', `--registry=${registry}`, tarball], { cwd: workspace, stdio: 'inherit', env: process.env });") + open(p, 'w').write(s) + print('patched verify-package.mjs') +else: + print('already patched') +PYEOF +fi + +######################################## +# 6+7. S8543/S8564: add package-lock.json where missing; switch npm install -> npm ci +######################################## +if [ ! -f package-lock.json ]; then + npm install --package-lock-only --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 + [ -f package-lock.json ] || { echo "LOCKFILE GENERATION FAILED for $BR"; exit 1; } + for wf in .github/workflows/ci.yml .github/workflows/publish.yml .github/workflows/supply-chain.yml; do + [ -f "$wf" ] && sed -i 's|npm install --no-audit --no-fund --ignore-scripts|npm ci --ignore-scripts|' "$wf" + done + # restore npm cache hint if we removed it earlier? keep simple: leave setup-node as-is. +fi + +fi # end non-pr18 + +git add -A +git commit -q -m "fix(quality): resolve SonarCloud new-code findings + +- pin pip/semgrep versions in l9-analysis.yml (githubactions:S8544) +- add initial value to consensus reduce() (typescript:S6959) +- use explicit comparators for sorts (typescript:S2871) +- invoke npm without PATH lookup in verify-package.mjs (javascript:S4036) +- commit package-lock.json and restore npm ci (githubactions:S8543, text:S8564)" || echo "NOTHING TO COMMIT for $BR" +NEW=$(git rev-parse HEAD) +echo "$BR -> $NEW" +cd /home/ubuntu/LLM-Router +git worktree remove -f "$WT" +git branch -D sfix-tmp 2>/dev/null || true +git update-ref "refs/sfix/$STAGE-$(echo "$BR" | tr '/' '_')" "$NEW" diff --git a/transplant.sh b/transplant.sh new file mode 100755 index 0000000..7e9326b --- /dev/null +++ b/transplant.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Transplant pack remediation commits onto live main history. +# For each pack branch (stacked order), construct a new commit whose tree = +# pack tree + preserved main-only files, parented on the previous transplant +# commit (chain rooted at origin/main). +set -euo pipefail +cd /home/ubuntu/LLM-Router + +ORDER=(02 08 09 10 12 14 15 11 13 16) +PARENT=$(git rev-parse origin/main) + +# Paths that exist on live main but are out of the pack's remediation scope. +# These must be preserved so required checks (l9-lint-test-node.yml) and +# governance/config remain intact on every PR branch. +PRESERVE=( + ".github/CODEOWNERS" + ".github/ISSUE_TEMPLATE" + ".github/PULL_REQUEST_TEMPLATE.md" + ".github/dependabot.yml" + ".github/governance" + ".github/workflows/l9-analysis.yml" + ".github/workflows/l9-governance.yml" + ".github/workflows/l9-lint-test-node.yml" + ".github/workflows/l9-nightly.yml" + ".github/workflows/l9-node-ts-monorepo.yml" + ".github/workflows/l9-pr-pipeline.yml" + ".github/workflows/l9-pre-commit.yml" + ".github/workflows/l9-release.yml" + ".github/workflows/l9-sbom.yml" + ".github/workflows/l9-scorecard.yml" + ".github/workflows/l9-security.yml" + "CODE_OF_CONDUCT.md" + "CONTRIBUTING.md" + "SECURITY.md" + "SUPPORT.md" +) + +export GIT_AUTHOR_NAME="Igor Beylin" +export GIT_AUTHOR_EMAIL="ib718@icloud.com" +export GIT_COMMITTER_NAME="Igor Beylin" +export GIT_COMMITTER_EMAIL="ib718@icloud.com" + +for p in "${ORDER[@]}"; do + PACK_REF="refs/remediation/remediation/pr-${p}-ready" + PACK_COMMIT=$(git rev-parse "$PACK_REF") + + # Build tree in a temp index: start from pack tree, overlay preserved paths from main. + TMPIDX=$(mktemp /tmp/idx-XXXX) + rm -f "$TMPIDX" + GIT_INDEX_FILE="$TMPIDX" git read-tree "$PACK_REF^{tree}" + for path in "${PRESERVE[@]}"; do + # Copy path entries from origin/main into the index (dir or file). + GIT_INDEX_FILE="$TMPIDX" git ls-tree -r origin/main -- "$path" | \ + GIT_INDEX_FILE="$TMPIDX" git update-index --index-info + done + TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree) + rm -f "$TMPIDX" + + # Reuse the pack commit's message, annotated with provenance. + MSG=$(git log -1 --format=%B "$PACK_COMMIT") + NEW=$(printf '%s\n\nTransplanted-From: %s (remediation pack 2026-07-20)\n' "$MSG" "$PACK_COMMIT" | \ + git commit-tree "$TREE" -p "$PARENT") + + git update-ref "refs/heads/transplant/pr-${p}" "$NEW" + echo "pr-${p}: pack=$PACK_COMMIT -> transplant=$NEW (parent=$PARENT)" + PARENT="$NEW" +done +echo "DONE"