From 0ad248d199fbe9e5bb9f19857b02d3fd9f1ed887 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:33:45 -0400 Subject: [PATCH 01/23] feat(ci): guard PR submission bursts and alert maintainers --- .github/PR_MODERATION.md | 140 ++++++++++++++++ .github/pr-burst-policy.json | 7 + .github/workflows/pr-burst-guard.yml | 39 +++++ CONTRIBUTING.md | 12 ++ scripts/ci/pr-burst-guard.js | 199 +++++++++++++++++++++++ tests/ci/pr-burst-guard-workflow.test.js | 87 ++++++++++ tests/ci/pr-burst-guard.test.js | 184 +++++++++++++++++++++ 7 files changed, 668 insertions(+) create mode 100644 .github/PR_MODERATION.md create mode 100644 .github/pr-burst-policy.json create mode 100644 .github/workflows/pr-burst-guard.yml create mode 100644 scripts/ci/pr-burst-guard.js create mode 100644 tests/ci/pr-burst-guard-workflow.test.js create mode 100644 tests/ci/pr-burst-guard.test.js diff --git a/.github/PR_MODERATION.md b/.github/PR_MODERATION.md new file mode 100644 index 000000000..c87f7cde8 --- /dev/null +++ b/.github/PR_MODERATION.md @@ -0,0 +1,140 @@ +# PR submission moderation + +The PR Burst Guard protects review capacity using creation metadata. It runs on +new and reopened PRs targeting any branch, after this workflow merges. It does not +judge whether a patch is useful. Existing open PRs are not bulk-closed on rollout. + +## Policy + +| Control | Default | Enforcement | +| --- | --- | --- | +| Native concurrent cap | Five open PRs, including drafts | Admin must enable GitHub setting | +| Burst allowance | Five creations per author in 20 minutes | Close and lock excess PRs | +| Severe burst | More than ten creations in 20 minutes | Attempt account block; alert maintainers | +| Known denied account | User ID `108231307`, currently `dajiaohuang` | Close and lock its incoming/reopened PR | +| Trusted contributors | Live repository write, maintain, or admin permission | Exempt from the burst guard | +| Notifications | `@affaan-m @haelyra` | One bot-authored issue per author per UTC day, plus one escalation comment | + +Edit `pr-burst-policy.json` to change thresholds, notification recipients or +denied numeric IDs. A native cap bypass list does not bypass the custom burst +guard. Granting write access is not required for native cap exceptions. + +Closed and draft PRs count toward burst detection. Counting uses stable author +IDs and paginated REST creation metadata, without the search index. A run checks +historical windows from the triggering PR's creation time through newer API +rows. Queue delays, out-of-order delivery and replaced pending runs cannot hide +a burst already present in that snapshot. Excess PRs are determined in creation +order across all scanned windows, with PR number breaking timestamp ties. +Already closed excess PRs count +but are not mutated again. + +Reopening an old ordinary PR does not add a new creation to the burst count. +Reopening a denied author's PR triggers denial even outside the time window. +The native concurrent cap supplies the pre-creation control. Actions moderation +happens after creation and cannot prevent every CI job from starting. Account +blocks cannot stop someone from using a different GitHub account. + +## Activate + +1. Merge the reviewed workflow and script into `main`. +2. As a repository administrator, enable the native cap under **Settings > + Moderation options > Interaction limits > Pull request limits**. Set five + concurrent PRs and include drafts. Alternatively, authenticate `gh` as an + administrator and run: + + ```sh + gh api --method PATCH repos/affaan-m/ECC/interaction-limits/pulls/creation-cap \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + -F enabled=true -F max_open_pull_requests=5 -F include_drafts=true + gh api repos/affaan-m/ECC/interaction-limits/pulls/creation-cap + ``` + +3. To enable automatic account blocking, `affaan-m` creates a dedicated + fine-grained token with the account permission **Block another user: write**. + Store it as repository Actions secret `PR_MODERATION_BLOCK_TOKEN`. Use GitHub's + secret UI or `gh secret set PR_MODERATION_BLOCK_TOKEN --repo affaan-m/ECC` + with its hidden prompt. Do not paste credentials into chat or source files. +4. Verify a genuine subsequent guard run and its job summary. Without the secret, + closure and alerting still run. A severe burst records blocking as unavailable + in the alert and fails the job so maintainers see the missing protection. + +ECC currently belongs to the personal account `affaan-m`. GitHub account blocking +applies across **all repositories owned by that account**. The script checks the +token's authenticated identity before calling the personal blocking API. A +collaborator's personal block cannot protect this repository. The normal +`GITHUB_TOKEN` cannot perform account blocking. Organization blocking requires +a separate implementation if repository ownership changes. + +GitHub's native cap counts concurrent open PRs. The custom guard counts creations +over time. Closing PRs can free native cap slots, but cannot reset burst counts. +A native cap can therefore stop a contributor before they reach the severe +threshold; the custom guard still catches rapid closing and resubmission. + +## Verify without moderating anyone + +```sh +node tests/ci/pr-burst-guard.test.js +node tests/ci/pr-burst-guard-workflow.test.js +node scripts/ci/validate-workflow-security.js +``` + +The workflow test executes the actual CLI against a disposable HTTP fixture. +It verifies close, lock, owner-identity blocking, secret separation, deduplicated +notifications, missing-secret failure and collaborator exemption. + +For a live read-only replay, supply `GITHUB_TOKEN` through your secure shell +environment, then run: + +```sh +GITHUB_REPOSITORY=affaan-m/ECC node scripts/ci/pr-burst-guard.js --dry-run --pr 3580 +``` + +Expected for the October 14 snapshot: author `jwcastillo`, count `14`, `block: +true`, and nine excess PRs. Dry run never uses the blocking secret or writes to +GitHub. Hosted event delivery and real account blocking require post-merge +verification; the local fixture cannot certify those platform operations. + +## Security and recovery + +- The privileged event checks out its trusted default-branch SHA and two moderation files. + It never checks out PR code, installs dependencies, or executes PR text. +- Credentials enter through environment variables. Owner identity verification + and account blocking receive the owner credential. Repository moderation uses + `GITHUB_TOKEN`. +- API failures cannot silently become an allowlist exemption. Only collaborator + lookup `404` means an outsider; other errors fail the run. +- Close or lock failures appear in the alert and fail the job. Block failures + preserve closure and alerting, then fail the job. +- Repeated runs update the bot-authored alert instead of creating new issues. + A contributor-created lookalike cannot suppress alerts. Escalation remains + recorded if a later run has a lower count. +- To roll back, disable **PR Burst Guard** in Actions. Remove its secret if + abandoning automatic blocking. Disable the native cap separately in Settings. +- To restore a contributor, the owner removes the account block. Remove any + denied user ID from the policy, then unlock and reopen selected PRs manually. + +## October 15 investigation + +Authenticated GitHub GraphQL returned zero `CLOSED_EVENT` or `REOPENED_EVENT` +entries across all 55 currently open `dajiaohuang` PRs. Their latest update was +October 8. The public timeline therefore supplies no evidence that they reopened +on October 15. Restored visibility after GitHub spam classification is a possible +explanation, but GitHub's private moderation history was unavailable. A block on +a collaborator's personal account would also have a different scope from an +owner block. The owner's actual block state was not available to this session. + +The visible newer burst was 14 PRs from `jwcastillo`, #3567 through #3580, between +October 14 at 19:01:37 and 19:07:14 UTC. Volume alone does not establish the quality +of those patches. No historical PR was closed or account blocked during this +investigation. The native cap API returned `403: Must have admin rights to +Repository` for the authenticated collaborator `haelyra`. + +This advances M0 maintenance reliability. Runtime sandbox, Capsule, Fleet, +release publication and shared application installation remain separate work. + +## GitHub references + +- [Native repository PR caps and bypass lists](https://docs.github.com/en/communities/moderating-comments-and-conversations/limiting-interactions-in-your-repository) +- [Repository cap REST endpoints](https://docs.github.com/en/rest/interactions/repos) +- [Personal account blocking REST endpoints](https://docs.github.com/en/rest/users/blocking) +- [Privileged pull request event security](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target) diff --git a/.github/pr-burst-policy.json b/.github/pr-burst-policy.json new file mode 100644 index 000000000..ce7d8927a --- /dev/null +++ b/.github/pr-burst-policy.json @@ -0,0 +1,7 @@ +{ + "windowMinutes": 20, + "closeAbove": 5, + "blockAbove": 10, + "deniedUserIDs": [108231307], + "notify": ["affaan-m", "haelyra"] +} diff --git a/.github/workflows/pr-burst-guard.yml b/.github/workflows/pr-burst-guard.yml new file mode 100644 index 000000000..550655339 --- /dev/null +++ b/.github/workflows/pr-burst-guard.yml @@ -0,0 +1,39 @@ +name: PR Burst Guard + +on: + pull_request_target: + types: [opened, reopened] + +# Process one author's ledger serially. A newer pending event replaces an older +# pending event; each run reads all PR states instead of relying on event counts. +concurrency: + group: pr-burst-${{ github.repository }}-${{ github.event.pull_request.user.id }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + moderate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # pull_request_target's SHA is the trusted default branch, never a PR head. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: | + .github/pr-burst-policy.json + scripts/ci/pr-burst-guard.js + sparse-checkout-cone-mode: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.x' + - name: Enforce PR submission policy + env: + GITHUB_TOKEN: ${{ github.token }} + PR_MODERATION_BLOCK_TOKEN: ${{ secrets.PR_MODERATION_BLOCK_TOKEN }} + run: node scripts/ci/pr-burst-guard.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5576d4699..71c844f8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -433,6 +433,18 @@ Translations live under `docs/` (e.g. `docs/zh-CN`, `docs/zh-TW`, `docs/ja-JP`). ## Pull Request Process +### Submission volume + +Please submit at most five PRs within any 20-minute period. Automated moderation +closes and locks excess PRs, including drafts. More than ten submissions within +20 minutes triggers a maintainer alert and an account-block attempt. Closed PRs +still count toward the window. Contributors with repository write access bypass +this policy. Maintainers can review exceptions through the moderation alert. + +The repository also supports GitHub's native concurrent open-PR cap, configured +separately by an administrator. See the [moderation runbook](.github/PR_MODERATION.md) +for activation, credential requirements, and recovery. + ### 1. PR Title Format ``` diff --git a/scripts/ci/pr-burst-guard.js b/scripts/ci/pr-burst-guard.js new file mode 100644 index 000000000..2329561c7 --- /dev/null +++ b/scripts/ci/pr-burst-guard.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** Metadata-only moderation. Never execute PR code or use PR titles/bodies. */ +const fs = require('node:fs'); +const path = require('node:path'); + +const LOGIN = /^[a-z\d](?:[a-z\d-]{0,38})(?:\[bot\])?$/i; + +function validatePolicy(policy) { + for (const key of ['windowMinutes', 'closeAbove', 'blockAbove']) { + if (!Number.isSafeInteger(policy[key]) || policy[key] < 1) throw new Error(`Invalid policy ${key}`); + } + if (policy.blockAbove <= policy.closeAbove) throw new Error('blockAbove must exceed closeAbove'); + if (!Array.isArray(policy.deniedUserIDs) || !policy.deniedUserIDs.every(Number.isSafeInteger)) { + throw new Error('Invalid deniedUserIDs'); + } + if (!Array.isArray(policy.notify) || !policy.notify.every(login => LOGIN.test(login))) { + throw new Error('Invalid notification logins'); + } +} + +function githubAPI(token, fetcher = fetch) { + if (!token) throw new Error('GitHub token is required'); + return async (method, route, body) => { + if (!/^\/[a-zA-Z0-9][a-zA-Z0-9/_.?=&%[\]-]*$/.test(route)) throw new Error('Invalid GitHub API path'); + const response = await fetcher(`https://api.github.com${route}`, { + method, redirect: 'error', signal: AbortSignal.timeout(30000), + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2026-03-10', 'Content-Type': 'application/json' }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!response.ok) { + // Never print response bodies, which can contain attacker-controlled text. + const error = new Error(`GitHub ${method} failed: HTTP ${response.status}`); + error.status = response.status; + throw error; + } + return response.status === 204 ? null : response.json(); + }; +} + +async function listPages(api, route, stop = () => false) { + const rows = []; + for (let page = 1; page <= 100; page++) { + const batch = await api('GET', `${route}&per_page=100&page=${page}`); + if (!Array.isArray(batch)) throw new Error('Expected GitHub list response'); + rows.push(...batch); + if (batch.length < 100 || stop(batch)) return rows; + } + throw new Error('Pagination limit reached; refusing an incomplete moderation decision'); +} + +function evaluate(rows, current, policy, end) { + validatePolicy(policy); + if (!Number.isFinite(end)) throw new Error('Invalid window timestamp'); + const since = end - policy.windowMinutes * 60000; + const recent = [...new Map([...rows, current].map(pr => [pr.number, pr])).values()] + .filter(pr => pr.user.id === current.user.id && Date.parse(pr.created_at) >= since && Date.parse(pr.created_at) <= end) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number); + const denied = policy.deniedUserIDs.includes(current.user.id); + const excess = denied ? [current] : recent.slice(policy.closeAbove); + return { count: recent.length, denied, block: denied || recent.length > policy.blockAbove, + excess: excess.map(pr => ({ number: pr.number, state: pr.state })), + since: new Date(since).toISOString(), until: new Date(end).toISOString() }; +} + +function strongestWindow(rows, current, policy, eventTime) { + const ordered = [...new Map([...rows, current].map(pr => [pr.number, pr])).values()] + .filter(pr => pr.user.id === current.user.id) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number); + const times = ordered.map(pr => Date.parse(pr.created_at)); + const excess = new Map(evaluate(rows, current, policy, eventTime).excess.map(pr => [pr.number, pr])); + let left = 0; + let count = 0; + let end = eventTime; + for (let right = 0; right < times.length; right++) { + while (times[left] < times[right] - policy.windowMinutes * 60000) left++; + if (times[right] >= eventTime && right - left + 1 >= count) { + count = right - left + 1; + end = times[right]; + } + if (times[right] >= eventTime && right - left + 1 > policy.closeAbove) { + excess.set(ordered[right].number, { number: ordered[right].number, state: ordered[right].state }); + } + } + const peak = evaluate(rows, current, policy, end); + return { ...peak, excess: peak.denied ? peak.excess : [...excess.values()] }; +} + +async function blockAuthor(blockAPI, owner, login) { + if (!blockAPI) return 'Unavailable: PR_MODERATION_BLOCK_TOKEN is not configured'; + try { + if (owner.type !== 'User') throw new Error('Only personal repository owners are supported'); + const identity = await blockAPI('GET', '/user'); + if (identity.login.toLowerCase() !== owner.login.toLowerCase()) { + throw new Error('Block token must use the repository owner identity'); + } + await blockAPI('PUT', `/user/blocks/${login}`); + return 'Blocked by repository owner (all repositories owned by this account)'; + } catch (error) { + return `Failed: ${error.message}`; + } +} + +async function notify(api, root, current, result, policy) { + // A bot-authored ledger prevents contributors from spoofing a suppression marker. + const marker = ``; + const issues = await listPages(api, `${root}/issues?state=all&sort=created&direction=desc`, + batch => batch.some(issue => issue.created_at && issue.created_at.slice(0, 10) < result.until.slice(0, 10))); + const existing = issues.find(issue => !issue.pull_request && issue.user.login === 'github-actions[bot]' + && issue.body?.includes(marker)); + const escalation = ''; + if (existing?.body.includes(escalation) && !result.block) return; + const mentions = policy.notify.map(login => `@${login}`).join(' '); + const body = [marker, result.block ? escalation : '', `${mentions}: PR submission moderation triggered.`, + '', `Author: @${current.user.login} (user ID ${current.user.id}).`, + `Observed: ${result.count} PR creations between ${result.since} and ${result.until}.`, + `Policy: close PRs beyond ${policy.closeAbove}; block beyond ${policy.blockAbove} in ${policy.windowMinutes} minutes.`, + `Denied account: ${result.denied}.`, `Blocking: ${result.blockStatus}.`, + `Excess PRs across scanned windows: ${result.excess.map(pr => `#${pr.number}`).join(', ') || 'none'}.`, + `Enforcement errors: ${result.errors.join('; ') || 'none'}.`, + '', 'Counts include closed and draft PRs. Volume triggers moderation, not a judgment about individual patch quality.', + 'Review the Actions run and PR timeline before lifting an account block.'].filter(Boolean).join('\n'); + if (!existing) { + await api('POST', `${root}/issues`, { title: `PR burst alert: ${current.user.login}`, body }); + } else { + // Notify again only when a soft-limit alert escalates to blocking. + if (result.block && !existing.body.includes(escalation)) { + await api('POST', `${root}/issues/${existing.number}/comments`, { + body: `${mentions}: the PR burst escalated. Blocking: ${result.blockStatus}.`, + }); + } + await api('PATCH', `${root}/issues/${existing.number}`, { body }); + } +} + +async function moderate({ api, blockAPI, repository, number, action = 'opened', policy, dryRun = false, now = Date.now() }) { + validatePolicy(policy); + if (!/^[a-z\d-]+\/[a-z\d_.-]+$/i.test(repository) || !Number.isSafeInteger(number) || number < 1) { + throw new Error('Invalid repository or PR number'); + } + if (!['opened', 'reopened'].includes(action)) throw new Error('Unsupported PR event'); + const root = `/repos/${repository}`; + const repo = await api('GET', root); + const current = await api('GET', `${root}/pulls/${number}`); + if (!LOGIN.test(current.user.login)) throw new Error('Invalid PR author login'); + let permission; + try { + permission = (await api('GET', `${root}/collaborators/${current.user.login}/permission`)).permission; + } catch (error) { + if (error.status !== 404) throw error; + permission = 'none'; + } + if (['admin', 'maintain', 'write'].includes(permission)) return { exempt: true, author: current.user.login }; + // Anchor opened events to GitHub's creation time so Actions queue delays cannot erase a burst. + const end = action === 'opened' ? Date.parse(current.created_at) : now; + const since = end - policy.windowMinutes * 60000; + const rows = await listPages(api, `${root}/pulls?state=all&sort=created&direction=desc`, + batch => batch.some(pr => Date.parse(pr.created_at) < since)); + // Inspect later windows too: delivery order and replaced pending runs must not + // let an older event conceal a newer burst already present in the API snapshot. + const result = { ...strongestWindow(rows, current, policy, end), author: current.user.login, + dryRun, errors: [], blockStatus: 'Below blocking threshold' }; + if (dryRun) { + if (result.block) result.blockStatus = 'Dry run: would attempt owner-scoped blocking'; + return result; + } + if (result.excess.length === 0) return result; + if (result.block) result.blockStatus = await blockAuthor(blockAPI, repo.owner, current.user.login); + for (const pr of result.excess) { + if (pr.state !== 'open') continue; + try { + await api('PATCH', `${root}/pulls/${pr.number}`, { state: 'closed' }); + await api('PUT', `${root}/issues/${pr.number}/lock`, { lock_reason: 'spam' }); + } catch (error) { + result.errors.push(`#${pr.number}: ${error.message}`); + } + } + await notify(api, root, current, result, policy); + return result; +} + +async function main() { + const dryRun = process.argv.includes('--dry-run'); + const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) : {}; + const prIndex = process.argv.indexOf('--pr'); + const number = prIndex >= 0 ? Number(process.argv[prIndex + 1]) : event.pull_request?.number; + const repository = process.env.GITHUB_REPOSITORY; + const policy = JSON.parse(fs.readFileSync(path.join(__dirname, '../../.github/pr-burst-policy.json'), 'utf8')); + const result = await moderate({ api: githubAPI(process.env.GITHUB_TOKEN), + blockAPI: !dryRun && process.env.PR_MODERATION_BLOCK_TOKEN ? githubAPI(process.env.PR_MODERATION_BLOCK_TOKEN) : undefined, + repository, number, action: event.action || 'opened', policy, dryRun }); + const report = JSON.stringify(result, null, 2); + console.log(report); + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## PR burst guard\n\n\`\`\`json\n${report}\n\`\`\`\n`); + if (result.errors?.length || /^(Failed|Unavailable):/.test(result.blockStatus || '')) process.exitCode = 1; +} + +if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); +module.exports = { evaluate, moderate, githubAPI, listPages, validatePolicy, strongestWindow }; diff --git a/tests/ci/pr-burst-guard-workflow.test.js b/tests/ci/pr-burst-guard-workflow.test.js new file mode 100644 index 000000000..52592b08e --- /dev/null +++ b/tests/ci/pr-burst-guard-workflow.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const http = require('node:http'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const exec = promisify(execFile); +const root = path.resolve(__dirname, '../..'); + +test('actual CLI closes six excess PRs, locks them, blocks with owner identity, and deduplicates alerts over HTTP', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-pr-guard-')); + const mutations = []; + let issue; + let permission = 'read'; + const rows = Array.from({ length: 11 }, (_, i) => ({ number: i + 1, + user: { id: 42, login: 'contributor' }, state: 'open', draft: i === 4, + created_at: new Date(Date.UTC(2026, 9, 14, 19, 0, i)).toISOString(), + body: '::error::untrusted PR body', title: '$(untrusted title)' })); + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + let text = ''; + for await (const chunk of req) text += chunk; + const body = text ? JSON.parse(text) : undefined; + const send = value => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; + if (req.method !== 'GET') mutations.push({ method: req.method, route: url.pathname, body, token: req.headers.authorization }); + if (url.pathname === '/repos/owner/repo') return send({ owner: { login: 'owner', type: 'User' } }); + if (url.pathname === '/user') { + assert.equal(req.headers.authorization, 'Bearer owner-token'); + return send({ login: 'owner' }); + } + if (req.method === 'GET' && url.pathname.endsWith('/pulls/11')) return send(rows[10]); + if (url.pathname.includes('/collaborators/')) return send({ permission }); + if (req.method === 'GET' && url.pathname.endsWith('/pulls')) return send(rows.slice().reverse()); + if (url.pathname.endsWith('/issues')) { + if (req.method === 'GET') return send(issue ? [issue] : []); + issue = { number: 88, user: { login: 'github-actions[bot]' }, ...body }; + return send(issue); + } + if (url.pathname.endsWith('/issues/88') && req.method === 'PATCH') { issue = { ...issue, ...body }; return send(issue); } + if (req.method === 'PATCH' && /\/pulls\/\d+$/.test(url.pathname)) { + const pr = rows.find(row => row.number === Number(url.pathname.split('/').pop())); + pr.state = body.state; return send(pr); + } + if (req.method === 'PUT') { res.statusCode = 204; return res.end(); } + res.statusCode = 404; send({ message: 'fixture route missing' }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + // Test-only transport redirection. Production offers no API host override. + const preload = path.join(tmp, 'transport.cjs'); + fs.writeFileSync(preload, `const original = global.fetch; global.fetch = (url, options) => original('http://127.0.0.1:${port}' + new URL(url).pathname + new URL(url).search, options);`); + const event = path.join(tmp, 'event.json'); + fs.writeFileSync(event, JSON.stringify({ action: 'opened', sender: { login: 'owner' }, pull_request: { number: 11 } })); + const summary = path.join(tmp, 'summary.md'); + const env = { ...process.env, NODE_OPTIONS: `--require=${preload}`, GITHUB_REPOSITORY: 'owner/repo', + GITHUB_TOKEN: 'moderation-token', PR_MODERATION_BLOCK_TOKEN: 'owner-token', + GITHUB_EVENT_PATH: event, GITHUB_STEP_SUMMARY: summary }; + const run = () => exec(process.execPath, ['scripts/ci/pr-burst-guard.js'], { cwd: root, env }); + const first = JSON.parse((await run()).stdout); + assert.equal(first.count, 11); + assert.match(first.blockStatus, /^Blocked/); + await run(); + assert.equal(mutations.filter(m => m.route.endsWith('/issues') && m.method === 'POST').length, 1); + assert.equal(mutations.filter(m => /\/pulls\/\d+$/.test(m.route)).length, 6); + assert.equal(mutations.filter(m => m.route.endsWith('/lock')).length, 6); + assert(mutations.some(m => m.route === '/user/blocks/contributor' && m.token === 'Bearer owner-token')); + assert(mutations.filter(m => m.route.startsWith('/repos/')).every(m => m.token === 'Bearer moderation-token')); + assert.doesNotMatch(fs.readFileSync(summary, 'utf8'), /untrusted|owner-token|moderation-token/); + assert(issue.body.includes('@affaan-m @haelyra')); + + delete env.PR_MODERATION_BLOCK_TOKEN; + await assert.rejects(run(), error => { + assert.equal(error.code, 1); + assert.match(JSON.parse(error.stdout).blockStatus, /not configured/); + return true; + }); + permission = 'write'; + assert.equal(JSON.parse((await run()).stdout).exempt, true); + } finally { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/ci/pr-burst-guard.test.js b/tests/ci/pr-burst-guard.test.js new file mode 100644 index 000000000..4a0bd3c83 --- /dev/null +++ b/tests/ci/pr-burst-guard.test.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { evaluate, moderate, githubAPI, listPages, strongestWindow } = require('../../scripts/ci/pr-burst-guard'); + +const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, deniedUserIDs: [999], notify: ['maintainer'] }; +const start = Date.parse('2026-10-14T19:00:00Z'); +const pull = (number, extra = {}) => ({ number, user: { id: 42, login: 'contributor' }, + created_at: new Date(start + number * 1000).toISOString(), state: 'open', ...extra }); +const pulls = n => Array.from({ length: n }, (_, i) => pull(i + 1)); + +function fixture(n, options = {}) { + const rows = options.rows || pulls(n); + const calls = []; + let issue; + const api = async (method, route, body) => { + calls.push({ method, route, body }); + if (route === '/repos/owner/repo') return { owner: { login: 'owner', type: 'User' } }; + if (route.endsWith(`/pulls/${n}`)) return rows.find(p => p.number === n); + if (route.includes('/collaborators/')) return { permission: options.permission || 'read' }; + if (route.includes('/pulls?')) return rows.slice().reverse(); + if (route.includes('/issues?')) return issue ? [issue] : []; + if (route.endsWith('/issues') && method === 'POST') { + issue = { number: 101, user: { login: 'github-actions[bot]' }, ...body }; + return issue; + } + if (route.endsWith('/issues/101') && method === 'PATCH') { issue = { ...issue, ...body }; return issue; } + if (route.endsWith('/issues/101/comments')) return {}; + if (method === 'PATCH') return {}; + if (method === 'PUT') return {}; + throw new Error(`Unexpected ${method} ${route}`); + }; + const args = { api, repository: 'owner/repo', number: n, action: 'opened', policy, ...options }; + return { args, calls, api }; +} + +test('five allowed, sixth closed, ten not blocked, eleventh escalates', () => { + for (const n of [5, 6, 10, 11]) { + const result = evaluate(pulls(n), pull(n), policy, start + n * 1000); + assert.equal(result.count, n); + assert.equal(result.block, n > 10); + assert.deepEqual(result.excess.map(p => p.number), Array.from({ length: Math.max(0, n - 5) }, (_, i) => i + 6)); + } +}); + +test('counts closed and draft PRs by stable user ID, excludes other authors and out-of-window rows', () => { + const rows = [...pulls(5), pull(6, { state: 'closed', draft: true }), pull(7, { user: { id: 99 } }), + pull(8, { created_at: new Date(start - 21 * 60000).toISOString() })]; + assert.equal(evaluate(rows, pull(6), policy, start + 6000).count, 6); +}); + +test('delayed events retain their creation window and deterministic number tie-break', () => { + const rows = pulls(11).map(p => ({ ...p, created_at: new Date(start).toISOString() })); + assert.equal(evaluate(rows.reverse(), rows[0], policy, start).block, true); + assert.deepEqual(evaluate(rows, rows[0], policy, start).excess.map(p => p.number), [6, 7, 8, 9, 10, 11]); +}); + +test('moderation closes and locks excess, then deduplicates maintainer alert', async () => { + const f = fixture(6); + await moderate(f.args); + await moderate(f.args); + assert.equal(f.calls.filter(c => c.method === 'POST' && c.route.endsWith('/issues')).length, 1); + assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/6') && c.body.state === 'closed')); + assert(f.calls.some(c => c.method === 'PUT' && c.route.endsWith('/issues/6/lock'))); +}); + +test('no owner credential reports blocking unavailable and still notifies', async () => { + const f = fixture(11); + const result = await moderate(f.args); + assert.match(result.blockStatus, /not configured/); + assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('not configured'))); +}); + +test('owner token identity checked before personal block, escalation notifies exactly once', async () => { + const ownerCalls = []; + const blockAPI = async (method, route) => { + ownerCalls.push({ method, route }); + if (route === '/user') return { login: 'owner' }; + return {}; + }; + const f = fixture(6, { blockAPI }); + await moderate(f.args); + f.args.number = 11; + // Use a second snapshot while retaining the alert ledger. + const original = f.args.api; + f.args.api = (method, route, body) => route.endsWith('/pulls/11') ? pull(11) + : route.includes('/pulls?') ? pulls(11).reverse() : original(method, route, body); + await moderate(f.args); + await moderate(f.args); + assert(ownerCalls.some(c => c.method === 'PUT' && c.route === '/user/blocks/contributor')); + assert.equal(f.calls.filter(c => c.route.endsWith('/comments')).length, 1); +}); + +test('wrong personal identity never blocks and records the failure', async () => { + const f = fixture(11, { blockAPI: async (method, route) => { + assert.equal(route, '/user'); return { login: 'someone-else' }; + } }); + assert.match((await moderate(f.args)).blockStatus, /owner identity/); +}); + +test('write/admin collaborators bypass; actor identity cannot exempt an outsider', async () => { + for (const permission of ['write', 'maintain', 'admin']) { + const f = fixture(11, { permission }); + assert.equal((await moderate(f.args)).exempt, true); + assert.equal(f.calls.filter(c => ['POST', 'PATCH', 'PUT'].includes(c.method)).length, 0); + } + const f = fixture(6, { actor: 'owner' }); + assert.equal((await moderate(f.args)).count, 6); +}); + +test('denylisted user is closed on reopen even outside the creation window', async () => { + const f = fixture(1, { action: 'reopened', rows: [pull(1, { user: { id: 999, login: 'denied' } })] }); + assert.equal((await moderate(f.args)).denied, true); + assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/1'))); +}); + +test('dry run performs no mutations or owner-token calls', async () => { + const f = fixture(11, { dryRun: true, blockAPI: async () => { throw new Error('secret used'); } }); + assert.equal((await moderate(f.args)).block, true); + assert(f.calls.every(c => c.method === 'GET')); +}); + +test('REST transport rejects off-host paths and never retries mutations', async () => { + const api = githubAPI('test-token', async () => { throw new Error('must not fetch'); }); + await assert.rejects(api('GET', '//attacker.test'), /path/); + let attempts = 0; + const limited = githubAPI('token', async () => { + attempts++; return { ok: false, status: 403 }; + }); + await assert.rejects(limited('PUT', '/user/blocks/example'), /403/); + assert.equal(attempts, 1); +}); + +test('out-of-order older event sees newer burst and retains historical peak', async () => { + const rows = [...pulls(11), pull(12, { created_at: new Date(start + 30 * 60000).toISOString() })]; + assert.equal(strongestWindow(rows, pull(6), policy, start + 6000).count, 11); + const f = fixture(6, { rows }); + assert.equal((await moderate(f.args)).block, true); +}); + +test('separate bursts close excess from each window even if pending events disappear', () => { + const rows = [...pulls(6), ...pulls(6).map(p => ({ ...p, number: p.number + 6, + created_at: new Date(start + 60 * 60000 + p.number * 1000).toISOString() }))]; + const result = strongestWindow(rows, pull(6), policy, start + 6000); + assert.equal(result.count, 6); + assert.deepEqual(result.excess.map(p => p.number), [6, 12]); +}); + +test('pagination includes page two and refuses silent truncation', async () => { + let calls = 0; + const rows = await listPages(async () => { calls++; return calls === 1 ? pulls(100) : pulls(1); }, '/repos/o/r/pulls?state=all'); + assert.equal(rows.length, 101); + assert.equal(calls, 2); + await assert.rejects(listPages(async () => pulls(100), '/repos/o/r/pulls?state=all'), /Pagination limit/); +}); + +test('permission errors fail safely; outsider404 permits moderation', async () => { + for (const status of [403, 404]) { + const f = fixture(6); + const original = f.args.api; + f.args.api = (method, route, body) => { + if (route.includes('/collaborators/')) throw Object.assign(new Error('permission lookup failed'), { status }); + return original(method, route, body); + }; + if (status === 403) await assert.rejects(moderate(f.args), /permission lookup/); + else assert.equal((await moderate(f.args)).count, 6); + } +}); + +test('closure failure still creates an alert with enforcement errors', async () => { + const f = fixture(6); + const original = f.args.api; + f.args.api = (method, route, body) => { + if (method === 'PATCH' && route.endsWith('/pulls/6')) throw new Error('close denied'); + return original(method, route, body); + }; + assert.match((await moderate(f.args)).errors[0], /close denied/); + assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('close denied'))); +}); + +test('reports only moderation metadata, never PR bodies or titles', async () => { + const f = fixture(6, { rows: pulls(6).map(p => ({ ...p, title: 'untrusted title', body: 'untrusted body' })) }); + assert.doesNotMatch(JSON.stringify(await moderate(f.args)), /untrusted/); +}); From 0cf3a25d57a64aca27d00495b0ff7891c545613f Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:16:13 -0400 Subject: [PATCH 02/23] docs(ci): keep PR moderation limited to submission rate --- .github/PR_MODERATION.md | 42 ++++++++++++---------------------------- CONTRIBUTING.md | 3 +-- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/.github/PR_MODERATION.md b/.github/PR_MODERATION.md index c87f7cde8..658327570 100644 --- a/.github/PR_MODERATION.md +++ b/.github/PR_MODERATION.md @@ -8,7 +8,6 @@ judge whether a patch is useful. Existing open PRs are not bulk-closed on rollou | Control | Default | Enforcement | | --- | --- | --- | -| Native concurrent cap | Five open PRs, including drafts | Admin must enable GitHub setting | | Burst allowance | Five creations per author in 20 minutes | Close and lock excess PRs | | Severe burst | More than ten creations in 20 minutes | Attempt account block; alert maintainers | | Known denied account | User ID `108231307`, currently `dajiaohuang` | Close and lock its incoming/reopened PR | @@ -16,8 +15,8 @@ judge whether a patch is useful. Existing open PRs are not bulk-closed on rollou | Notifications | `@affaan-m @haelyra` | One bot-authored issue per author per UTC day, plus one escalation comment | Edit `pr-burst-policy.json` to change thresholds, notification recipients or -denied numeric IDs. A native cap bypass list does not bypass the custom burst -guard. Granting write access is not required for native cap exceptions. +denied numeric IDs. This policy limits submission rate, with no limit on the +total number of open PRs a contributor can accumulate over time. Closed and draft PRs count toward burst detection. Counting uses stable author IDs and paginated REST creation metadata, without the search index. A run checks @@ -30,31 +29,18 @@ but are not mutated again. Reopening an old ordinary PR does not add a new creation to the burst count. Reopening a denied author's PR triggers denial even outside the time window. -The native concurrent cap supplies the pre-creation control. Actions moderation -happens after creation and cannot prevent every CI job from starting. Account +Actions moderation happens after creation and cannot prevent every CI job from starting. Account blocks cannot stop someone from using a different GitHub account. ## Activate 1. Merge the reviewed workflow and script into `main`. -2. As a repository administrator, enable the native cap under **Settings > - Moderation options > Interaction limits > Pull request limits**. Set five - concurrent PRs and include drafts. Alternatively, authenticate `gh` as an - administrator and run: - - ```sh - gh api --method PATCH repos/affaan-m/ECC/interaction-limits/pulls/creation-cap \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - -F enabled=true -F max_open_pull_requests=5 -F include_drafts=true - gh api repos/affaan-m/ECC/interaction-limits/pulls/creation-cap - ``` - -3. To enable automatic account blocking, `affaan-m` creates a dedicated +2. To enable automatic account blocking, `affaan-m` creates a dedicated fine-grained token with the account permission **Block another user: write**. Store it as repository Actions secret `PR_MODERATION_BLOCK_TOKEN`. Use GitHub's secret UI or `gh secret set PR_MODERATION_BLOCK_TOKEN --repo affaan-m/ECC` with its hidden prompt. Do not paste credentials into chat or source files. -4. Verify a genuine subsequent guard run and its job summary. Without the secret, +3. Verify a genuine subsequent guard run and its job summary. Without the secret, closure and alerting still run. A severe burst records blocking as unavailable in the alert and fails the job so maintainers see the missing protection. @@ -65,10 +51,8 @@ collaborator's personal block cannot protect this repository. The normal `GITHUB_TOKEN` cannot perform account blocking. Organization blocking requires a separate implementation if repository ownership changes. -GitHub's native cap counts concurrent open PRs. The custom guard counts creations -over time. Closing PRs can free native cap slots, but cannot reset burst counts. -A native cap can therefore stop a contributor before they reach the severe -threshold; the custom guard still catches rapid closing and resubmission. +Closing PRs cannot reset burst counts. Rapid closing and resubmission still +count as creations within the same rolling window. ## Verify without moderating anyone @@ -109,14 +93,14 @@ verification; the local fixture cannot certify those platform operations. A contributor-created lookalike cannot suppress alerts. Escalation remains recorded if a later run has a lower count. - To roll back, disable **PR Burst Guard** in Actions. Remove its secret if - abandoning automatic blocking. Disable the native cap separately in Settings. + abandoning automatic blocking. - To restore a contributor, the owner removes the account block. Remove any denied user ID from the policy, then unlock and reopen selected PRs manually. ## October 15 investigation Authenticated GitHub GraphQL returned zero `CLOSED_EVENT` or `REOPENED_EVENT` -entries across all 55 currently open `dajiaohuang` PRs. Their latest update was +entries across all 55 `dajiaohuang` PRs that were open at the investigation's start. Their latest update was October 8. The public timeline therefore supplies no evidence that they reopened on October 15. Restored visibility after GitHub spam classification is a possible explanation, but GitHub's private moderation history was unavailable. A block on @@ -125,16 +109,14 @@ owner block. The owner's actual block state was not available to this session. The visible newer burst was 14 PRs from `jwcastillo`, #3567 through #3580, between October 14 at 19:01:37 and 19:07:14 UTC. Volume alone does not establish the quality -of those patches. No historical PR was closed or account blocked during this -investigation. The native cap API returned `403: Must have admin rights to -Repository` for the authenticated collaborator `haelyra`. +of those patches. The maintainer subsequently authorized closing all 55 open +`dajiaohuang` PRs. All closures were verified, and a fresh paginated REST scan +found zero remaining open PRs from numeric user ID `108231307`. This advances M0 maintenance reliability. Runtime sandbox, Capsule, Fleet, release publication and shared application installation remain separate work. ## GitHub references -- [Native repository PR caps and bypass lists](https://docs.github.com/en/communities/moderating-comments-and-conversations/limiting-interactions-in-your-repository) -- [Repository cap REST endpoints](https://docs.github.com/en/rest/interactions/repos) - [Personal account blocking REST endpoints](https://docs.github.com/en/rest/users/blocking) - [Privileged pull request event security](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71c844f8f..bd8e3d96b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -441,8 +441,7 @@ closes and locks excess PRs, including drafts. More than ten submissions within still count toward the window. Contributors with repository write access bypass this policy. Maintainers can review exceptions through the moderation alert. -The repository also supports GitHub's native concurrent open-PR cap, configured -separately by an administrator. See the [moderation runbook](.github/PR_MODERATION.md) +See the [moderation runbook](.github/PR_MODERATION.md) for activation, credential requirements, and recovery. ### 1. PR Title Format From 6c557856af89a657f846a8ca15907cbdc1e41530 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 19 Oct 2026 18:08:53 -0400 Subject: [PATCH 03/23] feat(ci): cap open PRs per contributor at ten (#3639) Extend the PR burst guard with an open-PR cap. On every opened or reopened PR by a non-collaborator, the guard lists the repository's open PRs, keeps the author's oldest `maxOpen` (10) open and closes the newer ones with a bot comment that names the count and the limit. No lock, no maintainer alert: the cap is about review capacity, not spam. PRs the burst logic already closed in the same run are excluded so nothing is mutated twice. Collaborators with write access stay exempt, dry run reports without mutating, and the comment is deduplicated on a bot-authored marker. Haley asked for this on 2026-10-19: each contributor should only have 10 to 15 PRs open at a time. Existing open PRs are not bulk-closed; the cap applies as each author opens or reopens their next PR. Verification: 22 unit tests and the HTTP workflow test pass, validate-workflow-security passes, eslint and markdownlint clean. Dry run against affaan-m/ECC on #3621 reports openCount 19 and nine cap excess PRs for that author, with no writes. --- .github/PR_MODERATION.md | 13 ++++- .github/pr-burst-policy.json | 1 + CONTRIBUTING.md | 6 +++ scripts/ci/pr-burst-guard.js | 42 +++++++++++++-- tests/ci/pr-burst-guard-workflow.test.js | 1 + tests/ci/pr-burst-guard.test.js | 69 ++++++++++++++++++++++-- 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/.github/PR_MODERATION.md b/.github/PR_MODERATION.md index 658327570..27b13f5cc 100644 --- a/.github/PR_MODERATION.md +++ b/.github/PR_MODERATION.md @@ -10,13 +10,22 @@ judge whether a patch is useful. Existing open PRs are not bulk-closed on rollou | --- | --- | --- | | Burst allowance | Five creations per author in 20 minutes | Close and lock excess PRs | | Severe burst | More than ten creations in 20 minutes | Attempt account block; alert maintainers | +| Open-PR cap | Ten open PRs per author at any time | Close the newest PRs above the cap with an explanatory comment; no lock, no alert | | Known denied account | User ID `108231307`, currently `dajiaohuang` | Close and lock its incoming/reopened PR | | Trusted contributors | Live repository write, maintain, or admin permission | Exempt from the burst guard | | Notifications | `@affaan-m @haelyra` | One bot-authored issue per author per UTC day, plus one escalation comment | Edit `pr-burst-policy.json` to change thresholds, notification recipients or -denied numeric IDs. This policy limits submission rate, with no limit on the -total number of open PRs a contributor can accumulate over time. +denied numeric IDs, or `maxOpen` to change the open-PR cap. + +The open-PR cap is evaluated on the same opened and reopened events, after any +burst closures in that run, against a full paginated list of the repository's +open PRs. The author's oldest `maxOpen` PRs stay open; newer ones are closed with +a bot comment that names the count and the limit. Closing is reversible: once +the author is under the cap, reopening a closed PR passes. A PR the burst guard +already closed and locked in the same run is never touched again by the cap. The +cap does not bulk-close PRs that were already open when it shipped; it applies +as each author opens or reopens their next PR. Closed and draft PRs count toward burst detection. Counting uses stable author IDs and paginated REST creation metadata, without the search index. A run checks diff --git a/.github/pr-burst-policy.json b/.github/pr-burst-policy.json index ce7d8927a..9988e1566 100644 --- a/.github/pr-burst-policy.json +++ b/.github/pr-burst-policy.json @@ -2,6 +2,7 @@ "windowMinutes": 20, "closeAbove": 5, "blockAbove": 10, + "maxOpen": 10, "deniedUserIDs": [108231307], "notify": ["affaan-m", "haelyra"] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd8e3d96b..8fe695388 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -441,6 +441,12 @@ closes and locks excess PRs, including drafts. More than ten submissions within still count toward the window. Contributors with repository write access bypass this policy. Maintainers can review exceptions through the moderation alert. +Please also keep at most ten PRs open at a time. When you open or reopen a PR +above that cap, automation closes the newest ones with a comment explaining the +count. Get existing PRs merged or close the ones you no longer need, then reopen. +Ten open PRs is more than any one reviewer can keep in flight, so this keeps +review time on the work you care about most. + See the [moderation runbook](.github/PR_MODERATION.md) for activation, credential requirements, and recovery. diff --git a/scripts/ci/pr-burst-guard.js b/scripts/ci/pr-burst-guard.js index 2329561c7..d7f3f8653 100644 --- a/scripts/ci/pr-burst-guard.js +++ b/scripts/ci/pr-burst-guard.js @@ -6,7 +6,7 @@ const path = require('node:path'); const LOGIN = /^[a-z\d](?:[a-z\d-]{0,38})(?:\[bot\])?$/i; function validatePolicy(policy) { - for (const key of ['windowMinutes', 'closeAbove', 'blockAbove']) { + for (const key of ['windowMinutes', 'closeAbove', 'blockAbove', 'maxOpen']) { if (!Number.isSafeInteger(policy[key]) || policy[key] < 1) throw new Error(`Invalid policy ${key}`); } if (policy.blockAbove <= policy.closeAbove) throw new Error('blockAbove must exceed closeAbove'); @@ -86,6 +86,33 @@ function strongestWindow(rows, current, policy, eventTime) { return { ...peak, excess: peak.denied ? peak.excess : [...excess.values()] }; } +/** Open-PR cap: keep the author's oldest `maxOpen` open PRs, close the rest. PRs the burst + * guard already closed in this run are treated as closed so nothing is mutated twice. */ +function capExcess(openRows, current, policy, alreadyClosed = new Set()) { + validatePolicy(policy); + const open = [...new Map([...openRows, current].map(pr => [pr.number, pr])).values()] + .filter(pr => pr.user.id === current.user.id && pr.state === 'open' && !alreadyClosed.has(pr.number)) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number); + return { openCount: open.length, maxOpen: policy.maxOpen, + capExcess: open.slice(policy.maxOpen).map(pr => ({ number: pr.number, state: pr.state })) }; +} + +const CAP_MARKER = ''; + +async function enforceCap(api, root, pr, current, result) { + try { + await api('PATCH', `${root}/pulls/${pr.number}`, { state: 'closed' }); + const comments = await listPages(api, `${root}/issues/${pr.number}/comments?sort=created`); + if (comments.some(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(CAP_MARKER))) return; + await api('POST', `${root}/issues/${pr.number}/comments`, { body: [CAP_MARKER, + `Closed automatically: @${current.user.login} has ${result.openCount} open pull requests in this repository, above the limit of ${result.maxOpen} per contributor.`, + '', 'Get existing PRs merged, or close the ones you no longer need, then reopen this one. The limit is about review capacity, not the quality of this patch.', + 'Contributors with repository write access are exempt. Policy: `.github/PR_MODERATION.md`.'].join('\n') }); + } catch (error) { + result.errors.push(`#${pr.number}: ${error.message}`); + } +} + async function blockAuthor(blockAPI, owner, login) { if (!blockAPI) return 'Unavailable: PR_MODERATION_BLOCK_TOKEN is not configured'; try { @@ -160,11 +187,19 @@ async function moderate({ api, blockAPI, repository, number, action = 'opened', // let an older event conceal a newer burst already present in the API snapshot. const result = { ...strongestWindow(rows, current, policy, end), author: current.user.login, dryRun, errors: [], blockStatus: 'Below blocking threshold' }; + // Total open PRs is a separate, unwindowed list: the burst scan stops at the window edge. + const openRows = await listPages(api, `${root}/pulls?state=open&sort=created&direction=desc`); + Object.assign(result, capExcess(openRows, current, policy, new Set(result.excess.map(pr => pr.number)))); if (dryRun) { if (result.block) result.blockStatus = 'Dry run: would attempt owner-scoped blocking'; return result; } - if (result.excess.length === 0) return result; + if (result.excess.length > 0) await enforceBurst({ api, blockAPI, root, repo, current, result, policy }); + for (const pr of result.capExcess) await enforceCap(api, root, pr, current, result); + return result; +} + +async function enforceBurst({ api, blockAPI, root, repo, current, result, policy }) { if (result.block) result.blockStatus = await blockAuthor(blockAPI, repo.owner, current.user.login); for (const pr of result.excess) { if (pr.state !== 'open') continue; @@ -176,7 +211,6 @@ async function moderate({ api, blockAPI, repository, number, action = 'opened', } } await notify(api, root, current, result, policy); - return result; } async function main() { @@ -196,4 +230,4 @@ async function main() { } if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); -module.exports = { evaluate, moderate, githubAPI, listPages, validatePolicy, strongestWindow }; +module.exports = { evaluate, moderate, githubAPI, listPages, validatePolicy, strongestWindow, capExcess }; diff --git a/tests/ci/pr-burst-guard-workflow.test.js b/tests/ci/pr-burst-guard-workflow.test.js index 52592b08e..0ebe1f82b 100644 --- a/tests/ci/pr-burst-guard-workflow.test.js +++ b/tests/ci/pr-burst-guard-workflow.test.js @@ -40,6 +40,7 @@ test('actual CLI closes six excess PRs, locks them, blocks with owner identity, return send(issue); } if (url.pathname.endsWith('/issues/88') && req.method === 'PATCH') { issue = { ...issue, ...body }; return send(issue); } + if (/\/issues\/\d+\/comments$/.test(url.pathname)) return send(req.method === 'GET' ? [] : {}); if (req.method === 'PATCH' && /\/pulls\/\d+$/.test(url.pathname)) { const pr = rows.find(row => row.number === Number(url.pathname.split('/').pop())); pr.state = body.state; return send(pr); diff --git a/tests/ci/pr-burst-guard.test.js b/tests/ci/pr-burst-guard.test.js index 4a0bd3c83..36902dc4e 100644 --- a/tests/ci/pr-burst-guard.test.js +++ b/tests/ci/pr-burst-guard.test.js @@ -1,9 +1,9 @@ #!/usr/bin/env node const assert = require('node:assert/strict'); const { test } = require('node:test'); -const { evaluate, moderate, githubAPI, listPages, strongestWindow } = require('../../scripts/ci/pr-burst-guard'); +const { evaluate, moderate, githubAPI, listPages, strongestWindow, capExcess } = require('../../scripts/ci/pr-burst-guard'); -const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, deniedUserIDs: [999], notify: ['maintainer'] }; +const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, maxOpen: 10, deniedUserIDs: [999], notify: ['maintainer'] }; const start = Date.parse('2026-10-14T19:00:00Z'); const pull = (number, extra = {}) => ({ number, user: { id: 42, login: 'contributor' }, created_at: new Date(start + number * 1000).toISOString(), state: 'open', ...extra }); @@ -18,14 +18,15 @@ function fixture(n, options = {}) { if (route === '/repos/owner/repo') return { owner: { login: 'owner', type: 'User' } }; if (route.endsWith(`/pulls/${n}`)) return rows.find(p => p.number === n); if (route.includes('/collaborators/')) return { permission: options.permission || 'read' }; - if (route.includes('/pulls?')) return rows.slice().reverse(); + if (route.includes('/pulls?')) return rows.filter(p => !route.includes('state=open') || p.state === 'open').slice().reverse(); if (route.includes('/issues?')) return issue ? [issue] : []; + if (/\/issues\/\d+\/comments\?/.test(route) && method === 'GET') return options.comments || []; if (route.endsWith('/issues') && method === 'POST') { issue = { number: 101, user: { login: 'github-actions[bot]' }, ...body }; return issue; } if (route.endsWith('/issues/101') && method === 'PATCH') { issue = { ...issue, ...body }; return issue; } - if (route.endsWith('/issues/101/comments')) return {}; + if (route.endsWith('/comments') && method === 'POST') return {}; if (method === 'PATCH') return {}; if (method === 'PUT') return {}; throw new Error(`Unexpected ${method} ${route}`); @@ -178,6 +179,66 @@ test('closure failure still creates an alert with enforcement errors', async () assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('close denied'))); }); +// Spread PRs one hour apart so the burst window never fires; only the open-PR cap applies. +const spread = (n, extra = {}) => Array.from({ length: n }, (_, i) => pull(i + 1, + { created_at: new Date(start + i * 3600000).toISOString(), ...extra })); + +test('open-PR cap keeps the oldest ten open and closes the rest, newest first', () => { + const result = capExcess(spread(12), spread(12)[11], policy); + assert.equal(result.openCount, 12); + assert.deepEqual(result.capExcess.map(p => p.number), [11, 12]); + const closed = spread(12).map((p, i) => i < 3 ? { ...p, state: 'closed' } : p); + assert.equal(capExcess(closed, closed[11], policy).openCount, 9); + assert.equal(capExcess(spread(12), spread(12)[11], policy, new Set([11, 12])).capExcess.length, 0); + assert.equal(capExcess(spread(12, { user: { id: 7, login: 'other' } }), pull(1), policy).openCount, 1); +}); + +test('cap closes with an explanatory comment, no lock, no maintainer alert, and never comments twice', async () => { + const rows = spread(11); + const f = fixture(11, { rows }); + const result = await moderate(f.args); + assert.equal(result.count, 1); + assert.deepEqual(result.capExcess.map(p => p.number), [11]); + assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/11') && c.body.state === 'closed')); + const comment = f.calls.find(c => c.method === 'POST' && c.route.endsWith('/issues/11/comments')); + assert.match(comment.body.body, /11 open pull requests.*limit of 10/); + assert.equal(f.calls.filter(c => c.route.endsWith('/lock')).length, 0); + assert.equal(f.calls.filter(c => c.method === 'POST' && c.route.endsWith('/issues')).length, 0); + const again = fixture(11, { rows, comments: [{ user: { login: 'github-actions[bot]' }, body: comment.body.body }] }); + await moderate(again.args); + assert.equal(again.calls.filter(c => c.method === 'POST').length, 0); + // A contributor-authored lookalike comment cannot suppress the explanation. + const spoofed = fixture(11, { rows, comments: [{ user: { login: 'contributor' }, body: comment.body.body }] }); + await moderate(spoofed.args); + assert.equal(spoofed.calls.filter(c => c.method === 'POST').length, 1); +}); + +test('cap counts open PRs after burst closures, so one run never closes a PR twice', async () => { + const rows = [...spread(9), ...pulls(6).map(p => ({ ...p, number: p.number + 9, + created_at: new Date(start + 24 * 3600000 + p.number * 1000).toISOString() }))]; + const f = fixture(15, { rows }); + const result = await moderate(f.args); + assert.equal(result.count, 6); + assert.deepEqual(result.excess.map(p => p.number), [15]); + assert.equal(result.openCount, 14); + assert.deepEqual(result.capExcess.map(p => p.number), [11, 12, 13, 14]); + assert.equal(f.calls.filter(c => c.method === 'PATCH' && c.route.endsWith('/pulls/15')).length, 1); +}); + +test('cap dry run reports the excess without mutating; collaborators are exempt from the cap', async () => { + const f = fixture(12, { rows: spread(12), dryRun: true }); + assert.deepEqual((await moderate(f.args)).capExcess.map(p => p.number), [11, 12]); + assert(f.calls.every(c => c.method === 'GET')); + const w = fixture(12, { rows: spread(12), permission: 'write' }); + assert.equal((await moderate(w.args)).exempt, true); + assert.equal(w.calls.filter(c => c.method !== 'GET').length, 0); +}); + +test('policy requires a positive integer maxOpen', () => { + assert.throws(() => capExcess([], pull(1), { ...policy, maxOpen: 0 }), /maxOpen/); + assert.throws(() => capExcess([], pull(1), { ...policy, maxOpen: undefined }), /maxOpen/); +}); + test('reports only moderation metadata, never PR bodies or titles', async () => { const f = fixture(6, { rows: pulls(6).map(p => ({ ...p, title: 'untrusted title', body: 'untrusted body' })) }); assert.doesNotMatch(JSON.stringify(await moderate(f.args)), /untrusted/); From 8293d0da8fa910b96096e52ee5a18d91fcee5e13 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 19 Oct 2026 18:41:07 -0400 Subject: [PATCH 04/23] fix(commands): drop unrendered LITE_MODE_FLAG token from codeagent-wrapper calls (#3640) The multi-backend, multi-frontend, multi-plan, multi-execute and multi-workflow commands were copied from ccg-workflow's command templates, which carry {{LITE_MODE_FLAG}} and {{GEMINI_MODEL_FLAG}} for that project's installer to substitute. ECC has no substitution step, so bash passed the literal token to codeagent-wrapper as a positional argument, the backend name landed in the working-directory slot, and every call exited 1. Remove the tokens from the five commands and the ja-JP, tr and zh-CN docs copies so the wrapper receives a plain --backend invocation. A regression test scans every multi-* command and translated copy for codeagent-wrapper lines that still contain a {{TEMPLATE}} token. Fixes #3595 --- commands/multi-backend.md | 4 +- commands/multi-execute.md | 6 +- commands/multi-frontend.md | 4 +- commands/multi-plan.md | 2 +- commands/multi-workflow.md | 4 +- docs/ja-JP/commands/multi-backend.md | 4 +- docs/ja-JP/commands/multi-execute.md | 8 +- docs/ja-JP/commands/multi-frontend.md | 4 +- docs/ja-JP/commands/multi-plan.md | 4 +- docs/ja-JP/commands/multi-workflow.md | 6 +- docs/tr/commands/multi-backend.md | 4 +- docs/tr/commands/multi-execute.md | 8 +- docs/tr/commands/multi-frontend.md | 4 +- docs/tr/commands/multi-plan.md | 4 +- docs/tr/commands/multi-workflow.md | 6 +- docs/zh-CN/commands/multi-backend.md | 4 +- docs/zh-CN/commands/multi-execute.md | 8 +- docs/zh-CN/commands/multi-frontend.md | 4 +- docs/zh-CN/commands/multi-plan.md | 4 +- docs/zh-CN/commands/multi-workflow.md | 6 +- .../multi-model-wrapper-tokens.test.js | 95 +++++++++++++++++++ 21 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 tests/commands/multi-model-wrapper-tokens.test.js diff --git a/commands/multi-backend.md b/commands/multi-backend.md index 95cc95d83..1faa1c883 100644 --- a/commands/multi-backend.md +++ b/commands/multi-backend.md @@ -38,7 +38,7 @@ You are the **Backend Orchestrator**, coordinating multi-model collaboration for ``` # New session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -53,7 +53,7 @@ EOF", # Resume session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/commands/multi-execute.md b/commands/multi-execute.md index 2c0ac1c45..c70349d2d 100644 --- a/commands/multi-execute.md +++ b/commands/multi-execute.md @@ -29,7 +29,7 @@ $ARGUMENTS ``` # Resume session call (recommended) - Implementation Prototype Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -44,7 +44,7 @@ EOF", # New session call - Implementation Prototype Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -62,7 +62,7 @@ EOF", ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Scope: Audit the final code changes. diff --git a/commands/multi-frontend.md b/commands/multi-frontend.md index 939dc5afe..5807823bf 100644 --- a/commands/multi-frontend.md +++ b/commands/multi-frontend.md @@ -38,7 +38,7 @@ You are the **Frontend Orchestrator**, coordinating multi-model collaboration fo ``` # New session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend antigravity - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend antigravity - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -53,7 +53,7 @@ EOF", # Resume session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend antigravity resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend antigravity resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/commands/multi-plan.md b/commands/multi-plan.md index 3b4920761..ac9e5c259 100644 --- a/commands/multi-plan.md +++ b/commands/multi-plan.md @@ -28,7 +28,7 @@ $ARGUMENTS ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/commands/multi-workflow.md b/commands/multi-workflow.md index 5aad6cb4f..b5595e943 100644 --- a/commands/multi-workflow.md +++ b/commands/multi-workflow.md @@ -42,7 +42,7 @@ You are the **Orchestrator**, coordinating a multi-model collaborative system (R ``` # New session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -57,7 +57,7 @@ EOF", # Resume session call Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/docs/ja-JP/commands/multi-backend.md b/docs/ja-JP/commands/multi-backend.md index 17ce771fc..99787725e 100644 --- a/docs/ja-JP/commands/multi-backend.md +++ b/docs/ja-JP/commands/multi-backend.md @@ -34,7 +34,7 @@ ``` # 新規セッション呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> @@ -49,7 +49,7 @@ EOF", # セッション再開呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> diff --git a/docs/ja-JP/commands/multi-execute.md b/docs/ja-JP/commands/multi-execute.md index 393b8f1b3..d45ca8228 100644 --- a/docs/ja-JP/commands/multi-execute.md +++ b/docs/ja-JP/commands/multi-execute.md @@ -25,7 +25,7 @@ $ARGUMENTS ``` # セッション再開呼び出し(推奨) - 実装プロトタイプ Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <タスクの説明> @@ -40,7 +40,7 @@ EOF", # 新規セッション呼び出し - 実装プロトタイプ Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <タスクの説明> @@ -58,7 +58,7 @@ EOF", ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Scope: 最終的なコード変更を監査。 @@ -80,7 +80,7 @@ EOF", ``` **モデルパラメータの注意事項**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 +- ``: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 **ロールプロンプト**: diff --git a/docs/ja-JP/commands/multi-frontend.md b/docs/ja-JP/commands/multi-frontend.md index 3d2360b5c..9dbdff02c 100644 --- a/docs/ja-JP/commands/multi-frontend.md +++ b/docs/ja-JP/commands/multi-frontend.md @@ -34,7 +34,7 @@ ``` # 新規セッション呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> @@ -49,7 +49,7 @@ EOF", # セッション再開呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> diff --git a/docs/ja-JP/commands/multi-plan.md b/docs/ja-JP/commands/multi-plan.md index 433595745..5bd2095c0 100644 --- a/docs/ja-JP/commands/multi-plan.md +++ b/docs/ja-JP/commands/multi-plan.md @@ -24,7 +24,7 @@ $ARGUMENTS ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件> @@ -39,7 +39,7 @@ EOF", ``` **モデルパラメータの注意事項**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 +- ``: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 **ロールプロンプト**: diff --git a/docs/ja-JP/commands/multi-workflow.md b/docs/ja-JP/commands/multi-workflow.md index d2eabf098..8d40355e9 100644 --- a/docs/ja-JP/commands/multi-workflow.md +++ b/docs/ja-JP/commands/multi-workflow.md @@ -38,7 +38,7 @@ ``` # 新規セッション呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> @@ -53,7 +53,7 @@ EOF", # セッション再開呼び出し Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: <ロールプロンプトパス> Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)> @@ -68,7 +68,7 @@ EOF", ``` **モデルパラメータの注意事項**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 +- ``: `--backend gemini`を使用する場合、`--gemini-model gemini-3-pro-preview`で置き換える(末尾のスペースに注意); codexの場合は空文字列を使用 **ロールプロンプト**: diff --git a/docs/tr/commands/multi-backend.md b/docs/tr/commands/multi-backend.md index e551576e0..2f101fc55 100644 --- a/docs/tr/commands/multi-backend.md +++ b/docs/tr/commands/multi-backend.md @@ -34,7 +34,7 @@ Backend odaklı iş akışı (Research → Ideation → Plan → Execute → Opt ``` # Yeni session çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -49,7 +49,7 @@ EOF", # Session devam ettirme çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/docs/tr/commands/multi-execute.md b/docs/tr/commands/multi-execute.md index a9490efb7..822bad2ab 100644 --- a/docs/tr/commands/multi-execute.md +++ b/docs/tr/commands/multi-execute.md @@ -25,7 +25,7 @@ $ARGUMENTS ``` # Session devam ettirme çağrısı (önerilen) - Implementation Prototype Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -40,7 +40,7 @@ EOF", # Yeni session çağrısı - Implementation Prototype Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -58,7 +58,7 @@ EOF", ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Scope: Audit the final code changes. @@ -80,7 +80,7 @@ EOF", ``` **Model Parametre Notları**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan +- ``: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan **Role Prompts**: diff --git a/docs/tr/commands/multi-frontend.md b/docs/tr/commands/multi-frontend.md index babf3cf51..5d873eaa1 100644 --- a/docs/tr/commands/multi-frontend.md +++ b/docs/tr/commands/multi-frontend.md @@ -34,7 +34,7 @@ Frontend odaklı iş akışı (Research → Ideation → Plan → Execute → Op ``` # Yeni session çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -49,7 +49,7 @@ EOF", # Session devam ettirme çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: diff --git a/docs/tr/commands/multi-plan.md b/docs/tr/commands/multi-plan.md index 30e9d1017..b5686a484 100644 --- a/docs/tr/commands/multi-plan.md +++ b/docs/tr/commands/multi-plan.md @@ -24,7 +24,7 @@ $ARGUMENTS ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -39,7 +39,7 @@ EOF", ``` **Model Parametre Notları**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan +- ``: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan **Role Prompts**: diff --git a/docs/tr/commands/multi-workflow.md b/docs/tr/commands/multi-workflow.md index 0e94a2c75..9e7db5796 100644 --- a/docs/tr/commands/multi-workflow.md +++ b/docs/tr/commands/multi-workflow.md @@ -38,7 +38,7 @@ Kalite kontrol noktaları, MCP servisleri ve multi-model işbirliği ile yapıla ``` # Yeni session çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -53,7 +53,7 @@ EOF", # Session devam ettirme çağrısı Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -68,7 +68,7 @@ EOF", ``` **Model Parametre Notları**: -- `{{GEMINI_MODEL_FLAG}}`: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan +- ``: `--backend gemini` kullanırken, `--gemini-model gemini-3-pro-preview` ile değiştir (trailing space not edin); codex için boş string kullan **Role Prompts**: diff --git a/docs/zh-CN/commands/multi-backend.md b/docs/zh-CN/commands/multi-backend.md index 5eb5c6bac..6a3f2955d 100644 --- a/docs/zh-CN/commands/multi-backend.md +++ b/docs/zh-CN/commands/multi-backend.md @@ -35,7 +35,7 @@ ``` # 新会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示路径> 需求: <增强后的需求(若未增强则为 $ARGUMENTS)> @@ -50,7 +50,7 @@ EOF", # 恢复会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend codex resume - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示路径> 需求: <增强后的需求(若未增强则为 $ARGUMENTS)> diff --git a/docs/zh-CN/commands/multi-execute.md b/docs/zh-CN/commands/multi-execute.md index 83d067eb3..9ec056f03 100644 --- a/docs/zh-CN/commands/multi-execute.md +++ b/docs/zh-CN/commands/multi-execute.md @@ -25,7 +25,7 @@ $ARGUMENTS ``` # 恢复会话调用(推荐)- 实现原型 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -40,7 +40,7 @@ EOF", # 新建会话调用 - 实现原型 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -58,7 +58,7 @@ EOF", ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: Scope: 审计最终的代码变更。 @@ -81,7 +81,7 @@ EOF", **模型参数说明**: -* `{{GEMINI_MODEL_FLAG}}`:当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意尾随空格);对于 codex 使用空字符串 +* ``:当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意尾随空格);对于 codex 使用空字符串 **角色提示**: diff --git a/docs/zh-CN/commands/multi-frontend.md b/docs/zh-CN/commands/multi-frontend.md index cc52bf34b..5ce96d19b 100644 --- a/docs/zh-CN/commands/multi-frontend.md +++ b/docs/zh-CN/commands/multi-frontend.md @@ -35,7 +35,7 @@ ``` # 新会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示文件路径> 需求: <增强后的需求(若未增强则为$ARGUMENTS)> @@ -50,7 +50,7 @@ EOF", # 恢复会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示文件路径> 需求: <增强后的需求(若未增强则为$ARGUMENTS)> diff --git a/docs/zh-CN/commands/multi-plan.md b/docs/zh-CN/commands/multi-plan.md index 46e60fb7a..005ef9088 100644 --- a/docs/zh-CN/commands/multi-plan.md +++ b/docs/zh-CN/commands/multi-plan.md @@ -24,7 +24,7 @@ $ARGUMENTS ``` Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: Requirement: @@ -40,7 +40,7 @@ EOF", **模型参数说明**: -* `{{GEMINI_MODEL_FLAG}}`: 当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意尾随空格);对于 codex 使用空字符串 +* ``: 当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意尾随空格);对于 codex 使用空字符串 **角色提示**: diff --git a/docs/zh-CN/commands/multi-workflow.md b/docs/zh-CN/commands/multi-workflow.md index c29e661fc..48a7c9ba3 100644 --- a/docs/zh-CN/commands/multi-workflow.md +++ b/docs/zh-CN/commands/multi-workflow.md @@ -39,7 +39,7 @@ ``` # 新会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示文件路径> 需求: <增强后的需求(如未增强则为$ARGUMENTS)> @@ -54,7 +54,7 @@ EOF", # 恢复会话调用 Bash({ - command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend {{GEMINI_MODEL_FLAG}}resume - \"$PWD\" <<'EOF' + command: "~/.claude/bin/codeagent-wrapper --backend resume - \"$PWD\" <<'EOF' ROLE_FILE: <角色提示文件路径> 需求: <增强后的需求(如未增强则为$ARGUMENTS)> @@ -70,7 +70,7 @@ EOF", **模型参数说明**: -* `{{GEMINI_MODEL_FLAG}}`: 当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意末尾空格);对于 codex 使用空字符串 +* ``: 当使用 `--backend gemini` 时,替换为 `--gemini-model gemini-3-pro-preview`(注意末尾空格);对于 codex 使用空字符串 **角色提示词**: diff --git a/tests/commands/multi-model-wrapper-tokens.test.js b/tests/commands/multi-model-wrapper-tokens.test.js new file mode 100644 index 000000000..f604d758b --- /dev/null +++ b/tests/commands/multi-model-wrapper-tokens.test.js @@ -0,0 +1,95 @@ +'use strict'; + +// Regression test for #3595: the multi-* commands ship codeagent-wrapper +// calls copied from ccg-workflow templates. ccg-workflow's installer +// substitutes {{LITE_MODE_FLAG}} and {{GEMINI_MODEL_FLAG}} at install time; +// ECC has no such step, so an unrendered token reaches bash as a literal +// argument and the wrapper exits 1 on every call. + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const commandsDir = path.join(repoRoot, 'commands'); +const docsDir = path.join(repoRoot, 'docs'); + +const WRAPPER = 'codeagent-wrapper'; +const TEMPLATE_TOKEN = /\{\{[A-Z0-9_]+\}\}/; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + passed++; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +function listMultiCommandFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter(fileName => /^multi-.*\.md$/.test(fileName)) + .map(fileName => path.join(dir, fileName)) + .sort(); +} + +function listTranslatedCommandDirs() { + return fs + .readdirSync(docsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => path.join(docsDir, entry.name, 'commands')) + .filter(dir => fs.existsSync(dir)); +} + +function findWrapperTokenLines(filePath) { + const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/); + const hits = []; + lines.forEach((line, index) => { + if (line.includes(WRAPPER) && TEMPLATE_TOKEN.test(line)) { + hits.push(`${path.relative(repoRoot, filePath)}:${index + 1}`); + } + }); + return hits; +} + +console.log('\n=== Testing multi-* command wrapper invocations ===\n'); + +test('token scanner flags an unrendered ccg-workflow placeholder', () => { + const line = '~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex - "$PWD"'; + assert.ok(TEMPLATE_TOKEN.test(line)); + assert.ok(!TEMPLATE_TOKEN.test('~/.claude/bin/codeagent-wrapper --backend codex - "$PWD"')); +}); + +test('commands/multi-*.md exist and call codeagent-wrapper', () => { + const files = listMultiCommandFiles(commandsDir); + assert.ok(files.length >= 5, `expected at least 5 multi-* commands, found ${files.length}`); + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + assert.ok(content.includes(WRAPPER), `${path.relative(repoRoot, file)} does not call ${WRAPPER}`); + } +}); + +test('commands/multi-*.md pass no {{TEMPLATE}} tokens to codeagent-wrapper', () => { + const hits = listMultiCommandFiles(commandsDir).flatMap(findWrapperTokenLines); + assert.deepStrictEqual(hits, [], `unrendered template tokens at:\n${hits.join('\n')}`); +}); + +test('translated docs/*/commands/multi-*.md pass no {{TEMPLATE}} tokens to codeagent-wrapper', () => { + const hits = listTranslatedCommandDirs().flatMap(listMultiCommandFiles).flatMap(findWrapperTokenLines); + assert.deepStrictEqual(hits, [], `unrendered template tokens at:\n${hits.join('\n')}`); +}); + +if (failed > 0) { + console.log(`\nFailed: ${failed}`); + process.exit(1); +} + +console.log(`\nPassed: ${passed}`); From 5af4395e5c52643b7f5ecc716079cd253504b354 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 19 Oct 2026 18:41:26 -0400 Subject: [PATCH 05/23] test(lib): cover five untested scripts/lib modules (#3641) * test(lib): cover feedback-links routes and payload Pins the three public issue routes to the ECC repository and checks each template name actually exists under .github/ISSUE_TEMPLATE. Also locks the ecc.feedback.v1 payload shape, the no-diagnostics-upload promise, and that every call returns fresh copies so a caller cannot alter the frozen route table. * test(lib): cover cursor-agent-names prefixing and path handling Pins the ecc- prefix rule the Cursor install applies to agent files: idempotent, lowercase-prefix only, empty names pass through. Covers the relative path variant across backslash, repeated, and trailing separators, and that a leading separator cannot re-root the destination under the target agents directory. * test(lib): cover agent-tool-map Pi mapping and authority invariant Pins the Claude to Pi tool table: six core tools, every target a real Pi built-in, and read-only tools never escalating to bash, edit, or write. Covers the unsupported path for MCP, web, unknown, empty, and non-string names, and that prototype member names like constructor are not treated as table hits. * test(lib): cover compute-sponsor URL and passive copy Pins the compute URL to the bare https route with no path, query, or credentials, and that the sponsor copy links it exactly once and nowhere else. Locks the wording that keeps the link passive, keeps the ecc ito find bridge opt-in and non-reserving, and does not claim managed inference is live. The existing surface test only reads docs and CLI output, so the module itself had no direct test. * test(lib): cover invocation-environment precedence and isolation Pins the three branches every install path relies on: an explicit env is copied and wins, a scoped homeDir yields an empty environment so ambient variables cannot leak into a contained install, and anything else snapshots process.env. Also checks the copy is shallow, own-keys only, and never writes back. --- tests/lib/agent-tool-map.test.js | 125 +++++++++++++++++++++++ tests/lib/compute-sponsor.test.js | 77 ++++++++++++++ tests/lib/cursor-agent-names.test.js | 80 +++++++++++++++ tests/lib/feedback-links.test.js | 112 ++++++++++++++++++++ tests/lib/invocation-environment.test.js | 106 +++++++++++++++++++ 5 files changed, 500 insertions(+) create mode 100644 tests/lib/agent-tool-map.test.js create mode 100644 tests/lib/compute-sponsor.test.js create mode 100644 tests/lib/cursor-agent-names.test.js create mode 100644 tests/lib/feedback-links.test.js create mode 100644 tests/lib/invocation-environment.test.js diff --git a/tests/lib/agent-tool-map.test.js b/tests/lib/agent-tool-map.test.js new file mode 100644 index 000000000..50ba31664 --- /dev/null +++ b/tests/lib/agent-tool-map.test.js @@ -0,0 +1,125 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { CLAUDE_TO_PI_TOOLS, CLAUDE_MODEL_TIERS, mapToolToPi } = require('../../scripts/lib/agent-tool-map'); + +// Pi built-ins per @earendil-works/pi-coding-agent dist/core/tools/index.d.ts. +// `powershell` is Windows-only and never emitted; `ls` has no Claude source. +const PI_BUILTINS = new Set(['read', 'bash', 'powershell', 'edit', 'write', 'grep', 'find', 'ls']); +const PI_READ_ONLY = new Set(['read', 'grep', 'find', 'ls']); +const CLAUDE_READ_ONLY = ['Read', 'Grep', 'Glob']; + +test('the table covers exactly the six core Claude tools', () => { + assert.deepEqual(Object.keys(CLAUDE_TO_PI_TOOLS).sort(), ['Bash', 'Edit', 'Glob', 'Grep', 'Read', 'Write']); +}); + +test('every target is a documented Pi built-in and powershell is never emitted', () => { + for (const [claudeTool, piTool] of Object.entries(CLAUDE_TO_PI_TOOLS)) { + assert.ok(PI_BUILTINS.has(piTool), `${claudeTool} -> ${piTool} is not a Pi built-in`); + assert.notEqual(piTool, 'powershell', `${claudeTool} must not map to powershell`); + } +}); + +test('read-only Claude tools never gain shell or write authority in Pi', () => { + for (const claudeTool of CLAUDE_READ_ONLY) { + const mapped = mapToolToPi(claudeTool); + assert.equal(mapped.unsupported, false); + assert.ok(PI_READ_ONLY.has(mapped.tool), `${claudeTool} -> ${mapped.tool} escalates a read-only tool`); + } +}); + +test('mapped tools come back with their Pi name, unsupported false, and no note', () => { + assert.deepEqual(mapToolToPi('Read'), { tool: 'read', unsupported: false }); + assert.deepEqual(mapToolToPi('Grep'), { tool: 'grep', unsupported: false }); + assert.deepEqual(mapToolToPi('Glob'), { tool: 'find', unsupported: false }); + assert.deepEqual(mapToolToPi('Bash'), { tool: 'bash', unsupported: false }); + assert.deepEqual(mapToolToPi('Edit'), { tool: 'edit', unsupported: false }); + assert.deepEqual(mapToolToPi('Write'), { tool: 'write', unsupported: false }); +}); + +test('surrounding whitespace is trimmed before lookup', () => { + assert.deepEqual(mapToolToPi(' Read '), { tool: 'read', unsupported: false }); + assert.deepEqual(mapToolToPi('\tBash\n'), { tool: 'bash', unsupported: false }); +}); + +test('tool names are case sensitive, so a lowercase or shouted name is not guessed', () => { + for (const name of ['read', 'BASH', 'write', 'Glob ']) { + const mapped = mapToolToPi(name); + if (name.trim() === 'Glob') continue; + assert.equal(mapped.unsupported, true, `${JSON.stringify(name)} should be unsupported`); + assert.equal(mapped.tool, null); + } +}); + +test('WebSearch and WebFetch are deliberately unmapped', () => { + for (const name of ['WebSearch', 'WebFetch']) { + assert.deepEqual(mapToolToPi(name), { + tool: null, + unsupported: true, + note: `unmapped tool: ${name} (not a Pi built-in)` + }); + } +}); + +test('MCP tools are unsupported with a note that names the tool and the server step', () => { + const name = 'mcp__context7__query-docs'; + const mapped = mapToolToPi(name); + assert.equal(mapped.tool, null); + assert.equal(mapped.unsupported, true); + assert.ok(mapped.note.includes(name)); + assert.match(mapped.note, /configure the MCP server explicitly/); + assert.doesNotMatch(mapped.note, /^unmapped tool:/); +}); + +test('an mcp__ prefix after whitespace is still recognised as an MCP tool', () => { + assert.match(mapToolToPi(' mcp__x__y ').note, /^MCP tool mcp__x__y /); +}); + +test('unknown tools are reported, not dropped or guessed', () => { + assert.deepEqual(mapToolToPi('Task'), { + tool: null, + unsupported: true, + note: 'unmapped tool: Task (not a Pi built-in)' + }); +}); + +test('prototype property names are not treated as table entries', () => { + for (const name of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'valueOf']) { + const mapped = mapToolToPi(name); + assert.equal(mapped.tool, null, `${name} leaked a prototype member as a tool`); + assert.equal(mapped.unsupported, true); + assert.equal(typeof mapped.note, 'string'); + } +}); + +test('non-string and empty input is coerced and reported rather than thrown', () => { + for (const input of [undefined, null, 42, '', ' ']) { + const mapped = mapToolToPi(input); + assert.equal(mapped.tool, null, `${String(input)} should not map`); + assert.equal(mapped.unsupported, true); + assert.match(mapped.note, /^unmapped tool: /); + } + assert.ok(mapToolToPi(undefined).note.includes('undefined')); + assert.ok(mapToolToPi(42).note.includes('42')); +}); + +test('a result either carries a Pi tool or is unsupported, never both or neither', () => { + const inputs = [...Object.keys(CLAUDE_TO_PI_TOOLS), 'WebFetch', 'mcp__a__b', 'Task', '', 'read']; + for (const input of inputs) { + const mapped = mapToolToPi(input); + assert.equal(mapped.tool !== null, !mapped.unsupported, `${JSON.stringify(input)}`); + assert.equal('note' in mapped, mapped.unsupported, `${JSON.stringify(input)} note presence`); + } +}); + +test('the tables are frozen and the model tiers are the three Claude tiers in order', () => { + assert.ok(Object.isFrozen(CLAUDE_TO_PI_TOOLS)); + assert.ok(Object.isFrozen(CLAUDE_MODEL_TIERS)); + assert.deepEqual(CLAUDE_MODEL_TIERS, ['haiku', 'sonnet', 'opus']); + assert.throws(() => { + CLAUDE_TO_PI_TOOLS.WebFetch = 'bash'; + }, TypeError); + assert.equal(mapToolToPi('WebFetch').unsupported, true); +}); diff --git a/tests/lib/compute-sponsor.test.js b/tests/lib/compute-sponsor.test.js new file mode 100644 index 000000000..22c44a9dd --- /dev/null +++ b/tests/lib/compute-sponsor.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const computeSponsor = require('../../scripts/lib/compute-sponsor'); + +const { ITO_COMPUTE_URL, getComputeSponsorCopy } = computeSponsor; +const URL_TOKEN_PATTERN = /https?:\/\/[^\s<>"'`(){}\\]+/g; + +function urlsIn(text) { + return (text.match(URL_TOKEN_PATTERN) || []).map(token => token.replace(/[.,;:!?]+$/, '')); +} + +test('the compute URL is the exact Ito route with no path, query, hash, or credentials', () => { + const parsed = new URL(ITO_COMPUTE_URL); + assert.equal(parsed.protocol, 'https:'); + assert.equal(parsed.hostname, 'compute.itomarkets.com'); + assert.equal(parsed.port, ''); + assert.equal(parsed.username, ''); + assert.equal(parsed.password, ''); + assert.equal(parsed.pathname, '/'); + assert.equal(parsed.search, ''); + assert.equal(parsed.hash, ''); + assert.equal(ITO_COMPUTE_URL, 'https://compute.itomarkets.com'); +}); + +test('the copy links the compute URL exactly once and links nowhere else', () => { + assert.deepEqual(urlsIn(getComputeSponsorCopy()), [ITO_COMPUTE_URL]); +}); + +test('the copy names Ito as the preferred sponsor and keeps other providers open', () => { + const copy = getComputeSponsorCopy(); + assert.match(copy, /^Run or self-host any open-source model\./); + assert.match(copy, /Itô is ECC's preferred compute sponsor/); + assert.match(copy, /Any GPU provider works\./); +}); + +test('the copy keeps the sponsorship link passive', () => { + const copy = getComputeSponsorCopy(); + assert.match(copy, /This sponsorship link is passive/); + assert.match(copy, /does not invoke an RFQ, reserve capacity, provision compute, or configure serving/); +}); + +test('the copy keeps the ecc ito find bridge separate, opt-in, and non-reserving', () => { + const copy = getComputeSponsorCopy(); + assert.match(copy, /Separately, the opt-in "ecc ito find" bridge/); + assert.match(copy, /explicitly configured canonical Itô CLI/); + assert.match(copy, /submits a live authenticated RFQ; it does not reserve capacity\./); +}); + +test('the copy does not claim managed inference is live', () => { + assert.match(getComputeSponsorCopy(), /Managed inference through Itô is not live yet\.$/); +}); + +test('the copy is a single trimmed line of printable text', () => { + const copy = getComputeSponsorCopy(); + assert.equal(typeof copy, 'string'); + assert.ok(copy.length > 0); + assert.equal(copy, copy.trim()); + // eslint-disable-next-line no-control-regex + assert.doesNotMatch(copy, /[\x00-\x1f\x7f]/); + assert.doesNotMatch(copy, / {2}/); +}); + +test('the copy is deterministic across calls', () => { + assert.equal(getComputeSponsorCopy(), getComputeSponsorCopy()); +}); + +test('the module surface is frozen and exports only the URL and the copy builder', () => { + assert.ok(Object.isFrozen(computeSponsor)); + assert.deepEqual(Object.keys(computeSponsor).sort(), ['ITO_COMPUTE_URL', 'getComputeSponsorCopy']); + assert.throws(() => { + computeSponsor.ITO_COMPUTE_URL = 'https://example.invalid/'; + }, TypeError); + assert.equal(computeSponsor.ITO_COMPUTE_URL, 'https://compute.itomarkets.com'); +}); diff --git a/tests/lib/cursor-agent-names.test.js b/tests/lib/cursor-agent-names.test.js new file mode 100644 index 000000000..bbff29622 --- /dev/null +++ b/tests/lib/cursor-agent-names.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const { toCursorAgentFileName, toCursorAgentRelativePath } = require('../../scripts/lib/cursor-agent-names'); + +test('a bare file name gets the ecc- prefix', () => { + assert.equal(toCursorAgentFileName('planner.md'), 'ecc-planner.md'); + assert.equal(toCursorAgentFileName('code-reviewer.md'), 'ecc-code-reviewer.md'); +}); + +test('an already prefixed name is left alone, so the transform is idempotent', () => { + assert.equal(toCursorAgentFileName('ecc-planner.md'), 'ecc-planner.md'); + assert.equal(toCursorAgentFileName(toCursorAgentFileName('planner.md')), 'ecc-planner.md'); +}); + +test('empty and missing names pass through unchanged', () => { + assert.equal(toCursorAgentFileName(''), ''); + assert.equal(toCursorAgentFileName(undefined), undefined); + assert.equal(toCursorAgentFileName(null), null); +}); + +test('only a leading, lowercase ecc- counts as the prefix', () => { + assert.equal(toCursorAgentFileName('my-ecc-agent.md'), 'ecc-my-ecc-agent.md'); + assert.equal(toCursorAgentFileName('ECC-planner.md'), 'ecc-ECC-planner.md'); + assert.equal(toCursorAgentFileName('ecc.md'), 'ecc-ecc.md'); + assert.equal(toCursorAgentFileName('ecc_planner.md'), 'ecc-ecc_planner.md'); +}); + +test('a relative path keeps its directories and renames only the last segment', () => { + assert.equal(toCursorAgentRelativePath('review/planner.md'), path.join('review', 'ecc-planner.md')); + assert.equal(toCursorAgentRelativePath('a/b/c.md'), path.join('a', 'b', 'ecc-c.md')); + assert.equal(toCursorAgentRelativePath('ecc-review/planner.md'), path.join('ecc-review', 'ecc-planner.md')); +}); + +test('a path without directories behaves like a bare file name', () => { + assert.equal(toCursorAgentRelativePath('planner.md'), 'ecc-planner.md'); + assert.equal(toCursorAgentRelativePath('ecc-planner.md'), 'ecc-planner.md'); +}); + +test('backslash and repeated separators are normalized to the platform separator', () => { + assert.equal(toCursorAgentRelativePath('review\\planner.md'), path.join('review', 'ecc-planner.md')); + assert.equal(toCursorAgentRelativePath('review//planner.md'), path.join('review', 'ecc-planner.md')); + assert.equal(toCursorAgentRelativePath('review\\\\sub/planner.md'), path.join('review', 'sub', 'ecc-planner.md')); +}); + +test('a trailing separator is dropped and the last real segment is renamed', () => { + assert.equal(toCursorAgentRelativePath('review/planner.md/'), path.join('review', 'ecc-planner.md')); +}); + +test('empty, missing, and separator-only paths come back unchanged', () => { + assert.equal(toCursorAgentRelativePath(''), ''); + assert.equal(toCursorAgentRelativePath(undefined), undefined); + assert.equal(toCursorAgentRelativePath(null), null); + assert.equal(toCursorAgentRelativePath('/'), '/'); + assert.equal(toCursorAgentRelativePath('\\\\'), '\\\\'); +}); + +test('the result is always relative to the destination root', () => { + // The executor joins the result under the target agents directory, so a + // leading separator must not be able to re-root the destination. + const result = toCursorAgentRelativePath('/etc/planner.md'); + assert.equal(result, path.join('etc', 'ecc-planner.md')); + assert.equal(path.isAbsolute(result), false); +}); + +test('dot segments are not interpreted; the transform renames, it does not resolve', () => { + // The executor walks real directory entries, so these never reach the + // transform in practice. Pin that it does not quietly collapse them either. + assert.equal(toCursorAgentRelativePath('./planner.md'), path.join('.', 'ecc-planner.md')); + assert.equal(toCursorAgentRelativePath('../planner.md'), path.join('..', 'ecc-planner.md')); +}); + +test('the relative path transform composes the file name transform', () => { + for (const file of ['planner.md', 'ecc-planner.md', 'README']) { + assert.equal(toCursorAgentRelativePath(`nested/${file}`), path.join('nested', toCursorAgentFileName(file))); + } +}); diff --git a/tests/lib/feedback-links.test.js b/tests/lib/feedback-links.test.js new file mode 100644 index 000000000..c3777bc45 --- /dev/null +++ b/tests/lib/feedback-links.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { FEEDBACK_ROUTES, exitFeedbackLines, getFeedbackPayload, problemReportLines } = require('../../scripts/lib/feedback-links'); + +const ISSUE_TEMPLATE_DIR = path.join(__dirname, '..', '..', '.github', 'ISSUE_TEMPLATE'); +const ISSUES_NEW_URL = 'https://github.com/affaan-m/ECC/issues/new'; + +test('every route is a new-issue URL on the ECC repository with only a template query', () => { + assert.deepEqual(Object.keys(FEEDBACK_ROUTES).sort(), ['feature', 'feedback', 'problem']); + for (const [name, route] of Object.entries(FEEDBACK_ROUTES)) { + const parsed = new URL(route); + assert.equal(parsed.protocol, 'https:', `${name} protocol`); + assert.equal(parsed.hostname, 'github.com', `${name} host`); + assert.equal(parsed.pathname, '/affaan-m/ECC/issues/new', `${name} path`); + assert.equal(parsed.username, '', `${name} username`); + assert.equal(parsed.password, '', `${name} password`); + assert.equal(parsed.hash, '', `${name} hash`); + assert.deepEqual([...parsed.searchParams.keys()], ['template'], `${name} query keys`); + } +}); + +test('every route points at an issue template that exists in the repository', () => { + for (const [name, route] of Object.entries(FEEDBACK_ROUTES)) { + const template = new URL(route).searchParams.get('template'); + assert.match(template, /^[a-z-]+\.yml$/, `${name} template name`); + assert.ok(fs.existsSync(path.join(ISSUE_TEMPLATE_DIR, template)), `${name} route names ${template}, which is not in .github/ISSUE_TEMPLATE`); + } +}); + +test('each route maps to the intended template', () => { + assert.equal(FEEDBACK_ROUTES.problem, `${ISSUES_NEW_URL}?template=install-problem.yml`); + assert.equal(FEEDBACK_ROUTES.feedback, `${ISSUES_NEW_URL}?template=quick-feedback.yml`); + assert.equal(FEEDBACK_ROUTES.feature, `${ISSUES_NEW_URL}?template=feature-request.yml`); +}); + +test('the route table is frozen', () => { + assert.ok(Object.isFrozen(FEEDBACK_ROUTES)); + assert.throws(() => { + FEEDBACK_ROUTES.problem = 'https://example.invalid/'; + }, TypeError); + assert.throws(() => { + FEEDBACK_ROUTES.extra = 'https://example.invalid/'; + }, TypeError); + assert.equal(FEEDBACK_ROUTES.problem, `${ISSUES_NEW_URL}?template=install-problem.yml`); +}); + +test('the payload declares the public schema and no diagnostics upload', () => { + assert.deepEqual(getFeedbackPayload(), { + schemaVersion: 'ecc.feedback.v1', + privacy: 'public-github', + diagnosticsUploaded: false, + routes: { ...FEEDBACK_ROUTES } + }); +}); + +test('the payload survives a JSON round trip unchanged', () => { + const payload = getFeedbackPayload(); + assert.deepEqual(JSON.parse(JSON.stringify(payload)), payload); +}); + +test('each payload is a fresh copy so a caller cannot alter the route table', () => { + const first = getFeedbackPayload(); + const second = getFeedbackPayload(); + assert.notEqual(first, second); + assert.notEqual(first.routes, second.routes); + assert.deepEqual(first, second); + + first.routes.problem = 'https://example.invalid/'; + first.diagnosticsUploaded = true; + assert.equal(getFeedbackPayload().routes.problem, FEEDBACK_ROUTES.problem); + assert.equal(getFeedbackPayload().diagnosticsUploaded, false); +}); + +test('problem report lines carry the problem route and the redaction warning', () => { + const lines = problemReportLines(); + assert.equal(lines.length, 3); + assert.match(lines[0], /public GitHub issue/); + assert.equal(lines[1], FEEDBACK_ROUTES.problem); + assert.match(lines[2], /does not upload diagnostics/); + assert.match(lines[2], /Redact paths, repository names, prompts, and secrets/); +}); + +test('exit feedback lines carry the feedback route and promise not to block uninstall', () => { + const lines = exitFeedbackLines(); + assert.equal(lines.length, 3); + assert.match(lines[0], /public GitHub issue/); + assert.equal(lines[1], FEEDBACK_ROUTES.feedback); + assert.match(lines[2], /does not upload diagnostics/); + assert.match(lines[2], /block uninstall/); +}); + +test('every line is a single printable line, so join("\\n") output stays well formed', () => { + for (const line of [...problemReportLines(), ...exitFeedbackLines()]) { + assert.equal(typeof line, 'string'); + assert.ok(line.length > 0); + // eslint-disable-next-line no-control-regex + assert.doesNotMatch(line, /[\x00-\x1f\x7f]/); + assert.equal(line, line.trim()); + } +}); + +test('line arrays are rebuilt per call', () => { + const lines = problemReportLines(); + lines[1] = 'tampered'; + assert.equal(problemReportLines()[1], FEEDBACK_ROUTES.problem); + assert.notEqual(exitFeedbackLines(), exitFeedbackLines()); +}); diff --git a/tests/lib/invocation-environment.test.js b/tests/lib/invocation-environment.test.js new file mode 100644 index 000000000..509ec5508 --- /dev/null +++ b/tests/lib/invocation-environment.test.js @@ -0,0 +1,106 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { resolveInvocationEnvironment } = require('../../scripts/lib/invocation-environment'); + +const PROBE = 'ECC_INVOCATION_ENVIRONMENT_PROBE'; + +function withProbe(value, fn) { + const saved = process.env[PROBE]; + process.env[PROBE] = value; + try { + return fn(); + } finally { + if (saved === undefined) { + delete process.env[PROBE]; + } else { + process.env[PROBE] = saved; + } + } +} + +test('with no options the ambient process environment is returned as a copy', () => { + withProbe('ambient', () => { + const resolved = resolveInvocationEnvironment(); + assert.notEqual(resolved, process.env); + assert.deepEqual(resolved, { ...process.env }); + assert.equal(resolved[PROBE], 'ambient'); + }); +}); + +test('an empty options object behaves like no options', () => { + withProbe('ambient', () => { + assert.deepEqual(resolveInvocationEnvironment({}), { ...process.env }); + }); +}); + +test('mutating the copy never writes back to process.env', () => { + const resolved = resolveInvocationEnvironment(); + resolved[PROBE] = 'leaked'; + delete resolved.PATH; + assert.equal(process.env[PROBE], undefined); + assert.equal(typeof process.env.PATH, 'string'); +}); + +test('the copy is a snapshot and does not see later process.env changes', () => { + const resolved = resolveInvocationEnvironment(); + withProbe('later', () => { + assert.equal(resolved[PROBE], undefined); + }); +}); + +test('an explicit env is copied, not shared, and replaces the ambient environment', () => { + withProbe('ambient', () => { + const source = { HOME: '/scoped/home', PATH: '/scoped/bin' }; + const resolved = resolveInvocationEnvironment({ env: source }); + assert.notEqual(resolved, source); + assert.deepEqual(resolved, source); + assert.equal(resolved[PROBE], undefined); + + resolved.EXTRA = '1'; + assert.equal(source.EXTRA, undefined); + }); +}); + +test('only own enumerable keys of an explicit env are copied', () => { + const source = Object.create({ INHERITED: 'yes' }); + source.OWN = 'yes'; + Object.defineProperty(source, 'HIDDEN', { value: 'yes', enumerable: false }); + assert.deepEqual(resolveInvocationEnvironment({ env: source }), { OWN: 'yes' }); +}); + +test('env: null and env: undefined mean an empty environment, not the ambient one', () => { + withProbe('ambient', () => { + assert.deepEqual(resolveInvocationEnvironment({ env: null }), {}); + assert.deepEqual(resolveInvocationEnvironment({ env: undefined }), {}); + }); +}); + +test('an explicit env wins over homeDir', () => { + const resolved = resolveInvocationEnvironment({ env: { A: '1' }, homeDir: '/scoped' }); + assert.deepEqual(resolved, { A: '1' }); +}); + +test('a scoped homeDir gives an empty environment so ambient variables cannot leak into the install', () => { + withProbe('ambient', () => { + const resolved = resolveInvocationEnvironment({ homeDir: '/scoped/home' }); + assert.deepEqual(resolved, {}); + assert.equal(resolved.HOME, undefined); + assert.equal(resolved[PROBE], undefined); + }); +}); + +test('a blank or non-string homeDir does not count as scoped', () => { + withProbe('ambient', () => { + for (const homeDir of ['', ' ', '\n', null, undefined, 42, true]) { + assert.deepEqual(resolveInvocationEnvironment({ homeDir }), { ...process.env }, `homeDir ${JSON.stringify(homeDir)} should fall back to the ambient environment`); + } + }); +}); + +test('each call returns a distinct object', () => { + assert.notEqual(resolveInvocationEnvironment(), resolveInvocationEnvironment()); + assert.notEqual(resolveInvocationEnvironment({ homeDir: '/scoped' }), resolveInvocationEnvironment({ homeDir: '/scoped' })); +}); From 603e1dee1e88d1af4eb240553c3a95cb830c3a78 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 6 Oct 2026 21:42:00 -0400 Subject: [PATCH 06/23] refactor(hooks): flatten block-no-verify decision tree checkGitWords, executableWords, shellRole, updateShellState and checkCommand were each well over the complexity budget and checkCommand nested seven blocks deep. Split them into named helpers: git global option scanning and bypass flag search, per-wrapper handlers for command/exec/env/sudo, a shell option cluster classifier, declaration flag parsing, and a per-task/per-state/per-environment pipeline inside checkCommand. Every budget.spend call keeps its amount and order so the work quota tests still land in the same window. The module still only requires ./lib/shell-scan and exports run unchanged. Wrapper option sets moved to module constants instead of being rebuilt on every call. --- scripts/hooks/block-no-verify.js | 655 +++++++++++++++++++------------ 1 file changed, 403 insertions(+), 252 deletions(-) diff --git a/scripts/hooks/block-no-verify.js b/scripts/hooks/block-no-verify.js index 99589b299..5b1cfcee2 100644 --- a/scripts/hooks/block-no-verify.js +++ b/scripts/hooks/block-no-verify.js @@ -216,7 +216,23 @@ function gitEnvironmentOverride(environment, budget) { return false; } -function checkGitWords(words, budget, start = 0, environmentOverride = false) { +// True when a git global option at words[index] redirects hooks via config. +// The separate-operand forms always charge the operand so budget accounting +// does not depend on whether an earlier option already matched. +function gitGlobalOptionRedirectsHooks(words, index, value, budget) { + if (value === '-c' || value === '--config-env') { + const setting = words[index + 1]?.value || ''; + budget.spend(setting.length + 1); + return isHookRedirectSetting(setting); + } + if (value.startsWith('-c')) return isHookRedirectSetting(value.slice(2)); + if (value.startsWith('--config-env=')) return isHookRedirectSetting(value.slice('--config-env='.length)); + return false; +} + +// Walk git's global options after the executable. Returns the index of the +// subcommand word and whether any option redirected the hooks path. +function scanGitGlobalOptions(words, budget, start, environmentOverride) { let index = start + 1; let override = environmentOverride; for (; index < words.length; index++) { @@ -224,20 +240,16 @@ function checkGitWords(words, budget, start = 0, environmentOverride = false) { budget.spend(value.length + 1); if (!value.startsWith('-')) break; if (value === '--') { index++; break; } - if (value === '-c' || value === '--config-env') { - const setting = words[index + 1]?.value || ''; - budget.spend(setting.length + 1); - override ||= isHookRedirectSetting(setting); - } else if (value.startsWith('-c')) override ||= isHookRedirectSetting(value.slice(2)); - else if (value.startsWith('--config-env=')) override ||= isHookRedirectSetting(value.slice('--config-env='.length)); + if (gitGlobalOptionRedirectsHooks(words, index, value, budget)) override = true; if (GIT_GLOBAL_VALUES.has(value)) index++; } - const command = words[index]?.value; - budget.spend((command?.length || 0) + 1); - if (!PROTECTED_GIT_COMMANDS.has(command)) return null; - if (override) return `BLOCKED: Overriding core.hooksPath is not allowed with git ${command}. Git hooks must not be bypassed.`; + return { index, override }; +} + +// Scan a protected subcommand's own arguments for a hook-bypass flag. +function findGitBypassFlag(words, budget, start, command) { let skipNext = false; - for (index++; index < words.length; index++) { + for (let index = start; index < words.length; index++) { const value = words[index].value; budget.spend(value.length + 1); if (skipNext) { skipNext = false; continue; } @@ -246,9 +258,19 @@ function checkGitWords(words, budget, start = 0, environmentOverride = false) { if (commitOptionConsumesNextValue(value)) { skipNext = true; continue; } if (commitOptionContainsInlineValue(value)) continue; } - if (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) { - return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`; - } + if (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) return true; + } + return false; +} + +function checkGitWords(words, budget, start = 0, environmentOverride = false) { + const { index, override } = scanGitGlobalOptions(words, budget, start, environmentOverride); + const command = words[index]?.value; + budget.spend((command?.length || 0) + 1); + if (!PROTECTED_GIT_COMMANDS.has(command)) return null; + if (override) return `BLOCKED: Overriding core.hooksPath is not allowed with git ${command}. Git hooks must not be bypassed.`; + if (findGitBypassFlag(words, budget, index + 1, command)) { + return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`; } return null; } @@ -284,15 +306,45 @@ function validateEnvSplitEscapes(payload, budget) { } } +const ENV_VALUE_OPTIONS = new Set(['-u', '--unset', '-C', '--chdir']); +const ENV_FLAG_OPTIONS = new Set(['-i', '--ignore-environment', '-0', '--null']); +const SUDO_VALUE_OPTIONS = new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']); +const SUDO_FLAG_OPTIONS = new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']); +const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/; +const GIT_ENV_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/; + +function isEnvSplitFlag(flag) { + return flag === '-S' || flag === '--split-string' + || flag.startsWith('--split-string=') || flag.startsWith('-S'); +} + +// A value-taking wrapper option with its operand attached (`-uNAME`, `--unset=NAME`). +function hasAttachedOperand(flag, values) { + return [...values].some(value => value.startsWith('--') + ? flag.startsWith(`${value}=`) + : flag.startsWith(value) && flag.length > value.length); +} + +// Only literal word splitting is understood for env -S. Keep complex env +// syntax opaque rather than pretending to execute it as a shell. +function isLiteralWordList(scan) { + const command = scan.commands[0]; + return scan.commands.length === 1 && !command.nested.length + && !command.redirects.length && !command.words.some(word => word.dynamic); +} + // Only explicit option grammars remove wrapper operands. Unknown launchers are // opaque/conservative, never guessed from a name found among data arguments. function executableWords(words, budget, inherited = new Map(), callerValues = inherited) { budget.spend(inherited.size + 1); const environments = [new Map(inherited)]; const prefixAssignments = new Map(); + const dynamicAssignments = new Set(); let local = true; let assignmentOnly = true; - const dynamicAssignments = new Set(); + let i = 0; + let assignments = true; + let environmentAssignments = false; function result(values) { return { words: values, environments, prefixAssignments, local, assignmentOnly, dynamicAssignments }; } function suffix(start) { budget.spend(words.length - start); @@ -304,7 +356,7 @@ function executableWords(words, budget, inherited = new Map(), callerValues = in const equals = value.indexOf('='); const append = !environmentAssignments && value[equals - 1] === '+'; const key = value.slice(0, append ? equals - 1 : equals); - if (/^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/.test(key)) { + if (GIT_ENV_NAME.test(key)) { const operand = value.slice(equals + 1); const count = environments.length; budget.spend(count + 1); @@ -336,87 +388,139 @@ function executableWords(words, budget, inherited = new Map(), callerValues = in else environment.delete(name); } } - let i = 0; - let assignments = true; - let environmentAssignments = false; + // Wrapper handlers below return a final result to stop scanning, or + // undefined once the wrapper's own options are consumed and the scan + // should continue with the wrapped command. + function commandBuiltin(token) { + assignmentOnly = false; + local &&= token.value === 'command'; + i++; + while (words[i]?.value.startsWith('-')) { + const flag = words[i++].value; + budget.spend(flag.length + 1); + if (flag === '--') break; + if (/^-[pvV]+$/.test(flag) && /[vV]/.test(flag)) return result([]); + if (!/^-p+$/.test(flag)) return suffix(i - 1); + } + assignments = false; + return undefined; + } + function execBuiltin() { + assignmentOnly = false; + local = false; + i++; + while (words[i]?.value.startsWith('-')) { + const flag = words[i++].value; + budget.spend(flag.length + 1); + if (flag === '--') break; + if (/^-[cl]*a$/.test(flag)) i++; + else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1); + if (flag.slice(1).split('a', 1)[0].includes('c')) resetEnvironment(); + } + assignments = false; + return undefined; + } + // env -S splits a literal payload into argv words in place; words[i] is + // then re-examined as the first expanded word. + function envSplitString(flag) { + const separated = flag === '-S' || flag === '--split-string'; + const payload = separated ? words[i + 1]?.value || '' + : flag.slice(flag.startsWith('--split-string=') ? '--split-string='.length : 2); + budget.spend(payload.length + 1); + validateEnvSplitEscapes(payload, budget); + const scan = scanShell(payload, budget); + if (!isLiteralWordList(scan)) return suffix(i - 1); + // Pay for both slices and the expanded array before allocating them. + budget.spend(2 * words.length + scan.commands[0].words.length); + words = [...words.slice(0, i), ...scan.commands[0].words, ...words.slice(i + (separated ? 2 : 1))]; + return undefined; + } + function envResetFlag(flag) { + if (flag === '-i' || flag === '--ignore-environment') resetEnvironment(); + if (flag === '-u' || flag === '--unset') resetEnvironment(words[i + 1]?.value || ''); + else if (flag.startsWith('--unset=')) resetEnvironment(flag.slice('--unset='.length)); + else if (flag.startsWith('-u')) resetEnvironment(flag.slice(2)); + } + function envWrapper(name) { + assignmentOnly = false; + local = false; + const env = name === 'env'; + const values = env ? ENV_VALUE_OPTIONS : SUDO_VALUE_OPTIONS; + const flags = env ? ENV_FLAG_OPTIONS : SUDO_FLAG_OPTIONS; + i++; + while (words[i]?.value.startsWith('-')) { + const flag = words[i].value; + budget.spend(flag.length + 1); + if (flag === '--') { i++; break; } + if (env && isEnvSplitFlag(flag)) { + const stop = envSplitString(flag); + if (stop) return stop; + continue; + } + if (env) envResetFlag(flag); + if (values.has(flag)) i += 2; + else if (flags.has(flag) || hasAttachedOperand(flag, values)) i++; + else return suffix(i - 1); // Includes opaque env -S / sudo shell modes. + } + assignments = true; + environmentAssignments = true; + return undefined; + } while (i < words.length) { const token = words[i]; budget.spend(token.value.length + token.raw.length + 1); - if (assignments && /^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(environmentAssignments ? token.value : token.raw)) { assignment(token); i++; continue; } + if (assignments && SHELL_ASSIGNMENT.test(environmentAssignments ? token.value : token.raw)) { assignment(token); i++; continue; } if (!token.quoted && CONTROL_WORDS.has(token.value)) { i++; continue; } const name = basename(token.value); - if (name === 'command') { - assignmentOnly = false; - local &&= token.value === 'command'; - i++; - while (words[i]?.value.startsWith('-')) { - const flag = words[i++].value; - budget.spend(flag.length + 1); - if (flag === '--') break; - if (/^-[pvV]+$/.test(flag) && /[vV]/.test(flag)) return result([]); - if (!/^-p+$/.test(flag)) return suffix(i - 1); - } - assignments = false; continue; - } - if (name === 'exec') { - assignmentOnly = false; - local = false; - i++; - while (words[i]?.value.startsWith('-')) { - const flag = words[i++].value; - budget.spend(flag.length + 1); - if (flag === '--') break; - if (/^-[cl]*a$/.test(flag)) i++; - else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1); - if (flag.slice(1).split('a', 1)[0].includes('c')) resetEnvironment(); - } - assignments = false; continue; - } - if (name === 'env' || name === 'sudo' || name === 'doas') { - assignmentOnly = false; - local = false; - const env = name === 'env'; - const values = env - ? new Set(['-u', '--unset', '-C', '--chdir']) - : new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']); - const flags = env ? new Set(['-i', '--ignore-environment', '-0', '--null']) : new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']); - i++; - while (words[i]?.value.startsWith('-')) { - const flag = words[i].value; - budget.spend(flag.length + 1); - if (flag === '--') { i++; break; } - if (env && (flag === '-S' || flag === '--split-string' - || flag.startsWith('--split-string=') || flag.startsWith('-S'))) { - const separated = flag === '-S' || flag === '--split-string'; - const payload = separated ? words[i + 1]?.value || '' - : flag.slice(flag.startsWith('--split-string=') ? '--split-string='.length : 2); - budget.spend(payload.length + 1); - validateEnvSplitEscapes(payload, budget); - const scan = scanShell(payload, budget); - // Only literal word splitting is understood here. Keep complex env - // syntax opaque rather than pretending to execute it as a shell. - if (scan.commands.length !== 1 || scan.commands[0].nested.length - || scan.commands[0].redirects.length || scan.commands[0].words.some(word => word.dynamic)) return suffix(i - 1); - // Pay for both slices and the expanded array before allocating them. - budget.spend(2 * words.length + scan.commands[0].words.length); - words = [...words.slice(0, i), ...scan.commands[0].words, ...words.slice(i + (separated ? 2 : 1))]; - continue; - } - if (env && (flag === '-i' || flag === '--ignore-environment')) resetEnvironment(); - if (env && (flag === '-u' || flag === '--unset')) resetEnvironment(words[i + 1]?.value || ''); - else if (env && flag.startsWith('--unset=')) resetEnvironment(flag.slice('--unset='.length)); - else if (env && flag.startsWith('-u')) resetEnvironment(flag.slice(2)); - if (values.has(flag)) i += 2; - else if (flags.has(flag) || [...values].some(value => value.startsWith('--') ? flag.startsWith(`${value}=`) : flag.startsWith(value) && flag.length > value.length)) i++; - else return suffix(i - 1); // Includes opaque env -S / sudo shell modes. - } - assignments = true; environmentAssignments = true; continue; - } - return suffix(i); + let stop; + if (name === 'command') stop = commandBuiltin(token); + else if (name === 'exec') stop = execBuiltin(); + else if (name === 'env' || name === 'sudo' || name === 'doas') stop = envWrapper(name); + else return suffix(i); + if (stop) return stop; } return result([]); } +const SHELL_LONG_FLAGS = ['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login']; +const OPAQUE_SHELL = { kind: 'opaque', stdin: true }; + +// Classify one short shell flag inside an option cluster. +function classifyShellFlag(flag, shell) { + // Named-option arity is unproved for sh/dash/ksh: keep the invocation + // opaque instead of consuming a code flag as a guessed option operand. + if ((flag === 'o' || flag === 'O') && shell !== 'bash' && shell !== 'zsh') return 'opaque'; + if (flag === 'c') return 'code'; + if (flag === 's') return 'stdin'; + // zsh consumes the rest of this argv word as the option name, or one + // separate word if no suffix exists, then ends this option cluster. + if (shell === 'zsh' && flag === 'o') return 'zshOption'; + // These are not Bash's operand grammar; unmodeled zsh modes stay opaque. + if (shell === 'zsh' && (flag === 'O' || flag === 'b')) return 'opaque'; + if (flag === 'o' || flag === 'O') return 'operand'; + return 'abefhiklmnprtuvxBCEHPTD'.includes(flag) ? 'plain' : 'opaque'; +} + +// Bash accepts either sign and consumes a separate operand for each o/O +// even inside a cluster. The command string follows ALL option processing, +// not necessarily the argv word immediately after the first c flag. +// Returns null when the cluster makes the invocation opaque. +function scanShellOptionCluster(option, words, start, shell, budget) { + const cluster = { next: start + 1, code: false, stdin: false }; + for (let j = 1; j < option.length; j++) { + budget.spend(); + const kind = classifyShellFlag(option[j], shell); + if (kind === 'opaque') return null; + if (kind === 'code') cluster.code = true; + else if (kind === 'stdin') cluster.stdin = true; + else if (kind === 'zshOption') { + if (j + 1 === option.length && cluster.next < words.length) cluster.next++; + return cluster; + } else if (kind === 'operand' && cluster.next < words.length) cluster.next++; + } + return cluster; +} + function shellRole(words, budget, shell) { let i = 1; let stdin = false; @@ -428,33 +532,14 @@ function shellRole(words, budget, shell) { if (!/^[+-]/.test(option)) break; if (option === '--rcfile' || option === '--init-file') { i += 2; continue; } if (option.startsWith('--')) { - if (!['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login'].includes(option)) return { kind: 'opaque', stdin: true }; + if (!SHELL_LONG_FLAGS.includes(option)) return { ...OPAQUE_SHELL }; i++; continue; } - // Bash accepts either sign and consumes a separate operand for each o/O - // even inside a cluster. The command string follows ALL option processing, - // not necessarily the argv word immediately after the first c flag. - let next = i + 1; - for (let j = 1; j < option.length; j++) { - budget.spend(); - const flag = option[j]; - // Named-option arity is unproved for sh/dash/ksh: keep the invocation - // opaque instead of consuming a code flag as a guessed option operand. - if ((flag === 'o' || flag === 'O') && shell !== 'bash' && shell !== 'zsh') return { kind: 'opaque', stdin: true }; - if (flag === 'c') code = true; - else if (flag === 's') stdin = true; - else if (shell === 'zsh' && flag === 'o') { - // zsh consumes the rest of this argv word as the option name, or one - // separate word if no suffix exists, then ends this option cluster. - if (j + 1 === option.length && next < words.length) next++; - break; - } else if (shell === 'zsh' && (flag === 'O' || flag === 'b')) { - // These are not Bash's operand grammar; unmodeled zsh modes stay opaque. - return { kind: 'opaque', stdin: true }; - } else if (flag === 'o' || flag === 'O') { if (next < words.length) next++; } - else if (!'abefhiklmnprtuvxBCEHPTD'.includes(flag)) return { kind: 'opaque', stdin: true }; - } - i = next; + const cluster = scanShellOptionCluster(option, words, i, shell, budget); + if (!cluster) return { ...OPAQUE_SHELL }; + code ||= cluster.code; + stdin ||= cluster.stdin; + i = cluster.next; } if (code) return { kind: 'shell', code: words[i]?.value, stdin: false }; // A script filename and its positional arguments are not shell source text. @@ -761,7 +846,6 @@ function pipelineSources(command, budget) { return sources; } -const GIT_ENV_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/; const DECLARATIONS = new Set(['export', 'declare', 'typeset', 'readonly', 'unset']); function shellState(environment, budget) { @@ -825,31 +909,11 @@ function updateShellState(state, normalized, budget) { // Exact builtin names only: /some/path/export is an external executable. const name = words[0]?.value; if (!DECLARATIONS.has(name)) return result(false, false); - let exported = name === 'export' ? true : null; - let readonly = name === 'readonly'; - let passive = false; - let uncertain = dynamicAssignments.size > 0; - let i = 1; - for (; i < words.length; i++) { - const flag = words[i].value; - budget.spend(flag.length + 1); - if (flag === '--') { i++; break; } - if (!/^[+-]/.test(flag)) break; - if (name === 'export' && /^-[npf]+$/.test(flag)) { - if (flag.includes('n')) exported = false; - passive ||= flag.includes('f'); - } else if ((name === 'declare' || name === 'typeset') && /^[+-][xrgpf]+$/.test(flag)) { - if (flag.includes('x')) exported = flag[0] === '-'; - if (flag[0] === '-' && flag.includes('r')) readonly = true; - passive ||= /[pf]/.test(flag); - } else if (name === 'readonly' && /^-[pf]+$/.test(flag)) passive ||= flag.includes('f'); - else if (name === 'unset' && /^-[vf]+$/.test(flag)) passive ||= flag.includes('f'); - else uncertain = true; - } - if (passive && !uncertain) return result(true, false); + const declaration = parseDeclarationFlags(name, words, budget, dynamicAssignments.size > 0); + if (declaration.passive && !declaration.uncertain) return result(true, false); let changed = prefixAssignments.size > 0; assignPrefixes(); - for (; i < words.length; i++) { + for (let i = declaration.index; i < words.length; i++) { const value = words[i].value; budget.spend(2 * value.length + 1); const equals = value.indexOf('='); @@ -859,21 +923,65 @@ function updateShellState(state, normalized, budget) { changed = true; if (name !== 'unset' && equals >= 0) assign(key, value.slice(equals + 1), words[i].dynamic, append); budget.spend(states.length + 1); - for (const current of states) { - if (name === 'unset') { - if (equals < 0 && !current.readonly.has(key)) { - current.variables.delete(key); current.exported.delete(key); - } - } else { - if (exported === true || uncertain) current.exported.add(key); - else if (exported === false) current.exported.delete(key); - if (readonly || uncertain) current.readonly.add(key); - } - } + for (const current of states) applyDeclaration(current, key, equals, declaration); } // Unsupported attributes may transform values or reject the declaration. // Retain old and conservative literal states; never use them to prove reset. - return result(true, changed, uncertain); + return result(true, changed, declaration.uncertain); +} + +// Fold one declaration builtin flag into the parsed attribute set. +function applyDeclarationFlag(declaration, flag) { + const { name } = declaration; + if (name === 'export' && /^-[npf]+$/.test(flag)) { + if (flag.includes('n')) declaration.exported = false; + declaration.passive ||= flag.includes('f'); + return; + } + if ((name === 'declare' || name === 'typeset') && /^[+-][xrgpf]+$/.test(flag)) { + if (flag.includes('x')) declaration.exported = flag[0] === '-'; + if (flag[0] === '-' && flag.includes('r')) declaration.readonly = true; + declaration.passive ||= /[pf]/.test(flag); + return; + } + if (name === 'readonly' && /^-[pf]+$/.test(flag)) { declaration.passive ||= flag.includes('f'); return; } + if (name === 'unset' && /^-[vf]+$/.test(flag)) { declaration.passive ||= flag.includes('f'); return; } + declaration.uncertain = true; +} + +// Parse the option words of export/declare/typeset/readonly/unset. Returns the +// attributes they set and the index of the first operand. +function parseDeclarationFlags(name, words, budget, uncertain) { + const declaration = { + name, + exported: name === 'export' ? true : null, + readonly: name === 'readonly', + passive: false, + uncertain, + index: 1 + }; + for (; declaration.index < words.length; declaration.index++) { + const flag = words[declaration.index].value; + budget.spend(flag.length + 1); + if (flag === '--') { declaration.index++; break; } + if (!/^[+-]/.test(flag)) break; + applyDeclarationFlag(declaration, flag); + } + return declaration; +} + +// Apply a parsed declaration of `key` to one literal shell state. +function applyDeclaration(current, key, equals, declaration) { + const { name, exported, readonly, uncertain } = declaration; + if (name === 'unset') { + if (equals < 0 && !current.readonly.has(key)) { + current.variables.delete(key); current.exported.delete(key); + } + return; + } + if (exported === true || uncertain) current.exported.add(key); + else if (exported === false) current.exported.delete(key); + if (readonly || uncertain) current.readonly.add(key); } function checkCommand(input) { @@ -896,6 +1004,148 @@ function checkCommand(input) { } return null; } + // Split a text task into one pending task per scanned command. + function expandText(task) { + const scan = scanShell(task.text, budget); + const contexts = new Map([[scan.rootScope, task.context]]); + budget.spend(scan.commands.length + 1); + for (let i = scan.commands.length - 1; i >= 0; i--) pending.push({ ...task, command: scan.commands[i], contexts }); + } + // A closing scope folds its states back into the enclosing context when the + // scope ended a pipeline. + function exitScope(command, contexts) { + const closing = command.scopeExit; + const exited = contexts.get(closing); + const enclosing = contexts.get(closing.parent); + if (closing.pipelineLast && exited && enclosing) { + budget.spend(exited.states.length + 1); + enclosing.states.push(...exited.states); + } + } + // Materialize contexts for any scopes between the command and the nearest + // known ancestor, then return the command's own scope context. + function resolveScope(command, contexts) { + const missing = []; + for (let scope = command.scope; !contexts.has(scope); scope = scope.parent) { budget.spend(); missing.push(scope); } + while (missing.length) { + const scope = missing.pop(); + const parent = contexts.get(scope.parent); + contexts.set(scope, scope.isolated ? copyShellContext(parent, budget) : parent); + } + return contexts.get(command.scope); + } + function isConditional(scope) { + let conditional = false; + for (let current = scope; current; current = current.parent) { budget.spend(); conditional ||= current.conditional; } + return conditional; + } + // Stdin fed to a shell-like receiver (here-strings, heredocs, literal pipe + // producers) is scanned as source. Opaque receivers keep their text opaque. + function collectStdinSources(frame, role, environment) { + const opaque = role.kind === 'opaque'; + for (const redirect of frame.command.redirects) { + if (redirect.operator === '<<<') frame.childEnvironments.push({ code: redirect.word.value, opaque, environment }); + else if (redirect.operator === '<<' || redirect.operator === '<<-') frame.childEnvironments.push({ code: redirect.body, opaque, environment }); + } + if (frame.command.pipeFrom) { + for (const source of pipelineSources(frame.command.pipeFrom, budget)) frame.childEnvironments.push({ code: source.text, opaque: source.opaque || opaque, environment }); + } + } + // Inspect one command under one literal environment. Returns a block reason + // or null, and records any child source text the command would execute. + function inspectEnvironment(frame, role, normalized, environment) { + const { task, command } = frame; + const { words } = normalized; + const reason = task.opaque || role.kind === 'opaque' + ? inspectOpaque(command.words, task.text, environment) + : role.kind === 'git' ? checkGitWords(words, budget, 0, gitEnvironmentOverride(environment, budget)) : null; + if (reason) return reason; + if (role.code) { + if (normalized.local && words[0]?.value === 'eval') frame.sameShellCode = role.code; + else frame.childEnvironments.push({ code: role.code, opaque: false, environment }); + } + if (role.kind === 'sedExec') { + // Fragments sed will hand to the shell are scanned as real shell + // source, not as opaque text — this closes the GNU sed `e` gap. + for (const part of role.parts) { + budget.spend(part.length + 1); + frame.childEnvironments.push({ code: part, opaque: false, environment }); + } + } + if (role.stdin) collectStdinSources(frame, role, environment); + return null; + } + // Advance one literal shell state through the command: apply declarations + // and prefix assignments, then inspect the executable under each resulting + // environment. + function inspectState(frame, state) { + const { command, conditional } = frame; + const normalized = executableWords(command.words, budget, exportedEnvironment(state, budget), state.variables); + const { words, environments } = normalized; + const next = copyShellState(state, budget); + const evalPrefix = normalized.local && words[0]?.value === 'eval' && normalized.prefixAssignments.size > 0; + const mutation = updateShellState(next, evalPrefix ? { ...normalized, assignmentOnly: true } : normalized, budget); + if (evalPrefix) { + frame.alternatives.push(state); + budget.spend(mutation.states.length * (normalized.prefixAssignments.size + 1)); + for (const variant of mutation.states) for (const name of normalized.prefixAssignments.keys()) variant.exported.add(name); + } + budget.spend(mutation.states.length + 1); + frame.alternatives.push(...mutation.states); + if (mutation.changed && (conditional || mutation.uncertain)) frame.alternatives.push(state); + frame.changed ||= mutation.changed; + if (mutation.handled && !evalPrefix) return null; + const role = commandRole(words, budget); + budget.spend(environments.length + 1); + for (const environment of environments) { + const reason = inspectEnvironment(frame, role, normalized, environment); + if (reason) return reason; + } + return null; + } + // Commit the command's resulting states and queue whatever it would run. + function finishCommand(frame) { + const { command, context, parent, conditional, sameShellCode, changed } = frame; + context.states = frame.alternatives; + // Bash lastpipe and zsh can execute a final pipeline builtin in the + // parent shell. Preserve that possible state as well as isolation; this + // is deliberately conservative when the host shell/options are unknown. + if (command.pipeFrom && !command.pipeTo && !command.background && (changed || sameShellCode)) pending.push({ mergeInto: parent, context }); + for (const child of frame.childEnvironments) enqueue(child.code, child.opaque, { states: [shellState(child.environment, budget)] }); + if (sameShellCode) { + // A conditional eval may not run. Its nested scans have fresh lexical + // roots, so preserve the skipped branch across all delayed updates. + const evaluated = conditional ? copyShellContext(context, budget) : context; + if (conditional) pending.push({ mergeInto: context, context: evaluated }); + enqueue(sameShellCode, false, evaluated); + } + } + // Process one scanned command task. Returns a block reason or null. + function processCommand(task) { + const { command, contexts } = task; + if (command.scopeExit) { exitScope(command, contexts); return null; } + const parent = resolveScope(command, contexts); + const isolated = command.pipeFrom || command.pipeTo || command.background; + const context = task.commandContext || (isolated ? copyShellContext(parent, budget) : parent); + if (!task.nestedDone && command.nested.length) { + pending.push({ ...task, nestedDone: true, commandContext: context }); + budget.spend(command.nested.length + 1); + for (let i = command.nested.length - 1; i >= 0; i--) enqueue(command.nested[i], false, copyShellContext(context, budget)); + return null; + } + const frame = { + task, command, context, parent, + conditional: isConditional(command.scope), + alternatives: [], childEnvironments: [], sameShellCode: null, changed: false + }; + budget.spend(context.states.length + 1); + for (const state of context.states) { + const reason = inspectState(frame, state); + if (reason) return reason; + } + finishCommand(frame); + return null; + } try { while (pending.length) { const task = pending.pop(); @@ -904,108 +1154,9 @@ function checkCommand(input) { task.mergeInto.states.push(...task.context.states); continue; } - if (!task.command) { - const scan = scanShell(task.text, budget); - const contexts = new Map([[scan.rootScope, task.context]]); - budget.spend(scan.commands.length + 1); - for (let i = scan.commands.length - 1; i >= 0; i--) pending.push({ ...task, command: scan.commands[i], contexts }); - continue; - } - const { command, contexts } = task; - if (command.scopeExit) { - const closing = command.scopeExit; - const exited = contexts.get(closing); - const enclosing = contexts.get(closing.parent); - if (closing.pipelineLast && exited && enclosing) { - budget.spend(exited.states.length + 1); - enclosing.states.push(...exited.states); - } - continue; - } - const missing = []; - for (let scope = command.scope; !contexts.has(scope); scope = scope.parent) { budget.spend(); missing.push(scope); } - while (missing.length) { - const scope = missing.pop(); - const parent = contexts.get(scope.parent); - contexts.set(scope, scope.isolated ? copyShellContext(parent, budget) : parent); - } - const parent = contexts.get(command.scope); - const isolated = command.pipeFrom || command.pipeTo || command.background; - const context = task.commandContext || (isolated ? copyShellContext(parent, budget) : parent); - if (!task.nestedDone && command.nested.length) { - pending.push({ ...task, nestedDone: true, commandContext: context }); - budget.spend(command.nested.length + 1); - for (let i = command.nested.length - 1; i >= 0; i--) enqueue(command.nested[i], false, copyShellContext(context, budget)); - continue; - } - let conditional = false; - for (let scope = command.scope; scope; scope = scope.parent) { budget.spend(); conditional ||= scope.conditional; } - const alternatives = []; - const childEnvironments = []; - let sameShellCode = null; - let changed = false; - budget.spend(context.states.length + 1); - for (const state of context.states) { - const normalized = executableWords(command.words, budget, exportedEnvironment(state, budget), state.variables); - const { words, environments } = normalized; - const next = copyShellState(state, budget); - const evalPrefix = normalized.local && words[0]?.value === 'eval' && normalized.prefixAssignments.size > 0; - const mutation = updateShellState(next, evalPrefix ? { ...normalized, assignmentOnly: true } : normalized, budget); - if (evalPrefix) { - alternatives.push(state); - budget.spend(mutation.states.length * (normalized.prefixAssignments.size + 1)); - for (const variant of mutation.states) for (const name of normalized.prefixAssignments.keys()) variant.exported.add(name); - } - budget.spend(mutation.states.length + 1); - alternatives.push(...mutation.states); - if (mutation.changed && (conditional || mutation.uncertain)) alternatives.push(state); - changed ||= mutation.changed; - if (mutation.handled && !evalPrefix) continue; - const role = commandRole(words, budget); - budget.spend(environments.length + 1); - for (const environment of environments) { - const reason = task.opaque || role.kind === 'opaque' - ? inspectOpaque(command.words, task.text, environment) - : role.kind === 'git' ? checkGitWords(words, budget, 0, gitEnvironmentOverride(environment, budget)) : null; - if (reason) return { blocked: true, reason }; - if (role.code) { - if (normalized.local && words[0]?.value === 'eval') { - sameShellCode = role.code; - } - else childEnvironments.push({ code: role.code, opaque: false, environment }); - } - if (role.kind === 'sedExec') { - // Fragments sed will hand to the shell are scanned as real shell - // source, not as opaque text — this closes the GNU sed `e` gap. - for (const part of role.parts) { - budget.spend(part.length + 1); - childEnvironments.push({ code: part, opaque: false, environment }); - } - } - if (role.stdin) { - for (const redirect of command.redirects) { - if (redirect.operator === '<<<') childEnvironments.push({ code: redirect.word.value, opaque: role.kind === 'opaque', environment }); - else if (redirect.operator === '<<' || redirect.operator === '<<-') childEnvironments.push({ code: redirect.body, opaque: role.kind === 'opaque', environment }); - } - if (command.pipeFrom) { - for (const source of pipelineSources(command.pipeFrom, budget)) childEnvironments.push({ code: source.text, opaque: source.opaque || role.kind === 'opaque', environment }); - } - } - } - } - context.states = alternatives; - // Bash lastpipe and zsh can execute a final pipeline builtin in the - // parent shell. Preserve that possible state as well as isolation; this - // is deliberately conservative when the host shell/options are unknown. - if (command.pipeFrom && !command.pipeTo && !command.background && (changed || sameShellCode)) pending.push({ mergeInto: parent, context }); - for (const child of childEnvironments) enqueue(child.code, child.opaque, { states: [shellState(child.environment, budget)] }); - if (sameShellCode) { - // A conditional eval may not run. Its nested scans have fresh lexical - // roots, so preserve the skipped branch across all delayed updates. - const evaluated = conditional ? copyShellContext(context, budget) : context; - if (conditional) pending.push({ mergeInto: context, context: evaluated }); - enqueue(sameShellCode, false, evaluated); - } + if (!task.command) { expandText(task); continue; } + const reason = processCommand(task); + if (reason) return { blocked: true, reason }; } } catch (error) { if (error instanceof UnsupportedEnvSplitEscape) { From cfcd81c07601e004ae164de9477f9adc025991cd Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 8 Oct 2026 22:15:00 -0400 Subject: [PATCH 07/23] refactor(lib): share span readers in shell-substitution extractSubshellGroups and extractBraceGroups each carried their own copies of the quote-aware skipping for $(...), backtick and (...) spans, which is where the complexity and nesting warnings came from. The skipping now lives in findParenSpanEnd, findBacktickEnd and skipOuterSpan, with readSubshellBody and readBraceBody doing the body collection. Behavior is unchanged: the same bodies come back in the same order, trailing backslashes and unterminated spans resolve the same way, and the three exports keep their signatures. --- scripts/lib/shell-substitution.js | 511 +++++++++++------------------- 1 file changed, 193 insertions(+), 318 deletions(-) diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js index a2241770d..a5dec6fbb 100644 --- a/scripts/lib/shell-substitution.js +++ b/scripts/lib/shell-substitution.js @@ -94,6 +94,114 @@ function extractCommandSubstitutions(input, options = {}) { return [...iterateCommandSubstitutions(input, options)]; } +// --- Shared span readers ------------------------------------------------------ +// The group extractors below all need the same quote-aware skipping of +// `$(...)`, backtick and `(...)` spans. Each reader takes the index of the +// first character inside the span and returns where scanning should resume. + +function parenDelta(ch) { + if (ch === '(') return 1; + if (ch === ')') return -1; + return 0; +} + +/** + * Toggle quote state for a quote character at the current position. + * Returns true when `ch` opened or closed a quote. + */ +function toggleQuote(ch, prev, quotes) { + if (ch === "'" && !quotes.inDouble && prev !== '\\') { + quotes.inSingle = !quotes.inSingle; + return true; + } + if (ch === '"' && !quotes.inSingle && prev !== '\\') { + quotes.inDouble = !quotes.inDouble; + return true; + } + return false; +} + +/** + * Index just past the `)` that closes a paren span whose first inner character + * is at `start`, honoring quotes and escapes inside the span. + */ +function findParenSpanEnd(source, start) { + const quotes = { inSingle: false, inDouble: false }; + let depth = 1; + let i = start; + while (i < source.length && depth > 0) { + const inner = source[i]; + if (inner === '\\' && !quotes.inSingle) { + i += 2; + continue; + } + if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) { + depth += parenDelta(inner); + } + i += 1; + } + return i; +} + +/** + * Index of the backtick that closes a span whose first inner character is at + * `start`, or `source.length` when the span is unterminated. + */ +function findBacktickEnd(source, start) { + let i = start; + while (i < source.length && source[i] !== '`') { + if (source[i] === '\\' && i + 1 < source.length) { + i += 2; + continue; + } + i += 1; + } + return i; +} + +/** + * Top-level scan step shared by the group extractors: escapes, quote state, + * `$(...)` and backtick spans. Returns the last index consumed so a `for` + * loop's `i++` resumes after it, or -1 when the caller must inspect `source[i]`. + */ +function skipOuterSpan(source, i, quotes) { + const ch = source[i]; + if (ch === '\\' && !quotes.inSingle) return i + 1; + if (toggleQuote(ch, source[i - 1], quotes)) return i; + if (quotes.inSingle || quotes.inDouble) return i; + if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2) - 1; + if (ch === '`') return findBacktickEnd(source, i + 1); + return -1; +} + +/** + * Read a `(...)` subshell body starting just inside the opening paren. Returns + * the body text and the index of the closing paren (or where input ran out). + */ +function readSubshellBody(source, start) { + const quotes = { inSingle: false, inDouble: false }; + let depth = 1; + let body = ''; + let i = start; + while (i < source.length && depth > 0) { + const inner = source[i]; + if (inner === '\\' && !quotes.inSingle) { + // Keep the escape and its operand together; a trailing backslash at the + // end of an unterminated span is appended once and the scan stops. + body += source.slice(i, i + 2); + i = Math.min(i + 2, source.length); + continue; + } + if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) { + depth += parenDelta(inner); + if (depth === 0) break; + } + body += inner; + i += 1; + } + return { body, end: i }; +} + /** * Extract bodies of plain `(...)` subshell groups. * @@ -114,118 +222,89 @@ function extractCommandSubstitutions(input, options = {}) { function extractSubshellGroups(input) { const source = String(input || ''); const groups = []; - let inSingle = false; - let inDouble = false; + const quotes = { inSingle: false, inDouble: false }; for (let i = 0; i < source.length; i++) { - const ch = source[i]; - const prev = source[i - 1]; - - if (ch === '\\' && !inSingle) { - i += 1; + const skipped = skipOuterSpan(source, i, quotes); + if (skipped !== -1) { + i = skipped; continue; } - - if (ch === "'" && !inDouble && prev !== '\\') { - inSingle = !inSingle; - continue; - } - - if (ch === '"' && !inSingle && prev !== '\\') { - inDouble = !inDouble; - continue; - } - - if (inSingle || inDouble) { - continue; - } - - if (ch === '$' && source[i + 1] === '(') { - let depth = 1; - let skipInSingle = false; - let skipInDouble = false; - i += 2; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !skipInSingle) { - i += 2; - continue; - } - if (inner === "'" && !skipInDouble && innerPrev !== '\\') { - skipInSingle = !skipInSingle; - } else if (inner === '"' && !skipInSingle && innerPrev !== '\\') { - skipInDouble = !skipInDouble; - } else if (!skipInSingle && !skipInDouble) { - if (inner === '(') depth += 1; - else if (inner === ')') depth -= 1; - } - i += 1; - } - i -= 1; - continue; - } - - if (ch === '`') { - i += 1; - while (i < source.length && source[i] !== '`') { - if (source[i] === '\\' && i + 1 < source.length) { - i += 2; - continue; - } - i += 1; - } - continue; - } - - if (ch === '(') { - let depth = 1; - let body = ''; - let bodyInSingle = false; - let bodyInDouble = false; - i += 1; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !bodyInSingle) { - body += inner; - if (i + 1 < source.length) { - body += source[i + 1]; - i += 2; - } else { - // Trailing backslash at end of an unterminated span: advance past - // it so it is not appended a second time by the fallthrough below. - i += 1; - } - continue; - } - if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { - bodyInSingle = !bodyInSingle; - } else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { - bodyInDouble = !bodyInDouble; - } else if (!bodyInSingle && !bodyInDouble) { - if (inner === '(') { - depth += 1; - } else if (inner === ')') { - depth -= 1; - if (depth === 0) { - break; - } - } - } - body += inner; - i += 1; - } - if (body.trim()) { - groups.push(body); - groups.push(...extractSubshellGroups(body)); - } + if (source[i] !== '(') continue; + const { body, end } = readSubshellBody(source, i + 1); + i = end; + if (body.trim()) { + groups.push(body); + groups.push(...extractSubshellGroups(body)); } } return groups; } +/** + * Inside a brace body, `$(...)`, backtick and `(...)` spans are copied through + * verbatim so a `}` inside them cannot close the group. Returns the index just + * past the span, or -1 when `source[i]` does not open one. + */ +function findNestedSpanEnd(source, i) { + const ch = source[i]; + if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2); + if (ch === '`') return Math.min(findBacktickEnd(source, i + 1) + 1, source.length); + if (ch === '(') return findParenSpanEnd(source, i + 1); + return -1; +} + +// `{` is a reserved word only when followed by whitespace and preceded by the +// line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`). +function opensBraceGroup(source, i) { + if (source[i] !== '{' || !/\s/.test(source[i + 1] || '')) return false; + return i === 0 || /[\s;|&(]/.test(source[i - 1]); +} + +/** + * Read a `{ ...; }` brace body starting just after the opening brace. Returns + * the body text and the index of the closing brace (or where input ran out). + */ +function readBraceBody(source, start) { + const quotes = { inSingle: false, inDouble: false }; + let depth = 1; + let body = ''; + let i = start; + while (i < source.length && depth > 0) { + const inner = source[i]; + const prev = source[i - 1]; + if (inner === '\\' && !quotes.inSingle) { + body += source.slice(i, i + 2); + i = Math.min(i + 2, source.length); + continue; + } + if (toggleQuote(inner, prev, quotes) || quotes.inSingle || quotes.inDouble) { + body += inner; + i += 1; + continue; + } + const spanEnd = findNestedSpanEnd(source, i); + if (spanEnd !== -1) { + body += source.slice(i, spanEnd); + i = spanEnd; + continue; + } + if (inner === '{' && /\s/.test(source[i + 1] || '')) { + // Match the outer-scan boundary rule for nested `{` so tokens like + // `foo{` (no boundary, but followed by space via `foo{ bar`) cannot + // bump nested depth. + if (/[\s;|&(]/.test(prev)) depth += 1; + } else if (inner === '}' && (prev === ';' || /\s/.test(prev))) { + depth -= 1; + if (depth === 0) break; + } + body += inner; + i += 1; + } + return { body, end: i }; +} + /** * Extract bodies of `{ ...; }` brace groups. * @@ -250,228 +329,24 @@ function extractSubshellGroups(input) { function extractBraceGroups(input) { const source = String(input || ''); const groups = []; - let inSingle = false; - let inDouble = false; + const quotes = { inSingle: false, inDouble: false }; for (let i = 0; i < source.length; i++) { - const ch = source[i]; - const prev = source[i - 1]; - - if (ch === '\\' && !inSingle) { - i += 1; + const skipped = skipOuterSpan(source, i, quotes); + if (skipped !== -1) { + i = skipped; continue; } - - if (ch === "'" && !inDouble && prev !== '\\') { - inSingle = !inSingle; + if (source[i] === '(') { + i = findParenSpanEnd(source, i + 1) - 1; continue; } - - if (ch === '"' && !inSingle && prev !== '\\') { - inDouble = !inDouble; - continue; - } - - if (inSingle || inDouble) { - continue; - } - - if (ch === '$' && source[i + 1] === '(') { - let depth = 1; - let skipInSingle = false; - let skipInDouble = false; - i += 2; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !skipInSingle) { - i += 2; - continue; - } - if (inner === "'" && !skipInDouble && innerPrev !== '\\') { - skipInSingle = !skipInSingle; - } else if (inner === '"' && !skipInSingle && innerPrev !== '\\') { - skipInDouble = !skipInDouble; - } else if (!skipInSingle && !skipInDouble) { - if (inner === '(') depth += 1; - else if (inner === ')') depth -= 1; - } - i += 1; - } - i -= 1; - continue; - } - - if (ch === '`') { - i += 1; - while (i < source.length && source[i] !== '`') { - if (source[i] === '\\' && i + 1 < source.length) { - i += 2; - continue; - } - i += 1; - } - continue; - } - - if (ch === '(') { - let depth = 1; - let skipInSingle = false; - let skipInDouble = false; - i += 1; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !skipInSingle) { - i += 2; - continue; - } - if (inner === "'" && !skipInDouble && innerPrev !== '\\') { - skipInSingle = !skipInSingle; - } else if (inner === '"' && !skipInSingle && innerPrev !== '\\') { - skipInDouble = !skipInDouble; - } else if (!skipInSingle && !skipInDouble) { - if (inner === '(') depth += 1; - else if (inner === ')') depth -= 1; - } - i += 1; - } - i -= 1; - continue; - } - - if (ch === '{' && /\s/.test(source[i + 1] || '')) { - const prevIsBoundary = i === 0 || /[\s;|&(]/.test(prev); - if (!prevIsBoundary) continue; - - let depth = 1; - let body = ''; - let bodyInSingle = false; - let bodyInDouble = false; - i += 1; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !bodyInSingle) { - body += inner; - if (i + 1 < source.length) { - body += source[i + 1]; - i += 2; - } else { - // Trailing backslash at end of an unterminated span: advance past - // it so it is not appended a second time by the fallthrough below. - i += 1; - } - continue; - } - if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { - bodyInSingle = !bodyInSingle; - body += inner; - i += 1; - continue; - } - if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { - bodyInDouble = !bodyInDouble; - body += inner; - i += 1; - continue; - } - if (bodyInSingle || bodyInDouble) { - body += inner; - i += 1; - continue; - } - // Skip $(...) spans — a quoted `}` or `}`-as-text inside a - // substitution body must not close the enclosing brace group. - if (inner === '$' && source[i + 1] === '(') { - body += inner + source[i + 1]; - let subDepth = 1; - let subInSingle = false; - let subInDouble = false; - i += 2; - while (i < source.length && subDepth > 0) { - const c = source[i]; - const p = source[i - 1]; - body += c; - if (c === '\\' && !subInSingle && i + 1 < source.length) { - body += source[i + 1]; - i += 2; - continue; - } - if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle; - else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble; - else if (!subInSingle && !subInDouble) { - if (c === '(') subDepth += 1; - else if (c === ')') subDepth -= 1; - } - i += 1; - } - continue; - } - // Skip backtick spans for the same reason. - if (inner === '`') { - body += inner; - i += 1; - while (i < source.length && source[i] !== '`') { - if (source[i] === '\\' && i + 1 < source.length) { - body += source[i] + source[i + 1]; - i += 2; - continue; - } - body += source[i]; - i += 1; - } - if (i < source.length) { - body += source[i]; - i += 1; - } - continue; - } - // Skip plain (...) subshell spans for the same reason. - if (inner === '(') { - body += inner; - let subDepth = 1; - let subInSingle = false; - let subInDouble = false; - i += 1; - while (i < source.length && subDepth > 0) { - const c = source[i]; - const p = source[i - 1]; - body += c; - if (c === '\\' && !subInSingle && i + 1 < source.length) { - body += source[i + 1]; - i += 2; - continue; - } - if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle; - else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble; - else if (!subInSingle && !subInDouble) { - if (c === '(') subDepth += 1; - else if (c === ')') subDepth -= 1; - } - i += 1; - } - continue; - } - if (inner === '{' && /\s/.test(source[i + 1] || '')) { - // Match the outer-scan boundary rule for nested `{` so - // tokens like `foo{` (no boundary, but followed by space - // via `foo{ bar`) cannot bump nested depth. - const nestedPrevIsBoundary = /[\s;|&(]/.test(innerPrev); - if (nestedPrevIsBoundary) depth += 1; - } else if (inner === '}' && (innerPrev === ';' || /\s/.test(innerPrev))) { - depth -= 1; - if (depth === 0) { - break; - } - } - body += inner; - i += 1; - } - if (body.trim()) { - groups.push(body); - groups.push(...extractBraceGroups(body)); - } + if (!opensBraceGroup(source, i)) continue; + const { body, end } = readBraceBody(source, i + 1); + i = end; + if (body.trim()) { + groups.push(body); + groups.push(...extractBraceGroups(body)); } } From 7b1756670f23bca7ce2ab8cf103da49f9045fa67 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 10 Oct 2026 20:58:00 -0400 Subject: [PATCH 08/23] refactor(hooks): split gateguard gate and classifiers into smaller units gate() handled Edit, MultiEdit and shell in one 220-line function. It now builds a small context and dispatches to gateSingleEdit, gateMultiEdit and gateShell, with the first-touch, sibling-collapse and routine/destructive shell steps as their own functions. isDestructiveGit and isReadOnlyGitIntrospection became subcommand tables; unwrapLeadWrappers got one handler per wrapper; ddLauncherCommandIndex, isDestructiveFindExec and sanitizePath lost their inline option and code point ladders. condensedGateMsg takes an options object instead of seven positional parameters. Decision order, recorded metrics reasons, state keys and every denial message are unchanged, and the module exports are the same. --- scripts/hooks/gateguard-fact-force.js | 1175 +++++++++++++------------ 1 file changed, 612 insertions(+), 563 deletions(-) diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 1a2c54cd9..15b2a5da5 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -651,13 +651,14 @@ const DD_LAUNCHER_OPTIONS = { }; /** Return the command position after one explicitly supported launcher's options. */ + function ddLauncherCommandIndex(argv, index, name) { - const { values, optional, flags, nonCommand } = DD_LAUNCHER_OPTIONS[name]; + const options = DD_LAUNCHER_OPTIONS[name]; // GNU time uses getopt_long: unique prefixes resolve against its complete // eight-option table, including terminating help/version. Other launchers // retain their explicit spellings; this does not affect shell-keyword time. const timeLongOptions = name === 'time' - ? [...values, ...flags, ...nonCommand].filter(flag => flag.startsWith('--')) + ? [...options.values, ...options.flags, ...options.nonCommand].filter(flag => flag.startsWith('--')) : null; index += 1; while (index < argv.length) { @@ -672,38 +673,47 @@ function ddLauncherCommandIndex(argv, index, name) { index += 1; continue; } - if (arg.startsWith('--')) { - const separator = arg.indexOf('='); - let flag = separator === -1 ? arg : arg.slice(0, separator); - if (timeLongOptions && !timeLongOptions.includes(flag)) { - const matches = timeLongOptions.filter(option => option.startsWith(flag)); - if (matches.length !== 1) return argv.length; - [flag] = matches; - } - if (nonCommand && nonCommand.has(flag)) return argv.length; - if (values.has(flag)) index += separator === -1 ? 2 : 1; - else if (optional.has(flag) || (separator === -1 && flags.has(flag))) index += 1; - else return argv.length; - continue; - } - let consumesNext = false; - for (let offset = 1; offset < arg.length; offset += 1) { - const flag = `-${arg[offset]}`; - if (nonCommand && nonCommand.has(flag)) return argv.length; - if (values.has(flag)) { - consumesNext = offset + 1 === arg.length; - break; - } - if (optional.has(flag)) break; - if (!flags.has(flag)) return argv.length; - } - index += consumesNext ? 2 : 1; + const step = arg.startsWith('--') + ? ddLauncherLongOptionStep(arg, options, timeLongOptions) + : ddLauncherShortClusterStep(arg, options); + if (step === -1) return argv.length; + index += step; } // timeout's duration and taskset's mask or CPU list are data, followed by // exactly one executable position. return name === 'timeout' || name === 'taskset' ? index + 1 : index; } +// One long option of a dd launcher: how many argv positions it consumes, or +// -1 when the launcher can no longer reach a command position. +function ddLauncherLongOptionStep(arg, options, timeLongOptions) { + const { values, optional, flags, nonCommand } = options; + const separator = arg.indexOf('='); + let flag = separator === -1 ? arg : arg.slice(0, separator); + if (timeLongOptions && !timeLongOptions.includes(flag)) { + const matches = timeLongOptions.filter(option => option.startsWith(flag)); + if (matches.length !== 1) return -1; + [flag] = matches; + } + if (nonCommand && nonCommand.has(flag)) return -1; + if (values.has(flag)) return separator === -1 ? 2 : 1; + if (optional.has(flag) || (separator === -1 && flags.has(flag))) return 1; + return -1; +} + +// One short-option cluster of a dd launcher, same contract as above. +function ddLauncherShortClusterStep(arg, options) { + const { values, optional, flags, nonCommand } = options; + for (let offset = 1; offset < arg.length; offset += 1) { + const flag = `-${arg[offset]}`; + if (nonCommand && nonCommand.has(flag)) return -1; + if (values.has(flag)) return offset + 1 === arg.length ? 2 : 1; + if (optional.has(flag)) return 1; + if (!flags.has(flag)) return -1; + } + return 1; +} + /** * Resolve leading assignments, shell prefixes and sudo/doas/env into command * argv. Wrapper-specific option values never become executable names. Every @@ -715,6 +725,7 @@ function ddLauncherCommandIndex(argv, index, name) { * @param {boolean} [allowDdLaunchers] opt-in; other shared callers retain their grammar * @returns {string[]} normalized argv, or [] when no literal command resolves */ + function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers = false) { let argv = tokens.slice(); let index = 0; @@ -722,6 +733,126 @@ function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers let allowShellTime = allowShellBuiltins; const timeTokens = SHELL_TIME_TOKENS.get(tokens); let budget = tokens.reduce((size, token) => size + token.length + 1, 1); + + // Each wrapper handler consumes its own options and returns true when no + // literal command can resolve behind it. + function isReservedTime() { + return allowDdLaunchers && allowShellTime && argv[index] === 'time' && timeTokens && timeTokens.has(index); + } + function skipReservedTime() { + // Current Bash accepts only raw -p and -- as reserved-time options. + // Quotes/escapes make them executable words, unlike external time argv. + // The next command/exec builtin or assignment keeps shell semantics. + index += 1; + if (argv[index] === '-p' && timeTokens.has(index)) index += 1; + if (argv[index] === '--' && timeTokens.has(index)) index += 1; + return false; + } + function skipCommandBuiltin() { + allowShellTime = false; + index += 1; + while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { + const flag = argv[index++]; + if (flag === '--') break; + // -v/-V (including -pv) only describe names; no command executes. + if (!/^-[pVv]+$/.test(flag) || /[vV]/.test(flag)) return true; + } + allowAssignments = false; + return false; + } + function skipExecBuiltin() { + allowShellTime = false; + index += 1; + while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { + const flag = argv[index]; + if (flag === '--') { + index += 1; + break; + } + const option = wrapperValueOption(flag, EXEC_VALUE_FLAGS); + const flagLetters = option ? flag.slice(1, flag.indexOf('a')) : flag.slice(1); + if (!/^[cl]*$/.test(flagLetters)) return true; + index += option && option.value === undefined ? 2 : 1; + } + // exec replaces the shell with an external executable; its argument + // 'command' is not the shell's builtin, and A=1 is not an assignment. + allowShellBuiltins = false; + allowAssignments = false; + return false; + } + function skipDdLauncher(base) { + index = ddLauncherCommandIndex(argv, index, base); + allowShellTime = false; + allowShellBuiltins = false; + allowAssignments = false; + return false; + } + function skipPrivilegeWrapper(base) { + allowShellTime = false; + allowShellBuiltins = false; + allowAssignments = true; + const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS; + index += 1; + while (index < argv.length) { + const flag = argv[index]; + if (flag === '--') { + index += 1; + break; + } + if (flag === '-' || !flag.startsWith('-')) break; + const option = wrapperValueOption(flag, valueFlags); + index += option && option.value === undefined ? 2 : 1; + } + return false; + } + function expandEnvSplit(option) { + const separate = option.value === undefined; + const source = separate ? argv[index + 1] : option.value; + if (source === undefined || budget-- <= 0) return true; + const expanded = splitEnvWords(source); + if (!expanded) return true; + argv = [...expanded, ...argv.slice(index + (separate ? 2 : 1))]; + index = 0; + return false; + } + function skipEnvWrapper() { + allowShellTime = false; + allowShellBuiltins = false; + allowAssignments = true; + index += 1; + while (index < argv.length) { + const arg = argv[index]; + if (arg === '--') { + index += 1; + break; + } + const option = wrapperValueOption(arg, ENV_VALUE_FLAGS); + if (option && (option.name === '-S' || option.name === '--split-string')) { + if (expandEnvSplit(option)) return true; + continue; + } + if (option) { + index += option.value === undefined ? 2 : 1; + continue; + } + if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) { + index += 1; + continue; + } + break; + } + return false; + } + function leadWrapperFor(base) { + if (isReservedTime()) return skipReservedTime; + if (allowShellBuiltins && base === 'command') return skipCommandBuiltin; + if (allowShellBuiltins && base === 'exec') return skipExecBuiltin; + if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) return skipDdLauncher; + if (base === 'sudo' || base === 'doas') return skipPrivilegeWrapper; + if (base === 'env') return skipEnvWrapper; + return null; + } + while (index < argv.length && budget-- > 0) { while (allowAssignments && index < argv.length && SHELL_ASSIGNMENT.test(argv[index])) { index += 1; @@ -729,107 +860,9 @@ function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers } if (index >= argv.length) return []; const base = commandBasename(argv[index]); - if (allowDdLaunchers && allowShellTime && argv[index] === 'time' && timeTokens && timeTokens.has(index)) { - // Current Bash accepts only raw -p and -- as reserved-time options. - // Quotes/escapes make them executable words, unlike external time argv. - // The next command/exec builtin or assignment keeps shell semantics. - index += 1; - if (argv[index] === '-p' && timeTokens.has(index)) index += 1; - if (argv[index] === '--' && timeTokens.has(index)) index += 1; - continue; - } - if (allowShellBuiltins && base === 'command') { - allowShellTime = false; - index += 1; - while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { - const flag = argv[index++]; - if (flag === '--') break; - // -v/-V (including -pv) only describe names; no command executes. - if (!/^-[pVv]+$/.test(flag) || /[vV]/.test(flag)) return []; - } - allowAssignments = false; - continue; - } - if (allowShellBuiltins && base === 'exec') { - allowShellTime = false; - index += 1; - while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { - const flag = argv[index]; - if (flag === '--') { - index += 1; - break; - } - const option = wrapperValueOption(flag, EXEC_VALUE_FLAGS); - const flagLetters = option ? flag.slice(1, flag.indexOf('a')) : flag.slice(1); - if (!/^[cl]*$/.test(flagLetters)) return []; - index += option && option.value === undefined ? 2 : 1; - } - // exec replaces the shell with an external executable; its argument - // 'command' is not the shell's builtin, and A=1 is not an assignment. - allowShellBuiltins = false; - allowAssignments = false; - continue; - } - if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) { - index = ddLauncherCommandIndex(argv, index, base); - allowShellTime = false; - allowShellBuiltins = false; - allowAssignments = false; - continue; - } - if (base === 'sudo' || base === 'doas') { - allowShellTime = false; - allowShellBuiltins = false; - allowAssignments = true; - const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS; - index += 1; - while (index < argv.length) { - const flag = argv[index]; - if (flag === '--') { - index += 1; - break; - } - if (flag === '-' || !flag.startsWith('-')) break; - const option = wrapperValueOption(flag, valueFlags); - index += option && option.value === undefined ? 2 : 1; - } - continue; - } - if (base === 'env') { - allowShellTime = false; - allowShellBuiltins = false; - allowAssignments = true; - index += 1; - while (index < argv.length) { - const arg = argv[index]; - if (arg === '--') { - index += 1; - break; - } - const option = wrapperValueOption(arg, ENV_VALUE_FLAGS); - if (option && (option.name === '-S' || option.name === '--split-string')) { - const separate = option.value === undefined; - const source = separate ? argv[index + 1] : option.value; - if (source === undefined || budget-- <= 0) return []; - const expanded = splitEnvWords(source); - if (!expanded) return []; - argv = [...expanded, ...argv.slice(index + (separate ? 2 : 1))]; - index = 0; - continue; - } - if (option) { - index += option.value === undefined ? 2 : 1; - continue; - } - if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) { - index += 1; - continue; - } - break; - } - continue; - } - return argv.slice(index); + const wrapper = leadWrapperFor(base); + if (!wrapper) return argv.slice(index); + if (wrapper(base)) return []; } return []; } @@ -1067,6 +1100,113 @@ function pushTargetsSharedBranch(rest) { return false; } +/** Short-option cluster carrying `letter` (`-f`, `-fd`); long options never match. */ +function hasShortFlag(token, letter) { + return token.startsWith('-') && !token.startsWith('--') && token.slice(1).includes(letter); +} + +// `git checkout -- `, `git checkout .`, and the force forms +// (`--force` / `-f`) all discard uncommitted working-tree changes, +// mirroring the `switch` handler. +function isDestructiveGitCheckout(rest) { + return rest.some(t => t === '--' || t === '.' || t === '--force' || hasShortFlag(t, 'f')); +} + +// `git switch` can discard local working-tree changes in three forms: +// --discard-changes explicit discard +// --force / -f ignore conflicts and overwrite +// -C force-create (overwrites existing branch) +// Short combined form: -f, -fC, -Cf, -C +function isDestructiveGitSwitch(rest) { + return rest.some(t => t === '--discard-changes' || t === '--force' + || (t.startsWith('-') && !t.startsWith('--') && /[fC]/.test(t.slice(1)))); +} + +// Only `--force-with-lease` qualifies as a safety-checked force. +// `--force-if-includes` is a no-op when used WITHOUT +// `--force-with-lease` (per git-scm.com/docs/git-push), and when +// combined with a bare `--force` the bare force is still in effect. +// So `--force --force-if-includes` must be treated as destructive. +// +// A `+` refspec prefix (e.g. `git push origin +main`, +// `+refs/heads/main:refs/heads/main`) also forces a non-fast-forward +// update of that ref and is destructive on its own. +function isDestructiveGitPush(rest) { + let withLease = false; + let bareForce = false; + let plusRefspecForce = false; + for (const t of rest) { + if (t === '--force-with-lease' || t.startsWith('--force-with-lease=')) { + withLease = true; + continue; + } + if (t === '--force' || t.startsWith('--force=') || hasShortFlag(t, 'f')) { + bareForce = true; + continue; + } + // Refspec prefix: `+[:]`. Match tokens like `+main`, + // `+refs/heads/main`, `+HEAD:branch`, `+:branch`. Exclude bare + // `+` and numeric-only `+123` which are not refspecs. + if (t.startsWith('+') && t.length > 1 && /^\+(?:[a-zA-Z_/.:]|HEAD)/.test(t)) plusRefspecForce = true; + } + if (bareForce || (plusRefspecForce && !withLease)) return true; + // A lease-checked force still rewrites a shared branch's history. + return withLease && pushTargetsSharedBranch(rest); +} + +// `git branch -D` (long spelling: `--delete --force`) deletes a +// branch even when it is unmerged, orphaning its commits. Plain +// `-d` refuses when unmerged, so it is safe to leave ungated. +function isDestructiveGitBranch(rest) { + let del = false; + let force = false; + for (const t of rest) { + if (t === '--delete') { del = true; continue; } + if (t === '--force') { force = true; continue; } + if (!t.startsWith('-') || t.startsWith('--')) continue; + const body = t.slice(1); + if (body.includes('D')) return true; + if (body.includes('d')) del = true; + if (body.includes('f')) force = true; + } + return del && force; +} + +// `git restore ` overwrites the working tree from the index +// by default, the modern spelling of gated `git checkout -- `. +// Only `--staged` alone is non-destructive (it leaves the file on +// disk untouched); `--worktree` (the default target) is destructive. +function isDestructiveGitRestore(rest) { + const has = (long, short) => rest.some(t => t === long || hasShortFlag(t, short)); + const staged = has('--staged', 'S'); + const worktree = has('--worktree', 'W'); + return worktree || !staged; +} + +// Subcommand -> predicate over the tokens after it. Each entry is the +// smallest rule that identifies the destructive spelling of that command. +const DESTRUCTIVE_GIT_SUBCOMMANDS = new Map([ + ['reset', rest => rest.includes('--hard')], + ['checkout', isDestructiveGitCheckout], + // `git clean -f`, `-fd`, `-fdx`, `-df`, `--force` + ['clean', rest => rest.some(t => t === '--force' || hasShortFlag(t, 'f'))], + ['push', isDestructiveGitPush], + ['commit', rest => rest.includes('--amend')], + // `git rm -r` / `-rf` / `-r -f`: destructive within the index too. + ['rm', rest => rest.some(t => t.startsWith('-') && !t.startsWith('--') && /[rR]/.test(t.slice(1)))], + ['switch', isDestructiveGitSwitch], + ['branch', isDestructiveGitBranch], + // `drop` destroys one stash entry, `clear` the entire stash. + // `list`, `show`, `pop` and `apply` keep the entries recoverable. + ['stash', rest => rest[0] === 'drop' || rest[0] === 'clear'], + // `expire` and `delete` remove the recovery net that makes every + // other gated git command recoverable. + ['reflog', rest => rest[0] === 'expire' || rest[0] === 'delete'], + // `git update-ref -d ` deletes a ref directly. + ['update-ref', rest => rest.includes('-d') || rest.includes('--delete')], + ['restore', isDestructiveGitRestore] +]); + /** * Detect destructive `git` invocations: `reset --hard`, `checkout --`, * `clean -f...`, `push --force` (`--force-with-lease` only to a shared @@ -1077,149 +1217,12 @@ function pushTargetsSharedBranch(rest) { * @param {string[]} tokens * @returns {boolean} */ + function isDestructiveGit(tokens) { const sub = findGitSubcommand(tokens); if (!sub) return false; - const { command, rest } = sub; - - if (command === 'reset') { - return rest.includes('--hard'); - } - - if (command === 'checkout') { - // `git checkout -- `, `git checkout .`, and the force forms - // (`--force` / `-f`) all discard uncommitted working-tree changes, - // mirroring the `switch` handler below. - return rest.some(t => { - if (t === '--' || t === '.' || t === '--force') return true; - if (!t.startsWith('-') || t.startsWith('--')) return false; - return t.slice(1).includes('f'); - }); - } - - if (command === 'clean') { - // `git clean -f`, `-fd`, `-fdx`, `-df`, `--force` - return rest.some(t => { - if (t === '--force') return true; - if (!t.startsWith('-') || t.startsWith('--')) return false; - return t.slice(1).includes('f'); - }); - } - - if (command === 'push') { - // Only `--force-with-lease` qualifies as a safety-checked force. - // `--force-if-includes` is a no-op when used WITHOUT - // `--force-with-lease` (per git-scm.com/docs/git-push), and when - // combined with a bare `--force` the bare force is still in effect. - // So `--force --force-if-includes` must be treated as destructive. - // - // A `+` refspec prefix (e.g. `git push origin +main`, - // `+refs/heads/main:refs/heads/main`) also forces a non-fast-forward - // update of that ref and is destructive on its own. - let withLease = false; - let bareForce = false; - let plusRefspecForce = false; - for (const t of rest) { - if (t === '--force-with-lease' || t.startsWith('--force-with-lease=')) { - withLease = true; - continue; - } - if (t === '--force' || t.startsWith('--force=')) { - bareForce = true; - continue; - } - if (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes('f')) { - bareForce = true; - continue; - } - // Refspec prefix: `+[:]`. Match tokens like `+main`, - // `+refs/heads/main`, `+HEAD:branch`, `+:branch`. Exclude bare - // `+` and numeric-only `+123` which are not refspecs. - if (t.startsWith('+') && t.length > 1 && /^\+(?:[a-zA-Z_/.:]|HEAD)/.test(t)) { - plusRefspecForce = true; - } - } - if (bareForce || (plusRefspecForce && !withLease)) return true; - // A lease-checked force still rewrites a shared branch's history. - return withLease && pushTargetsSharedBranch(rest); - } - - if (command === 'commit') { - return rest.includes('--amend'); - } - - if (command === 'rm') { - // `git rm -r` / `-rf` / `-r -f` — destructive within the index too. - let hasR = false; - for (const t of rest) { - if (!t.startsWith('-') || t.startsWith('--')) continue; - if (/[rR]/.test(t.slice(1))) hasR = true; - } - return hasR; - } - - if (command === 'switch') { - // `git switch` can discard local working-tree changes in three forms: - // --discard-changes explicit discard - // --force / -f ignore conflicts and overwrite - // -C force-create (overwrites existing branch) - return rest.some(t => { - if (t === '--discard-changes' || t === '--force') return true; - if (!t.startsWith('-') || t.startsWith('--')) return false; - // Short combined form: -f, -fC, -Cf, -C - const body = t.slice(1); - return /[fC]/.test(body); - }); - } - - if (command === 'branch') { - // `git branch -D` (long spelling: `--delete --force`) deletes a - // branch even when it is unmerged, orphaning its commits. Plain - // `-d` refuses when unmerged, so it is safe to leave ungated. - let del = false; - let force = false; - for (const t of rest) { - if (t === '--delete') { del = true; continue; } - if (t === '--force') { force = true; continue; } - if (!t.startsWith('-') || t.startsWith('--')) continue; - const body = t.slice(1); - if (body.includes('D')) return true; - if (body.includes('d')) del = true; - if (body.includes('f')) force = true; - } - return del && force; - } - - if (command === 'stash') { - // `drop` destroys one stash entry, `clear` the entire stash. - // `list`, `show`, `pop` and `apply` keep the entries recoverable. - return rest[0] === 'drop' || rest[0] === 'clear'; - } - - if (command === 'reflog') { - // `expire` and `delete` remove the recovery net that makes every - // other gated git command recoverable. - return rest[0] === 'expire' || rest[0] === 'delete'; - } - - if (command === 'update-ref') { - // `git update-ref -d ` deletes a ref directly. - return rest.includes('-d') || rest.includes('--delete'); - } - - if (command === 'restore') { - // `git restore ` overwrites the working tree from the index - // by default, the modern spelling of gated `git checkout -- `. - // Only `--staged` alone is non-destructive (it leaves the file on - // disk untouched); `--worktree` (the default target) is destructive. - const has = (long, short) => rest.some(t => - t === long || (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes(short))); - const staged = has('--staged', 'S'); - const worktree = has('--worktree', 'W'); - return worktree || !staged; - } - - return false; + const check = DESTRUCTIVE_GIT_SUBCOMMANDS.get(sub.command); + return check ? check(sub.rest) : false; } /** @@ -1298,6 +1301,7 @@ const FIND_EXEC_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir']); * @param {string | string[]} command raw segment or already dequoted argv * @returns {boolean} */ + function isDestructiveFindExec(command) { const quoteAware = Array.isArray(command); const tokens = quoteAware ? command : tokenize(String(command || '').trim()); @@ -1314,30 +1318,41 @@ function isDestructiveFindExec(command) { continue; } if (!FIND_EXEC_ACTIONS.has(action)) continue; - const execTokens = []; - for (index += 1; index < tokens.length; index += 1) { - const token = tokens[index]; - if (token === ';' || token === '\\;' || ( - token === '+' && (action === '-exec' || action === '-execdir') && - execTokens[execTokens.length - 1] === '{}' - )) break; - execTokens.push(token); - } - if (execTokens.length === 0) continue; - // The legacy raw fallback can split quoted prose into apparent actions. - // Preserve its old rm/Git coverage, but classify dd only from real argv. - if (quoteAware && isDestructiveDd(execTokens, false)) return true; - const baseCmd = commandBasename(execTokens[0]); - // Preserve main's existing rm/rmdir/unlink and git-reset handling. - if (baseCmd === 'rm' || baseCmd === 'rmdir' || baseCmd === 'unlink') return true; - if (baseCmd === 'git') { - const sub = findGitSubcommand(execTokens); - if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) return true; - } + const exec = readFindExecArgv(tokens, index + 1, action); + index = exec.index; + if (exec.argv.length && isDestructiveFindExecArgv(exec.argv, quoteAware)) return true; } return false; } +// Collect the argv of one find action starting at `start`. Returns the argv +// and the index of its terminator (or the end of the token list). +function readFindExecArgv(tokens, start, action) { + const argv = []; + let index = start; + for (; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === ';' || token === '\\;' || ( + token === '+' && (action === '-exec' || action === '-execdir') && + argv[argv.length - 1] === '{}' + )) break; + argv.push(token); + } + return { argv, index }; +} + +function isDestructiveFindExecArgv(argv, quoteAware) { + // The legacy raw fallback can split quoted prose into apparent actions. + // Preserve its old rm/Git coverage, but classify dd only from real argv. + if (quoteAware && isDestructiveDd(argv, false)) return true; + const baseCmd = commandBasename(argv[0]); + // Preserve main's existing rm/rmdir/unlink and git-reset handling. + if (baseCmd === 'rm' || baseCmd === 'rmdir' || baseCmd === 'unlink') return true; + if (baseCmd !== 'git') return false; + const sub = findGitSubcommand(argv); + return Boolean(sub && sub.command === 'reset' && sub.rest.includes('--hard')); +} + function isDestructiveBash(command) { // SQL phrases live in command bodies, not as flag-bearing // arguments, so we still match them by regex - but on the input @@ -1817,37 +1832,51 @@ function inRange(code, [lo, hi]) { return code >= lo && code <= hi; } + function sanitizePath(filePath) { - // Strip control chars (including null), bidi overrides, separators, - // and the dangerous invisible characters defined by the constants - // above (mirroring scripts/ci/check-unicode-safety.js), so a denial - // message cannot carry content a human reviewer cannot see. let sanitized = ''; for (const char of String(filePath || '')) { - const code = char.codePointAt(0); - const isAsciiControl = - code <= ASCII_CONTROL_MAX || code === ASCII_DELETE || inRange(code, C1_CONTROLS); - const isBidiOverride = - inRange(code, BIDI_MARKS) || inRange(code, BIDI_EMBEDDINGS) || inRange(code, BIDI_ISOLATES); - const isUnicodeSeparator = code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR; - const isDangerousInvisible = - inRange(code, ZERO_WIDTHS) || - code === WORD_JOINER || - code === BYTE_ORDER_MARK || - code === SOFT_HYPHEN || - inRange(code, VARIATION_SELECTORS) || - inRange(code, VARIATION_SUPPLEMENTS) || - inRange(code, TAG_BLOCK) || - code === MONGOLIAN_VOWEL_SEPARATOR || - code === HANGUL_CHOSEONG_FILLER || - code === HANGUL_JUNGSEONG_FILLER || - code === HANGUL_FILLER || - inRange(code, INVISIBLE_MATH_OPERATORS); - sanitized += isAsciiControl || isBidiOverride || isUnicodeSeparator || isDangerousInvisible ? ' ' : char; + sanitized += isUnsafePathCodePoint(char.codePointAt(0)) ? ' ' : char; } return sanitized.trim().slice(0, SANITIZED_PATH_MAX_LENGTH); } +// Control chars (including null), bidi overrides, separators, and the +// dangerous invisible characters defined by the constants above (mirroring +// scripts/ci/check-unicode-safety.js), so a denial message cannot carry +// content a human reviewer cannot see. +function isUnsafePathCodePoint(code) { + return isAsciiControlCodePoint(code) + || isBidiOverrideCodePoint(code) + || code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR + || isDangerousInvisibleCodePoint(code); +} + +function isAsciiControlCodePoint(code) { + return code <= ASCII_CONTROL_MAX || code === ASCII_DELETE || inRange(code, C1_CONTROLS); +} + +function isBidiOverrideCodePoint(code) { + return inRange(code, BIDI_MARKS) || inRange(code, BIDI_EMBEDDINGS) || inRange(code, BIDI_ISOLATES); +} + +function isDangerousInvisibleCodePoint(code) { + return ( + inRange(code, ZERO_WIDTHS) || + code === WORD_JOINER || + code === BYTE_ORDER_MARK || + code === SOFT_HYPHEN || + inRange(code, VARIATION_SELECTORS) || + inRange(code, VARIATION_SUPPLEMENTS) || + inRange(code, TAG_BLOCK) || + code === MONGOLIAN_VOWEL_SEPARATOR || + code === HANGUL_CHOSEONG_FILLER || + code === HANGUL_JUNGSEONG_FILLER || + code === HANGUL_FILLER || + inRange(code, INVISIBLE_MATH_OPERATORS) + ); +} + function normalizeForMatch(value) { return String(value || '') .replace(/\\/g, '/') @@ -1859,75 +1888,47 @@ function isClaudeSettingsPath(filePath) { return /(^|\/)\.claude\/settings(?:\.[^/]+)?\.json$/.test(normalized); } + function isReadOnlyGitIntrospection(command) { const trimmed = String(command || '').trim(); - if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) { - return false; - } - - const segments = splitCommandSegments(trimmed); - if (segments.length !== 1) { - return false; - } - + if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) return false; + if (splitCommandSegments(trimmed).length !== 1) return false; const tokens = tokenizeAllowlistedShellWords(trimmed); - if (!tokens) { - return false; - } - if (commandBasename(tokens[0]) !== 'git' || tokens.length < 2) { - return false; - } + if (!tokens || commandBasename(tokens[0]) !== 'git' || tokens.length < 2) return false; + const check = READ_ONLY_GIT_SUBCOMMANDS.get(tokens[1].toLowerCase()); + return check ? check(tokens.slice(2)) : false; +} - const subcommand = tokens[1].toLowerCase(); - const args = tokens.slice(2); +const READ_ONLY_GIT_SHOW_REF = /^[a-zA-Z0-9._:/ -]+$/; +const READ_ONLY_GIT_DIFF_ARGS = new Set(['--name-only', '--name-status', '--cached', '--staged', '--stat']); - if (subcommand === 'status') { - return args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg)); +// Permits: git show , git show --stat, git show --name-only, +// git show --stat, git show --name-only +function isReadOnlyGitShow(args) { + if (args.length === 1) { + const arg = args[0]; + if (arg === '--stat' || arg === '--name-only') return true; + return !arg.startsWith('--') && READ_ONLY_GIT_SHOW_REF.test(arg); } - - if (subcommand === 'diff') { - const allowedDiffArgs = new Set(['--name-only', '--name-status', '--cached', '--staged', '--stat']); - // git diff without arguments is read-only introspection - if (args.length === 0) return true; - return args.length <= 2 && args.every(arg => allowedDiffArgs.has(arg)); + if (args.length === 2) { + const [first, second] = args; + return !first.startsWith('--') && READ_ONLY_GIT_SHOW_REF.test(first) && (second === '--stat' || second === '--name-only'); } - - if (subcommand === 'log') { - return args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg)); - } - - if (subcommand === 'show') { - // Permite: git show , git show --stat, git show --name-only, - // git show --stat, git show --name-only - if (args.length === 0) return false; - if (args.length === 1) { - const arg = args[0]; - if (arg === '--stat' || arg === '--name-only') return true; - // ref - return !arg.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(arg); - } - if (args.length === 2) { - const [first, second] = args; - // ref + flag - if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) && (second === '--stat' || second === '--name-only')) { - return true; - } - return false; - } - return false; - } - - if (subcommand === 'branch') { - return args.length === 1 && args[0] === '--show-current'; - } - - if (subcommand === 'rev-parse') { - return args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1]); - } - return false; } +// Subcommand -> predicate over its arguments. Only these exact spellings are +// treated as read-only introspection that skips the shell gate. +const READ_ONLY_GIT_SUBCOMMANDS = new Map([ + ['status', args => args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg))], + // git diff without arguments is read-only introspection + ['diff', args => args.length === 0 || (args.length <= 2 && args.every(arg => READ_ONLY_GIT_DIFF_ARGS.has(arg)))], + ['log', args => args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg))], + ['show', isReadOnlyGitShow], + ['branch', args => args.length === 1 && args[0] === '--show-current'], + ['rev-parse', args => args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1])] +]); + // --- Gate messages --- /** @@ -1971,7 +1972,7 @@ function firstTouchGateMsg(filePath, isWrite, cls, profile) { * (#2142). Carries the denial ordinal so consecutive denials differ * textually, and a one-line recovery hint instead of the multi-line block. */ -function condensedGateMsg(action, filePath, ordinal, cls = 'code', targetNote = '', profile = null, missNote = '') { +function condensedGateMsg(action, filePath, ordinal, { cls = 'code', targetNote = '', profile = null, missNote = '' } = {}) { const safe = sanitizePath(filePath); const hint = condensedHintFor(cls, action === 'creation', profile, questionOptions()); return ( @@ -2018,7 +2019,7 @@ function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked = }); if (denials > getFullDenialBudget()) { const action = isWrite ? 'creation' : 'edit'; - return denyResult(condensedGateMsg(action, filePath, denials, cls, targetNote, profile, missNote), { includeRecoveryHint: false }); + return denyResult(condensedGateMsg(action, filePath, denials, { cls, targetNote, profile, missNote }), { includeRecoveryHint: false }); } const message = withNoteBeforeRetry(firstTouchGateMsg(filePath, isWrite, cls, profile), missNote); return denyResult(targetNote ? withNoteBeforeRetry(message, targetNote) : message, { @@ -2331,6 +2332,7 @@ function run(rawInput) { } } + function gate(rawInput) { let data; try { @@ -2348,212 +2350,259 @@ function gate(rawInput) { const rawToolName = data.tool_name || ''; const toolInput = data.tool_input || {}; - // Normalize: case-insensitive matching via lookup map - const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', notebookedit: 'NotebookEdit', bash: 'Bash', powershell: 'PowerShell' }; const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName; if (isMetricsEnabled()) { pendingMetrics = { tool: toolName, data, entries: [], keys: new Set() }; } - const inSubagent = isSubagentInvocation(data); let turnScanner = null; - const getTurnScan = () => (turnScanner || (turnScanner = createTurnScanner(data)))(); + const ctx = { + rawInput, + data, + toolInput, + toolName, + inSubagent: isSubagentInvocation(data), + getTurnScan: () => (turnScanner || (turnScanner = createTurnScanner(data)))() + }; - if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') { - const filePath = (toolName === 'NotebookEdit' ? toolInput.notebook_path : toolInput.file_path) || ''; + if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') return gateSingleEdit(ctx); + if (toolName === 'MultiEdit') return gateMultiEdit(ctx); + if (toolName === 'Bash' || toolName === 'PowerShell') return gateShell(ctx); + return rawInput; // allow +} + +// Normalize: case-insensitive matching via lookup map +const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', notebookedit: 'NotebookEdit', bash: 'Bash', powershell: 'PowerShell' }; + +// --- Edit / Write / NotebookEdit --- + +function gateSingleEdit(ctx) { + const { toolName, toolInput, data, rawInput, inSubagent } = ctx; + const filePath = (toolName === 'NotebookEdit' ? toolInput.notebook_path : toolInput.file_path) || ''; + const exempt = exemptReason(filePath, data); + if (exempt) { + recordDecision('pass-exempt', exempt, { target: filePath }); + return rawInput; // allow + } + + if (inSubagent) { + const denial = subagentRestrictedDenial(filePath, data, toolName === 'Write'); + if (!denial) recordDecision('pass-subagent', 'subagent', { target: filePath }); + return denial || rawInput; + } + + const fileKey = canonicalPathKey(filePath, data); + if (!isChecked([fileKey, filePath])) return firstTouchSingleEdit(ctx, filePath, fileKey); + + recordDecision('pass-checked', 'checked', { target: filePath }); + return rawInput; // allow +} + +function singleEditProfile(ctx, filePath, cls, restricted) { + const { toolName, toolInput, data } = ctx; + if (restricted || toolName === 'NotebookEdit' || !needsProfile(toolName, cls)) return null; + return changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data); +} + +// see docs/gateguard/design-notes.md#sibling-collapse +function newFileCollapse(ctx, filePath, cls, isNewFile, restricted) { + if (!isNewFile || restricted) return { sibling: null, dirGate: undefined }; + const turnId = currentTurnId(ctx.getTurnScan()); + const collapsible = turnId !== null || !transcriptPathFor(ctx.data); + const gateKey = collapsible ? newFileGateKey(filePath, ctx.data, cls) : null; + const sibling = gateKey ? findSiblingGate(gateKey, turnId) : null; + const dirGate = gateKey ? { key: gateKey, turn: turnId, first: sanitizePath(filePath) } : undefined; + return { sibling, dirGate }; +} + +function firstTouchSingleEdit(ctx, filePath, fileKey) { + const { toolName, data, rawInput, getTurnScan } = ctx; + const isNewFile = toolName === 'Write' && isNewFileTarget(filePath, data); + const cls = classifyTargetFor(filePath, data); + // see docs/gateguard/design-notes.md#sensitive-targets + const { sensitive, linked, restricted } = allowanceFacts(filePath, data); + const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, isNewFile, data); + if (credit) { + if (!markCheckedAndCountCredit(fileKey, cls)) { + return allowWithStateWarning(); + } + recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive }); + return { additionalContext: creditNote(credit, filePath), exitCode: 0 }; + } + const profile = singleEditProfile(ctx, filePath, cls, restricted); + if (toolName === 'Edit' && isTrivialChange(cls, restricted, profile)) { + if (!countTrivialAllow()) { + return allowWithStateWarning(); + } + recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile }); + return { additionalContext: trivialNote(filePath), exitCode: 0 }; + } + const { sibling, dirGate } = newFileCollapse(ctx, filePath, cls, isNewFile, restricted); + if (sibling) { + if (!markCheckedAndCountSibling(fileKey)) { + return allowWithStateWarning(); + } + recordDecision('sibling', 'same-turn-dir', { target: filePath, cls, sensitive, profile }); + return { additionalContext: siblingNote(sibling), exitCode: 0 }; + } + const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); + const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, dirGate, cap }); + if (!ok) { + return allowWithStateWarning(); + } + if (denials === null) { + // see docs/gateguard/design-notes.md#denial-cap + recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile }); + return rawInput; + } + const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, isNewFile, data); + return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, linked, profile, miss }); +} + +// --- MultiEdit --- + +function gateMultiEdit(ctx) { + const { toolInput, data, rawInput, inSubagent } = ctx; + const edits = multiEditEntries(toolInput); + if (inSubagent) return gateMultiEditSubagent(edits, data, rawInput); + + const batch = { notes: [], trivialKeys: new Set() }; + for (const edit of edits) { + const filePath = (edit && edit.file_path) || ''; const exempt = exemptReason(filePath, data); if (exempt) { - recordDecision('pass-exempt', exempt, { target: filePath }); - return rawInput; // allow + recordDecision('pass-exempt', exempt, { target: filePath, key: `exempt:${filePath}` }); + continue; } - - if (inSubagent) { - const denial = subagentRestrictedDenial(filePath, data, toolName === 'Write'); - if (!denial) recordDecision('pass-subagent', 'subagent', { target: filePath }); - return denial || rawInput; - } - const fileKey = canonicalPathKey(filePath, data); - if (!isChecked([fileKey, filePath])) { - const isNewFile = toolName === 'Write' && isNewFileTarget(filePath, data); - const cls = classifyTargetFor(filePath, data); - // see docs/gateguard/design-notes.md#sensitive-targets - const { sensitive, linked, restricted } = allowanceFacts(filePath, data); - const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, isNewFile, data); - if (credit) { - if (!markCheckedAndCountCredit(fileKey, cls)) { - return allowWithStateWarning(); - } - recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive }); - return { additionalContext: creditNote(credit, filePath), exitCode: 0 }; - } - const profile = restricted || toolName === 'NotebookEdit' || !needsProfile(toolName, cls) - ? null - : changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data); - if (toolName === 'Edit' && isTrivialChange(cls, restricted, profile)) { - if (!countTrivialAllow()) { - return allowWithStateWarning(); - } - recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile }); - return { additionalContext: trivialNote(filePath), exitCode: 0 }; - } - const turnId = isNewFile && !restricted ? currentTurnId(getTurnScan()) : null; - const collapsible = isNewFile && !restricted && (turnId !== null || !transcriptPathFor(data)); - const gateKey = collapsible ? newFileGateKey(filePath, data, cls) : null; - const sibling = gateKey ? findSiblingGate(gateKey, turnId) : null; - if (sibling) { - if (!markCheckedAndCountSibling(fileKey)) { - return allowWithStateWarning(); - } - recordDecision('sibling', 'same-turn-dir', { target: filePath, cls, sensitive, profile }); - return { additionalContext: siblingNote(sibling), exitCode: 0 }; - } - const dirGate = gateKey ? { key: gateKey, turn: turnId, first: sanitizePath(filePath) } : undefined; - const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); - const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, dirGate, cap }); - if (!ok) { - return allowWithStateWarning(); - } - if (denials === null) { - // see docs/gateguard/design-notes.md#denial-cap - recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile }); - return rawInput; - } - const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, isNewFile, data); - return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, linked, profile, miss }); - } - - recordDecision('pass-checked', 'checked', { target: filePath }); - return rawInput; // allow - } - - if (toolName === 'MultiEdit') { - const edits = multiEditEntries(toolInput); - if (inSubagent) { - for (const edit of edits) { - const filePath = (edit && edit.file_path) || ''; - if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) continue; - const denial = subagentRestrictedDenial(filePath, data, false); - if (denial) return denial; - } - recordDecision('pass-subagent', 'subagent'); - return rawInput; - } - - const notes = []; - const trivialKeys = new Set(); - for (const edit of edits) { - const filePath = (edit && edit.file_path) || ''; - const exempt = exemptReason(filePath, data); - if (exempt) { - recordDecision('pass-exempt', exempt, { target: filePath, key: `exempt:${filePath}` }); - continue; - } - const fileKey = canonicalPathKey(filePath, data); - if (trivialKeys.has(fileKey)) continue; - if (!isChecked([fileKey, filePath])) { - const cls = classifyTargetFor(filePath, data); - const { sensitive, linked, restricted } = allowanceFacts(filePath, data); - const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, false, data); - if (credit) { - if (!markCheckedAndCountCredit(fileKey, cls)) { - return allowWithStateWarning(); - } - recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive, key: fileKey }); - notes.push(creditNote(credit, filePath)); - continue; - } - const profile = restricted || !needsProfile('MultiEdit', cls) - ? null - : changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data); - if (isTrivialChange(cls, restricted, profile)) { - if (!countTrivialAllow()) { - return allowWithStateWarning(); - } - trivialKeys.add(fileKey); - recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile, key: fileKey }); - notes.push(trivialNote(filePath)); - continue; - } - const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); - const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, cap }); - if (!ok) { - return allowWithStateWarning(); - } - if (denials === null) { - // see docs/gateguard/design-notes.md#denial-cap - recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile, key: fileKey }); - continue; - } - const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, false, data); - return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, linked, profile, miss }); - } + if (batch.trivialKeys.has(fileKey)) continue; + if (isChecked([fileKey, filePath])) { recordDecision('pass-checked', 'checked', { target: filePath, key: fileKey }); + continue; } - if (notes.length > 0) { - return { additionalContext: notes, exitCode: 0 }; - } - return rawInput; // allow + const result = firstTouchMultiEdit(ctx, edits, filePath, fileKey, batch); + if (result) return result; } - - if (toolName === 'Bash' || toolName === 'PowerShell') { - const command = toolInput.command || ''; - if (isReadOnlyGitIntrospection(command)) { - recordDecision('pass', 'readonly-git'); - return rawInput; - } - - if (classifyDestructiveCommand(toolName, command).length > 0) { - // Gate destructive commands on first attempt; allow retry after facts presented - const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16); - if (!isChecked(key)) { - if (!markChecked(key)) { - return allowWithStateWarning(); - } - recordDecision('destructive-deny', 'destructive'); - return denyResult(destructiveBashMsg(), { includeRecoveryHint: false }); - } - recordDecision('pass-checked', 'destructive-retry'); - return rawInput; // allow retry after facts presented - } - - // Operator opt-out: skip the routine shell gate entirely. The destructive - // gate above still fires. This is the documented escape hatch for hosts - // (Cursor, OpenCode, etc.) where the once-per-session routine gate is - // friction without signal. - if (isRoutineBashGateDisabled()) { - recordDecision('pass-exempt', 'routine-disabled'); - return rawInput; // routine gate opted out via env - } - - if (!isChecked(ROUTINE_BASH_SESSION_KEY)) { - if (isReadOnlyShellCommand(toolName, command)) { - if (!countRoutineReadonlyPass()) { - return allowWithStateWarning(); - } - recordDecision('routine-readonly', 'readonly'); - return rawInput; - } - if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { - return allowWithStateWarning(); - } - recordDecision('routine-deny', 'first-command'); - const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID; - const narrowRecoveryHint = toolName === 'PowerShell' - ? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT - : ROUTINE_BASH_NARROW_RECOVERY_HINT; - return denyResult(routineShellMsg(toolName), { - hookIds: [hookId], - narrowRecoveryHint - }); - } - - recordDecision('pass-checked', 'routine-checked'); - return rawInput; // allow + if (batch.notes.length > 0) { + return { additionalContext: batch.notes, exitCode: 0 }; } - return rawInput; // allow } +function gateMultiEditSubagent(edits, data, rawInput) { + for (const edit of edits) { + const filePath = (edit && edit.file_path) || ''; + if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) continue; + const denial = subagentRestrictedDenial(filePath, data, false); + if (denial) return denial; + } + recordDecision('pass-subagent', 'subagent'); + return rawInput; +} + +// Returns a gate result to stop on, or null when the batch loop should move +// to the next edit. `batch` collects notes and trivially allowed file keys. +function firstTouchMultiEdit(ctx, edits, filePath, fileKey, batch) { + const { data, getTurnScan } = ctx; + const cls = classifyTargetFor(filePath, data); + const { sensitive, linked, restricted } = allowanceFacts(filePath, data); + const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, false, data); + if (credit) { + if (!markCheckedAndCountCredit(fileKey, cls)) { + return allowWithStateWarning(); + } + recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive, key: fileKey }); + batch.notes.push(creditNote(credit, filePath)); + return null; + } + const profile = restricted || !needsProfile('MultiEdit', cls) + ? null + : changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data); + if (isTrivialChange(cls, restricted, profile)) { + if (!countTrivialAllow()) { + return allowWithStateWarning(); + } + batch.trivialKeys.add(fileKey); + recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile, key: fileKey }); + batch.notes.push(trivialNote(filePath)); + return null; + } + const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); + const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, cap }); + if (!ok) { + return allowWithStateWarning(); + } + if (denials === null) { + // see docs/gateguard/design-notes.md#denial-cap + recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile, key: fileKey }); + return null; + } + const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, false, data); + return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, linked, profile, miss }); +} + +// --- Bash / PowerShell --- + +function gateShell(ctx) { + const { toolName, toolInput, rawInput } = ctx; + const command = toolInput.command || ''; + if (isReadOnlyGitIntrospection(command)) { + recordDecision('pass', 'readonly-git'); + return rawInput; + } + + if (classifyDestructiveCommand(toolName, command).length > 0) return gateDestructiveShell(command, rawInput); + + // Operator opt-out: skip the routine shell gate entirely. The destructive + // gate above still fires. This is the documented escape hatch for hosts + // (Cursor, OpenCode, etc.) where the once-per-session routine gate is + // friction without signal. + if (isRoutineBashGateDisabled()) { + recordDecision('pass-exempt', 'routine-disabled'); + return rawInput; // routine gate opted out via env + } + + if (!isChecked(ROUTINE_BASH_SESSION_KEY)) return gateRoutineShell(toolName, command, rawInput); + + recordDecision('pass-checked', 'routine-checked'); + return rawInput; // allow +} + +// Gate destructive commands on first attempt; allow retry after facts presented +function gateDestructiveShell(command, rawInput) { + const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16); + if (isChecked(key)) { + recordDecision('pass-checked', 'destructive-retry'); + return rawInput; // allow retry after facts presented + } + if (!markChecked(key)) { + return allowWithStateWarning(); + } + recordDecision('destructive-deny', 'destructive'); + return denyResult(destructiveBashMsg(), { includeRecoveryHint: false }); +} + +function gateRoutineShell(toolName, command, rawInput) { + if (isReadOnlyShellCommand(toolName, command)) { + if (!countRoutineReadonlyPass()) { + return allowWithStateWarning(); + } + recordDecision('routine-readonly', 'readonly'); + return rawInput; + } + if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { + return allowWithStateWarning(); + } + recordDecision('routine-deny', 'first-command'); + const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID; + const narrowRecoveryHint = toolName === 'PowerShell' + ? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT + : ROUTINE_BASH_NARROW_RECOVERY_HINT; + return denyResult(routineShellMsg(toolName), { + hookIds: [hookId], + narrowRecoveryHint + }); +} + module.exports = { run, classifyDestructiveCommand, From 35c7681d47e510f407bd6a68f42d25b4909b4feb Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 12 Oct 2026 23:05:00 -0400 Subject: [PATCH 09/23] refactor(hooks): break up session-end summary and main flow extractSessionSummary did user-message filtering and two kinds of tool_use collection in one loop body six blocks deep. The per-entry work now lives in userMessageText, recordToolUse and collectEntry. main() is down to the sequence of steps: resolveTranscriptPath, sessionShortId, maybeGenerateLlmSummary, then updateSessionFile or createSessionFile, with the shared footer and empty-session template as constants. Call order, log lines, file names and the written markdown are unchanged, including the exact summary block replacement and the migration path for files without markers. --- scripts/hooks/session-end.js | 341 +++++++++++++++++++---------------- 1 file changed, 182 insertions(+), 159 deletions(-) diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index c7431eea7..1235cf7a9 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -18,6 +18,61 @@ const SUMMARY_START_MARKER = ''; const SUMMARY_END_MARKER = ''; const SESSION_SEPARATOR = '\n---\n'; +// Harness noise: local command echoes, caveats, system reminders. +const HARNESS_NOISE = /^<(local-command-caveat|local-command-stdout|command-name|command-message|command-args|system-reminder|task-notification)/i; +const TRANSCRIPT_UUID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const NEXT_SESSION_FOOTER = '\n\n### Notes for Next Session\n-\n\n### Context to Load\n```\n[relevant files]\n```'; +const EMPTY_SESSION_BODY = '## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n```\n[relevant files]\n```'; + +/** + * Text of a user turn, or '' when the entry is not a user ask worth keeping. + */ +function userMessageText(entry) { + if (entry.type !== 'user' && entry.role !== 'user' && entry.message?.role !== 'user') return ''; + // Support both direct content and nested message.content (Claude Code JSONL format) + const rawContent = entry.message?.content ?? entry.content; + // Skip tool_result carrier turns — they are not user asks. + if (Array.isArray(rawContent) && rawContent.some(c => c && c.type === 'tool_result')) return ''; + const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : ''; + const cleaned = stripAnsi(text).trim(); + // `isMeta` is also used for genuine channel- and plugin-originated + // human prompts. Exclude known structured noise instead of + // discarding every metadata-marked user turn. + return HARNESS_NOISE.test(cleaned) ? '' : cleaned; +} + +/** + * Record one tool invocation: its name, and its target when it edits a file. + */ +function recordToolUse(toolName, filePath, tally) { + if (toolName) tally.toolsUsed.add(toolName); + if (filePath && (toolName === 'Edit' || toolName === 'Write')) { + tally.filesModified.add(filePath); + } +} + +/** + * Fold one transcript entry into the running tally. + */ +function collectEntry(entry, tally) { + // Collect user messages (first 200 chars each) + const text = userMessageText(entry); + if (text) tally.userMessages.push(text.slice(0, 200)); + + // Collect tool names and modified files (direct tool_use entries) + if (entry.type === 'tool_use' || entry.tool_name) { + const toolName = entry.tool_name || entry.name || ''; + recordToolUse(toolName, entry.tool_input?.file_path || entry.input?.file_path || '', tally); + } + + // Extract tool uses from assistant message content blocks (Claude Code JSONL format) + if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) { + for (const block of entry.message.content) { + if (block.type === 'tool_use') recordToolUse(block.name || '', block.input?.file_path || '', tally); + } + } +} + /** * Extract a meaningful summary from the session transcript. * Reads the JSONL transcript and pulls out key information: @@ -30,58 +85,12 @@ function extractSessionSummary(transcriptPath) { if (!content) return null; const lines = content.split('\n').filter(Boolean); - const userMessages = []; - const toolsUsed = new Set(); - const filesModified = new Set(); + const tally = { userMessages: [], toolsUsed: new Set(), filesModified: new Set() }; let parseErrors = 0; for (const line of lines) { try { - const entry = JSON.parse(line); - - // Collect user messages (first 200 chars each) - if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') { - // Support both direct content and nested message.content (Claude Code JSONL format) - const rawContent = entry.message?.content ?? entry.content; - // Skip tool_result carrier turns — they are not user asks. - const isToolResult = Array.isArray(rawContent) && rawContent.some(c => c && c.type === 'tool_result'); - const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : ''; - const cleaned = stripAnsi(text).trim(); - // Skip harness noise: local command echoes, caveats, system reminders. - const isNoise = /^<(local-command-caveat|local-command-stdout|command-name|command-message|command-args|system-reminder|task-notification)/i.test(cleaned); - // `isMeta` is also used for genuine channel- and plugin-originated - // human prompts. Exclude known structured noise above instead of - // discarding every metadata-marked user turn. - if (cleaned && !isToolResult && !isNoise) { - userMessages.push(cleaned.slice(0, 200)); - } - } - - // Collect tool names and modified files (direct tool_use entries) - if (entry.type === 'tool_use' || entry.tool_name) { - const toolName = entry.tool_name || entry.name || ''; - if (toolName) toolsUsed.add(toolName); - - const filePath = entry.tool_input?.file_path || entry.input?.file_path || ''; - if (filePath && (toolName === 'Edit' || toolName === 'Write')) { - filesModified.add(filePath); - } - } - - // Extract tool uses from assistant message content blocks (Claude Code JSONL format) - if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) { - for (const block of entry.message.content) { - if (block.type === 'tool_use') { - const toolName = block.name || ''; - if (toolName) toolsUsed.add(toolName); - - const filePath = block.input?.file_path || ''; - if (filePath && (toolName === 'Edit' || toolName === 'Write')) { - filesModified.add(filePath); - } - } - } - } + collectEntry(JSON.parse(line), tally); } catch { parseErrors++; } @@ -91,13 +100,13 @@ function extractSessionSummary(transcriptPath) { log(`[SessionEnd] Skipped ${parseErrors}/${lines.length} unparseable transcript lines`); } - if (userMessages.length === 0) return null; + if (tally.userMessages.length === 0) return null; return { - userMessages: userMessages.slice(-10), // Last 10 user messages - toolsUsed: Array.from(toolsUsed).slice(0, 20), - filesModified: Array.from(filesModified).slice(0, 30), - totalMessages: userMessages.length + userMessages: tally.userMessages.slice(-10), // Last 10 user messages + toolsUsed: Array.from(tally.toolsUsed).slice(0, 20), + filesModified: Array.from(tally.filesModified).slice(0, 30), + totalMessages: tally.userMessages.length }; } @@ -174,25 +183,130 @@ function mergeSessionHeader(content, today, currentTime, metadata) { return `${nextHeader}${SESSION_SEPARATOR}${body}`; } -async function main() { - // Parse stdin JSON to get transcript_path; fall back to env var on missing, - // empty, or non-string values as well as on malformed JSON. - let transcriptPath = null; +/** + * Parse stdin JSON to get transcript_path; fall back to env var on missing, + * empty, or non-string values as well as on malformed JSON. + */ +function resolveTranscriptPath() { try { const input = JSON.parse(stdinData); if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) { - transcriptPath = input.transcript_path; + return input.transcript_path; } } catch { // Malformed stdin: fall through to the env-var fallback below. } - if (!transcriptPath) { - const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; - if (typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0) { - transcriptPath = envTranscriptPath; + const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; + return typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0 ? envTranscriptPath : null; +} + +/** + * Derive shortId from transcript_path UUID when available, using the SAME + * last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps + * backward compatibility for normal sessions (the derived shortId matches what + * getSessionIdShort() would have produced from the same UUID), while making + * every session map to a unique filename based on its own transcript UUID. + * + * Without this, a parent session and any `claude -p ...` subprocess spawned by + * another Stop hook share the project-name fallback filename, and the subprocess + * overwrites the parent's summary. See issue #1494 for full repro details. + */ +function sessionShortId(transcriptPath) { + const m = transcriptPath ? path.basename(transcriptPath).match(TRANSCRIPT_UUID) : null; + // Run through sanitizeSessionId() for byte-for-byte parity with + // getSessionIdShort(sessionId.slice(-8)). + const derived = m ? sanitizeSessionId(m[1].slice(-8).toLowerCase()) : null; + return derived || getSessionIdShort(); +} + +/** + * Decide whether to call LLM for a richer summary. + * Triggers: context remaining < 20%, or every 50 user messages as a baseline. + */ +function maybeGenerateLlmSummary(transcriptPath, summary) { + const contextPct = getContextRemainingPct(transcriptPath); + const isContextLow = contextPct !== null && contextPct < getContextThreshold(); + const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10); + const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 50; + const isPeriodicTurn = summary.totalMessages > 0 && summary.totalMessages % safeInterval === 0; + if (!isContextLow && !isPeriodicTurn) return null; + + log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`); + let skipped = false; + const llmSummary = generateSessionSummary(transcriptPath, { + onSkip: reason => { + skipped = true; + log(`[SessionEnd] LLM summary skipped (${reason}); falling back to mechanical extraction`); + } + }); + if (llmSummary) { + log('[SessionEnd] LLM summary generated successfully'); + } else if (!skipped) { + log('[SessionEnd] LLM summary failed; falling back to mechanical extraction'); + } + return llmSummary; +} + +function summaryBlockFor(summary, llmSummary) { + return llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : buildSummaryBlock(summary); +} + +/** + * Replace only the generated summary block. This keeps repeated Stop + * invocations idempotent and preserves user-authored sections in the same + * session file. + */ +function replaceSummaryBlock(content, summaryBlock) { + // Use function replacers: summaryBlock embeds raw user-message text, and a + // string replacement argument interprets $-sequences ($&, $$, $`, $', $n). + // A $& in a user message would otherwise re-inject the entire matched block + // and corrupt the persisted summary. A function replacer is treated literally. + if (content.includes(SUMMARY_START_MARKER) && content.includes(SUMMARY_END_MARKER)) { + return content.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock); + } + // Migration path for files created before summary markers existed. + return content.replace( + /## (?:Session Summary|Current State)[\s\S]*?$/, + () => `${summaryBlock}${NEXT_SESSION_FOOTER}\n` + ); +} + +function updateSessionFile(sessionFile, { today, currentTime, metadata, summary, llmSummary }) { + const existing = readFile(sessionFile); + let updatedContent = existing; + + if (existing) { + const merged = mergeSessionHeader(existing, today, currentTime, metadata); + if (merged) { + updatedContent = merged; + } else { + log(`[SessionEnd] Failed to normalize header in ${sessionFile}`); } } + if (summary && updatedContent) { + updatedContent = replaceSummaryBlock(updatedContent, summaryBlockFor(summary, llmSummary)); + } + + if (updatedContent) { + writeFile(sessionFile, updatedContent); + } + + log(`[SessionEnd] Updated session file: ${sessionFile}`); +} + +function createSessionFile(sessionFile, { today, currentTime, metadata, summary, llmSummary }) { + const block = llmSummary || summary ? summaryBlockFor(summary, llmSummary) : null; + const summarySection = block ? `${block}${NEXT_SESSION_FOOTER}` : EMPTY_SESSION_BODY; + const template = `${buildSessionHeader(today, currentTime, metadata)}${SESSION_SEPARATOR}${summarySection}\n`; + + writeFile(sessionFile, template); + log(`[SessionEnd] Created session file: ${sessionFile}`); +} + +async function main() { + const transcriptPath = resolveTranscriptPath(); + // ECC's LLM summary helper launches a one-shot Claude subprocess whose Stop // hooks inherit this dedicated marker. Skip that known internal session // before touching session state. Transcript cardinality is not a safe proxy: @@ -218,111 +332,20 @@ async function main() { const sessionsDir = getSessionsDir(); const today = getDateString(); - // Derive shortId from transcript_path UUID when available, using the SAME - // last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps - // backward compatibility for normal sessions (the derived shortId matches what - // getSessionIdShort() would have produced from the same UUID), while making - // every session map to a unique filename based on its own transcript UUID. - // - // Without this, a parent session and any `claude -p ...` subprocess spawned by - // another Stop hook share the project-name fallback filename, and the subprocess - // overwrites the parent's summary. See issue #1494 for full repro details. - let shortId = null; - if (transcriptPath) { - const m = path.basename(transcriptPath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i); - if (m) { - // Run through sanitizeSessionId() for byte-for-byte parity with - // getSessionIdShort(sessionId.slice(-8)). - shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase()); - } - } - if (!shortId) { - shortId = getSessionIdShort(); - } + const shortId = sessionShortId(transcriptPath); const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); - const sessionMetadata = getSessionMetadata(); + const metadata = getSessionMetadata(); ensureDir(sessionsDir); const currentTime = getTimeString(); - - // Decide whether to call LLM for a richer summary. - // Triggers: context remaining < 20%, or every 50 user messages as a baseline. - let llmSummary = null; - if (transcriptPath && summary && transcriptExists) { - const contextPct = getContextRemainingPct(transcriptPath); - const isContextLow = contextPct !== null && contextPct < getContextThreshold(); - const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10); - const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 50; - const isPeriodicTurn = summary.totalMessages > 0 && summary.totalMessages % safeInterval === 0; - if (isContextLow || isPeriodicTurn) { - log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`); - let skipped = false; - llmSummary = generateSessionSummary(transcriptPath, { - onSkip: reason => { - skipped = true; - log(`[SessionEnd] LLM summary skipped (${reason}); falling back to mechanical extraction`); - } - }); - if (llmSummary) { - log('[SessionEnd] LLM summary generated successfully'); - } else if (!skipped) { - log('[SessionEnd] LLM summary failed; falling back to mechanical extraction'); - } - } - } + const llmSummary = transcriptPath && summary && transcriptExists ? maybeGenerateLlmSummary(transcriptPath, summary) : null; + const context = { today, currentTime, metadata, summary, llmSummary }; if (fs.existsSync(sessionFile)) { - const existing = readFile(sessionFile); - let updatedContent = existing; - - if (existing) { - const merged = mergeSessionHeader(existing, today, currentTime, sessionMetadata); - if (merged) { - updatedContent = merged; - } else { - log(`[SessionEnd] Failed to normalize header in ${sessionFile}`); - } - } - - // If we have a new summary, update only the generated summary block. - // This keeps repeated Stop invocations idempotent and preserves - // user-authored sections in the same session file. - if (summary && updatedContent) { - const summaryBlock = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : buildSummaryBlock(summary); - - // Use function replacers: summaryBlock embeds raw user-message text, and a - // string replacement argument interprets $-sequences ($&, $$, $`, $', $n). - // A $& in a user message would otherwise re-inject the entire matched block - // and corrupt the persisted summary. A function replacer is treated literally. - if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) { - updatedContent = updatedContent.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock); - } else { - // Migration path for files created before summary markers existed. - updatedContent = updatedContent.replace( - /## (?:Session Summary|Current State)[\s\S]*?$/, - () => `${summaryBlock}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n` - ); - } - } - - if (updatedContent) { - writeFile(sessionFile, updatedContent); - } - - log(`[SessionEnd] Updated session file: ${sessionFile}`); + updateSessionFile(sessionFile, context); } else { - // Create new session file - const block = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : summary ? buildSummaryBlock(summary) : null; - const summarySection = block - ? `${block}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`` - : `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``; - - const template = `${buildSessionHeader(today, currentTime, sessionMetadata)}${SESSION_SEPARATOR}${summarySection} -`; - - writeFile(sessionFile, template); - log(`[SessionEnd] Created session file: ${sessionFile}`); + createSessionFile(sessionFile, context); } process.exit(0); From d3c49a80df67e6c4004ddfe59ebda99bc38b5d75 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 14 Oct 2026 21:27:00 -0400 Subject: [PATCH 10/23] refactor(ci): split validate-hooks into per-shape validators validateHookEntry handled command, http and prompt hooks in one function; it now dispatches to validateCommandHook, validateHttpHook and validatePromptHook with the inline node -e check in validateInlineJs. validateHooks reads the file, loads the sidecar and validates the object or legacy array shape through readHooksFile, loadMetadata, validateObjectFormat and validateArrayFormat, so the deepest nesting is gone. Error messages, their order, exit codes and the --update-fingerprints flow are unchanged. --- scripts/ci/validate-hooks.js | 441 ++++++++++++++++++++++------------- 1 file changed, 274 insertions(+), 167 deletions(-) diff --git a/scripts/ci/validate-hooks.js b/scripts/ci/validate-hooks.js index 2156b07dd..1523e8653 100644 --- a/scripts/ci/validate-hooks.js +++ b/scripts/ci/validate-hooks.js @@ -82,6 +82,86 @@ function isNonEmptyStringArray(value) { return Array.isArray(value) && value.length > 0 && value.every(item => isNonEmptyString(item)); } +function isStringRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.values(value).every(item => typeof item === 'string'); +} + +/** + * Compile the inline program of a `node -e "..."` command so a typo in + * hooks.json fails validation instead of every hook invocation. + * @returns {boolean} true if errors were found + */ +function validateInlineJs(command, label) { + const nodeEMatch = command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s); + if (!nodeEMatch) return false; + try { + new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t')); + return false; + } catch (syntaxErr) { + console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`); + return true; + } +} + +/** @returns {boolean} true if errors were found */ +function validateCommandHook(hook, label) { + let hasErrors = false; + + if ('async' in hook && typeof hook.async !== 'boolean') { + console.error(`ERROR: ${label} 'async' must be a boolean`); + hasErrors = true; + } + + if (!isNonEmptyString(hook.command) && !isNonEmptyStringArray(hook.command)) { + console.error(`ERROR: ${label} missing or invalid 'command' field`); + hasErrors = true; + } else if (typeof hook.command === 'string' && validateInlineJs(hook.command, label)) { + hasErrors = true; + } + + return hasErrors; +} + +/** @returns {boolean} true if errors were found */ +function validateHttpHook(hook, label) { + let hasErrors = false; + + if (!isNonEmptyString(hook.url)) { + console.error(`ERROR: ${label} missing or invalid 'url' field`); + hasErrors = true; + } + + if ('headers' in hook && !isStringRecord(hook.headers)) { + console.error(`ERROR: ${label} 'headers' must be an object with string values`); + hasErrors = true; + } + + if ('allowedEnvVars' in hook && (!Array.isArray(hook.allowedEnvVars) || !hook.allowedEnvVars.every(value => isNonEmptyString(value)))) { + console.error(`ERROR: ${label} 'allowedEnvVars' must be an array of strings`); + hasErrors = true; + } + + return hasErrors; +} + +/** prompt and agent hooks share one shape. @returns {boolean} true if errors were found */ +function validatePromptHook(hook, label) { + let hasErrors = false; + + if (!isNonEmptyString(hook.prompt)) { + console.error(`ERROR: ${label} missing or invalid 'prompt' field`); + hasErrors = true; + } + + if ('model' in hook && !isNonEmptyString(hook.model)) { + console.error(`ERROR: ${label} 'model' must be a non-empty string`); + hasErrors = true; + } + + return hasErrors; +} + /** * Validate a single hook entry has required fields and valid inline JS * @param {object} hook - Hook object with type and command fields @@ -105,27 +185,7 @@ function validateHookEntry(hook, label) { } if (hook.type === 'command') { - if ('async' in hook && typeof hook.async !== 'boolean') { - console.error(`ERROR: ${label} 'async' must be a boolean`); - hasErrors = true; - } - - if (!isNonEmptyString(hook.command) && !isNonEmptyStringArray(hook.command)) { - console.error(`ERROR: ${label} missing or invalid 'command' field`); - hasErrors = true; - } else if (typeof hook.command === 'string') { - const nodeEMatch = hook.command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s); - if (nodeEMatch) { - try { - new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t')); - } catch (syntaxErr) { - console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`); - hasErrors = true; - } - } - } - - return hasErrors; + return validateCommandHook(hook, label) || hasErrors; } if ('async' in hook) { @@ -134,35 +194,10 @@ function validateHookEntry(hook, label) { } if (hook.type === 'http') { - if (!isNonEmptyString(hook.url)) { - console.error(`ERROR: ${label} missing or invalid 'url' field`); - hasErrors = true; - } - - if ('headers' in hook && (typeof hook.headers !== 'object' || hook.headers === null || Array.isArray(hook.headers) || !Object.values(hook.headers).every(value => typeof value === 'string'))) { - console.error(`ERROR: ${label} 'headers' must be an object with string values`); - hasErrors = true; - } - - if ('allowedEnvVars' in hook && (!Array.isArray(hook.allowedEnvVars) || !hook.allowedEnvVars.every(value => isNonEmptyString(value)))) { - console.error(`ERROR: ${label} 'allowedEnvVars' must be an array of strings`); - hasErrors = true; - } - - return hasErrors; + return validateHttpHook(hook, label) || hasErrors; } - if (!isNonEmptyString(hook.prompt)) { - console.error(`ERROR: ${label} missing or invalid 'prompt' field`); - hasErrors = true; - } - - if ('model' in hook && !isNonEmptyString(hook.model)) { - console.error(`ERROR: ${label} 'model' must be a non-empty string`); - hasErrors = true; - } - - return hasErrors; + return validatePromptHook(hook, label) || hasErrors; } /** @@ -237,7 +272,15 @@ function validateAgainstSchema(document, schemaPath, label) { return true; } -function validateHooks() { +function isObjectFormat(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function isValidMatcherValue(value) { + return typeof value === 'string' || (typeof value === 'object' && value !== null); +} + +function readHooksFile() { if (!fs.existsSync(HOOKS_FILE)) { console.log('No hooks.json found, skipping validation'); process.exit(0); @@ -250,45 +293,193 @@ function validateHooks() { console.error(`ERROR: Invalid JSON in hooks.json: ${e.message}`); process.exit(1); } + return data; +} - // Without a sidecar, hooks.json keeps its legacy inline ids. With one, the - // sidecar is the sole owner of id/description and hooks.json must stay - // within Claude Code's schema. - let metadata = null; - const metadataPath = metadataPathFor(HOOKS_FILE); - if (fs.existsSync(metadataPath)) { +/** + * Load and check the hooks.metadata.json sidecar, if present. + * + * Without a sidecar, hooks.json keeps its legacy inline ids. With one, the + * sidecar is the sole owner of id/description and hooks.json must stay + * within Claude Code's schema. Exits the process on any sidecar error. + * + * @returns {object|null} parsed (and possibly refreshed) metadata, or null + */ +function loadMetadata(data, metadataPath) { + if (!fs.existsSync(metadataPath)) { + return null; + } + + let metadata; + try { + metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + } catch (e) { + console.error(`ERROR: Invalid JSON in ${METADATA_FILENAME}: ${e.message}`); + process.exit(1); + } + + if (validateHarnessCompatibility(data)) { + process.exit(1); + } + + if (UPDATE_FINGERPRINTS) { try { - metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - } catch (e) { - console.error(`ERROR: Invalid JSON in ${METADATA_FILENAME}: ${e.message}`); + metadata = withRefreshedFingerprints(data, metadata); + } catch (error) { + console.error(`ERROR: ${error.message}`); process.exit(1); } + } - if (validateHarnessCompatibility(data)) { - process.exit(1); + if (validateAgainstSchema(metadata, METADATA_SCHEMA_PATH, METADATA_FILENAME)) { + process.exit(1); + } + + const mismatches = findMetadataMismatches(data, metadata); + if (mismatches.length > 0) { + for (const mismatch of mismatches) { + console.error(`ERROR: ${mismatch}`); + } + process.exit(1); + } + + return metadata; +} + +/** + * Validate each hook entry of one matcher. + * @returns {boolean} true if errors were found + */ +function validateHookEntries(entries, label) { + let hasErrors = false; + for (let j = 0; j < entries.length; j++) { + if (validateHookEntry(entries[j], `${label}.hooks[${j}]`)) { + hasErrors = true; + } + } + return hasErrors; +} + +/** + * Validate one matcher of the object format. `ids` tracks stable ids when the + * document requires them, and is null otherwise. + * @returns {boolean} true if errors were found + */ +function validateObjectMatcher(eventType, index, matcher, ids) { + const matcherLabel = `${eventType}[${index}]`; + let hasErrors = false; + + if (ids && !isNonEmptyString(matcher.id)) { + console.error(`ERROR: ${matcherLabel} missing or invalid 'id' field`); + hasErrors = true; + } else if (ids && ids.has(matcher.id)) { + console.error( + `ERROR: ${matcherLabel} has duplicate id '${matcher.id}' (already used by ${ids.get(matcher.id)})` + ); + hasErrors = true; + } else if (ids) { + ids.set(matcher.id, matcherLabel); + } + + if (!('matcher' in matcher) && !EVENTS_WITHOUT_MATCHER.has(eventType)) { + console.error(`ERROR: ${matcherLabel} missing 'matcher' field`); + hasErrors = true; + } else if ('matcher' in matcher && !isValidMatcherValue(matcher.matcher)) { + console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`); + hasErrors = true; + } + + if (!matcher.hooks || !Array.isArray(matcher.hooks) || matcher.hooks.length === 0) { + console.error(`ERROR: ${matcherLabel} missing 'hooks' array`); + hasErrors = true; + } else if (validateHookEntries(matcher.hooks, matcherLabel)) { + hasErrors = true; + } + + return hasErrors; +} + +/** + * Object format: { EventType: [matchers] } + * @returns {{ hasErrors: boolean, totalMatchers: number }} + */ +function validateObjectFormat(hooks, requiresStableIds) { + const ids = requiresStableIds ? new Map() : null; + let hasErrors = false; + let totalMatchers = 0; + + for (const [eventType, matchers] of Object.entries(hooks)) { + if (!VALID_EVENTS.includes(eventType)) { + console.error(`ERROR: Invalid event type: ${eventType}`); + hasErrors = true; + continue; } - if (UPDATE_FINGERPRINTS) { - try { - metadata = withRefreshedFingerprints(data, metadata); - } catch (error) { - console.error(`ERROR: ${error.message}`); - process.exit(1); + if (!Array.isArray(matchers)) { + console.error(`ERROR: ${eventType} must be an array`); + hasErrors = true; + continue; + } + + for (let i = 0; i < matchers.length; i++) { + const matcher = matchers[i]; + if (typeof matcher !== 'object' || matcher === null) { + console.error(`ERROR: ${eventType}[${i}] is not an object`); + hasErrors = true; + continue; } - } - - if (validateAgainstSchema(metadata, METADATA_SCHEMA_PATH, METADATA_FILENAME)) { - process.exit(1); - } - - const mismatches = findMetadataMismatches(data, metadata); - if (mismatches.length > 0) { - for (const mismatch of mismatches) { - console.error(`ERROR: ${mismatch}`); + if (validateObjectMatcher(eventType, i, matcher, ids)) { + hasErrors = true; } - process.exit(1); + totalMatchers++; } + } + return { hasErrors, totalMatchers }; +} + +/** @returns {boolean} true if errors were found */ +function validateLegacyHook(hook, index) { + let hasErrors = false; + + if (!('matcher' in hook)) { + console.error(`ERROR: Hook ${index} missing 'matcher' field`); + hasErrors = true; + } else if (!isValidMatcherValue(hook.matcher)) { + console.error(`ERROR: Hook ${index} has invalid 'matcher' field`); + hasErrors = true; + } + + if (!hook.hooks || !Array.isArray(hook.hooks)) { + console.error(`ERROR: Hook ${index} missing 'hooks' array`); + hasErrors = true; + } else if (validateHookEntries(hook.hooks, `Hook ${index}`)) { + hasErrors = true; + } + + return hasErrors; +} + +/** + * Array format (legacy): [{ matcher, hooks }] + * @returns {{ hasErrors: boolean, totalMatchers: number }} + */ +function validateArrayFormat(hooks) { + let hasErrors = false; + for (let i = 0; i < hooks.length; i++) { + if (validateLegacyHook(hooks[i], i)) { + hasErrors = true; + } + } + return { hasErrors, totalMatchers: hooks.length }; +} + +function validateHooks() { + let data = readHooksFile(); + + const metadataPath = metadataPathFor(HOOKS_FILE); + const metadata = loadMetadata(data, metadataPath); + if (metadata) { // Validate the merged view so the id/description rules below still apply. data = applyHooksMetadata(data, metadata); } @@ -300,103 +491,19 @@ function validateHooks() { // Support both object format { hooks: {...} } and array format const hooks = data.hooks || data; - const requiresStableIds = Boolean( - data - && typeof data === 'object' - && !Array.isArray(data) - && data.hooks - && typeof data.hooks === 'object' - && !Array.isArray(data.hooks) - ); - let hasErrors = false; - let totalMatchers = 0; - const matcherIdLocations = new Map(); + const requiresStableIds = isObjectFormat(data) && isObjectFormat(data.hooks); + let result; if (typeof hooks === 'object' && !Array.isArray(hooks)) { - // Object format: { EventType: [matchers] } - for (const [eventType, matchers] of Object.entries(hooks)) { - if (!VALID_EVENTS.includes(eventType)) { - console.error(`ERROR: Invalid event type: ${eventType}`); - hasErrors = true; - continue; - } - - if (!Array.isArray(matchers)) { - console.error(`ERROR: ${eventType} must be an array`); - hasErrors = true; - continue; - } - - for (let i = 0; i < matchers.length; i++) { - const matcher = matchers[i]; - if (typeof matcher !== 'object' || matcher === null) { - console.error(`ERROR: ${eventType}[${i}] is not an object`); - hasErrors = true; - continue; - } - const matcherLabel = `${eventType}[${i}]`; - if (requiresStableIds && !isNonEmptyString(matcher.id)) { - console.error(`ERROR: ${matcherLabel} missing or invalid 'id' field`); - hasErrors = true; - } else if (requiresStableIds && matcherIdLocations.has(matcher.id)) { - console.error( - `ERROR: ${matcherLabel} has duplicate id '${matcher.id}' (already used by ${matcherIdLocations.get(matcher.id)})` - ); - hasErrors = true; - } else if (requiresStableIds) { - matcherIdLocations.set(matcher.id, matcherLabel); - } - if (!('matcher' in matcher) && !EVENTS_WITHOUT_MATCHER.has(eventType)) { - console.error(`ERROR: ${matcherLabel} missing 'matcher' field`); - hasErrors = true; - } else if ('matcher' in matcher && typeof matcher.matcher !== 'string' && (typeof matcher.matcher !== 'object' || matcher.matcher === null)) { - console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`); - hasErrors = true; - } - if (!matcher.hooks || !Array.isArray(matcher.hooks) || matcher.hooks.length === 0) { - console.error(`ERROR: ${matcherLabel} missing 'hooks' array`); - hasErrors = true; - } else { - // Validate each hook entry - for (let j = 0; j < matcher.hooks.length; j++) { - if (validateHookEntry(matcher.hooks[j], `${matcherLabel}.hooks[${j}]`)) { - hasErrors = true; - } - } - } - totalMatchers++; - } - } + result = validateObjectFormat(hooks, requiresStableIds); } else if (Array.isArray(hooks)) { - // Array format (legacy) - for (let i = 0; i < hooks.length; i++) { - const hook = hooks[i]; - if (!('matcher' in hook)) { - console.error(`ERROR: Hook ${i} missing 'matcher' field`); - hasErrors = true; - } else if (typeof hook.matcher !== 'string' && (typeof hook.matcher !== 'object' || hook.matcher === null)) { - console.error(`ERROR: Hook ${i} has invalid 'matcher' field`); - hasErrors = true; - } - if (!hook.hooks || !Array.isArray(hook.hooks)) { - console.error(`ERROR: Hook ${i} missing 'hooks' array`); - hasErrors = true; - } else { - // Validate each hook entry - for (let j = 0; j < hook.hooks.length; j++) { - if (validateHookEntry(hook.hooks[j], `Hook ${i}.hooks[${j}]`)) { - hasErrors = true; - } - } - } - totalMatchers++; - } + result = validateArrayFormat(hooks); } else { console.error('ERROR: hooks.json must be an object or array'); process.exit(1); } - if (hasErrors) { + if (result.hasErrors) { process.exit(1); } @@ -405,7 +512,7 @@ function validateHooks() { console.log(`Updated fingerprints in ${METADATA_FILENAME}`); } - console.log(`Validated ${totalMatchers} hook matchers`); + console.log(`Validated ${result.totalMatchers} hook matchers`); } validateHooks(); From 944ea9d0503acbf60a6987b5f49171b1e64f7892 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 14 Oct 2026 21:58:00 -0400 Subject: [PATCH 11/23] fix(ci): unescape node -e inline programs in one pass validateInlineJs unescaped the shell body with four chained replaces, so a backslash pair followed by n or t was unescaped twice: the pair became one backslash, then the new backslash-n became a newline. A valid hook command such as console.log('\\n') failed validation with an unterminated string. CodeQL flags the chain as double unescaping. One regex pass maps each escape exactly once. The regression test feeds the escaped-backslash program through the validator CLI. --- scripts/ci/validate-hooks.js | 9 ++++++++- tests/ci/validators.test.js | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/ci/validate-hooks.js b/scripts/ci/validate-hooks.js index 1523e8653..338bfbb83 100644 --- a/scripts/ci/validate-hooks.js +++ b/scripts/ci/validate-hooks.js @@ -87,6 +87,13 @@ function isStringRecord(value) { && Object.values(value).every(item => typeof item === 'string'); } +const INLINE_ESCAPES = { '\\': '\\', '"': '"', n: '\n', t: '\t' }; + +/** Unescape a shell double-quoted `node -e` body in one pass, so `\\n` stays a literal backslash and n. */ +function unescapeInlineJs(source) { + return source.replace(/\\(["\\nt])/g, (match, ch) => INLINE_ESCAPES[ch]); +} + /** * Compile the inline program of a `node -e "..."` command so a typo in * hooks.json fails validation instead of every hook invocation. @@ -96,7 +103,7 @@ function validateInlineJs(command, label) { const nodeEMatch = command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s); if (!nodeEMatch) return false; try { - new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t')); + new vm.Script(unescapeInlineJs(nodeEMatch[1])); return false; } catch (syntaxErr) { console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`); diff --git a/tests/ci/validators.test.js b/tests/ci/validators.test.js index ace7ccbdb..1eda199f9 100644 --- a/tests/ci/validators.test.js +++ b/tests/ci/validators.test.js @@ -1039,6 +1039,21 @@ function runTests() { cleanupTestDir(testDir); })) passed++; else failed++; + if (test('unescapes inline JS in one pass so an escaped backslash before n stays literal', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + // Shell body `console.log('\\n')` is a valid program; a sequential unescape turned it into a raw newline. + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ id: 'test:escaped-backslash-inline-js', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "console.log(\'\\\\n\')"' }] }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 0, `Should pass escaped-backslash inline JS, stderr: ${result.stderr}`); + cleanupTestDir(testDir); + })) passed++; else failed++; + if (test('validates array command format', () => { const testDir = createTestDir(); const hooksFile = path.join(testDir, 'hooks.json'); From 8c1925722ce7e065c6bdbb9d1ad122bfa57a13af Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 7 Oct 2026 22:31:00 -0400 Subject: [PATCH 12/23] refactor(lib): split powershell destructive-command scanners The here-string normalizer, comment stripper, and balanced-group reader each carried their own copy of the same inert-region state machine. They now share createLexState, consumeInertChar, and openInertRegion. The container extractor, statement tokenizer, nested pwsh scanner, cmd scanner, Start-Process scanner, and top-level statement walk are split into small named helpers that each handle one region or one command family. No classification change. Verified against the recorded test inputs plus mutated variants with an order-sensitive comparison of findings. Clears the eleven complexity and prefer-const warnings on this file. --- scripts/lib/powershell-destructive-command.js | 2012 +++++++++-------- 1 file changed, 1080 insertions(+), 932 deletions(-) diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index c5b90d048..5ecb6cac6 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -113,88 +113,169 @@ function isCommandFlag(token) { isParameterPrefix(token, 'commandwithargs') || name === 'cwa'; } +/** + * Shared inert-region tracking for the character scanners below. Comments, + * `${}` variable names, quoted strings, and backtick escapes never contain + * container delimiters or here-string openers, so every scanner skips them + * the same way. + */ +function createLexState() { + return { + quote: null, + lineComment: false, + blockComment: false, + bracedVariable: false, + comment: false, + }; +} + +/** + * Consume the character at `index` when it belongs to an open inert region. + * Returns the last index consumed, or -1 when the character is live and the + * caller must interpret it. `state.comment` reports whether the consumed run + * is comment text. + */ +function consumeInertChar(state, input, index) { + const char = input[index]; + const next = input[index + 1]; + state.comment = false; + if (state.lineComment) { + if (char === '\n' || char === '\r') { + state.lineComment = false; + } else { + state.comment = true; + } + return index; + } + if (state.blockComment) { + if (char === '#' && next === '>') { + state.blockComment = false; + state.comment = true; + return index + 1; + } + state.comment = char !== '\n' && char !== '\r'; + return index; + } + if (state.bracedVariable) { + if (char === '`') return index + 1; + if (char === '}') state.bracedVariable = false; + return index; + } + if (state.quote === "'") { + if (char === "'" && next === "'") return index + 1; + if (char === "'") state.quote = null; + return index; + } + if (char === '`') return index + 1; + if (state.quote === '"') { + if (char === '"') state.quote = null; + return index; + } + return -1; +} + +/** + * Open an inert region at a live character. Returns the last index consumed, + * or -1 when the character does not start a region. + */ +function openInertRegion(state, input, index) { + const char = input[index]; + const next = input[index + 1]; + state.comment = false; + if (char === "'" || char === '"') { + state.quote = char; + return index; + } + if (char === '$' && next === '{') { + state.bracedVariable = true; + return index + 1; + } + if (char === '<' && next === '#') { + state.blockComment = true; + state.comment = true; + return index + 1; + } + if (char === '#') { + state.lineComment = true; + state.comment = true; + return index; + } + return -1; +} + +function hereStringOpenerLineEnd(input, index) { + let openerLineEnd = index + 2; + while (input[openerLineEnd] === ' ' || input[openerLineEnd] === '\t') openerLineEnd += 1; + if (input[openerLineEnd] === '\r' && input[openerLineEnd + 1] === '\n') openerLineEnd += 1; + return openerLineEnd; +} + +function hereStringClosingEnd(input, openerLineEnd, quote) { + for (let lineStart = openerLineEnd + 1; lineStart < input.length;) { + let contentStart = lineStart; + while (input[contentStart] === ' ' || input[contentStart] === '\t') contentStart += 1; + if (input[contentStart] === quote && input[contentStart + 1] === '@') { + return contentStart + 2; + } + while (lineStart < input.length && input[lineStart] !== '\n') lineStart += 1; + if (lineStart < input.length) lineStart += 1; + } + return -1; +} + +// Expandable here-strings execute their unescaped subexpressions while the +// string value is being formed, independently of any later consumer. +function collectHereStringSubexpressions(input, start, end, executablePayloads) { + for (let offset = start; offset < end; offset += 1) { + if (input[offset] === '`') { + offset += 1; + continue; + } + if (input[offset] !== '$' || input[offset + 1] !== '(') continue; + const group = readBalancedGroup(input, offset + 1, '(', ')'); + if (!group || group.end > end) break; + executablePayloads.push(group.body); + offset = group.end - 1; + } +} + +function applyReplacements(output, replacements) { + let normalized = ''; + let cursor = 0; + for (const replacement of replacements) { + normalized += output.slice(cursor, replacement.start).join(''); + normalized += replacement.value; + cursor = replacement.end; + } + return normalized + output.slice(cursor).join(''); +} + function normalizeHereStrings(input, executablePayloads = []) { const output = [...input]; const replacements = []; - let ordinaryQuote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = 0; index < input.length - 1; index += 1) { - const char = input[index]; - const next = input[index + 1]; - if (lineComment) { - if (char === '\n' || char === '\r') lineComment = false; - continue; - } - if (blockComment) { - if (char === '#' && next === '>') { - blockComment = false; - index += 1; - } - continue; - } - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - if (ordinaryQuote === "'") { - if (char === "'" && input[index + 1] === "'") index += 1; - else if (char === "'") ordinaryQuote = null; - continue; - } - if (char === '`') { - index += 1; - continue; - } - if (ordinaryQuote === '"') { - if (char === '"') ordinaryQuote = null; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - if (char === '<' && next === '#') { - blockComment = true; - index += 1; - continue; - } - if (char === '#') { - lineComment = true; - continue; - } - - if (input[index] !== '@' || (input[index + 1] !== "'" && input[index + 1] !== '"')) { - if (char === "'" || char === '"') ordinaryQuote = char; + const inertEnd = consumeInertChar(state, input, index); + if (inertEnd !== -1) { + index = inertEnd; continue; } const quote = input[index + 1]; - let openerLineEnd = index + 2; - while (input[openerLineEnd] === ' ' || input[openerLineEnd] === '\t') openerLineEnd += 1; - if (input[openerLineEnd] === '\r' && input[openerLineEnd + 1] === '\n') openerLineEnd += 1; + if (input[index] !== '@' || (quote !== "'" && quote !== '"')) { + const regionEnd = openInertRegion(state, input, index); + if (regionEnd !== -1) index = regionEnd; + continue; + } + + const openerLineEnd = hereStringOpenerLineEnd(input, index); if (input[openerLineEnd] !== '\n') { if (openerLineEnd >= input.length) break; continue; } - let closingEnd = -1; - for (let lineStart = openerLineEnd + 1; lineStart < input.length;) { - let contentStart = lineStart; - while (input[contentStart] === ' ' || input[contentStart] === '\t') contentStart += 1; - if (input[contentStart] === quote && input[contentStart + 1] === '@') { - closingEnd = contentStart + 2; - break; - } - while (lineStart < input.length && input[lineStart] !== '\n') lineStart += 1; - if (lineStart < input.length) lineStart += 1; - } - + const closingEnd = hereStringClosingEnd(input, openerLineEnd, quote); const contentEnd = closingEnd === -1 ? input.length : closingEnd - 2; const content = input.slice(openerLineEnd + 1, contentEnd); @@ -206,21 +287,8 @@ function normalizeHereStrings(input, executablePayloads = []) { start: index, value: `'${content.replace(/'/g, "''")}'`, }); - - // Expandable here-strings execute their unescaped subexpressions while the - // string value is being formed, independently of any later consumer. if (quote === '"') { - for (let offset = openerLineEnd + 1; offset < contentEnd; offset += 1) { - if (input[offset] === '`') { - offset += 1; - continue; - } - if (input[offset] !== '$' || input[offset + 1] !== '(') continue; - const group = readBalancedGroup(input, offset + 1, '(', ')'); - if (!group || group.end > contentEnd) break; - executablePayloads.push(group.body); - offset = group.end - 1; - } + collectHereStringSubexpressions(input, openerLineEnd + 1, contentEnd, executablePayloads); } if (closingEnd === -1) break; @@ -228,94 +296,25 @@ function normalizeHereStrings(input, executablePayloads = []) { } if (replacements.length === 0) return output.join(''); - let normalized = ''; - let cursor = 0; - for (const replacement of replacements) { - normalized += output.slice(cursor, replacement.start).join(''); - normalized += replacement.value; - cursor = replacement.end; + return applyReplacements(output, replacements); +} + +function blankRange(output, start, end) { + for (let offset = start; offset <= end; offset += 1) { + if (output[offset] !== '\n' && output[offset] !== '\r') output[offset] = ' '; } - normalized += output.slice(cursor).join(''); - return normalized; } function stripPowerShellComments(input) { const output = [...input]; - let quote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = 0; index < input.length; index += 1) { - const char = input[index]; - const next = input[index + 1]; - - if (lineComment) { - if (char === '\n' || char === '\r') { - lineComment = false; - } else { - output[index] = ' '; - } - continue; - } - - if (blockComment) { - if (char === '#' && next === '>') { - output[index] = ' '; - output[index + 1] = ' '; - blockComment = false; - index += 1; - } else if (char !== '\n' && char !== '\r') { - output[index] = ' '; - } - continue; - } - - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - - if (quote === "'") { - if (char === "'" && next === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - index += 1; - continue; - } - if (quote === '"') { - if (char === '"') quote = null; - continue; - } - if (char === "'" || char === '"') { - quote = char; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - - if (char === '<' && next === '#') { - output[index] = ' '; - output[index + 1] = ' '; - blockComment = true; - index += 1; - continue; - } - - if (char === '#') { - output[index] = ' '; - lineComment = true; - } + let end = consumeInertChar(state, input, index); + if (end === -1) end = openInertRegion(state, input, index); + if (end === -1) continue; + if (state.comment) blankRange(output, index, end); + index = end; } return output.join(''); @@ -328,71 +327,17 @@ function stripPowerShellComments(input) { */ function readBalancedGroup(input, openingIndex, open, close) { let depth = 1; - let quote = null; - let lineComment = false; - let blockComment = false; - let bracedVariable = false; + const state = createLexState(); for (let index = openingIndex + 1; index < input.length; index += 1) { + let end = consumeInertChar(state, input, index); + if (end === -1) end = openInertRegion(state, input, index); + if (end !== -1) { + index = end; + continue; + } + const char = input[index]; - const next = input[index + 1]; - - if (lineComment) { - if (char === '\n' || char === '\r') lineComment = false; - continue; - } - if (blockComment) { - if (char === '#' && next === '>') { - blockComment = false; - index += 1; - } - continue; - } - if (bracedVariable) { - if (char === '`') index += 1; - else if (char === '}') bracedVariable = false; - continue; - } - - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - index += 1; - continue; - } - - if (quote === '"') { - if (char === '"') quote = null; - continue; - } - - if (char === "'" || char === '"') { - quote = char; - continue; - } - - if (char === '$' && next === '{') { - bracedVariable = true; - index += 1; - continue; - } - - if (char === '<' && next === '#') { - blockComment = true; - index += 1; - continue; - } - if (char === '#') { - lineComment = true; - continue; - } - if (char === open) { depth += 1; } else if (char === close) { @@ -645,8 +590,11 @@ function staticCommandResult(body) { return command && /^[A-Za-z_][\w./\\-]*$/.test(command) ? command : null; } -function staticStringArrayResult(body) { - const input = String(body || ''); +/** + * Split an argument list on top-level commas, keeping quotes and nested + * parentheses intact. Returns null for unbalanced input. + */ +function splitStaticArrayItems(input) { const items = []; let item = ''; let quote = null; @@ -668,17 +616,25 @@ function staticStringArrayResult(body) { item += char; continue; } - if (!quote && char === '(') depth += 1; - if (!quote && char === ')') depth -= 1; - if (!quote && depth === 0 && char === ',') { - items.push(item); - item = ''; - continue; + if (!quote) { + if (char === '(') depth += 1; + else if (char === ')') depth -= 1; + else if (depth === 0 && char === ',') { + items.push(item); + item = ''; + continue; + } } item += char; } if (quote || depth !== 0) return null; items.push(item); + return items; +} + +function staticStringArrayResult(body) { + const items = splitStaticArrayItems(String(body || '')); + if (!items) return null; const values = items.map(value => staticScalarResult(value)); return values.length > 0 && values.every(value => value !== null) ? values.join(' ') @@ -735,203 +691,272 @@ function isPipedToPowerShellStdin(input, end) { * blocks while masking every container from the outer statement pass. `$()` * also executes inside double quotes. Other containers are literal there. */ -function extractExecutableContainers(input, options = {}) { - const bodies = []; - const deferredFunctions = []; - const masked = [...input]; - let quote = null; - let bracedVariable = false; - let context = ''; - let contextTruncated = false; +const CONTEXT_CLAUSE_KEYWORDS = /^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|function|filter|workflow|begin|process|end|dynamicparam|clean)\b/i; - const resetContext = () => { - context = ''; - contextTruncated = false; +function createContainerScan(input, options) { + return { + input, + options, + bodies: [], + deferredFunctions: [], + masked: [...input], + quote: null, + bracedVariable: false, + context: '', + contextTruncated: false, }; +} - const appendContext = value => { - for (const contextChar of value) { - if (contextChar === ';' || contextChar === '}') { - resetContext(); - } else if (contextChar === '\n' || contextChar === '\r') { - const clause = currentClause(context); - if (/^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|function|filter|workflow|begin|process|end|dynamicparam|clean)\b/i.test(clause)) { - if (context && !context.endsWith(' ')) context += ' '; - } else { - resetContext(); - } - } else if (/\s/.test(contextChar)) { - if (context && !context.endsWith(' ')) context += ' '; +function resetContainerContext(scan) { + scan.context = ''; + scan.contextTruncated = false; +} + +function appendContextSpace(scan) { + if (scan.context && !scan.context.endsWith(' ')) scan.context += ' '; +} + +function appendContainerContext(scan, value) { + for (const contextChar of value) { + if (contextChar === ';' || contextChar === '}') { + resetContainerContext(scan); + } else if (contextChar === '\n' || contextChar === '\r') { + if (CONTEXT_CLAUSE_KEYWORDS.test(currentClause(scan.context))) { + appendContextSpace(scan); } else { - context += contextChar; - } - if (context.length > MAX_CONTEXT_LENGTH) { - context = context.slice(-Math.floor(MAX_CONTEXT_LENGTH / 2)); - contextTruncated = true; + resetContainerContext(scan); } + } else if (/\s/.test(contextChar)) { + appendContextSpace(scan); + } else { + scan.context += contextChar; } + if (scan.context.length > MAX_CONTEXT_LENGTH) { + scan.context = scan.context.slice(-Math.floor(MAX_CONTEXT_LENGTH / 2)); + scan.contextTruncated = true; + } + } +} + +function maskRange(masked, start, end) { + for (let offset = start; offset < end; offset += 1) masked[offset] = ' '; +} + +function consumeBracedVariableChar(scan, index) { + const { input } = scan; + const char = input[index]; + if (char === '`' && index + 1 < input.length) { + appendContainerContext(scan, input[index + 1]); + return index + 1; + } + if (char === '}') { + scan.context += char; + scan.bracedVariable = false; + } else { + appendContainerContext(scan, char); + } + return index; +} + +// A backtick outside quotes folds the escaped character into the clause +// context; inside quotes it only protects the next character. +function consumeContainerEscape(scan, index) { + const { input } = scan; + if (!scan.quote && index + 1 < input.length) { + const escaped = input[index + 1]; + appendContainerContext(scan, escaped === '\n' || escaped === '\r' ? ' ' : escaped); + if (escaped === '\r' && input[index + 2] === '\n') return index + 2; + } + return index + 1; +} + +/** + * Consume characters that cannot open a container: `${}` names, single-quoted + * text, backtick escapes, and quote delimiters. Returns the last consumed + * index, or -1 for a live character. + */ +function consumeContainerInertChar(scan, index) { + const { input } = scan; + const char = input[index]; + if (scan.bracedVariable) return consumeBracedVariableChar(scan, index); + if (scan.quote === "'") { + if (char === "'" && input[index + 1] === "'") return index + 1; + if (char === "'") scan.quote = null; + return index; + } + if (char === '`') return consumeContainerEscape(scan, index); + if (!scan.quote && char === "'") { + scan.quote = "'"; + appendContainerContext(scan, ' '); + return index; + } + if (!scan.quote && char === '$' && input[index + 1] === '{') { + appendContainerContext(scan, '${'); + scan.bracedVariable = true; + return index + 1; + } + if (char === '"') { + scan.quote = scan.quote === '"' ? null : '"'; + if (scan.quote === '"') appendContainerContext(scan, ' '); + return index; + } + return -1; +} + +function containerDescriptor(input, index, flags) { + const { isSubexpression, isArrayExpression, isScriptBlock } = flags; + return { + ...flags, + isHashtable: isScriptBlock && input[index - 1] === '@', + openingIndex: isSubexpression || isArrayExpression ? index + 1 : index, + open: isScriptBlock ? '{' : '(', + close: isScriptBlock ? '}' : ')', }; +} + +/** + * Classify the live character at `index`. Returns a container descriptor, or + * null after folding a non-container character into the clause context. + */ +function readContainerOpener(scan, index) { + const { input, quote } = scan; + const char = input[index]; + const isSubexpression = char === '$' && input[index + 1] === '('; + if (quote === '"' && !isSubexpression) return null; + + const isArrayExpression = !quote && char === '@' && input[index + 1] === '('; + const isGroupingExpression = !quote && char === '('; + const isScriptBlock = !quote && char === '{'; + if (!isSubexpression && !isArrayExpression && !isGroupingExpression && !isScriptBlock) { + if (!quote) appendContainerContext(scan, char); + return null; + } + const isCmdPayloadGroup = isGroupingExpression && + /(?:^|\s)cmd(?:\.exe)?\s+\/[ck](?:\s|$)/i.test(currentClause(scan.context)); + if (isCmdPayloadGroup) { + appendContainerContext(scan, char); + return null; + } + return containerDescriptor(input, index, { + isSubexpression, + isArrayExpression, + isGroupingExpression, + isScriptBlock, + }); +} + +function queueCreatedScriptBlock(scan, group, clause, invokedAfter) { + const { options } = scan; + const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test(clause); + if (!createsScriptBlock || !(invokedAfter || options.executeBareScriptBlocks)) return; + const scalarReference = variableReference(group.body); + const scriptText = staticStringResult(group.body) || + (scalarReference ? options.staticScalars?.get(scalarReference) : null); + if (scriptText) { + scan.bodies.push({ body: scriptText, options: { executeBareScriptBlocks: true } }); + } +} + +function queueContainerBody(scan, container, group, info) { + const { options } = scan; + const { isScriptBlock, isHashtable } = container; + const { prefix, invokesContainer, invokedAfter } = info; + const clause = currentClause(prefix); + const shouldScan = scan.contextTruncated || !isScriptBlock || isHashtable || invokedAfter || + isExecutableScriptBlock(prefix, options); + if (shouldScan) { + const executesNestedScriptBlocks = isScriptBlock && /^switch\b/i.test(clause); + scan.bodies.push({ + body: group.body, + options: { + executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || + invokedAfter || executesNestedScriptBlocks || + (!isScriptBlock && invokesContainer), + }, + }); + } else { + const functionName = deferredScriptBlockName(prefix); + if (functionName) scan.deferredFunctions.push({ body: group.body, functionName }); + } + queueCreatedScriptBlock(scan, group, clause, invokedAfter); +} + +function staticContainerCommand(scan, container, group, info) { + const clause = currentClause(info.prefix); + if (container.isSubexpression || info.invokesContainer) { + const output = staticOutputResult(group.body); + if (output !== null || !container.isSubexpression) return output; + const scalarReference = variableReference(group.body); + return scalarReference ? scan.options.staticScalars?.get(scalarReference) ?? null : null; + } + if (/^(?:start-process|saps|start)\b/i.test(clause)) return staticStringArrayResult(group.body); + if (/^new-object\b/i.test(clause)) return staticTypeNameResult(group.body); + if (isPipedToPowerShellStdin(scan.input, group.end)) return staticScalarResult(group.body); + return staticCommandResult(group.body); +} + +function resolveContainerCommand(scan, container, group, info) { + const resolvedCommand = container.isScriptBlock + ? null + : staticContainerCommand(scan, container, group, info); + if (resolvedCommand || container.isScriptBlock) return resolvedCommand; + const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test( + maskQuotedStrings(group.body) + ); + const dynamic = invokesDynamicResult(info.prefix, { + groupingExpression: container.isGroupingExpression, + }); + return dynamic && !executableBlockExpression ? DYNAMIC_EXECUTION_MARKER : null; +} + +function recordContainer(scan, container, group, index) { + const withinDoubleQuote = scan.quote === '"'; + const prefix = scan.context; + const invokesContainer = invokesContainerResult(prefix, { + groupingExpression: container.isGroupingExpression, + }); + const invokedAfter = isInvokedAfterContainer(scan.input, group.end); + queueContainerBody(scan, container, group, { prefix, invokesContainer, invokedAfter }); + maskRange(scan.masked, index, group.end); + + const resolvedCommand = resolveContainerCommand(scan, container, group, { prefix, invokesContainer }); + if (resolvedCommand) { + scan.masked[index] = resolvedCommand; + if (!withinDoubleQuote) appendContainerContext(scan, resolvedCommand); + } else if (container.isScriptBlock) { + if (invokesContainer) { + scan.context = prefix; + } else { + resetContainerContext(scan); + } + } else if (!withinDoubleQuote) { + appendContainerContext(scan, ' '); + } +} + +function extractExecutableContainers(input, options = {}) { + const scan = createContainerScan(input, options); for (let index = 0; index < input.length; index += 1) { - const char = input[index]; - - if (bracedVariable) { - if (char === '`' && index + 1 < input.length) { - appendContext(input[index + 1]); - index += 1; - } else if (char === '}') { - context += char; - bracedVariable = false; - } else { - appendContext(char); - } + const inertEnd = consumeContainerInertChar(scan, index); + if (inertEnd !== -1) { + index = inertEnd; continue; } - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - index += 1; - } else if (char === "'") { - quote = null; - } - continue; - } - if (char === '`') { - if (!quote && index + 1 < input.length) { - const escaped = input[index + 1]; - appendContext(escaped === '\n' || escaped === '\r' ? ' ' : escaped); - if (escaped === '\r' && input[index + 2] === '\n') index += 1; - } - index += 1; - continue; - } + const container = readContainerOpener(scan, index); + if (!container) continue; - if (!quote && char === "'") { - quote = "'"; - appendContext(' '); - continue; - } - - if (!quote && char === '$' && input[index + 1] === '{') { - appendContext('${'); - bracedVariable = true; - index += 1; - continue; - } - - if (char === '"') { - quote = quote === '"' ? null : '"'; - if (quote === '"') appendContext(' '); - continue; - } - - const isSubexpression = char === '$' && input[index + 1] === '('; - if (quote === '"' && !isSubexpression) continue; - - const isArrayExpression = !quote && char === '@' && input[index + 1] === '('; - const isGroupingExpression = !quote && char === '('; - const isScriptBlock = !quote && char === '{'; - const isHashtable = isScriptBlock && input[index - 1] === '@'; - const isCmdPayloadGroup = isGroupingExpression && - /(?:^|\s)cmd(?:\.exe)?\s+\/[ck](?:\s|$)/i.test(currentClause(context)); - if (!isSubexpression && !isArrayExpression && !isGroupingExpression && !isScriptBlock) { - if (!quote) appendContext(char); - continue; - } - if (isCmdPayloadGroup) { - appendContext(char); - continue; - } - - const openingIndex = isSubexpression || isArrayExpression ? index + 1 : index; - const open = isScriptBlock ? '{' : '('; - const close = isScriptBlock ? '}' : ')'; - const group = readBalancedGroup(input, openingIndex, open, close); + const group = readBalancedGroup(input, container.openingIndex, container.open, container.close); if (!group) { - for (let offset = index; offset < input.length; offset += 1) masked[offset] = ' '; + maskRange(scan.masked, index, input.length); break; } - - const withinDoubleQuote = quote === '"'; - const prefix = context; - const invokesContainer = invokesContainerResult(prefix, { - groupingExpression: isGroupingExpression, - }); - const invokedAfter = isInvokedAfterContainer(input, group.end); - const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test( - currentClause(prefix) - ); - const shouldScan = contextTruncated || !isScriptBlock || isHashtable || invokedAfter || - isExecutableScriptBlock(prefix, options); - if (shouldScan) { - const executesNestedScriptBlocks = isScriptBlock && /^switch\b/i.test(currentClause(prefix)); - bodies.push({ - body: group.body, - options: { - executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || - invokedAfter || executesNestedScriptBlocks || - (!isScriptBlock && invokesContainer), - }, - }); - } else { - const functionName = deferredScriptBlockName(prefix); - if (functionName) deferredFunctions.push({ body: group.body, functionName }); - } - if (createsScriptBlock && (invokedAfter || options.executeBareScriptBlocks)) { - const scalarReference = variableReference(group.body); - const scriptText = staticStringResult(group.body) || - (scalarReference ? options.staticScalars?.get(scalarReference) : null); - if (scriptText) { - bodies.push({ body: scriptText, options: { executeBareScriptBlocks: true } }); - } - } - for (let offset = index; offset < group.end; offset += 1) { - masked[offset] = ' '; - } - let resolvedCommand = null; - if (!isScriptBlock) { - if (isSubexpression || invokesContainer) { - resolvedCommand = staticOutputResult(group.body); - if (resolvedCommand === null && isSubexpression) { - const scalarReference = variableReference(group.body); - if (scalarReference) { - resolvedCommand = options.staticScalars?.get(scalarReference) ?? null; - } - } - } else if (/^(?:start-process|saps|start)\b/i.test(currentClause(prefix))) { - resolvedCommand = staticStringArrayResult(group.body); - } else if (/^new-object\b/i.test(currentClause(prefix))) { - resolvedCommand = staticTypeNameResult(group.body); - } else if (isPipedToPowerShellStdin(input, group.end)) { - resolvedCommand = staticScalarResult(group.body); - } else { - resolvedCommand = staticCommandResult(group.body); - } - } - const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test( - maskQuotedStrings(group.body) - ); - if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix, { - groupingExpression: isGroupingExpression, - }) && !executableBlockExpression) { - resolvedCommand = DYNAMIC_EXECUTION_MARKER; - } - if (resolvedCommand) { - masked[index] = resolvedCommand; - if (!withinDoubleQuote) appendContext(resolvedCommand); - } else if (isScriptBlock) { - if (invokesContainer) { - context = prefix; - } else { - resetContext(); - } - } else if (!withinDoubleQuote) { - appendContext(' '); - } + recordContainer(scan, container, group, index); index = group.end - 1; } - return { bodies, deferredFunctions, outer: masked.join('') }; + return { bodies: scan.bodies, deferredFunctions: scan.deferredFunctions, outer: scan.masked.join('') }; } /** @@ -939,181 +964,226 @@ function extractExecutableContainers(input, options = {}) { * are interpreted before token comparison so `Rem`ove-Item` normalizes to the * command PowerShell executes. Backslashes remain ordinary characters. */ -function parseStatements(input) { - const statements = []; - let statement = []; - let segment = []; - let segmentQuotedTokens = []; - let segmentQuoteKinds = []; - let segmentTokenSources = []; - let segmentInlineValueQuoteKinds = []; - let word = ''; - let wordSource = ''; - let wordHasQuotedContent = false; - let wordHasUnquotedContent = false; - let wordQuoteKind = null; - let wordInlineValueQuoteKind = null; - let wordInlineValueQuoteClosed = false; - let quote = null; - let parenDepth = 0; - let callOperatorPending = false; +function createWordState() { + return { + text: '', + source: '', + hasQuotedContent: false, + hasUnquotedContent: false, + quoteKind: null, + inlineValueQuoteKind: null, + inlineValueQuoteClosed: false, + }; +} - const flushWord = () => { - if (word) { - segment.push(word); - segmentQuotedTokens.push(wordHasQuotedContent && !wordHasUnquotedContent); - segmentQuoteKinds.push( - wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null - ); - segmentTokenSources.push(wordSource); - segmentInlineValueQuoteKinds.push( - wordInlineValueQuoteClosed && wordInlineValueQuoteKind !== 'mixed' - ? wordInlineValueQuoteKind - : null - ); +function createSegmentState() { + return { + tokens: [], + quotedTokens: [], + quoteKinds: [], + tokenSources: [], + inlineValueQuoteKinds: [], + }; +} + +function createStatementParser() { + return { + statements: [], + statement: [], + segment: createSegmentState(), + word: createWordState(), + quote: null, + parenDepth: 0, + callOperatorPending: false, + }; +} + +function markInlineValueMixed(word) { + if (word.inlineValueQuoteClosed) word.inlineValueQuoteKind = 'mixed'; +} + +function appendUnquotedChar(word, char) { + word.text += char; + word.source += char; + word.hasUnquotedContent = true; +} + +function appendQuotedChar(word, char) { + word.text += char; + word.source += char; + word.hasQuotedContent = true; +} + +function flushWord(parser) { + const { word, segment } = parser; + if (word.text) { + const quotedOnly = word.hasQuotedContent && !word.hasUnquotedContent; + segment.tokens.push(word.text); + segment.quotedTokens.push(quotedOnly); + segment.quoteKinds.push(quotedOnly ? word.quoteKind : null); + segment.tokenSources.push(word.source); + segment.inlineValueQuoteKinds.push( + word.inlineValueQuoteClosed && word.inlineValueQuoteKind !== 'mixed' + ? word.inlineValueQuoteKind + : null + ); + } + parser.word = createWordState(); +} + +function flushSegment(parser) { + flushWord(parser); + const { segment } = parser; + if (segment.tokens.length) { + Object.defineProperties(segment.tokens, { + invokedByCallOperator: { value: parser.callOperatorPending }, + quotedTokens: { value: segment.quotedTokens }, + quoteKinds: { value: segment.quoteKinds }, + tokenSources: { value: segment.tokenSources }, + inlineValueQuoteKinds: { value: segment.inlineValueQuoteKinds }, + }); + parser.statement.push(segment.tokens); + parser.callOperatorPending = false; + } + parser.segment = createSegmentState(); +} + +function flushStatement(parser) { + flushSegment(parser); + if (parser.statement.length) parser.statements.push(parser.statement); + parser.statement = []; +} + +// Each consume* helper returns the last input index it consumed. +function consumeSingleQuotedChar(parser, input, index) { + const { word } = parser; + const char = input[index]; + if (char === "'" && input[index + 1] === "'") { + word.text += "'"; + word.source += "''"; + return index + 1; + } + if (char === "'") { + parser.quote = null; + if (word.inlineValueQuoteKind === "'") word.inlineValueQuoteClosed = true; + return index; + } + appendQuotedChar(word, char); + return index; +} + +function consumeDoubleQuotedChar(parser, input, index) { + const { word } = parser; + const char = input[index]; + if (char === '"') { + parser.quote = null; + if (word.inlineValueQuoteKind === '"') word.inlineValueQuoteClosed = true; + return index; + } + appendQuotedChar(word, char); + return index; +} + +function consumeBacktickEscape(parser, input, index) { + const { word } = parser; + if (index + 1 >= input.length) { + word.text += '`'; + word.source += '`'; + return index; + } + const escaped = input[index + 1]; + word.source += `\`${escaped}`; + if (escaped === '\n' || escaped === '\r') { + if (escaped === '\r' && input[index + 2] === '\n') { + word.source += '\n'; + return index + 2; } - word = ''; - wordSource = ''; - wordHasQuotedContent = false; - wordHasUnquotedContent = false; - wordQuoteKind = null; - wordInlineValueQuoteKind = null; - wordInlineValueQuoteClosed = false; - }; - const flushSegment = () => { - flushWord(); - if (segment.length) { - Object.defineProperties(segment, { - invokedByCallOperator: { value: callOperatorPending }, - quotedTokens: { value: segmentQuotedTokens }, - quoteKinds: { value: segmentQuoteKinds }, - tokenSources: { value: segmentTokenSources }, - inlineValueQuoteKinds: { value: segmentInlineValueQuoteKinds }, - }); - statement.push(segment); - callOperatorPending = false; - } - segment = []; - segmentQuotedTokens = []; - segmentQuoteKinds = []; - segmentTokenSources = []; - segmentInlineValueQuoteKinds = []; - }; - const flushStatement = () => { - flushSegment(); - if (statement.length) statements.push(statement); - statement = []; - }; + return index + 1; + } + markInlineValueMixed(word); + word.text += escaped; + if (parser.quote) word.hasQuotedContent = true; + else word.hasUnquotedContent = true; + return index + 1; +} + +function openWordQuote(parser, char) { + const { word } = parser; + if (word.inlineValueQuoteClosed) { + word.inlineValueQuoteKind = 'mixed'; + } else if (word.inlineValueQuoteKind === null && /^-+[^:\s]+:$/.test(word.text)) { + word.inlineValueQuoteKind = char; + } + parser.quote = char; + word.hasQuotedContent = true; + word.quoteKind = word.quoteKind === null || word.quoteKind === char ? char : 'mixed'; +} + +// Parentheses, statement and pipeline separators, the call operator, and +// whitespace. Returns false when `char` is an ordinary word character. +function consumeStructuralChar(parser, char) { + if (char === '(') { + markInlineValueMixed(parser.word); + parser.parenDepth += 1; + appendUnquotedChar(parser.word, char); + return true; + } + if (char === ')' && parser.parenDepth > 0) { + markInlineValueMixed(parser.word); + parser.parenDepth -= 1; + appendUnquotedChar(parser.word, char); + return true; + } + if (parser.parenDepth === 0 && (char === ';' || char === '\n' || char === '\r')) { + flushStatement(parser); + return true; + } + if (parser.parenDepth === 0 && char === '|') { + flushSegment(parser); + return true; + } + if (parser.parenDepth === 0 && char === '&') { + if (parser.word.text || parser.segment.tokens.length) flushStatement(parser); + parser.callOperatorPending = true; + return true; + } + if (/\s/.test(char)) { + flushWord(parser); + return true; + } + return false; +} + +function parseStatements(input) { + const parser = createStatementParser(); for (let index = 0; index < input.length; index += 1) { const char = input[index]; - if (quote === "'") { - if (char === "'" && input[index + 1] === "'") { - word += "'"; - wordSource += "''"; - index += 1; - } else if (char === "'") { - quote = null; - if (wordInlineValueQuoteKind === "'") wordInlineValueQuoteClosed = true; - } else { - word += char; - wordSource += char; - wordHasQuotedContent = true; - } + if (parser.quote === "'") { + index = consumeSingleQuotedChar(parser, input, index); continue; } - if (char === '`') { - if (index + 1 >= input.length) { - word += '`'; - wordSource += '`'; - continue; - } - const escaped = input[index + 1]; - wordSource += `\`${escaped}`; - index += 1; - if (escaped === '\n' || escaped === '\r') { - if (escaped === '\r' && input[index + 1] === '\n') { - wordSource += '\n'; - index += 1; - } - } else { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - word += escaped; - if (quote) wordHasQuotedContent = true; - else wordHasUnquotedContent = true; - } + index = consumeBacktickEscape(parser, input, index); continue; } - - if (quote === '"') { - if (char === '"') { - quote = null; - if (wordInlineValueQuoteKind === '"') wordInlineValueQuoteClosed = true; - } else { - word += char; - wordSource += char; - wordHasQuotedContent = true; - } + if (parser.quote === '"') { + index = consumeDoubleQuotedChar(parser, input, index); continue; } - if (char === "'" || char === '"') { - if (wordInlineValueQuoteClosed) { - wordInlineValueQuoteKind = 'mixed'; - } else if (wordInlineValueQuoteKind === null && /^-+[^:\s]+:$/.test(word)) { - wordInlineValueQuoteKind = char; - } - quote = char; - wordHasQuotedContent = true; - wordQuoteKind = wordQuoteKind === null || wordQuoteKind === char ? char : 'mixed'; + openWordQuote(parser, char); continue; } + if (consumeStructuralChar(parser, char)) continue; - if (char === '(') { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - parenDepth += 1; - word += char; - wordSource += char; - wordHasUnquotedContent = true; - continue; - } - if (char === ')' && parenDepth > 0) { - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - parenDepth -= 1; - word += char; - wordSource += char; - wordHasUnquotedContent = true; - continue; - } - - if (parenDepth === 0 && (char === ';' || char === '\n' || char === '\r')) { - flushStatement(); - continue; - } - if (parenDepth === 0 && char === '|') { - flushSegment(); - continue; - } - if (parenDepth === 0 && char === '&') { - if (word || segment.length) flushStatement(); - callOperatorPending = true; - continue; - } - if (/\s/.test(char)) { - flushWord(); - continue; - } - - if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; - word += char; - wordSource += char; - wordHasUnquotedContent = true; + markInlineValueMixed(parser.word); + appendUnquotedChar(parser.word, char); } - flushStatement(); - return statements; + flushStatement(parser); + return parser.statements; } function maskQuotedStrings(input) { @@ -1267,84 +1337,101 @@ function staticPipelineInput(tokens, state, findings) { return null; } +function scanEncodedCommandFlag(tokens, index, scan) { + const { findings, scanState } = scan; + const token = tokens[index]; + const inlinePayload = parameterValue(token); + let encodedPayload = inlinePayload || tokens[index + 1]; + const payloadIndex = index + 1; + const quoteKind = tokens.quoteKinds?.[payloadIndex]; + const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; + if ((inlinePayload && inlineQuoteKind !== "'") || + (!inlinePayload && encodedPayload && quoteKind !== "'")) { + const source = inlinePayload + ? parameterValue(tokens.tokenSources?.[index] || token) + : tokens.tokenSources?.[payloadIndex] ?? encodedPayload; + const expanded = expandStaticDoubleQuotedString( + source || encodedPayload, + scanState, + findings + ); + if (expanded === null) return; + encodedPayload = expanded; + } + const decoded = decodeUtf16LeBase64(encodedPayload); + if (decoded !== null) addNestedScan(decoded, scan.depth, findings, scan.analysis, {}, scanState); +} + +// Inline `-Command:value` payloads. Returns null when expansion is unresolved. +function resolveInlineCommandPayload(tokens, index, scan) { + const token = tokens[index]; + const inlinePayload = parameterValue(token); + const rest = tokens.slice(index + 1); + if (tokens.inlineValueQuoteKinds?.[index] === "'") return [inlinePayload, ...rest].join(' '); + const inlineSource = parameterValue(tokens.tokenSources?.[index] || token); + const expanded = expandStaticDoubleQuotedString( + inlineSource || inlinePayload, + scan.scanState, + scan.findings + ); + return expanded === null ? null : [expanded, ...rest].join(' '); +} + +// `-Command value` payloads. Returns null when expansion is unresolved. +function resolvePositionalCommandPayload(tokens, payloadIndex, payload, scan) { + const { findings, scanState } = scan; + const quoteKind = tokens.quoteKinds?.[payloadIndex]; + if (tokens[payloadIndex] && quoteKind !== "'") { + const expanded = expandStaticDoubleQuotedString( + tokens.tokenSources?.[payloadIndex] ?? tokens[payloadIndex], + scanState, + findings + ); + return expanded === null ? null : [expanded, ...tokens.slice(payloadIndex + 1)].join(' '); + } + const payloadReference = quoteKind === "'" ? null : variableReference(payload); + if (!payloadReference) return payload; + const staticValue = scanState?.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return null; + } + return staticValue; +} + +function scanCommandFlag(tokens, index, scan, upstreamTokens) { + const { findings, scanState } = scan; + const inlinePayload = parameterValue(tokens[index]); + const rawPayload = inlinePayload + ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') + : tokens.slice(index + 1).join(' '); + const pipelinePayload = rawPayload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null; + const payload = inlinePayload + ? resolveInlineCommandPayload(tokens, index, scan) + : resolvePositionalCommandPayload(tokens, index + 1, rawPayload, scan); + if (payload === null) return; + if (pipelinePayload || (payload && payload !== '-')) { + addNestedScan( + pipelinePayload || payload, + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + scanState + ); + } +} + function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upstreamTokens = null) { + const scan = { depth, findings, analysis, scanState }; for (let index = 1; index < tokens.length; index += 1) { const token = tokens[index]; - if (isEncodedCommandFlag(token)) { - const inlinePayload = parameterValue(token); - let encodedPayload = inlinePayload || tokens[index + 1]; - const payloadIndex = index + 1; - const quoteKind = tokens.quoteKinds?.[payloadIndex]; - const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; - if ((inlinePayload && inlineQuoteKind !== "'") || - (!inlinePayload && encodedPayload && quoteKind !== "'")) { - const source = inlinePayload - ? parameterValue(tokens.tokenSources?.[index] || token) - : tokens.tokenSources?.[payloadIndex] ?? encodedPayload; - const expanded = expandStaticDoubleQuotedString( - source || encodedPayload, - scanState, - findings - ); - if (expanded === null) return; - encodedPayload = expanded; - } - const decoded = decodeUtf16LeBase64(encodedPayload); - if (decoded !== null) addNestedScan(decoded, depth, findings, analysis, {}, scanState); + scanEncodedCommandFlag(tokens, index, scan); return; } - if (isCommandFlag(token)) { - const inlinePayload = parameterValue(token); - let payload = inlinePayload - ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') - : tokens.slice(index + 1).join(' '); - const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null; - const payloadIndex = index + 1; - const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; - if (inlinePayload && inlineQuoteKind !== "'") { - const inlineSource = parameterValue(tokens.tokenSources?.[index] || token); - const expanded = expandStaticDoubleQuotedString( - inlineSource || inlinePayload, - scanState, - findings - ); - if (expanded === null) return; - payload = [expanded, ...tokens.slice(index + 1)].join(' '); - } else if (inlinePayload) { - payload = [inlinePayload, ...tokens.slice(index + 1)].join(' '); - } else if (tokens[payloadIndex] && tokens.quoteKinds?.[payloadIndex] !== "'") { - const expanded = expandStaticDoubleQuotedString( - tokens.tokenSources?.[payloadIndex] ?? tokens[payloadIndex], - scanState, - findings - ); - if (expanded === null) return; - payload = [expanded, ...tokens.slice(payloadIndex + 1)].join(' '); - } else { - const payloadReference = tokens.quoteKinds?.[payloadIndex] === "'" - ? null - : variableReference(payload); - if (payloadReference) { - const staticValue = scanState?.staticScalars.get(payloadReference); - if (staticValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - return; - } - payload = staticValue; - } - } - if (pipelinePayload || (payload && payload !== '-')) { - addNestedScan( - pipelinePayload || payload, - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - scanState - ); - } + scanCommandFlag(tokens, index, scan, upstreamTokens); return; } } @@ -1377,111 +1464,105 @@ function splitCmdSegments(payload) { return segments; } -function scanCmdWords(inputWords, depth, findings, analysis, scanState, wrapperDepth = 0) { - if (wrapperDepth > 64) { - findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); - return; - } - - let words = inputWords.filter(Boolean).map(word => String(word)); - if (words.length === 0) return; - words[0] = words[0].replace(/^@+/, '').replace(/^\(+/, ''); - words[words.length - 1] = words[words.length - 1].replace(/\)+$/, ''); - +function stripCmdRedirections(words) { while (words.length > 0 && /^\d*(?:>>?|<>?|< index > 0 && /^else$/i.test(word)); + const trueBranch = elseIndex === -1 ? words : words.slice(0, elseIndex); + scanCmdWords(trueBranch.slice(cmdIfCommandIndex(trueBranch)), scan, wrapperDepth + 1); + if (elseIndex !== -1) { + scanCmdWords(words.slice(elseIndex + 1), scan, wrapperDepth + 1); + } +} + +// Words after `start [options] [title] ...`, skipping options that take a +// value and a leading window title when a known command follows it. +function cmdStartTarget(inputWords) { + let words = inputWords.slice(1); + while (words.length > 0 && /^\//.test(words[0])) { + const option = words.shift().toLowerCase(); + if (/^\/(?:d|node|affinity)$/.test(option)) words.shift(); + } + const knownCommands = new Set([ + ...CMD_DELETE_COMMANDS, + ...POWERSHELL_COMMANDS, + 'call', + 'cmd', + 'for', + 'if', + 'start', + ]); + if (words.length > 1 && !knownCommands.has(commandBasename(words[0]))) { + const commandIndex = words.findIndex(word => knownCommands.has(commandBasename(word))); + if (commandIndex > 0) words = words.slice(commandIndex); + } + return words; +} + +function scanCmdWords(inputWords, scan, wrapperDepth = 0) { + if (wrapperDepth > 64) { + scan.findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); + return; + } + + const words = inputWords.filter(Boolean).map(word => String(word)); + if (words.length === 0) return; + words[0] = words[0].replace(/^@+/, '').replace(/^\(+/, ''); + words[words.length - 1] = words[words.length - 1].replace(/\)+$/, ''); + stripCmdRedirections(words); if (words.length === 0) return; - let firstCommand = commandBasename(words[0].replace(/^@+/, '').replace(/^\(+/, '')); + const firstCommand = commandBasename(words[0].replace(/^@+/, '').replace(/^\(+/, '')); if (firstCommand === 'if') { - const elseIndex = words.findIndex((word, index) => index > 0 && /^else$/i.test(word)); - const trueBranch = elseIndex === -1 ? words : words.slice(0, elseIndex); - let commandIndex = 1; - if (/^\/i$/i.test(trueBranch[commandIndex])) commandIndex += 1; - if (/^not$/i.test(trueBranch[commandIndex])) commandIndex += 1; - if (/^(?:exist|defined|errorlevel|cmdextversion)$/i.test(trueBranch[commandIndex])) { - commandIndex += 2; - } else if (/^(?:equ|neq|lss|leq|gtr|geq)$/i.test(trueBranch[commandIndex + 1])) { - commandIndex += 3; - } else { - commandIndex += 1; - } - scanCmdWords( - trueBranch.slice(commandIndex), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - if (elseIndex !== -1) { - scanCmdWords( - words.slice(elseIndex + 1), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - } + scanCmdIf(words, scan, wrapperDepth); return; } if (firstCommand === 'for') { const doIndex = words.findIndex(word => /^do$/i.test(word)); - if (doIndex !== -1) { - scanCmdWords( - words.slice(doIndex + 1), - depth, - findings, - analysis, - scanState, - wrapperDepth + 1 - ); - } + if (doIndex !== -1) scanCmdWords(words.slice(doIndex + 1), scan, wrapperDepth + 1); return; } if (firstCommand === 'call') { - scanCmdWords(words.slice(1), depth, findings, analysis, scanState, wrapperDepth + 1); + scanCmdWords(words.slice(1), scan, wrapperDepth + 1); return; } if (firstCommand === 'start') { - words = words.slice(1); - while (words.length > 0 && /^\//.test(words[0])) { - const option = words.shift().toLowerCase(); - if (/^\/(?:d|node|affinity)$/.test(option)) words.shift(); - } - const knownCommands = new Set([ - ...CMD_DELETE_COMMANDS, - ...POWERSHELL_COMMANDS, - 'call', - 'cmd', - 'for', - 'if', - 'start', - ]); - if (words.length > 1 && !knownCommands.has(commandBasename(words[0]))) { - const commandIndex = words.findIndex(word => knownCommands.has(commandBasename(word))); - if (commandIndex > 0) words = words.slice(commandIndex); - } - scanCmdWords(words, depth, findings, analysis, scanState, wrapperDepth + 1); + scanCmdWords(cmdStartTarget(words), scan, wrapperDepth + 1); return; } if (POWERSHELL_COMMANDS.has(firstCommand) || firstCommand === 'cmd') { addNestedScan( [firstCommand, ...words.slice(1)].join(' '), - depth, - findings, - analysis, + scan.depth, + scan.findings, + scan.analysis, { executeBareScriptBlocks: true }, - scanState + scan.scanState ); return; } if (CMD_DELETE_COMMANDS.has(firstCommand) && words.slice(1).some(word => /^[-/]s$/i.test(word))) { - findings.add(RULE_IDS.CMD_RECURSIVE_DELETE); + scan.findings.add(RULE_IDS.CMD_RECURSIVE_DELETE); } } @@ -1493,14 +1574,9 @@ function scanCmd(tokens, depth, findings, analysis, scanState) { .slice(flagIndex + 1) .filter(token => token !== '--%') .join(' '); + const scan = { depth, findings, analysis, scanState }; for (const segment of splitCmdSegments(payload)) { - scanCmdWords( - segment.trim().split(/\s+/), - depth, - findings, - analysis, - scanState - ); + scanCmdWords(segment.trim().split(/\s+/), scan); } } @@ -1558,41 +1634,43 @@ function normalizeArgumentList(parts) { return payload.replace(/\s*,\s*/g, ' ').trim(); } -function scanStartProcess(tokens, depth, findings, analysis, scanState) { - const command = commandBasename(tokens[0]); - if (!['start-process', 'saps', 'start'].includes(command)) return; - if (tokens.slice(1).some((token, index) => - !(tokens.quotedTokens || [])[index + 1] && +function hasSplattedStartProcessArguments(tokens, quotedTokens) { + return tokens.slice(1).some((token, index) => + !quotedTokens[index + 1] && /^@(?:(?:global|script|local|private):)?[A-Za-z_][\w-]*$/i.test(token) - )) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - return; - } - - let executable = null; - let argumentParts = null; - const quotedTokens = tokens.quotedTokens || []; + ); +} +function startProcessFilePath(tokens, quotedTokens) { for (let index = 1; index < tokens.length; index += 1) { if (quotedTokens[index]) continue; - const parameter = startProcessParameterName(tokens[index]); - if (parameter !== 'filepath') continue; - executable = parameterValue(tokens[index]) || tokens[index + 1] || null; - break; + if (startProcessParameterName(tokens[index]) !== 'filepath') continue; + return parameterValue(tokens[index]) || tokens[index + 1] || null; } + return null; +} +function startProcessArgumentListParts(tokens, quotedTokens, index) { + const inlineValue = parameterValue(tokens[index]); + const end = tokens.findIndex( + (candidate, candidateIndex) => candidateIndex > index && + !quotedTokens[candidateIndex] && startProcessParameterName(candidate) + ); + const remaining = tokens.slice(index + 1, end === -1 ? tokens.length : end); + return inlineValue ? [inlineValue, ...remaining] : remaining; +} + +// Walk named parameters once: pick up `-ArgumentList` and, when `-FilePath` +// was absent, the first positional value as the executable. +function startProcessNamedTarget(tokens, quotedTokens, filePath) { + let executable = filePath; + let argumentParts = null; for (let index = 1; index < tokens.length; index += 1) { const token = tokens[index]; const quoted = quotedTokens[index] === true; const parameter = quoted ? null : startProcessParameterName(token); if (parameter === 'argumentlist') { - const inlineValue = parameterValue(token); - const end = tokens.findIndex( - (candidate, candidateIndex) => candidateIndex > index && - !quotedTokens[candidateIndex] && startProcessParameterName(candidate) - ); - const remaining = tokens.slice(index + 1, end === -1 ? tokens.length : end); - argumentParts = inlineValue ? [inlineValue, ...remaining] : remaining; + argumentParts = startProcessArgumentListParts(tokens, quotedTokens, index); break; } if (parameter) { @@ -1600,48 +1678,65 @@ function scanStartProcess(tokens, depth, findings, analysis, scanState) { continue; } if (!quoted && token.startsWith('-')) continue; - if (executable !== null) continue; - if (executable === null) { - executable = token; - } - } - - if (argumentParts === null && executable !== null) { - let executableSeen = false; - for (let index = 1; index < tokens.length; index += 1) { - const token = tokens[index]; - const parameter = quotedTokens[index] ? null : startProcessParameterName(token); - if (parameter) { - if (parameter === 'filepath') executableSeen = true; - if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; - continue; - } - if (!executableSeen && token === executable) { - executableSeen = true; - continue; - } - if (executableSeen) { - argumentParts = tokens.slice(index); - break; - } - if (!quotedTokens[index] && token.startsWith('-')) continue; + if (executable === null) executable = token; + } + return { executable, argumentParts }; +} + +// Positional arguments are everything after the executable once named +// parameters and their values are skipped. +function startProcessPositionalArguments(tokens, quotedTokens, executable) { + let executableSeen = false; + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + const parameter = quotedTokens[index] ? null : startProcessParameterName(token); + if (parameter) { + if (parameter === 'filepath') executableSeen = true; + if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; + continue; } + if (!executableSeen && token === executable) { + executableSeen = true; + continue; + } + if (executableSeen) return tokens.slice(index); + } + return null; +} + +function startProcessTarget(tokens, quotedTokens) { + const filePath = startProcessFilePath(tokens, quotedTokens); + const target = startProcessNamedTarget(tokens, quotedTokens, filePath); + if (target.argumentParts === null && target.executable !== null) { + target.argumentParts = startProcessPositionalArguments(tokens, quotedTokens, target.executable); + } + return target; +} + +function scanStartProcess(tokens, depth, findings, analysis, scanState) { + const command = commandBasename(tokens[0]); + if (!['start-process', 'saps', 'start'].includes(command)) return; + const quotedTokens = tokens.quotedTokens || []; + if (hasSplattedStartProcessArguments(tokens, quotedTokens)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; } + const { executable, argumentParts } = startProcessTarget(tokens, quotedTokens); const nestedCommand = commandBasename(executable); + if (!POWERSHELL_COMMANDS.has(nestedCommand) && nestedCommand !== 'cmd') return; + let argumentList = argumentParts ? normalizeArgumentList(argumentParts) : ''; - if (POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') { - const argumentReference = variableReference(argumentList); - if (argumentReference) { - const staticValue = scanState.staticScalars.get(argumentReference); - if (staticValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - return; - } - argumentList = staticValue; + const argumentReference = variableReference(argumentList); + if (argumentReference) { + const staticValue = scanState.staticScalars.get(argumentReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; } + argumentList = staticValue; } - if ((POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') && argumentList) { + if (argumentList) { addNestedScan( `${executable} ${argumentList}`, depth, @@ -1868,18 +1963,14 @@ function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, stat } } -function scanPowerShell(command, depth, findings, analysis = null, options = {}, scanState = null) { - const raw = normalizeSmartQuotes(command); - if (!raw.trim()) return; +const DIRECT_COMMAND_FINDINGS = Object.freeze({ + 'clear-content': RULE_IDS.CLEAR_CONTENT, + clc: RULE_IDS.CLEAR_CONTENT, + 'clear-disk': RULE_IDS.CLEAR_DISK, + 'format-volume': RULE_IDS.FORMAT_VOLUME, +}); - const state = scanState || createScanState(); - - const hereStringExpressions = []; - const normalizedHereStrings = normalizeHereStrings(raw, hereStringExpressions); - const withoutComments = stripPowerShellComments(normalizedHereStrings); - collectStaticScalarAssignments(withoutComments, state); - const unquoted = maskQuotedStrings(withoutComments); - scanInvokeScriptCalls(withoutComments, unquoted, depth, findings, analysis, state); +function scanStaticDotNetCalls(unquoted, findings, state) { if (/\[\s*(?:system\.)?io\.directory\s*\]\s*::\s*delete\s*\(/i.test(unquoted)) { findings.add(RULE_IDS.DOTNET_DIRECTORY_DELETE); } @@ -1891,6 +1982,201 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {}, while ((activatorMatch = activatorPattern.exec(unquoted)) !== null) { recordInvocation(state, `__class__:${activatorMatch[1].toLowerCase()}`); } +} + +function recordInvokedBlockVariables(unquoted, state) { + const invokedBlockVariable = /(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.(?!getnewclosure\b)[A-Za-z_][\w-]*)*)(?:\.getnewclosure\s*\(\s*\))+\.\s*(?:invoke|invokereturnasis|invokewithcontext)\s*\(/gi; + let invokedBlockMatch; + while ((invokedBlockMatch = invokedBlockVariable.exec(unquoted)) !== null) { + recordInvocation(state, invokedBlockMatch[1].toLowerCase()); + } +} + +function scanAliasInvocation(commandName, tokens, executable, scan) { + const { state } = scan; + const aliasTarget = state.aliases.get(commandName); + if (aliasTarget) { + addNestedScan( + [aliasTarget, ...tokens.slice(1)].join(' '), + scan.depth, + scan.findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } + if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) { + for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) { + state.aliases.set(definition.name, definition.value); + } + } +} + +function recordNewObjectClass(tokens, quotedTokens, scan) { + const { findings, state } = scan; + let className = newObjectClassName(tokens, quotedTokens); + const classReference = variableReference(className); + if (classReference) { + const staticClassName = state.staticScalars.get(classReference); + if (staticClassName === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + className = null; + } else { + className = staticClassName; + } + } + if (className && /^[A-Za-z_][\w-]*$/.test(className)) { + recordInvocation(state, `__class__:${className.toLowerCase()}`); + } +} + +function recordClassInvocations(commandName, tokens, executable, scan) { + const { state } = scan; + const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i); + if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`); + if (commandName === 'new-object') recordNewObjectClass(tokens, executable.quotedTokens, scan); + const invokedVariable = commandName.match( + /^((?:\$\{[^}]+\}|\$(?:[a-z_][\w-]*:)?[a-z_][\w-]*(?:\[[^\]]+\]|\.[a-z_][\w-]*)*))(?:\.getnewclosure\(\))*\.(?:invoke|invokereturnasis|invokewithcontext)(?:\(|$)/i + ); + if (invokedVariable) recordInvocation(state, invokedVariable[1].toLowerCase()); + if (commandName === '.' && tokens[1]) { + recordInvocation(state, commandBasename(tokens[1])); + } +} + +// `& $variable ...` where the variable holds a static command string. +function scanCallOperatorReference(tokens, upstreamTokens, scan) { + const { findings, state } = scan; + const invokedReference = variableReference(tokens[0]); + if (!invokedReference || state.deferredFunctions.has(invokedReference)) return; + const commandValue = state.staticScalars.get(invokedReference); + if (commandValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } else if (POWERSHELL_COMMANDS.has(commandBasename(commandValue))) { + scanNestedPowerShell( + [commandValue, ...tokens.slice(1)], + scan.depth, + findings, + scan.analysis, + state, + upstreamTokens + ); + } else { + addNestedScan( + [commandValue, ...tokens.slice(1)].join(' '), + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } +} + +function scanInvokeExpression(tokens, scan) { + const { findings, state } = scan; + let payload = tokens.slice(1).join(' '); + const payloadReference = variableReference(payload); + if (payloadReference) { + const staticValue = state.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; + } + payload = staticValue; + } + if (payload) { + addNestedScan( + payload, + scan.depth, + findings, + scan.analysis, + { executeBareScriptBlocks: true }, + state + ); + } +} + +function scanCommandFamily(commandName, tokens, upstreamTokens, scan) { + const { findings, state } = scan; + if (POWERSHELL_COMMANDS.has(commandName)) { + scanNestedPowerShell(tokens, scan.depth, findings, scan.analysis, state, upstreamTokens); + } else if (commandName === 'cmd') { + scanCmd(tokens, scan.depth, findings, scan.analysis, state); + } else if (commandName === 'invoke-expression' || commandName === 'iex') { + scanInvokeExpression(tokens, scan); + } else if (Object.hasOwn(DIRECT_COMMAND_FINDINGS, commandName)) { + findings.add(DIRECT_COMMAND_FINDINGS[commandName]); + } +} + +/** + * Scan one pipeline segment. Returns null for inert segments, otherwise + * whether the segment deletes and whether it carries `-Recurse`. + */ +function scanStatementSegment(statement, index, scan) { + const { findings, state } = scan; + const segmentTokens = statement[index]; + const executable = executableSegment(segmentTokens); + const tokens = executable.tokens; + if (tokens.length === 0) return null; + if (executable.firstTokenQuoted && !segmentTokens.invokedByCallOperator) return null; + + const commandName = commandBasename(tokens[0]); + recordInvocation(state, commandName); + scanAliasInvocation(commandName, tokens, executable, scan); + recordClassInvocations(commandName, tokens, executable, scan); + markPowerShellElevation(tokens, scan.analysis); + scanStartProcess(tokens, scan.depth, findings, scan.analysis, state); + scanScriptBlockConsumer(tokens, executable.quotedTokens, findings, state); + if (tokens.some(token => commandBasename(token) === DYNAMIC_EXECUTION_MARKER)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } + if (segmentTokens.invokedByCallOperator) { + scanCallOperatorReference(tokens, statement[index - 1], scan); + } + scanCommandFamily(commandName, tokens, statement[index - 1], scan); + + return { + deletes: scanDeleteSegment(tokens, findings, executable.quotedTokens), + recurses: tokens.some( + (token, tokenIndex) => !executable.quotedTokens[tokenIndex] && + isEnabledSwitch(token, 'recurse') + ), + }; +} + +function scanStatement(statement, scan) { + const deleteSegments = new Set(); + const recurseSegments = new Set(); + + for (let index = 0; index < statement.length; index += 1) { + const result = scanStatementSegment(statement, index, scan); + if (!result) continue; + if (result.deletes) deleteSegments.add(index); + if (result.recurses) recurseSegments.add(index); + } + + const hasUpstreamRecurse = [...recurseSegments].some(index => !deleteSegments.has(index)); + if (statement.length > 1 && deleteSegments.size > 0 && hasUpstreamRecurse) { + scan.findings.add(RULE_IDS.PIPELINE_RECURSE); + } +} + +function scanPowerShell(command, depth, findings, analysis = null, options = {}, scanState = null) { + const raw = normalizeSmartQuotes(command); + if (!raw.trim()) return; + + const state = scanState || createScanState(); + const scan = { depth, findings, analysis, state }; + + const hereStringExpressions = []; + const normalizedHereStrings = normalizeHereStrings(raw, hereStringExpressions); + const withoutComments = stripPowerShellComments(normalizedHereStrings); + collectStaticScalarAssignments(withoutComments, state); + const unquoted = maskQuotedStrings(withoutComments); + scanInvokeScriptCalls(withoutComments, unquoted, depth, findings, analysis, state); + scanStaticDotNetCalls(unquoted, findings, state); for (const payload of hereStringExpressions) { addNestedScan(payload, depth, findings, analysis, { executeBareScriptBlocks: true }, state); } @@ -1902,152 +2188,14 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {}, for (const definition of deferredFunctions) { registerDeferredFunction(state, { ...definition, depth }); } - const invokedBlockVariable = /(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.(?!getnewclosure\b)[A-Za-z_][\w-]*)*)(?:\.getnewclosure\s*\(\s*\))+\.\s*(?:invoke|invokereturnasis|invokewithcontext)\s*\(/gi; - let invokedBlockMatch; - while ((invokedBlockMatch = invokedBlockVariable.exec(unquoted)) !== null) { - recordInvocation(state, invokedBlockMatch[1].toLowerCase()); - } + recordInvokedBlockVariables(unquoted, state); for (const entry of bodies) { addNestedScan(entry.body, depth, findings, analysis, entry.options, state); } for (const statement of parseStatements(outer)) { - const deleteSegments = new Set(); - const recurseSegments = new Set(); - - for (let index = 0; index < statement.length; index += 1) { - const segmentTokens = statement[index]; - const executable = executableSegment(segmentTokens); - const tokens = executable.tokens; - if (tokens.length === 0) continue; - if (executable.firstTokenQuoted && !segmentTokens.invokedByCallOperator) continue; - const commandName = commandBasename(tokens[0]); - recordInvocation(state, commandName); - const aliasTarget = state.aliases.get(commandName); - if (aliasTarget) { - addNestedScan( - [aliasTarget, ...tokens.slice(1)].join(' '), - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) { - for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) { - state.aliases.set(definition.name, definition.value); - } - } - const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i); - if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`); - if (commandName === 'new-object') { - let className = newObjectClassName(tokens, executable.quotedTokens); - const classReference = variableReference(className); - if (classReference) { - const staticClassName = state.staticScalars.get(classReference); - if (staticClassName === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - className = null; - } else { - className = staticClassName; - } - } - if (className && /^[A-Za-z_][\w-]*$/.test(className)) { - recordInvocation(state, `__class__:${className.toLowerCase()}`); - } - } - const invokedVariable = commandName.match( - /^((?:\$\{[^}]+\}|\$(?:[a-z_][\w-]*:)?[a-z_][\w-]*(?:\[[^\]]+\]|\.[a-z_][\w-]*)*))(?:\.getnewclosure\(\))*\.(?:invoke|invokereturnasis|invokewithcontext)(?:\(|$)/i - ); - if (invokedVariable) recordInvocation(state, invokedVariable[1].toLowerCase()); - if (commandName === '.' && tokens[1]) { - recordInvocation(state, commandBasename(tokens[1])); - } - markPowerShellElevation(tokens, analysis); - scanStartProcess(tokens, depth, findings, analysis, state); - scanScriptBlockConsumer(tokens, executable.quotedTokens, findings, state); - if (tokens.some(token => commandBasename(token) === DYNAMIC_EXECUTION_MARKER)) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - } - - const invokedReference = segmentTokens.invokedByCallOperator - ? variableReference(tokens[0]) - : null; - if (invokedReference && !state.deferredFunctions.has(invokedReference)) { - const commandValue = state.staticScalars.get(invokedReference); - if (commandValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - } else if (POWERSHELL_COMMANDS.has(commandBasename(commandValue))) { - scanNestedPowerShell( - [commandValue, ...tokens.slice(1)], - depth, - findings, - analysis, - state, - statement[index - 1] - ); - } else { - addNestedScan( - [commandValue, ...tokens.slice(1)].join(' '), - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - } - - if (POWERSHELL_COMMANDS.has(commandName)) { - scanNestedPowerShell(tokens, depth, findings, analysis, state, statement[index - 1]); - } else if (commandName === 'cmd') { - scanCmd(tokens, depth, findings, analysis, state); - } else if (commandName === 'invoke-expression' || commandName === 'iex') { - let payload = tokens.slice(1).join(' '); - const payloadReference = variableReference(payload); - if (payloadReference) { - const staticValue = state.staticScalars.get(payloadReference); - if (staticValue === undefined) { - findings.add(RULE_IDS.DYNAMIC_EXECUTION); - payload = ''; - } else { - payload = staticValue; - } - } - if (payload) { - addNestedScan( - payload, - depth, - findings, - analysis, - { executeBareScriptBlocks: true }, - state - ); - } - } else if (commandName === 'clear-content' || commandName === 'clc') { - findings.add(RULE_IDS.CLEAR_CONTENT); - } else if (commandName === 'clear-disk') { - findings.add(RULE_IDS.CLEAR_DISK); - } else if (commandName === 'format-volume') { - findings.add(RULE_IDS.FORMAT_VOLUME); - } - - if (scanDeleteSegment(tokens, findings, executable.quotedTokens)) deleteSegments.add(index); - if (tokens.some( - (token, tokenIndex) => !executable.quotedTokens[tokenIndex] && - isEnabledSwitch(token, 'recurse') - )) { - recurseSegments.add(index); - } - } - - const hasUpstreamRecurse = [...recurseSegments].some(index => !deleteSegments.has(index)); - if (statement.length > 1 && deleteSegments.size > 0 && hasUpstreamRecurse) { - findings.add(RULE_IDS.PIPELINE_RECURSE); - } + scanStatement(statement, scan); } - } function resolveDeferredFunctions(findings, analysis, state) { From 3849426a959b6dd21821f78aa6366b8ee224dc1f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 9 Oct 2026 21:12:00 -0400 Subject: [PATCH 13/23] refactor(ci): share the catalog count replacer Six summary-line replacers in catalog.js each took seven to nine positional capture groups and rebuilt the line by hand. They now go through one catalogCountReplacer factory that takes the regex group count and the groups holding the agents, skills, and commands counts. Every other group is written back verbatim. Output is byte-identical across the real docs and several synthetic catalogs. Clears the six max-params warnings on this file. --- scripts/ci/catalog.js | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/scripts/ci/catalog.js b/scripts/ci/catalog.js index 2a079082d..1aa5d8a0b 100644 --- a/scripts/ci/catalog.js +++ b/scripts/ci/catalog.js @@ -316,13 +316,29 @@ function parseCrossHarnessIdentityExpectations(content, source) { ]; } +/** + * Build a replacer for a summary line whose capture groups alternate between + * literal text and the agent, skill, and command counts. `groupCount` is the + * number of capture groups in the regex; `countGroups` lists the 1-based + * groups that hold the three counts, in that order. Every other group is + * written back verbatim. + */ +function catalogCountReplacer(catalog, groupCount, countGroups) { + const counts = [catalog.agents.count, catalog.skills.count, catalog.commands.count]; + return (...args) => { + const groups = args.slice(1, groupCount + 1); + countGroups.forEach((groupIndex, position) => { + groups[groupIndex - 1] = counts[position]; + }); + return groups.join(''); + }; +} + function syncCrossHarnessIdentity(content, catalog, source) { return replaceOrThrow( content, /(with\s+)(\d+)(\s+specialized agents,\s+)(\d+)(\s+skills,\s+(?:and\s+)?)(\d+)(\s+commands)/i, - (_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => ( - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}` - ), + catalogCountReplacer(catalog, 7, [2, 4, 6]), source ); } @@ -482,8 +498,7 @@ function syncEnglishAgents(content, catalog) { nextContent = replaceOrThrow( nextContent, /(providing\s+)(\d+)(\s+specialized agents,\s+)(\d+)(\+?)(\s+skills,\s+)(\d+)(\s+commands)/i, - (_, prefix, __, agentsSuffix, ___, skillsPlus, skillsSuffix, ____, commandsSuffix) => - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsPlus}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 8, [2, 4, 7]), 'AGENTS.md summary' ); nextContent = replaceOrThrow( @@ -512,8 +527,7 @@ function syncZhRootReadme(content, catalog) { return replaceOrThrow( content, /(你现在可以使用\s+)(\d+)(\s+个代理、\s*)(\d+)(\s*个技能和\s*)(\d+)(\s*个命令[。.!!]?)/i, - (_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 7, [2, 4, 6]), 'README.zh-CN.md quick-start summary' ); } @@ -524,8 +538,7 @@ function syncZhDocsReadme(content, catalog) { nextContent = replaceOrThrow( nextContent, /(你现在可以使用\s+)(\d+)(\s+个智能体、\s*)(\d+)(\s*项技能和\s*)(\d+)(\s*个命令了[。.!!]?)/i, - (_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 7, [2, 4, 6]), 'docs/zh-CN/README.md quick-start summary' ); nextContent = replaceOrThrow( @@ -574,8 +587,7 @@ function syncZhAgents(content, catalog) { nextContent = replaceOrThrow( nextContent, /(提供\s+)(\d+)(\s+个专业代理、\s*)(\d+)(\+?)(\s*项技能、\s*)(\d+)(\s+条命令)/i, - (_, prefix, __, agentsSuffix, ___, skillsPlus, skillsSuffix, ____, commandsSuffix) => - `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsPlus}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 8, [2, 4, 7]), 'docs/zh-CN/AGENTS.md summary' ); nextContent = replaceOrThrow( @@ -616,8 +628,7 @@ function syncCatalogDescription(content, catalog, source, getDescription, setDes const nextDescription = replaceOrThrow( description, /(\d+)(\s+agents,\s+)(\d+)(\s+skills,\s+)(\d+)(\s+legacy command shims?)/i, - (_, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => - `${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`, + catalogCountReplacer(catalog, 6, [1, 3, 5]), source ); From 1e787f62f5f751d22aca31e7e4d7960249601cfb Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 11 Oct 2026 20:47:00 -0400 Subject: [PATCH 14/23] refactor(lib): split install-lifecycle repair and inspect paths executeRepairOperation, executeUninstallOperation, and inspectManagedOperation now dispatch on operation kind to one small function per kind, with the repeated uninstall result shapes and previous-content restore pulled into helpers. executeRepairOperation takes an options object instead of four trailing positionals. analyzeRecord is a sequence of collect* steps, and the per-record repair closure inside repairInstalledStates hands off to runRecordRepair with the legacy OpenCode, build-only dry run, and managed repair paths as separate functions. Result objects keep the same keys in the same order, and lock, build, and require ordering is unchanged. Clears the max-params and complexity warnings on this file. --- scripts/lib/install-lifecycle.js | 1639 +++++++++++++++--------------- 1 file changed, 803 insertions(+), 836 deletions(-) diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 1ef50b03c..c500a2b70 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -312,330 +312,418 @@ function shouldRepairFromRecordedOperations(state) { return getManagedOperations(state).some(operation => operation.kind !== 'copy-file'); } -function executeRepairOperation( - repoRoot, - operation, - trustedRoot, - linkIndex = null, - target = null, - settingsLockHeld = false, - writeOptions = {} -) { +function repairCopyFile(repoRoot, operation, trustedRoot, options) { + const sourcePath = resolveOperationSourcePath(repoRoot, operation); + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`); + } + + if (operation.contentTransform || isMarkdownPath(operation.destinationPath)) { + const source = readFileWithMetadataNoFollow(sourcePath, 'utf8'); + writeContainedFile( + operation.destinationPath, + getExpectedCopyFileContent(operation, source.content, options.linkIndex), + trustedRoot, + 'repair', + source.mode & 0o777, + options.writeOptions + ); + } else { + copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair'); + } + return operation.destinationPath; +} + +function repairRenderTemplate(operation, trustedRoot) { + const renderedContent = getOperationTextContent(operation); + if (renderedContent === null) { + throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`); + } + + writeContainedFile(operation.destinationPath, renderedContent, trustedRoot, 'repair'); + return operation.destinationPath; +} + +function repairMergeJson(operation, trustedRoot) { + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`); + } + + const existingDestination = getContainedExistingPath(operation.destinationPath, trustedRoot, 'repair'); + const currentValue = existingDestination + ? readJsonNoFollow( + getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath + ) + : {}; + const mergedValue = deepMergeJson(currentValue, payload); + + writeContainedFile(operation.destinationPath, formatJson(mergedValue), trustedRoot, 'repair'); + return operation.destinationPath; +} + +function repairClaudeSettings(operation, trustedRoot, options) { + assertClaudeSettingsDestination(operation, trustedRoot, options.target); + const managedHooks = validateManagedHooks(operation.managedHooks); + const previousManagedHooks = operation.previousManagedHooks + ? validateManagedHooks(operation.previousManagedHooks, 'previous managed hooks') + : null; + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'repair' + ); + const settingsPath = existingDestination + ? getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath + : prepareContainedWriteDestination(operation.destinationPath, trustedRoot, 'repair'); + updateSettingsAtomic( + settingsPath, + currentSettings => repairManagedHooks(currentSettings, managedHooks, { + previousManagedHooks, + }), + { + lockHeld: options.settingsLockHeld, + beforeCommit() { + getManagedDestination(settingsPath, trustedRoot, 'repair'); + }, + } + ); + return operation.destinationPath; +} + +function repairRemove(operation, trustedRoot) { + const removedPath = removeContainedPath( + operation.destinationPath, + trustedRoot, + 'repair', + { recursive: true, force: true } + ); + return removedPath ? operation.destinationPath : null; +} + +/** + * Apply one recorded operation to the install root. `options` carries + * `linkIndex`, `target`, `settingsLockHeld`, and `writeOptions`. + */ +function executeRepairOperation(repoRoot, operation, trustedRoot, options = {}) { // Install-state is attacker-controllable; never write/delete outside the // adapter-derived trusted root, regardless of what the state file claims // (GHSA-hfpv-w6mp-5g95). - if (operation.kind === 'copy-file') { - const sourcePath = resolveOperationSourcePath(repoRoot, operation); - if (!sourcePath || !fs.existsSync(sourcePath)) { - throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`); - } + const { + linkIndex = null, + target = null, + settingsLockHeld = false, + writeOptions = {}, + } = options; + const resolved = { linkIndex, target, settingsLockHeld, writeOptions }; - if (operation.contentTransform || isMarkdownPath(operation.destinationPath)) { - const source = readFileWithMetadataNoFollow(sourcePath, 'utf8'); - writeContainedFile( - operation.destinationPath, - getExpectedCopyFileContent(operation, source.content, linkIndex), - trustedRoot, - 'repair', - source.mode & 0o777, - writeOptions - ); - } else { - copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair'); - } - return operation.destinationPath; + switch (operation.kind) { + case 'copy-file': + return repairCopyFile(repoRoot, operation, trustedRoot, resolved); + case 'render-template': + return repairRenderTemplate(operation, trustedRoot); + case 'merge-json': + return repairMergeJson(operation, trustedRoot); + case 'update-claude-settings': + return repairClaudeSettings(operation, trustedRoot, resolved); + case 'remove': + return repairRemove(operation, trustedRoot); + default: + throw new Error(`Unsupported repair operation kind: ${operation.kind}`); + } +} + +function uninstallNoop() { + return { + removedPaths: [], + cleanupTargets: [] + }; +} + +function uninstallRetained(operation) { + return { + removedPaths: [], + cleanupTargets: [], + retainedPaths: [operation.destinationPath] + }; +} + +// Delete the managed destination and report it, or report nothing when it +// was already gone. +function uninstallRemovePath(operation, trustedRoot) { + const removedPath = removeContainedPath( + operation.destinationPath, + trustedRoot, + 'uninstall', + { force: true } + ); + if (!removedPath) return uninstallNoop(); + + return { + removedPaths: [operation.destinationPath], + cleanupTargets: [removedPath] + }; +} + +// Restore the pre-install content when the operation recorded it. Returns +// null when nothing was recorded so the caller can fall through. +function restorePreviousContent(operation, trustedRoot) { + const previousContent = getOperationPreviousContent(operation); + if (previousContent !== null) { + writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); + return uninstallNoop(); } - if (operation.kind === 'render-template') { - const renderedContent = getOperationTextContent(operation); - if (renderedContent === null) { - throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`); - } - - writeContainedFile(operation.destinationPath, renderedContent, trustedRoot, 'repair'); - return operation.destinationPath; + const previousJson = getOperationPreviousJson(operation); + if (previousJson !== undefined) { + writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); + return uninstallNoop(); } - if (operation.kind === 'merge-json') { - const payload = getOperationJsonPayload(operation); - if (payload === undefined) { - throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`); - } + return null; +} - const existingDestination = getContainedExistingPath(operation.destinationPath, trustedRoot, 'repair'); - const currentValue = existingDestination - ? readJsonNoFollow( - getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath - ) - : {}; - const mergedValue = deepMergeJson(currentValue, payload); +// Whether a drifted or symlinked copy must stay in place. Returns the +// uninstall result to report, or null when the copy may be removed. +function retainDriftedCopy(operation, trustedRoot) { + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'uninstall', + { allowFinalSymlink: true } + ); + if (!existingDestination) return uninstallNoop(); + if (fs.lstatSync(existingDestination).isSymbolicLink()) return uninstallRetained(operation); - writeContainedFile(operation.destinationPath, formatJson(mergedValue), trustedRoot, 'repair'); - return operation.destinationPath; + const recordedDigest = operation.contentSha256; + const currentDigest = /^[a-f0-9]{64}$/i.test(recordedDigest || '') + ? crypto.createHash('sha256') + .update(readFileNoFollow(existingDestination)) + .digest('hex') + : null; + if (!currentDigest || currentDigest !== recordedDigest.toLowerCase()) { + return uninstallRetained(operation); + } + return null; +} + +function uninstallCopyFile(operation, trustedRoot, options) { + if (options.preserveDriftedCopies) { + const retained = retainDriftedCopy(operation, trustedRoot); + if (retained) return retained; + } + return uninstallRemovePath(operation, trustedRoot); +} + +function uninstallRenderTemplate(operation, trustedRoot) { + return restorePreviousContent(operation, trustedRoot) || uninstallRemovePath(operation, trustedRoot); +} + +function uninstallMergeJson(operation, trustedRoot) { + const restored = restorePreviousContent(operation, trustedRoot); + if (restored) return restored; + + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'uninstall' + ); + if (!existingDestination) return uninstallNoop(); + + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`); } - if (operation.kind === 'update-claude-settings') { - assertClaudeSettingsDestination(operation, trustedRoot, target); - const managedHooks = validateManagedHooks(operation.managedHooks); - const previousManagedHooks = operation.previousManagedHooks - ? validateManagedHooks(operation.previousManagedHooks, 'previous managed hooks') - : null; - const existingDestination = getContainedExistingPath( - operation.destinationPath, - trustedRoot, - 'repair' - ); - const settingsPath = existingDestination - ? getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath - : prepareContainedWriteDestination(operation.destinationPath, trustedRoot, 'repair'); - updateSettingsAtomic( - settingsPath, - currentSettings => repairManagedHooks(currentSettings, managedHooks, { - previousManagedHooks, - }), - { - lockHeld: settingsLockHeld, - beforeCommit() { - getManagedDestination(settingsPath, trustedRoot, 'repair'); - }, - } - ); - return operation.destinationPath; - } - - if (operation.kind === 'remove') { + const currentValue = readJsonNoFollow( + getManagedDestination(existingDestination, trustedRoot, 'uninstall').managedPath + ); + const nextValue = deepRemoveJsonSubset(currentValue, payload); + if (nextValue === JSON_REMOVE_SENTINEL) { const removedPath = removeContainedPath( operation.destinationPath, trustedRoot, - 'repair', - { recursive: true, force: true } + 'uninstall', + { force: true } ); - return removedPath ? operation.destinationPath : null; + return { + removedPaths: removedPath ? [operation.destinationPath] : [], + cleanupTargets: removedPath ? [removedPath] : [] + }; } - throw new Error(`Unsupported repair operation kind: ${operation.kind}`); + writeContainedFile(operation.destinationPath, formatJson(nextValue), trustedRoot, 'uninstall'); + return uninstallNoop(); +} + +function uninstallClaudeSettings(operation, trustedRoot, options) { + assertClaudeSettingsDestination(operation, trustedRoot, options.target); + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'uninstall' + ); + if (!existingDestination) return uninstallNoop(); + + const settingsPath = getManagedDestination( + existingDestination, + trustedRoot, + 'uninstall' + ).managedPath; + const uninstalled = updateSettingsAtomic( + settingsPath, + currentSettings => uninstallManagedHooks(currentSettings, operation.managedHooks), + { + lockHeld: Boolean(options.settingsLockHeld), + beforeCommit() { + getManagedDestination(settingsPath, trustedRoot, 'uninstall'); + }, + } + ); + + return { + removedPaths: [], + cleanupTargets: [], + retainedPaths: uninstalled.retained.length > 0 + ? [operation.destinationPath] + : [] + }; +} + +function uninstallRemoveOperation(operation, trustedRoot) { + return restorePreviousContent(operation, trustedRoot) || uninstallNoop(); } function executeUninstallOperation(operation, trustedRoot, options = {}) { // Confine deletes to the trusted install root (GHSA-hfpv-w6mp-5g95). - if (operation.kind === 'copy-file') { - if (options.preserveDriftedCopies) { - const existingDestination = getContainedExistingPath( - operation.destinationPath, - trustedRoot, - 'uninstall', - { allowFinalSymlink: true } - ); - if (!existingDestination) { - return { - removedPaths: [], - cleanupTargets: [] - }; - } - if (fs.lstatSync(existingDestination).isSymbolicLink()) { - return { - removedPaths: [], - cleanupTargets: [], - retainedPaths: [operation.destinationPath] - }; - } - const recordedDigest = operation.contentSha256; - const currentDigest = /^[a-f0-9]{64}$/i.test(recordedDigest || '') - ? crypto.createHash('sha256') - .update(readFileNoFollow(existingDestination)) - .digest('hex') - : null; - if (!currentDigest || currentDigest !== recordedDigest.toLowerCase()) { - return { - removedPaths: [], - cleanupTargets: [], - retainedPaths: [operation.destinationPath] - }; - } - } + switch (operation.kind) { + case 'copy-file': + return uninstallCopyFile(operation, trustedRoot, options); + case 'render-template': + return uninstallRenderTemplate(operation, trustedRoot); + case 'merge-json': + return uninstallMergeJson(operation, trustedRoot); + case 'update-claude-settings': + return uninstallClaudeSettings(operation, trustedRoot, options); + case 'remove': + return uninstallRemoveOperation(operation, trustedRoot); + default: + throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`); + } +} - const removedPath = removeContainedPath( - operation.destinationPath, - trustedRoot, - 'uninstall', - { force: true } +function inspectionResult(status, operation, destinationPath, extra = {}) { + return { + status, + operation, + destinationPath, + ...extra + }; +} + +// Compare installed bytes with what the recorded copy operation should have +// written. Throws when either side cannot be read. +function copyFileContentMatches(operation, inspectedPath, copySourcePath, linkIndex) { + // A deactivation transform changes the desired bytes. A historical + // active-file digest proves ownership, not completion of that transition. + const deactivatesOpenCode = operation.contentTransform === 'opencode-disable-ecc-hooks' + || operation.contentTransform === 'opencode-disable-plugin-entrypoint'; + if (hasRecordedContentDigest(operation) && !deactivatesOpenCode) { + return fileMatchesRecordedContent(inspectedPath, operation); + } + if (operation.contentTransform || isMarkdownPath(operation.destinationPath)) { + return readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent( + operation, + readFileNoFollow(copySourcePath, 'utf8'), + linkIndex ); - if (!removedPath) { - return { - removedPaths: [], - cleanupTargets: [] - }; - } + } + return areFilesEqual(copySourcePath, inspectedPath); +} - return { - removedPaths: [operation.destinationPath], - cleanupTargets: [removedPath] - }; +function inspectCopyFileOperation(operation, inspectedPath, copySourcePath, linkIndex) { + const { destinationPath } = operation; + const sourcePath = copySourcePath; + if (!copySourcePath || !fs.existsSync(copySourcePath)) { + return inspectionResult('missing-source', operation, destinationPath, { sourcePath }); } - if (operation.kind === 'render-template') { - const previousContent = getOperationPreviousContent(operation); - if (previousContent !== null) { - writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - const previousJson = getOperationPreviousJson(operation); - if (previousJson !== undefined) { - writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - const removedPath = removeContainedPath( - operation.destinationPath, - trustedRoot, - 'uninstall', - { force: true } - ); - if (!removedPath) { - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - return { - removedPaths: [operation.destinationPath], - cleanupTargets: [removedPath] - }; + let contentMatches; + try { + contentMatches = copyFileContentMatches(operation, inspectedPath, copySourcePath, linkIndex); + } catch (_error) { + return inspectionResult('unverified', operation, destinationPath, { sourcePath }); } - if (operation.kind === 'merge-json') { - const previousContent = getOperationPreviousContent(operation); - if (previousContent !== null) { - writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } + return inspectionResult(contentMatches ? 'ok' : 'drifted', operation, destinationPath, { sourcePath }); +} - const previousJson = getOperationPreviousJson(operation); - if (previousJson !== undefined) { - writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - const existingDestination = getContainedExistingPath( - operation.destinationPath, - trustedRoot, - 'uninstall' - ); - if (!existingDestination) { - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - const payload = getOperationJsonPayload(operation); - if (payload === undefined) { - throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`); - } - - const currentValue = readJsonNoFollow( - getManagedDestination(existingDestination, trustedRoot, 'uninstall').managedPath - ); - const nextValue = deepRemoveJsonSubset(currentValue, payload); - if (nextValue === JSON_REMOVE_SENTINEL) { - const removedPath = removeContainedPath( - operation.destinationPath, - trustedRoot, - 'uninstall', - { force: true } - ); - return { - removedPaths: removedPath ? [operation.destinationPath] : [], - cleanupTargets: removedPath ? [removedPath] : [] - }; - } - - writeContainedFile(operation.destinationPath, formatJson(nextValue), trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; +function inspectRenderTemplateOperation(operation, inspectedPath) { + const { destinationPath } = operation; + const renderedContent = getOperationTextContent(operation); + if (renderedContent === null) { + return inspectionResult('unverified', operation, destinationPath); } - if (operation.kind === 'update-claude-settings') { - assertClaudeSettingsDestination(operation, trustedRoot, options.target); - const existingDestination = getContainedExistingPath( - operation.destinationPath, - trustedRoot, - 'uninstall' - ); - if (!existingDestination) { - return { - removedPaths: [], - cleanupTargets: [] - }; + try { + if (readFileNoFollow(inspectedPath, 'utf8') !== renderedContent) { + return inspectionResult('drifted', operation, destinationPath); } - - const settingsPath = getManagedDestination( - existingDestination, - trustedRoot, - 'uninstall' - ).managedPath; - const uninstalled = updateSettingsAtomic( - settingsPath, - currentSettings => uninstallManagedHooks(currentSettings, operation.managedHooks), - { - lockHeld: Boolean(options.settingsLockHeld), - beforeCommit() { - getManagedDestination(settingsPath, trustedRoot, 'uninstall'); - }, - } - ); - - return { - removedPaths: [], - cleanupTargets: [], - retainedPaths: uninstalled.retained.length > 0 - ? [operation.destinationPath] - : [] - }; + } catch { + return inspectionResult('drifted', operation, destinationPath); } - if (operation.kind === 'remove') { - const previousContent = getOperationPreviousContent(operation); - if (previousContent !== null) { - writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } + return inspectionResult('ok', operation, destinationPath); +} - const previousJson = getOperationPreviousJson(operation); - if (previousJson !== undefined) { - writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); - return { - removedPaths: [], - cleanupTargets: [] - }; - } - - return { - removedPaths: [], - cleanupTargets: [] - }; +function inspectMergeJsonOperation(operation, inspectedPath) { + const { destinationPath } = operation; + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + return inspectionResult('unverified', operation, destinationPath); } - throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`); + try { + const currentValue = readJsonNoFollow(inspectedPath); + if (!jsonContainsSubset(currentValue, payload)) { + return inspectionResult('drifted', operation, destinationPath); + } + } catch (_error) { + return inspectionResult('drifted', operation, destinationPath); + } + + return inspectionResult('ok', operation, destinationPath); +} + +function inspectClaudeSettingsOperation(operation, inspectedPath, trustedRoot, target) { + const { destinationPath } = operation; + try { + assertClaudeSettingsDestination(operation, trustedRoot, target); + } catch (_error) { + return inspectionResult('unsafe-destination', operation, destinationPath, { + reason: 'non-canonical-claude-settings' + }); + } + let managedHooks; + try { + managedHooks = validateManagedHooks(operation.managedHooks); + } catch (_error) { + return inspectionResult('unverified', operation, destinationPath); + } + + try { + const inspection = inspectManagedHooks( + readJsonNoFollow(inspectedPath), + managedHooks + ); + return inspectionResult(inspection.status, operation, destinationPath, { + managedHookInspection: inspection + }); + } catch (error) { + return inspectionResult('invalid-settings', operation, destinationPath, { + error: `Failed to inspect Claude settings at ${destinationPath}: ${error.message}` + }); + } } function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null, target = null) { @@ -656,32 +744,17 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n { allowFinalSymlink: operation.kind === 'remove' } ); } catch (error) { - return { - status: 'unsafe-destination', - operation, - destinationPath, + return inspectionResult('unsafe-destination', operation, destinationPath, { reason: error && error.code === 'ECC_FINAL_DESTINATION_SYMLINK' ? 'final-symlink' : 'outside-root' - }; + }); } const inspectedPath = managedDestination.managedPath; if (operation.kind === 'remove') { - if (managedDestination.exists) { - return { - status: 'drifted', - operation, - destinationPath - }; - } - - return { - status: 'ok', - operation, - destinationPath - }; + return inspectionResult(managedDestination.exists ? 'drifted' : 'ok', operation, destinationPath); } let copySourcePath = null; @@ -689,188 +762,26 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n try { copySourcePath = resolveOperationSourcePath(repoRoot, operation); } catch { - return { - status: 'unsafe-source', - operation, - destinationPath - }; + return inspectionResult('unsafe-source', operation, destinationPath); } } if (!managedDestination.exists) { - return { - status: 'missing', - operation, - destinationPath - }; + return inspectionResult('missing', operation, destinationPath); } - if (operation.kind === 'copy-file') { - if (!copySourcePath || !fs.existsSync(copySourcePath)) { - return { - status: 'missing-source', - operation, - destinationPath, - sourcePath: copySourcePath - }; - } - - let contentMatches; - try { - // A deactivation transform changes the desired bytes. A historical - // active-file digest proves ownership, not completion of that transition. - const deactivatesOpenCode = operation.contentTransform === 'opencode-disable-ecc-hooks' - || operation.contentTransform === 'opencode-disable-plugin-entrypoint'; - contentMatches = hasRecordedContentDigest(operation) && !deactivatesOpenCode - ? fileMatchesRecordedContent(inspectedPath, operation) - : operation.contentTransform || isMarkdownPath(operation.destinationPath) - ? readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent( - operation, - readFileNoFollow(copySourcePath, 'utf8'), - linkIndex - ) - : areFilesEqual(copySourcePath, inspectedPath); - } catch (_error) { - return { - status: 'unverified', - operation, - destinationPath, - sourcePath: copySourcePath - }; - } - - if (!contentMatches) { - return { - status: 'drifted', - operation, - destinationPath, - sourcePath: copySourcePath - }; - } - - return { - status: 'ok', - operation, - destinationPath, - sourcePath: copySourcePath - }; + switch (operation.kind) { + case 'copy-file': + return inspectCopyFileOperation(operation, inspectedPath, copySourcePath, linkIndex); + case 'render-template': + return inspectRenderTemplateOperation(operation, inspectedPath); + case 'merge-json': + return inspectMergeJsonOperation(operation, inspectedPath); + case 'update-claude-settings': + return inspectClaudeSettingsOperation(operation, inspectedPath, trustedRoot, target); + default: + return inspectionResult('unverified', operation, destinationPath); } - - if (operation.kind === 'render-template') { - const renderedContent = getOperationTextContent(operation); - if (renderedContent === null) { - return { - status: 'unverified', - operation, - destinationPath - }; - } - - try { - if (readFileNoFollow(inspectedPath, 'utf8') !== renderedContent) { - return { - status: 'drifted', - operation, - destinationPath - }; - } - } catch { - return { - status: 'drifted', - operation, - destinationPath - }; - } - - return { - status: 'ok', - operation, - destinationPath - }; - } - - if (operation.kind === 'merge-json') { - const payload = getOperationJsonPayload(operation); - if (payload === undefined) { - return { - status: 'unverified', - operation, - destinationPath - }; - } - - try { - const currentValue = readJsonNoFollow(inspectedPath); - if (!jsonContainsSubset(currentValue, payload)) { - return { - status: 'drifted', - operation, - destinationPath - }; - } - } catch (_error) { - return { - status: 'drifted', - operation, - destinationPath - }; - } - - return { - status: 'ok', - operation, - destinationPath - }; - } - - if (operation.kind === 'update-claude-settings') { - try { - assertClaudeSettingsDestination(operation, trustedRoot, target); - } catch (_error) { - return { - status: 'unsafe-destination', - operation, - destinationPath, - reason: 'non-canonical-claude-settings' - }; - } - let managedHooks; - try { - managedHooks = validateManagedHooks(operation.managedHooks); - } catch (_error) { - return { - status: 'unverified', - operation, - destinationPath - }; - } - - try { - const inspection = inspectManagedHooks( - readJsonNoFollow(inspectedPath), - managedHooks - ); - return { - status: inspection.status, - operation, - destinationPath, - managedHookInspection: inspection - }; - } catch (error) { - return { - status: 'invalid-settings', - operation, - destinationPath, - error: `Failed to inspect Claude settings at ${destinationPath}: ${error.message}` - }; - } - } - - return { - status: 'unverified', - operation, - destinationPath - }; } function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, target = null) { @@ -1134,61 +1045,43 @@ function determineStatus(issues) { return 'ok'; } -function analyzeRecord(record, context) { - const issues = []; - - if (record.legacyLayout === 'antigravity') { - issues.push(buildIssue( - 'warning', - 'legacy-antigravity-layout', - 'Legacy Antigravity install-state remains under .agent. Review and move any preserved modified or unmanaged files out of .agent, then rerun the Antigravity install to finish migration.' - )); +const LEGACY_LAYOUT_ISSUES = Object.freeze({ + antigravity: { + code: 'legacy-antigravity-layout', + message: 'Legacy Antigravity install-state remains under .agent. Review and move any preserved modified or unmanaged files out of .agent, then rerun the Antigravity install to finish migration.' + }, + opencode: { + code: 'legacy-opencode-layout', + message: 'Legacy OpenCode install-state remains under ~/.opencode. Rerun the OpenCode install or repair command to migrate unchanged ECC-managed files to ~/.config/opencode; modified files are preserved for review.' } +}); - if (record.legacyLayout === 'opencode') { - issues.push(buildIssue( - 'warning', - 'legacy-opencode-layout', - 'Legacy OpenCode install-state remains under ~/.opencode. Rerun the OpenCode install or repair command to migrate unchanged ECC-managed files to ~/.config/opencode; modified files are preserved for review.' - )); +function collectLegacyLayoutIssues(record, issues) { + if (!Object.hasOwn(LEGACY_LAYOUT_ISSUES, record.legacyLayout)) return; + const legacyIssue = LEGACY_LAYOUT_ISSUES[record.legacyLayout]; + issues.push(buildIssue('warning', legacyIssue.code, legacyIssue.message)); +} + +// Returns true when hook-deactivation planning itself failed, so the later +// resolution-drift check does not report the same failure twice. +function collectOpenCodeHookIssues(record, context, issues) { + if (record.adapter.target !== 'opencode') return false; + let checks; + try { + checks = prepareOpenCodeHookDeactivationChecks(record, context, { requireInactive: true }); + } catch (error) { + issues.push(buildIssue('error', 'resolution-unavailable', error.message)); + return true; } - - if (record.error) { - issues.push(buildIssue('error', 'invalid-install-state', record.error)); - return { - ...record, - status: determineStatus(issues), - issues - }; - } - - const state = record.state; - if (!state) { - return { - ...record, - status: 'missing', - issues - }; - } - - let planningFailureReported = false; - if (record.adapter.target === 'opencode') { - let checks; - try { - checks = prepareOpenCodeHookDeactivationChecks(record, context, { requireInactive: true }); - } catch (error) { - planningFailureReported = true; - issues.push(buildIssue('error', 'resolution-unavailable', error.message)); - } - if (checks) { - try { - for (const check of checks) assertOpenCodeRepairHookDeactivation(check.plan, check.options); - } catch (error) { - issues.push(buildIssue('error', 'opencode-hook-consent-violation', error.message)); - } - } + try { + for (const check of checks) assertOpenCodeRepairHookDeactivation(check.plan, check.options); + } catch (error) { + issues.push(buildIssue('error', 'opencode-hook-consent-violation', error.message)); } + return false; +} +function collectTargetPathIssues(record, state, issues) { if (!fs.existsSync(state.target.root)) { issues.push(buildIssue('error', 'missing-target-root', `Target root does not exist: ${state.target.root}`)); } @@ -1210,15 +1103,16 @@ function analyzeRecord(record, context) { }) ); } +} - const managedOperations = getManagedOperations(state); +function collectOperationHealthIssues(record, context, state, issues) { const operationHealth = summarizeManagedOperationHealth( context.repoRoot, record.targetRoot, - managedOperations, + getManagedOperations(state), record.adapter.target ); - const missingManagedOperations = operationHealth.missing; + const destinationPaths = entries => entries.map(entry => entry.destinationPath); if (operationHealth.unsafeDestination.length > 0) { issues.push( @@ -1246,15 +1140,15 @@ function analyzeRecord(record, context) { 'error', 'invalid-claude-settings', operationHealth.invalidSettings[0].error, - { paths: operationHealth.invalidSettings.map(entry => entry.destinationPath) } + { paths: destinationPaths(operationHealth.invalidSettings) } ) ); } - if (missingManagedOperations.length > 0) { + if (operationHealth.missing.length > 0) { issues.push( - buildIssue('error', 'missing-managed-files', `${missingManagedOperations.length} managed file(s) are missing`, { - paths: missingManagedOperations.map(entry => entry.destinationPath) + buildIssue('error', 'missing-managed-files', `${operationHealth.missing.length} managed file(s) are missing`, { + paths: destinationPaths(operationHealth.missing) }) ); } @@ -1262,7 +1156,7 @@ function analyzeRecord(record, context) { if (operationHealth.drifted.length > 0) { issues.push( buildIssue('warning', 'drifted-managed-files', `${operationHealth.drifted.length} managed file(s) differ from the source repo`, { - paths: operationHealth.drifted.map(entry => entry.destinationPath) + paths: destinationPaths(operationHealth.drifted) }) ); } @@ -1278,11 +1172,13 @@ function analyzeRecord(record, context) { if (operationHealth.unverified.length > 0) { issues.push( buildIssue('warning', 'unverified-managed-operations', `${operationHealth.unverified.length} managed operation(s) could not be content-verified`, { - paths: operationHealth.unverified.map(entry => entry.destinationPath).filter(Boolean) + paths: destinationPaths(operationHealth.unverified).filter(Boolean) }) ); } +} +function collectVersionIssues(context, state, issues) { if (state.source.manifestVersion !== context.manifestVersion) { issues.push(buildIssue('warning', 'manifest-version-mismatch', `Recorded manifest version ${state.source.manifestVersion} differs from current manifest version ${context.manifestVersion}`)); } @@ -1290,24 +1186,55 @@ function analyzeRecord(record, context) { if (context.packageVersion && state.source.repoVersion && state.source.repoVersion !== context.packageVersion) { issues.push(buildIssue('warning', 'repo-version-mismatch', `Recorded repo version ${state.source.repoVersion} differs from current repo version ${context.packageVersion}`)); } +} - if (!state.request.legacyMode && !planningFailureReported) { - try { - const desiredPlan = resolveRecordedManifestPlan(record, context); +function collectResolutionDriftIssues(record, context, state, issues) { + try { + const desiredPlan = resolveRecordedManifestPlan(record, context); - if (!compareStringArrays(desiredPlan.selectedModuleIds, state.resolution.selectedModules) || !compareStringArrays(desiredPlan.skippedModuleIds, state.resolution.skippedModules)) { - issues.push( - buildIssue('warning', 'resolution-drift', 'Current manifest resolution differs from recorded install-state', { - expectedSelectedModules: desiredPlan.selectedModuleIds, - recordedSelectedModules: state.resolution.selectedModules, - expectedSkippedModules: desiredPlan.skippedModuleIds, - recordedSkippedModules: state.resolution.skippedModules - }) - ); - } - } catch (error) { - issues.push(buildIssue('error', 'resolution-unavailable', error.message)); + if (!compareStringArrays(desiredPlan.selectedModuleIds, state.resolution.selectedModules) || !compareStringArrays(desiredPlan.skippedModuleIds, state.resolution.skippedModules)) { + issues.push( + buildIssue('warning', 'resolution-drift', 'Current manifest resolution differs from recorded install-state', { + expectedSelectedModules: desiredPlan.selectedModuleIds, + recordedSelectedModules: state.resolution.selectedModules, + expectedSkippedModules: desiredPlan.skippedModuleIds, + recordedSkippedModules: state.resolution.skippedModules + }) + ); } + } catch (error) { + issues.push(buildIssue('error', 'resolution-unavailable', error.message)); + } +} + +function analyzeRecord(record, context) { + const issues = []; + collectLegacyLayoutIssues(record, issues); + + if (record.error) { + issues.push(buildIssue('error', 'invalid-install-state', record.error)); + return { + ...record, + status: determineStatus(issues), + issues + }; + } + + const state = record.state; + if (!state) { + return { + ...record, + status: 'missing', + issues + }; + } + + const planningFailureReported = collectOpenCodeHookIssues(record, context, issues); + collectTargetPathIssues(record, state, issues); + collectOperationHealthIssues(record, context, state, issues); + collectVersionIssues(context, state, issues); + if (!state.request.legacyMode && !planningFailureReported) { + collectResolutionDriftIssues(record, context, state, issues); } return { @@ -1564,6 +1491,319 @@ function preflightOpenCodeHookDeactivation(record, context, options = {}) { } } +function repairErrorResult(record, error) { + return { + adapter: record.adapter, + status: 'error', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs: [], + error + }; +} + +function acquireRepairSettingsLock(record, options) { + const settingsPathToLock = !options.dryRun + && getManagedOperations(record.state || {}).some( + operation => operation.kind === 'update-claude-settings' + ) + ? getClaudeSettingsPath(record.targetRoot) + : null; + return settingsPathToLock ? acquireSettingsLock(settingsPathToLock) : null; +} + +// Returns an error result when the OpenCode build fails, otherwise null. +function runOpencodeBuild(repair) { + try { + repair.buildOpencodeRunner(repair.context.repoRoot); + return null; + } catch (error) { + return repairErrorResult(repair.record, formatBuildErrorMessage(error)); + } +} + +// Legacy ~/.opencode layouts are migrated by applying the canonical plan +// rather than repairing files in place. +function repairLegacyOpencodeLayout(repair, build) { + const { record, context, options, opencodeLease } = repair; + const { needsOpencodeBuild, opencodeBuildRepairPath } = build; + if (needsOpencodeBuild && !options.dryRun) { + const buildFailure = runOpencodeBuild(repair); + if (buildFailure) return buildFailure; + } + + const canonicalPlan = createRepairPlanFromRecord(record, context, { + exemptValidationCodes: options.dryRun && needsOpencodeBuild + ? [OPENCODE_PLUGIN_NOT_BUILT_CODE] + : [], + }); + assertOpenCodeRepairHookDeactivation(canonicalPlan); + const buildPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; + const planPaths = canonicalPlan.operations.map(operation => operation.destinationPath); + const plannedRepairs = [...new Set([ + ...buildPaths, + ...planPaths, + ...getManagedOperations(record.state).map(operation => operation.destinationPath), + record.installStatePath, + ])]; + + if (options.dryRun) { + return { + adapter: record.adapter, + status: 'planned', + installStatePath: canonicalPlan.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: false, + warnings: canonicalPlan.warnings, + error: null, + }; + } + + // Load lazily to avoid a module cycle during install-lifecycle startup. + const { applyInstallPlan } = require('./install/apply'); + const appliedPlan = applyInstallPlan(canonicalPlan, { opencodeLease }); + return { + adapter: record.adapter, + status: 'repaired', + installStatePath: canonicalPlan.installStatePath, + repairedPaths: [...buildPaths, ...planPaths], + plannedRepairs: [], + stateRefreshed: true, + warnings: appliedPlan.warnings, + error: null, + }; +} + +// Dry run while the OpenCode plugin is not built: plan around the missing +// artifact instead of failing validation. +function planOpencodeBuildRepair(repair, build) { + const { record, context } = repair; + const rawPlan = createRepairPlanFromRecord(record, context, { + exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], + }); + const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record); + const operationHealth = summarizeManagedOperationHealth( + context.repoRoot, + record.targetRoot, + desiredPlan.operations, + record.adapter.target + ); + const unsafeOperationResult = getUnsafeOperationResult(record, operationHealth); + if (unsafeOperationResult) return unsafeOperationResult; + + const repairOperations = [ + ...operationHealth.missing.map(entry => ({ ...entry.operation })), + ...operationHealth.drifted.map(entry => ({ ...entry.operation })), + ]; + const plannedRepairs = [ + build.opencodeBuildRepairPath, + ...repairOperations.map(operation => operation.destinationPath), + ]; + + return { + adapter: record.adapter, + status: 'planned', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: false, + warnings: desiredPlan.warnings, + error: null + }; +} + +// Missing and drifted managed files, plus settings operations whose managed +// hooks changed since the previous install. Deduplicated by kind and path. +function collectRepairOperations(operationHealth, desiredPlan) { + return [ + ...operationHealth.missing.map(entry => ({ ...entry.operation })), + ...operationHealth.drifted.map(entry => ({ ...entry.operation })), + ...desiredPlan.operations + .filter(operation => ( + operation.kind === 'update-claude-settings' + && operation.previousManagedHooks + && !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks) + )) + .map(operation => ({ ...operation })), + ].filter((operation, index, items) => items.findIndex(candidate => ( + candidate.kind === operation.kind + && candidate.destinationPath === operation.destinationPath + )) === index); +} + +function executeGuardedRepairOperation(repair, plan, operation, repairedPaths) { + const { record, context, settingsLockHeld } = repair; + const { desiredPlan, migration, activationSnapshot, repairLinkIndex } = plan; + const apply = require('./install/apply'); + if (operation.sourceRelativePath === 'manifests/install-assets/commonjs-scripts-package.json') { + apply.assertOpenCodeScriptBoundary(desiredPlan, new Set(repairedPaths)); + } + if (desiredPlan.target === 'opencode') { + apply.assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot); + } + assertNoNewUserOwnedFile(migration, operation, desiredPlan); + const repairedPath = executeRepairOperation(context.repoRoot, operation, record.targetRoot, { + linkIndex: repairLinkIndex, + target: record.adapter.target, + settingsLockHeld, + writeOptions: apply.getOpenCodeActivationWriteOptions(operation, activationSnapshot), + }); + if (repairedPath) { + repairedPaths.push(repairedPath); + } +} + +function removeLegacyMigrationPaths(record, migration, repairedPaths) { + for (const operation of migration.legacyOperationsToRemove) { + const removedPath = removeContainedPath( + operation.destinationPath, + record.targetRoot, + 'migrate managed Claude skill', + { force: true } + ); + if (removedPath) { + repairedPaths.push(removedPath); + } + } +} + +function applyRecordRepairs(repair, build, plan) { + const { record } = repair; + const { needsOpencodeBuild, opencodeBuildRepairPath } = build; + const { desiredPlan, migration, activationSnapshot, repairOperations } = plan; + const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; + const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; + if (desiredPlan.target === 'opencode') { + const { assertOpenCodeActivationUnchanged } = require('./install/apply'); + // Health inspection must not let a changed activation become owned by + // a bridge checkpoint before the per-write check can reject it. + for (const destinationPath of activationSnapshot.keys()) { + assertOpenCodeActivationUnchanged(desiredPlan, { destinationPath }, activationSnapshot); + } + } + if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { + writeRefreshedInstallState(record, migration.bridgeState); + } + + for (const operation of repairOperations) { + executeGuardedRepairOperation(repair, plan, operation, repairedPaths); + } + if (hasLegacyMigration) { + removeLegacyMigrationPaths(record, migration, repairedPaths); + } + + const changedInstalledBytes = repairOperations.length > 0 + || needsOpencodeBuild + || hasLegacyMigration; + const statePreviewToWrite = changedInstalledBytes + ? desiredPlan.statePreview + : { + ...desiredPlan.statePreview, + installedAt: record.state.installedAt, + source: { ...record.state.source }, + }; + assertOpenCodeRepairHookDeactivation(desiredPlan, { requireInactive: true }); + writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths); + + return { + adapter: record.adapter, + status: changedInstalledBytes ? 'repaired' : 'ok', + installStatePath: record.installStatePath, + repairedPaths, + plannedRepairs: [], + stateRefreshed: true, + warnings: desiredPlan.warnings, + error: null + }; +} + +function repairManagedRecord(repair, build) { + const { record, context, options } = repair; + const { needsOpencodeBuild, opencodeBuildRepairPath } = build; + const rawPlan = createRepairPlanFromRecord(record, context); + require('./install/apply').assertOpenCodeScriptBoundary(rawPlan); + const { + migration, + plan: desiredPlan, + } = prepareRepairMigration(rawPlan, record); + const activationSnapshot = assertOpenCodeRepairHookDeactivation(desiredPlan); + const operationHealth = summarizeManagedOperationHealth( + context.repoRoot, + record.targetRoot, + desiredPlan.operations, + record.adapter.target + ); + + const unsafeOperationResult = getUnsafeOperationResult(record, operationHealth); + if (unsafeOperationResult) return unsafeOperationResult; + + if (operationHealth.missingSource.length > 0) { + return { + adapter: record.adapter, + status: 'error', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs: [], + warnings: desiredPlan.warnings, + error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}` + }; + } + + const repairOperations = collectRepairOperations(operationHealth, desiredPlan); + const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot); + const legacyMigrationPaths = migration.legacyOperationsToRemove.map( + operation => operation.destinationPath + ); + const plannedRepairs = [...new Set([ + ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), + ...repairOperations.map(operation => operation.destinationPath), + ...legacyMigrationPaths, + ])]; + + if (options.dryRun) { + return { + adapter: record.adapter, + status: plannedRepairs.length > 0 ? 'planned' : 'ok', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: plannedRepairs.length === 0, + warnings: desiredPlan.warnings, + error: null + }; + } + + return applyRecordRepairs(repair, build, { + desiredPlan, + migration, + activationSnapshot, + repairOperations, + repairLinkIndex, + }); +} + +// Repair one discovered record. `repair` carries the record, lifecycle +// context, caller options, the OpenCode build runner, an optional OpenCode +// lease, and whether the Claude settings lock is already held. +function runRecordRepair(repair) { + const { record, context, options } = repair; + const needsOpencodeBuild = record.adapter.target === 'opencode' + && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); + const build = { + needsOpencodeBuild, + opencodeBuildRepairPath: path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT), + }; + + if (record.legacyLayout === 'opencode') return repairLegacyOpencodeLayout(repair, build); + if (needsOpencodeBuild && options.dryRun) return planOpencodeBuildRepair(repair, build); + if (needsOpencodeBuild) { + const buildFailure = runOpencodeBuild(repair); + if (buildFailure) return buildFailure; + } + return repairManagedRecord(repair, build); +} + function repairInstalledStates(options = {}) { const repoRoot = options.repoRoot ? path.resolve(options.repoRoot) : DEFAULT_REPO_ROOT; const manifests = loadInstallManifests({ repoRoot }); @@ -1591,295 +1831,22 @@ function repairInstalledStates(options = {}) { const results = records.map(initialRecord => { let record = initialRecord; const performRepair = (opencodeLease) => { - if (record.error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: record.error - }; - } + if (record.error) return repairErrorResult(record, record.error); let releaseSettingsLock = null; try { preflightOpenCodeHookDeactivation(record, context); - const settingsPathToLock = !options.dryRun - && getManagedOperations(record.state || {}).some( - operation => operation.kind === 'update-claude-settings' - ) - ? getClaudeSettingsPath(record.targetRoot) - : null; - if (settingsPathToLock) { - releaseSettingsLock = acquireSettingsLock(settingsPathToLock); - } - const needsOpencodeBuild = record.adapter.target === 'opencode' - && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); - const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT); - - if (record.legacyLayout === 'opencode') { - if (needsOpencodeBuild && !options.dryRun) { - try { - buildOpencodeRunner(context.repoRoot); - } catch (error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: formatBuildErrorMessage(error), - }; - } - } - - const canonicalPlan = createRepairPlanFromRecord(record, context, { - exemptValidationCodes: options.dryRun && needsOpencodeBuild - ? [OPENCODE_PLUGIN_NOT_BUILT_CODE] - : [], - }); - assertOpenCodeRepairHookDeactivation(canonicalPlan); - const plannedRepairs = [...new Set([ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...canonicalPlan.operations.map(operation => operation.destinationPath), - ...getManagedOperations(record.state).map(operation => operation.destinationPath), - record.installStatePath, - ])]; - - if (options.dryRun) { - return { - adapter: record.adapter, - status: 'planned', - installStatePath: canonicalPlan.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: false, - warnings: canonicalPlan.warnings, - error: null, - }; - } - - // Load lazily to avoid a module cycle during install-lifecycle startup. - const { applyInstallPlan } = require('./install/apply'); - const appliedPlan = applyInstallPlan(canonicalPlan, { opencodeLease }); - return { - adapter: record.adapter, - status: 'repaired', - installStatePath: canonicalPlan.installStatePath, - repairedPaths: [ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...canonicalPlan.operations.map(operation => operation.destinationPath), - ], - plannedRepairs: [], - stateRefreshed: true, - warnings: appliedPlan.warnings, - error: null, - }; - } - - if (needsOpencodeBuild && options.dryRun) { - const rawPlan = createRepairPlanFromRecord(record, context, { - exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], - }); - const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record); - const operationHealth = summarizeManagedOperationHealth( - context.repoRoot, - record.targetRoot, - desiredPlan.operations, - record.adapter.target - ); - const unsafeOperationResult = getUnsafeOperationResult( - record, - operationHealth - ); - if (unsafeOperationResult) { - return unsafeOperationResult; - } - const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; - const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]; - - return { - adapter: record.adapter, - status: 'planned', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: false, - warnings: desiredPlan.warnings, - error: null - }; - } - - if (needsOpencodeBuild) { - try { - buildOpencodeRunner(context.repoRoot); - } catch (error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: formatBuildErrorMessage(error) - }; - } - } - - const rawPlan = createRepairPlanFromRecord(record, context); - require('./install/apply').assertOpenCodeScriptBoundary(rawPlan); - const { - migration, - plan: desiredPlan, - } = prepareRepairMigration(rawPlan, record); - const activationSnapshot = assertOpenCodeRepairHookDeactivation(desiredPlan); - const operationHealth = summarizeManagedOperationHealth( - context.repoRoot, - record.targetRoot, - desiredPlan.operations, - record.adapter.target - ); - - const unsafeOperationResult = getUnsafeOperationResult( + releaseSettingsLock = acquireRepairSettingsLock(record, options); + return runRecordRepair({ record, - operationHealth - ); - if (unsafeOperationResult) { - return unsafeOperationResult; - } - - if (operationHealth.missingSource.length > 0) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - warnings: desiredPlan.warnings, - error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}` - }; - } - - const repairOperations = [ - ...operationHealth.missing.map(entry => ({ ...entry.operation })), - ...operationHealth.drifted.map(entry => ({ ...entry.operation })), - ...desiredPlan.operations - .filter(operation => ( - operation.kind === 'update-claude-settings' - && operation.previousManagedHooks - && !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks) - )) - .map(operation => ({ ...operation })), - ].filter((operation, index, items) => items.findIndex(candidate => ( - candidate.kind === operation.kind - && candidate.destinationPath === operation.destinationPath - )) === index); - const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot); - const legacyMigrationPaths = migration.legacyOperationsToRemove.map( - operation => operation.destinationPath - ); - const plannedRepairs = [...new Set([ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...repairOperations.map(operation => operation.destinationPath), - ...legacyMigrationPaths, - ])]; - - if (options.dryRun) { - return { - adapter: record.adapter, - status: plannedRepairs.length > 0 ? 'planned' : 'ok', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: plannedRepairs.length === 0, - warnings: desiredPlan.warnings, - error: null - }; - } - - const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; - const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; - if (desiredPlan.target === 'opencode') { - const { assertOpenCodeActivationUnchanged } = require('./install/apply'); - // Health inspection must not let a changed activation become owned by - // a bridge checkpoint before the per-write check can reject it. - for (const destinationPath of activationSnapshot.keys()) { - assertOpenCodeActivationUnchanged(desiredPlan, { destinationPath }, activationSnapshot); - } - } - if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { - writeRefreshedInstallState(record, migration.bridgeState); - } - - for (const operation of repairOperations) { - if (operation.sourceRelativePath === 'manifests/install-assets/commonjs-scripts-package.json') { - require('./install/apply').assertOpenCodeScriptBoundary(desiredPlan, new Set(repairedPaths)); - } - if (desiredPlan.target === 'opencode') { - const { assertOpenCodeActivationUnchanged } = require('./install/apply'); - assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot); - } - assertNoNewUserOwnedFile(migration, operation, desiredPlan); - const repairedPath = executeRepairOperation( - context.repoRoot, - operation, - record.targetRoot, - repairLinkIndex, - record.adapter.target, - Boolean(releaseSettingsLock), - require('./install/apply').getOpenCodeActivationWriteOptions(operation, activationSnapshot) - ); - if (repairedPath) { - repairedPaths.push(repairedPath); - } - } - if (hasLegacyMigration) { - for (const operation of migration.legacyOperationsToRemove) { - const removedPath = removeContainedPath( - operation.destinationPath, - record.targetRoot, - 'migrate managed Claude skill', - { force: true } - ); - if (removedPath) { - repairedPaths.push(removedPath); - } - } - } - const changedInstalledBytes = repairOperations.length > 0 - || needsOpencodeBuild - || hasLegacyMigration; - const statePreviewToWrite = changedInstalledBytes - ? desiredPlan.statePreview - : { - ...desiredPlan.statePreview, - installedAt: record.state.installedAt, - source: { ...record.state.source }, - }; - assertOpenCodeRepairHookDeactivation(desiredPlan, { requireInactive: true }); - writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths); - - return { - adapter: record.adapter, - status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration) - ? 'repaired' - : 'ok', - installStatePath: record.installStatePath, - repairedPaths, - plannedRepairs: [], - stateRefreshed: true, - warnings: desiredPlan.warnings, - error: null - }; + context, + options, + buildOpencodeRunner, + opencodeLease, + settingsLockHeld: Boolean(releaseSettingsLock), + }); } catch (error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: error.message - }; + return repairErrorResult(record, error.message); } finally { if (releaseSettingsLock) releaseSettingsLock(); } From 905bb16ff23b3096f35dcc5b6e34713cd2add312 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 13 Oct 2026 22:58:00 -0400 Subject: [PATCH 15/23] refactor(lib): split gateguard file-context scanners by token cFamilyStartsFresh ran one 130-line loop over template literals, comments, regex-vs-division, C# and Rust raw strings, quotes, words, and punctuation. It now keeps scanner state in one object and walks a fixed list of step functions, each returning consumed, abort, or not-mine. The Python f-string field scan, the shell heredoc and quote tokens, and the PowerShell here-string, block comment, and hash tokens get the same treatment. Verified byte-for-byte against the recorded test inputs, four hundred real source files from the repo, random prefixes, and single-character mutations across every flavor spec. Clears the five complexity warnings on this file. --- scripts/lib/gateguard-file-context.js | 627 ++++++++++++++++---------- 1 file changed, 379 insertions(+), 248 deletions(-) diff --git a/scripts/lib/gateguard-file-context.js b/scripts/lib/gateguard-file-context.js index 4c571c16d..32eeaf6f3 100644 --- a/scripts/lib/gateguard-file-context.js +++ b/scripts/lib/gateguard-file-context.js @@ -169,26 +169,32 @@ function wordStart(text, i) { return start; } +function cFamilyBacktickEnd(text, i, flavor) { + if (flavor === 'kotlin') return quotedEnd(text, i, '`', false); + if (flavor !== 'go') return -1; + const end = text.indexOf('`', i + 1); + return end === -1 ? -1 : end + 1; +} + +// Single quotes: a Rust char or lifetime, a C digit separator inside a +// numeric literal, or an ordinary char literal. +function cFamilySingleQuoteEnd(text, i, flavor) { + if (flavor === 'rust') { + const end = rustCharEnd(text, i); + return end === -1 ? null : end + 1; + } + if (flavor === 'c' && isIdentChar(text[i - 1]) && isIdentChar(text[i + 1])) { + const first = text[wordStart(text, i)]; + if (first >= '0' && first <= '9') return null; + } + return quotedEnd(text, i, "'", false); +} + function cFamilyStringEnd(text, i, spec) { const ch = text[i]; const flavor = spec.flavor; - if (ch === '`') { - if (flavor === 'kotlin') return quotedEnd(text, i, '`', false); - if (flavor !== 'go') return -1; - const end = text.indexOf('`', i + 1); - return end === -1 ? -1 : end + 1; - } - if (ch === "'") { - if (flavor === 'rust') { - const end = rustCharEnd(text, i); - return end === -1 ? null : end + 1; - } - if (flavor === 'c' && isIdentChar(text[i - 1]) && isIdentChar(text[i + 1])) { - const first = text[wordStart(text, i)]; - if (first >= '0' && first <= '9') return null; - } - return quotedEnd(text, i, "'", false); - } + if (ch === '`') return cFamilyBacktickEnd(text, i, flavor); + if (ch === "'") return cFamilySingleQuoteEnd(text, i, flavor); if (flavor === 'c') { const start = wordStart(text, i); const prefix = text.slice(start, i); @@ -201,166 +207,253 @@ function cFamilyStringEnd(text, i, spec) { return quotedEnd(text, i, '"', flavor === 'rust'); } -function cFamilyStartsFresh(text, spec) { +// Scanner state for cFamilyStartsFresh. `frames` tracks template-literal +// nesting, `parens` whether each open paren started a control header, +// `lastSig` what the previous significant token was (op, value, close). +function createCFamilyScan(text, spec) { const js = spec.flavor === 'js'; - const frames = [{ template: false, depth: 0 }]; - const parens = []; - let lastWord = ''; - let lastSig = 'op'; - let lineEnd = ''; - let continued = false; - let i = js && text.startsWith('#!') ? lineEndAt(text, 0) : 0; - while (i < text.length) { - const frame = frames[frames.length - 1]; - const ch = text[i]; - const next = text[i + 1]; - if (frame.template) { - if (ch === '\\') { - i += 2; - } else if (ch === '`') { - frames.pop(); - lastSig = 'value'; - i++; - } else if (ch === '$' && next === '{') { - frames.push({ template: false, depth: 0 }); - lastSig = 'op'; - i += 2; - } else { - i++; - } + return { + text, + spec, + js, + frames: [{ template: false, depth: 0 }], + parens: [], + lastWord: '', + lastSig: 'op', + lineEnd: '', + continued: false, + i: js && text.startsWith('#!') ? lineEndAt(text, 0) : 0, + }; +} + +// Each step* helper below returns true after consuming input, false when the +// prefix cannot end fresh, and null when the character was not its concern. + +function stepTemplate(s) { + const { text, frames } = s; + const ch = text[s.i]; + if (ch === '\\') { + s.i += 2; + } else if (ch === '`') { + frames.pop(); + s.lastSig = 'value'; + s.i++; + } else if (ch === '$' && text[s.i + 1] === '{') { + frames.push({ template: false, depth: 0 }); + s.lastSig = 'op'; + s.i += 2; + } else { + s.i++; + } +} + +function stepComment(s) { + const { text, spec } = s; + if (text[s.i] !== '/') return null; + const next = text[s.i + 1]; + if (next === '/') { + const end = lineEndAt(text, s.i); + if (text.slice(s.i, end).trimEnd().endsWith('\\')) return false; + s.i = end; + return true; + } + if (next === '*') { + const end = spec.rustDocs ? nestedBlockEnd(text, s.i) : text.indexOf('*/', s.i + 2); + if (end === -1) return false; + s.i = spec.rustDocs ? end : end + 2; + s.lineEnd = ''; + return true; + } + return null; +} + +// A `/` that is not a comment: a regex literal after an operator, or +// division. A regex right after `}` is ambiguous and never fresh. +function stepJsSlash(s) { + const { text } = s; + if (s.lastSig === 'op') { + const end = regexEnd(text, s.i); + if (end === -1) return false; + s.lineEnd = text[end - 1]; + s.lastSig = 'value'; + s.i = end; + return true; + } + if (s.lastSig === 'close' && /[/'"`]/.test(text.slice(s.i + 1, lineEndAt(text, s.i)))) return false; + s.lastSig = 'op'; + s.i++; + return true; +} + +function stepJs(s) { + if (!s.js) return null; + const { text, spec, frames } = s; + const ch = text[s.i]; + const next = text[s.i + 1]; + if (ch === '`') { + frames.push({ template: true, depth: 0 }); + s.i++; + return true; + } + if (ch === '/') return stepJsSlash(s); + if (ch === '<' && (next === '!' || (spec.jsx && (isLetter(next) || next === '/' || next === '>')))) return false; + if (ch === '-' && next === '-' && text[s.i + 2] === '>') return false; + const frame = frames[frames.length - 1]; + if (frames.length > 1 && ch === '{') frame.depth++; + if (frames.length > 1 && ch === '}') { + if (frame.depth === 0) { + frames.pop(); + s.i++; + return true; + } + frame.depth--; + } + return null; +} + +function stepCSharpString(s) { + const { text, spec } = s; + const ch = text[s.i]; + if (spec.flavor !== 'csharp' || (ch !== '@' && ch !== '$')) return null; + if (!/^[@$]{1,3}"/.test(text.slice(s.i, s.i + 4))) return null; + const end = csharpStringEnd(text, s.i); + if (end === -1) return false; + s.lineEnd = '"'; + s.i = end; + return true; +} + +function stepRustRaw(s) { + const { text, spec } = s; + const ch = text[s.i]; + const next = text[s.i + 1]; + if (spec.flavor !== 'rust' || isIdentChar(text[s.i - 1])) return null; + if (ch !== 'r' && !((ch === 'b' || ch === 'c') && next === 'r')) return null; + const end = rustRawEnd(text, s.i); + if (end === -1) return false; + if (end === null) return null; + s.lineEnd = text[end - 1]; + s.lastSig = 'value'; + s.i = end; + return true; +} + +function stepString(s) { + const { text, spec } = s; + const ch = text[s.i]; + if (ch !== '"' && ch !== "'" && ch !== '`') return null; + const end = cFamilyStringEnd(text, s.i, spec); + if (end === -1) return false; + if (end === null) { + s.i++; + return true; + } + s.lineEnd = text[end - 1]; + s.lastSig = 'value'; + s.i = end; + return true; +} + +function stepWord(s) { + const { text, js } = s; + if (!isIdentChar(text[s.i])) return null; + let end = s.i; + while (end < text.length && isIdentChar(text[end])) end++; + s.lastWord = text.slice(s.i, end); + s.lastSig = js && JS_REGEX_KEYWORDS.has(s.lastWord) ? 'op' : 'value'; + s.lineEnd = text[end - 1]; + s.i = end; + return true; +} + +function stepPunctuation(s) { + const ch = s.text[s.i]; + if (ch === '(') { + s.parens.push(s.lastSig === 'value' && JS_CONTROL_HEADERS.has(s.lastWord)); + s.lastSig = 'op'; + } else if (ch === ')') { + s.lastSig = s.parens.pop() ? 'op' : 'value'; + } else { + s.lastSig = ch === '}' ? 'close' : ch === ']' ? 'value' : 'op'; + } + s.lastWord = ''; + s.i++; +} + +const C_FAMILY_TOKEN_STEPS = [stepJs, stepCSharpString, stepRustRaw, stepString, stepWord]; + +function stepToken(s) { + for (const step of C_FAMILY_TOKEN_STEPS) { + const result = step(s); + if (result !== null) return result; + } + return null; +} + +function cFamilyStartsFresh(text, spec) { + const s = createCFamilyScan(text, spec); + while (s.i < text.length) { + const ch = text[s.i]; + if (s.frames[s.frames.length - 1].template) { + stepTemplate(s); continue; } if (isNewline(ch)) { - continued = lineEnd === '\\'; - lineEnd = ''; - i++; + s.continued = s.lineEnd === '\\'; + s.lineEnd = ''; + s.i++; continue; } if (isSpace(ch)) { - i++; + s.i++; continue; } - if (ch === '/' && next === '/') { - const end = lineEndAt(text, i); - if (text.slice(i, end).trimEnd().endsWith('\\')) return false; - i = end; + const comment = stepComment(s); + if (comment !== null) { + if (!comment) return false; continue; } - if (ch === '/' && next === '*') { - const end = spec.rustDocs ? nestedBlockEnd(text, i) : text.indexOf('*/', i + 2); - if (end === -1) return false; - i = spec.rustDocs ? end : end + 2; - lineEnd = ''; - continue; - } - lineEnd = ch; - if (js) { - if (ch === '`') { - frames.push({ template: true, depth: 0 }); - i++; - continue; - } - if (ch === '/') { - if (lastSig === 'op') { - const end = regexEnd(text, i); - if (end === -1) return false; - lineEnd = text[end - 1]; - lastSig = 'value'; - i = end; - continue; - } - if (lastSig === 'close' && /[/'"`]/.test(text.slice(i + 1, lineEndAt(text, i)))) return false; - lastSig = 'op'; - i++; - continue; - } - if (ch === '<' && (next === '!' || (spec.jsx && (isLetter(next) || next === '/' || next === '>')))) return false; - if (ch === '-' && next === '-' && text[i + 2] === '>') return false; - if (frames.length > 1 && ch === '{') frame.depth++; - if (frames.length > 1 && ch === '}') { - if (frame.depth === 0) { - frames.pop(); - i++; - continue; - } - frame.depth--; - } - } - if (spec.flavor === 'csharp' && (ch === '@' || ch === '$') && /^[@$]{1,3}"/.test(text.slice(i, i + 4))) { - const end = csharpStringEnd(text, i); - if (end === -1) return false; - lineEnd = '"'; - i = end; - continue; - } - if (spec.flavor === 'rust' && (ch === 'r' || ((ch === 'b' || ch === 'c') && next === 'r')) && !isIdentChar(text[i - 1])) { - const end = rustRawEnd(text, i); - if (end === -1) return false; - if (end !== null) { - lineEnd = text[end - 1]; - lastSig = 'value'; - i = end; - continue; - } - } - if (ch === '"' || ch === "'" || ch === '`') { - const end = cFamilyStringEnd(text, i, spec); - if (end === -1) return false; - if (end !== null) { - lineEnd = text[end - 1]; - lastSig = 'value'; - i = end; - continue; - } - i++; - continue; - } - if (isIdentChar(ch)) { - let end = i; - while (end < text.length && isIdentChar(text[end])) end++; - lastWord = text.slice(i, end); - lastSig = js && JS_REGEX_KEYWORDS.has(lastWord) ? 'op' : 'value'; - lineEnd = text[end - 1]; - i = end; - continue; - } - if (ch === '(') { - parens.push(lastSig === 'value' && JS_CONTROL_HEADERS.has(lastWord)); - lastSig = 'op'; - } else if (ch === ')') { - lastSig = parens.pop() ? 'op' : 'value'; - } else { - lastSig = ch === '}' ? 'close' : ch === ']' ? 'value' : 'op'; - } - lastWord = ''; - i++; + s.lineEnd = ch; + const token = stepToken(s); + if (token === false) return false; + if (token === null) stepPunctuation(s); } - return frames.length === 1 && !continued; + return s.frames.length === 1 && !s.continued; +} + +// One character inside an f-string replacement field. Returns the last index +// consumed, or -1 when the field cannot be read. Updates `field.depth`. +function pythonFieldStep(text, j, quote, field) { + const ch = text[j]; + if (ch === '{') { + field.depth++; + } else if (ch === '}') { + field.depth--; + } else if ((ch === '"' || ch === "'") && ch !== quote) { + let k = j + 1; + while (k < text.length && text[k] !== ch) { + if (text[k] === quote || text[k] === '\\' || isNewline(text[k])) return -1; + k++; + } + if (k >= text.length) return -1; + return k; + } else if (ch === quote || ch === '#' || ch === '\\' || isNewline(ch)) { + return -1; + } + return j; } function pythonStringEnd(text, i, prefix) { const quote = text[i]; const triple = text[i + 1] === quote && text[i + 2] === quote; + const quoteLength = triple ? 3 : 1; const fString = prefix.toLowerCase().includes('f'); - let depth = 0; - for (let j = i + (triple ? 3 : 1); j < text.length; j++) { + const field = { depth: 0 }; + for (let j = i + quoteLength; j < text.length; j++) { const ch = text[j]; - if (fString && depth > 0) { - if (ch === '{') { - depth++; - } else if (ch === '}') { - depth--; - } else if ((ch === '"' || ch === "'") && ch !== quote) { - let k = j + 1; - while (k < text.length && text[k] !== ch) { - if (text[k] === quote || text[k] === '\\' || isNewline(text[k])) return -1; - k++; - } - if (k >= text.length) return -1; - j = k; - } else if (ch === quote || ch === '#' || ch === '\\' || isNewline(ch)) { - return -1; - } + if (fString && field.depth > 0) { + j = pythonFieldStep(text, j, quote, field); + if (j === -1) return -1; continue; } if (ch === '\\') { @@ -368,11 +461,11 @@ function pythonStringEnd(text, i, prefix) { if (text[j] === '\r' && text[j + 1] === '\n') j++; } else if (fString && ch === '{') { if (text[j + 1] === '{') j++; - else depth = 1; + else field.depth = 1; } else if (isNewline(ch) && !triple) { return -1; } else if (ch === quote && (!triple || (text[j + 1] === quote && text[j + 2] === quote))) { - return j + (triple ? 3 : 1); + return j + quoteLength; } } return -1; @@ -489,63 +582,89 @@ function shDoubleQuoteScan(text, i, level) { return -1; } +// Move a line scanner to `end`, or report that the token never closed. +function advanceTo(s, end) { + if (end === -1) return false; + s.i = end; + return true; +} + +// A newline ends any pending heredoc bodies before scanning resumes. +function shNewline(s) { + const { text } = s; + s.continued = s.lineEnd === '\\'; + s.lineEnd = ''; + s.i += text[s.i] === '\r' && text[s.i + 1] === '\n' ? 2 : 1; + if (s.pending.length > 0) { + s.i = skipHeredocs(text, s.i, s.pending); + if (s.i === -1) return false; + s.pending.length = 0; + } + return true; +} + +function shQuotedToken(s) { + const { text } = s; + const ch = text[s.i]; + if (ch === "'") return advanceTo(s, shSingleQuoteEnd(text, s.i)); + if (ch === '"') return advanceTo(s, shDoubleQuoteScan(text, s.i, 0)); + const end = text.indexOf('`', s.i + 1); + if (end === -1 || /['"\\#\n\r]/.test(text.slice(s.i + 1, end))) return false; + s.i = end + 1; + return true; +} + +function shHashToken(s) { + const { text } = s; + const starts = hashStartsComment(text, s.i); + if (starts === null) return false; + s.i = starts ? lineEndAt(text, s.i) : s.i + 1; + if (starts) s.lineEnd = ''; + return true; +} + +function shHeredocToken(s) { + const { text } = s; + if (text[s.i + 2] === '<') { + s.i += 3; + return true; + } + const doc = heredocDelimiter(text, s.i); + if (!doc) return false; + s.pending.push(doc); + s.i = doc.end; + return true; +} + function shStartsFresh(text) { if (hasLoneCarriageReturn(text)) return false; - const pending = []; - let lineEnd = ''; - let continued = false; - let i = 0; - while (i < text.length) { - const ch = text[i]; + const s = { text, pending: [], lineEnd: '', continued: false, i: 0 }; + while (s.i < text.length) { + const ch = text[s.i]; if (isNewline(ch)) { - continued = lineEnd === '\\'; - lineEnd = ''; - i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1; - if (pending.length > 0) { - i = skipHeredocs(text, i, pending); - if (i === -1) return false; - pending.length = 0; - } + if (!shNewline(s)) return false; continue; } if (isBlank(ch)) { - i++; + s.i++; continue; } - lineEnd = ch; + s.lineEnd = ch; + let ok = true; if (ch === '\\') { - i += isNewline(text[i + 1]) ? 1 : 2; - } else if (ch === "'") { - const end = shSingleQuoteEnd(text, i); - if (end === -1) return false; - i = end; - } else if (ch === '"') { - const end = shDoubleQuoteScan(text, i, 0); - if (end === -1) return false; - i = end; - } else if (ch === '`') { - const end = text.indexOf('`', i + 1); - if (end === -1 || /['"\\#\n\r]/.test(text.slice(i + 1, end))) return false; - i = end + 1; + s.i += isNewline(text[s.i + 1]) ? 1 : 2; + } else if (ch === "'" || ch === '"' || ch === '`') { + ok = shQuotedToken(s); } else if (ch === '#') { - const starts = hashStartsComment(text, i); - if (starts === null) return false; - i = starts ? lineEndAt(text, i) : i + 1; - if (starts) lineEnd = ''; - } else if (ch === '<' && text[i + 1] === '<') { - if (text[i + 2] === '<') { - i += 3; - continue; - } - const doc = heredocDelimiter(text, i); - if (!doc) return false; - pending.push(doc); - i = doc.end; + ok = shHashToken(s); + } else if (ch === '<' && text[s.i + 1] === '<') { + ok = shHeredocToken(s); } else { - i++; + s.i++; } + if (!ok) return false; } - return !continued && pending.length === 0; + return !s.continued && s.pending.length === 0; } function psSingleQuoteEnd(text, i) { @@ -597,66 +716,78 @@ function psDoubleQuoteScan(text, i, level) { return -1; } +// End of a PowerShell here-string opened at `i`: the closer must start its +// own line, and nothing may follow the opener on its line. +function psHereStringEnd(text, i, quote) { + const open = lineEndAt(text, i); + if (text.slice(i + 2, open).trim() !== '') return -1; + let at = open; + while (at < text.length) { + at += text[at] === '\r' && text[at + 1] === '\n' ? 2 : 1; + if (text.startsWith(`${quote}@`, at)) return at + 2; + at = lineEndAt(text, at); + } + return -1; +} + +function psBlockCommentToken(s) { + const { text } = s; + if (!psHashStartsComment(text, s.i)) return false; + const end = text.indexOf('#>', s.i + 2); + if (end === -1) return false; + s.i = end + 2; + s.lineEnd = ''; + return true; +} + +function psHashToken(s) { + const { text } = s; + if (psHashStartsComment(text, s.i)) { + s.i = lineEndAt(text, s.i); + s.lineEnd = ''; + return true; + } + if (isIdentChar(text[s.i - 1]) || '-.:'.includes(text[s.i - 1])) { + s.i++; + return true; + } + return false; +} + function psStartsFresh(text) { if (PS_SMART_QUOTES.test(text)) return false; - let lineEnd = ''; - let continued = false; - let i = 0; - while (i < text.length) { - const ch = text[i]; - const next = text[i + 1]; + const s = { text, lineEnd: '', continued: false, i: 0 }; + while (s.i < text.length) { + const ch = text[s.i]; + const next = text[s.i + 1]; if (isNewline(ch)) { - continued = lineEnd === '`'; - lineEnd = ''; - i++; + s.continued = s.lineEnd === '`'; + s.lineEnd = ''; + s.i++; continue; } if (isBlank(ch)) { - i++; + s.i++; continue; } - lineEnd = ch; + s.lineEnd = ch; + let ok = true; if (ch === '`') { - i += isNewline(next) ? 1 : 2; + s.i += isNewline(next) ? 1 : 2; } else if (ch === '@' && (next === '"' || next === "'")) { - const open = lineEndAt(text, i); - if (text.slice(i + 2, open).trim() !== '') return false; - let at = open; - let found = -1; - while (at < text.length) { - at += text[at] === '\r' && text[at + 1] === '\n' ? 2 : 1; - if (text.startsWith(`${next}@`, at)) { - found = at + 2; - break; - } - at = lineEndAt(text, at); - } - if (found === -1) return false; - i = found; + ok = advanceTo(s, psHereStringEnd(text, s.i, next)); } else if (ch === "'" || ch === '"') { - const end = ch === "'" ? psSingleQuoteEnd(text, i) : psDoubleQuoteScan(text, i, 0); - if (end === -1) return false; - i = end; + ok = advanceTo(s, ch === "'" ? psSingleQuoteEnd(text, s.i) : psDoubleQuoteScan(text, s.i, 0)); } else if (ch === '<' && next === '#') { - if (!psHashStartsComment(text, i)) return false; - const end = text.indexOf('#>', i + 2); - if (end === -1) return false; - i = end + 2; - lineEnd = ''; + ok = psBlockCommentToken(s); } else if (ch === '#') { - if (psHashStartsComment(text, i)) { - i = lineEndAt(text, i); - lineEnd = ''; - } else if (isIdentChar(text[i - 1]) || '-.:'.includes(text[i - 1])) { - i++; - } else { - return false; - } + ok = psHashToken(s); } else { - i++; + s.i++; } + if (!ok) return false; } - return !continued; + return !s.continued; } function batchStartsFresh(text) { From d53359a9741741c8fa809ff6f23ee251e0659b89 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 15 Oct 2026 21:36:00 -0400 Subject: [PATCH 16/23] refactor(scripts): table-drive the operator readiness dashboard parseArgs now reads flags from two lookup tables, one for flags that take a value in either form and one for standalone switches, instead of thirty-odd if blocks. The ECC Tools evidence and gap text live in ordered tier tables keyed by roadmap markers, with the shared marker lists named once. buildRequirements reads its docs and readiness checks up front, then assembles the list from four requirement groups. The PR and issue budget rows share one builder. Flag errors, requirement order, and every message string are unchanged. Verified by comparing parseArgs over a flag matrix and buildReport over the repo, an empty root, and one synthetic roadmap per marker. Clears the four complexity warnings on this file. --- scripts/operator-readiness-dashboard.js | 666 +++++++++++------------- 1 file changed, 299 insertions(+), 367 deletions(-) diff --git a/scripts/operator-readiness-dashboard.js b/scripts/operator-readiness-dashboard.js index 4de990840..6f4f393ef 100644 --- a/scripts/operator-readiness-dashboard.js +++ b/scripts/operator-readiness-dashboard.js @@ -63,6 +63,36 @@ function normalizeRelativePrefix(value) { return normalized ? `${normalized}/` : ''; } +// Flags that take a value, as `--flag value` or `--flag=value`. +const VALUE_FLAGS = Object.freeze({ + '--format': (parsed, value) => { parsed.format = value.toLowerCase(); }, + '--write': (parsed, value) => { parsed.writePath = path.resolve(value); }, + '--root': (parsed, value) => { parsed.root = path.resolve(value); }, + '--repo': (parsed, value) => { parsed.repos.push(value); }, + '--allow-untracked': (parsed, value) => { parsed.allowUntracked.push(value); }, + '--max-open-prs': (parsed, value) => { + parsed.thresholds.maxOpenPrs = parseIntegerFlag(value, '--max-open-prs'); + }, + '--max-open-issues': (parsed, value) => { + parsed.thresholds.maxOpenIssues = parseIntegerFlag(value, '--max-open-issues'); + }, + '--max-dirty-files': (parsed, value) => { + parsed.thresholds.maxDirtyFiles = parseIntegerFlag(value, '--max-dirty-files'); + }, + '--generated-at': (parsed, value) => { parsed.generatedAt = value; }, +}); + +// Flags that stand alone. They never accept an inline `=value`. +const BOOLEAN_FLAGS = Object.freeze({ + '--help': parsed => { parsed.help = true; }, + '-h': parsed => { parsed.help = true; }, + '--json': parsed => { parsed.format = 'json'; }, + '--markdown': parsed => { parsed.format = 'markdown'; }, + '--skip-github': parsed => { parsed.skipGithub = true; }, + '--use-env-github-token': parsed => { parsed.useEnvGithubToken = true; }, + '--exit-code': parsed => { parsed.exitCode = true; }, +}); + function parseArgs(argv) { const args = argv.slice(2); const parsed = { @@ -81,133 +111,18 @@ function parseArgs(argv) { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; + const separator = arg.indexOf('='); + const name = separator === -1 ? arg : arg.slice(0, separator); - if (arg === '--help' || arg === '-h') { - parsed.help = true; + if (Object.hasOwn(VALUE_FLAGS, name)) { + const value = separator === -1 ? readValue(args, index, arg) : arg.slice(separator + 1); + if (separator === -1) index += 1; + VALUE_FLAGS[name](parsed, value); continue; } - if (arg === '--format') { - parsed.format = readValue(args, index, arg).toLowerCase(); - index += 1; - continue; - } - - if (arg.startsWith('--format=')) { - parsed.format = arg.slice('--format='.length).toLowerCase(); - continue; - } - - if (arg === '--json') { - parsed.format = 'json'; - continue; - } - - if (arg === '--markdown') { - parsed.format = 'markdown'; - continue; - } - - if (arg === '--write') { - parsed.writePath = path.resolve(readValue(args, index, arg)); - index += 1; - continue; - } - - if (arg.startsWith('--write=')) { - parsed.writePath = path.resolve(arg.slice('--write='.length)); - continue; - } - - if (arg === '--root') { - parsed.root = path.resolve(readValue(args, index, arg)); - index += 1; - continue; - } - - if (arg.startsWith('--root=')) { - parsed.root = path.resolve(arg.slice('--root='.length)); - continue; - } - - if (arg === '--repo') { - parsed.repos.push(readValue(args, index, arg)); - index += 1; - continue; - } - - if (arg.startsWith('--repo=')) { - parsed.repos.push(arg.slice('--repo='.length)); - continue; - } - - if (arg === '--skip-github') { - parsed.skipGithub = true; - continue; - } - - if (arg === '--allow-untracked') { - parsed.allowUntracked.push(readValue(args, index, arg)); - index += 1; - continue; - } - - if (arg.startsWith('--allow-untracked=')) { - parsed.allowUntracked.push(arg.slice('--allow-untracked='.length)); - continue; - } - - if (arg === '--max-open-prs') { - parsed.thresholds.maxOpenPrs = parseIntegerFlag(readValue(args, index, arg), arg); - index += 1; - continue; - } - - if (arg.startsWith('--max-open-prs=')) { - parsed.thresholds.maxOpenPrs = parseIntegerFlag(arg.slice('--max-open-prs='.length), '--max-open-prs'); - continue; - } - - if (arg === '--max-open-issues') { - parsed.thresholds.maxOpenIssues = parseIntegerFlag(readValue(args, index, arg), arg); - index += 1; - continue; - } - - if (arg.startsWith('--max-open-issues=')) { - parsed.thresholds.maxOpenIssues = parseIntegerFlag(arg.slice('--max-open-issues='.length), '--max-open-issues'); - continue; - } - - if (arg === '--max-dirty-files') { - parsed.thresholds.maxDirtyFiles = parseIntegerFlag(readValue(args, index, arg), arg); - index += 1; - continue; - } - - if (arg.startsWith('--max-dirty-files=')) { - parsed.thresholds.maxDirtyFiles = parseIntegerFlag(arg.slice('--max-dirty-files='.length), '--max-dirty-files'); - continue; - } - - if (arg === '--use-env-github-token') { - parsed.useEnvGithubToken = true; - continue; - } - - if (arg === '--generated-at') { - parsed.generatedAt = readValue(args, index, arg); - index += 1; - continue; - } - - if (arg.startsWith('--generated-at=')) { - parsed.generatedAt = arg.slice('--generated-at='.length); - continue; - } - - if (arg === '--exit-code') { - parsed.exitCode = true; + if (separator === -1 && Object.hasOwn(BOOLEAN_FLAGS, arg)) { + BOOLEAN_FLAGS[arg](parsed); continue; } @@ -341,129 +256,72 @@ function agentShieldEnterpriseEvidence(roadmap) { return 'AgentShield enterprise PR evidence is mirrored in the GA roadmap'; } +// Roadmap markers, newest milestone first. Each tier is [markers, message] +// and the first tier with any marker present wins. +const ANNOUNCEMENT_GATE_READY_MARKERS = [ + 'announcementGateReady` is `true', + 'Native GitHub payments announcement gate is ready', + 'd3d62df83fa075660fa4530c3e0edc311a4355fe', +]; +const SELECTED_TARGET_GATE_MARKERS = [ + 'selected-target official announcement gate', + 'billing gate env-file operator path', + '72119a1', + '16a5bb3', + 'select-ready-target', + 'f14ed2fe-a219-470c-8119-63429e197027', +]; +const PROVENANCE_COUNT_MARKERS = ['d5f60db', 'Marketplace-source provenance counts']; +const TARGET_ACCOUNT_READBACK_MARKERS = ['target account billing readback', '632e059']; +const WRANGLER_OAUTH_READBACK_MARKERS = ['Wrangler OAuth readback', '42653f9']; +const WEBHOOK_PROVENANCE_MARKERS = ['Marketplace webhook provenance', '2859678']; +const KV_READBACK_MARKERS = ['billing:kv-readback', '95d0bec']; +const PRODUCTION_READBACK_MARKERS = ['production Marketplace readback state', 'eb69412']; +const PROMOTION_TRACE_MARKERS = [ + 'hosted promotion judge audit traces', + 'operator-visible promotion output values', +]; + +const ECC_TOOLS_EVIDENCE_TIERS = [ + [ANNOUNCEMENT_GATE_READY_MARKERS, 'billing announcement gate, selected-target announcement gate, billing gate env-file operator path, non-breaking operator bearer path, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler selected-target readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, ready Marketplace Pro target selection, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'], + [SELECTED_TARGET_GATE_MARKERS, 'billing announcement gate, selected-target announcement gate, billing gate env-file operator path, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, ready Marketplace Pro target selection, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'], + [['69ca535', 'team feedback controls', 'e56fc1a'], 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'], + [PROVENANCE_COUNT_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, and sanitized Marketplace plan/action provenance counts are mirrored in the GA roadmap'], + [TARGET_ACCOUNT_READBACK_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'], + [WRANGLER_OAUTH_READBACK_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'], + [WEBHOOK_PROVENANCE_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'], + [KV_READBACK_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, and aggregate production billing KV readback are mirrored in the GA roadmap'], + [PRODUCTION_READBACK_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, and production KV readback state are mirrored in the GA roadmap'], + [PROMOTION_TRACE_MARKERS, 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, and hosted promotion judge audit traces are mirrored in the GA roadmap'], +]; +const ECC_TOOLS_EVIDENCE_FALLBACK = 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, and harness-route policy linking are mirrored in the GA roadmap'; + +const ECC_TOOLS_GAP_TIERS = [ + [ANNOUNCEMENT_GATE_READY_MARKERS, 'repeat KV readback and selected-target announcement gate immediately before launch; keep native-payments copy behind the final release, plugin, URL, and owner-approval gates'], + [[...SELECTED_TARGET_GATE_MARKERS, 'old "no Marketplace-managed Pro target billing-state" blocker is cleared'], 'obtain or rotate the local/internal INTERNAL_API_SECRET bearer-token path, via exported env or ignored --env-file, then run the live selected-target billing announcement gate before publishing native-payments copy'], + [['1Password CLI authorization timed out', 'Cloudflare API auth returned `Authentication error [code: 10000]`'], 'authorize Cloudflare API or 1Password CLI access, configure the target Marketplace Pro account and INTERNAL_API_SECRET, create or replay Marketplace Pro webhook state, then rerun target readback and the live announcement gate'], + [['Wrangler OAuth now works', '6904e4fb-bec7-4787-90e2-759f077a628c'], 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, configure the target account and INTERNAL_API_SECRET, then rerun target readback and the live announcement gate'], + [PROVENANCE_COUNT_MARKERS, 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, then run `billing:kv-readback -- --wrangler --wrangler-bin ./node_modules/.bin/wrangler --account --require-ready`, followed by the live announcement gate'], + [TARGET_ACCOUNT_READBACK_MARKERS, 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, then run `billing:kv-readback -- --account --require-ready` with working Cloudflare API auth or repaired Wrangler OAuth, followed by the live announcement gate'], + [WRANGLER_OAUTH_READBACK_MARKERS, 'create or verify Marketplace-managed Pro billing-state with webhook provenance, then run `billing:kv-readback -- --require-ready` with working Cloudflare API auth or repaired Wrangler OAuth, followed by the live announcement gate'], + [WEBHOOK_PROVENANCE_MARKERS, 'replace the invalid Cloudflare credential, create or verify Marketplace-managed Pro billing-state with webhook provenance, then run `billing:kv-readback -- --require-ready` and the live announcement gate'], + [KV_READBACK_MARKERS, 'create or verify a Marketplace-managed Pro billing-state, then run the official live announcement gate'], + [PRODUCTION_READBACK_MARKERS, 'complete Marketplace purchase/webhook readback, then run the live announcement gate'], + [PROMOTION_TRACE_MARKERS, 'live Marketplace test-account readback pending'], +]; +const ECC_TOOLS_GAP_FALLBACK = 'live Marketplace test-account readback, hosted promotion telemetry, and richer operator review UX pending'; + +function roadmapTierMessage(roadmap, tiers, fallback) { + const tier = tiers.find(([markers]) => markers.some(marker => roadmap.includes(marker))); + return tier ? tier[1] : fallback; +} + function eccToolsNextLevelEvidence(roadmap) { - if (roadmap.includes('announcementGateReady` is `true') - || roadmap.includes('Native GitHub payments announcement gate is ready') - || roadmap.includes('d3d62df83fa075660fa4530c3e0edc311a4355fe')) { - return 'billing announcement gate, selected-target announcement gate, billing gate env-file operator path, non-breaking operator bearer path, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler selected-target readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, ready Marketplace Pro target selection, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'; - } - - if (roadmap.includes('selected-target official announcement gate') - || roadmap.includes('billing gate env-file operator path') - || roadmap.includes('72119a1') - || roadmap.includes('16a5bb3') - || roadmap.includes('select-ready-target') - || roadmap.includes('f14ed2fe-a219-470c-8119-63429e197027')) { - return 'billing announcement gate, selected-target announcement gate, billing gate env-file operator path, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, ready Marketplace Pro target selection, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'; - } - - if (roadmap.includes('69ca535') - || roadmap.includes('team feedback controls') - || roadmap.includes('e56fc1a')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, sanitized Marketplace plan/action provenance counts, hosted team-learning feedback controls, and ECC-Tools Dependabot alert remediation are mirrored in the GA roadmap'; - } - - if (roadmap.includes('d5f60db') - || roadmap.includes('Marketplace-source provenance counts')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, provenance-aware Marketplace billing-state gates, and sanitized Marketplace plan/action provenance counts are mirrored in the GA roadmap'; - } - - if (roadmap.includes('target account billing readback') - || roadmap.includes('632e059')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, target-account billing readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'; - } - - if (roadmap.includes('Wrangler OAuth readback') - || roadmap.includes('42653f9')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, Wrangler OAuth readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'; - } - - if (roadmap.includes('Marketplace webhook provenance') - || roadmap.includes('2859678')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, aggregate production billing KV readback, and provenance-aware Marketplace billing-state gates are mirrored in the GA roadmap'; - } - - if (roadmap.includes('billing:kv-readback') - || roadmap.includes('95d0bec')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, and aggregate production billing KV readback are mirrored in the GA roadmap'; - } - - if (roadmap.includes('production Marketplace readback state') - || roadmap.includes('eb69412')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, hosted promotion judge audit traces, billing announcement preflight, and production KV readback state are mirrored in the GA roadmap'; - } - - if (roadmap.includes('hosted promotion judge audit traces') - || roadmap.includes('operator-visible promotion output values')) { - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, harness-route policy linking, policy-promotion Action-output telemetry, operator-visible promotion output details, and hosted promotion judge audit traces are mirrored in the GA roadmap'; - } - - return 'billing announcement gate, hosted analysis lanes, AgentShield fleet-summary consumption, hosted finding evidence paths, and harness-route policy linking are mirrored in the GA roadmap'; + return roadmapTierMessage(roadmap, ECC_TOOLS_EVIDENCE_TIERS, ECC_TOOLS_EVIDENCE_FALLBACK); } function eccToolsNextLevelGap(roadmap) { - if (roadmap.includes('announcementGateReady` is `true') - || roadmap.includes('Native GitHub payments announcement gate is ready') - || roadmap.includes('d3d62df83fa075660fa4530c3e0edc311a4355fe')) { - return 'repeat KV readback and selected-target announcement gate immediately before launch; keep native-payments copy behind the final release, plugin, URL, and owner-approval gates'; - } - - if (roadmap.includes('selected-target official announcement gate') - || roadmap.includes('billing gate env-file operator path') - || roadmap.includes('72119a1') - || roadmap.includes('16a5bb3') - || roadmap.includes('select-ready-target') - || roadmap.includes('f14ed2fe-a219-470c-8119-63429e197027') - || roadmap.includes('old "no Marketplace-managed Pro target billing-state" blocker is cleared')) { - return 'obtain or rotate the local/internal INTERNAL_API_SECRET bearer-token path, via exported env or ignored --env-file, then run the live selected-target billing announcement gate before publishing native-payments copy'; - } - - if (roadmap.includes('1Password CLI authorization timed out') - || roadmap.includes('Cloudflare API auth returned `Authentication error [code: 10000]`')) { - return 'authorize Cloudflare API or 1Password CLI access, configure the target Marketplace Pro account and INTERNAL_API_SECRET, create or replay Marketplace Pro webhook state, then rerun target readback and the live announcement gate'; - } - - if (roadmap.includes('Wrangler OAuth now works') - || roadmap.includes('6904e4fb-bec7-4787-90e2-759f077a628c')) { - return 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, configure the target account and INTERNAL_API_SECRET, then rerun target readback and the live announcement gate'; - } - - if (roadmap.includes('d5f60db') - || roadmap.includes('Marketplace-source provenance counts')) { - return 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, then run `billing:kv-readback -- --wrangler --wrangler-bin ./node_modules/.bin/wrangler --account --require-ready`, followed by the live announcement gate'; - } - - if (roadmap.includes('target account billing readback') - || roadmap.includes('632e059')) { - return 'create or verify Marketplace-managed Pro target billing-state with webhook provenance, then run `billing:kv-readback -- --account --require-ready` with working Cloudflare API auth or repaired Wrangler OAuth, followed by the live announcement gate'; - } - - if (roadmap.includes('Wrangler OAuth readback') - || roadmap.includes('42653f9')) { - return 'create or verify Marketplace-managed Pro billing-state with webhook provenance, then run `billing:kv-readback -- --require-ready` with working Cloudflare API auth or repaired Wrangler OAuth, followed by the live announcement gate'; - } - - if (roadmap.includes('Marketplace webhook provenance') - || roadmap.includes('2859678')) { - return 'replace the invalid Cloudflare credential, create or verify Marketplace-managed Pro billing-state with webhook provenance, then run `billing:kv-readback -- --require-ready` and the live announcement gate'; - } - - if (roadmap.includes('billing:kv-readback') - || roadmap.includes('95d0bec')) { - return 'create or verify a Marketplace-managed Pro billing-state, then run the official live announcement gate'; - } - - if (roadmap.includes('production Marketplace readback state') - || roadmap.includes('eb69412')) { - return 'complete Marketplace purchase/webhook readback, then run the live announcement gate'; - } - - if (roadmap.includes('hosted promotion judge audit traces') - || roadmap.includes('operator-visible promotion output values')) { - return 'live Marketplace test-account readback pending'; - } - - return 'live Marketplace test-account readback, hosted promotion telemetry, and richer operator review UX pending'; + return roadmapTierMessage(roadmap, ECC_TOOLS_GAP_TIERS, ECC_TOOLS_GAP_FALLBACK); } function supplyChainLocalProtectionEvidence({ roadmap, scripts }) { @@ -624,30 +482,43 @@ function buildGrowthSummary(rootDir) { }; } -function buildRequirements(rootDir, platformReport) { - const roadmap = readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md'); - const publicationReadiness = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-readiness.md'); - const namingMatrix = readText(rootDir, 'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md'); - const releasePublicationChecklist = readText(rootDir, 'docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md'); - const releaseUrlLedger = readText(rootDir, 'docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md'); - const publicationEvidenceMay19 = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md'); - const hypergrowthCommandCenter = readText(rootDir, 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md'); - const partnerSponsorTalksPack = readText(rootDir, 'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md'); - const releaseVideoProduction = readText(rootDir, 'docs/releases/2.0.0-rc.1/video-suite-production.md'); - const ownerQueueCleanup = readText(rootDir, 'docs/releases/2.0.0-rc.1/owner-queue-cleanup-2026-05-18.md'); - const ownerApprovalPacket = readText(rootDir, 'docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md'); - const previewManifest = readText(rootDir, 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md'); - const previewPackSmoke = readText(rootDir, 'scripts/preview-pack-smoke.js'); - const releaseVideoSuite = readText(rootDir, 'scripts/release-video-suite.js'); - const progressSync = readText(rootDir, 'docs/architecture/progress-sync-contract.md'); - const observabilityReadiness = readText(rootDir, 'docs/architecture/observability-readiness.md'); - const stalePrSalvage = readText(rootDir, 'docs/stale-pr-salvage-ledger.md'); - const legacyInventory = readText(rootDir, 'docs/legacy-artifact-inventory.md'); - const supplyChainRunbook = readText(rootDir, 'docs/security/supply-chain-incident-response.md'); - const supplyChainWorkflow = readText(rootDir, '.github/workflows/supply-chain-watch.yml'); +// Three-way readiness text: fully ready, partially ready, or neither. +function tiered(ready, partial, [whenReady, whenPartial, otherwise]) { + if (ready) return whenReady; + return partial ? whenPartial : otherwise; +} + +const TIERED_STATUS = ['current', 'in_progress', 'not_complete']; + +function readReleaseDocs(rootDir) { const packageJson = readPackage(rootDir); - const scripts = packageJson.scripts || {}; - const legacyContext = { stalePrSalvage, legacyInventory, roadmap }; + return { + roadmap: readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md'), + publicationReadiness: readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-readiness.md'), + namingMatrix: readText(rootDir, 'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md'), + releasePublicationChecklist: readText(rootDir, 'docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md'), + releaseUrlLedger: readText(rootDir, 'docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md'), + publicationEvidenceMay19: readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-19.md'), + hypergrowthCommandCenter: readText(rootDir, 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md'), + partnerSponsorTalksPack: readText(rootDir, 'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md'), + releaseVideoProduction: readText(rootDir, 'docs/releases/2.0.0-rc.1/video-suite-production.md'), + ownerQueueCleanup: readText(rootDir, 'docs/releases/2.0.0-rc.1/owner-queue-cleanup-2026-05-18.md'), + ownerApprovalPacket: readText(rootDir, 'docs/releases/2.0.0-rc.1/owner-approval-packet-2026-05-19.md'), + previewManifest: readText(rootDir, 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md'), + previewPackSmoke: readText(rootDir, 'scripts/preview-pack-smoke.js'), + releaseVideoSuite: readText(rootDir, 'scripts/release-video-suite.js'), + progressSync: readText(rootDir, 'docs/architecture/progress-sync-contract.md'), + observabilityReadiness: readText(rootDir, 'docs/architecture/observability-readiness.md'), + stalePrSalvage: readText(rootDir, 'docs/stale-pr-salvage-ledger.md'), + legacyInventory: readText(rootDir, 'docs/legacy-artifact-inventory.md'), + supplyChainRunbook: readText(rootDir, 'docs/security/supply-chain-incident-response.md'), + supplyChainWorkflow: readText(rootDir, '.github/workflows/supply-chain-watch.yml'), + scripts: packageJson.scripts || {}, + }; +} + +function assessReadiness(rootDir, docs) { + const { scripts, previewManifest, previewPackSmoke, publicationEvidenceMay19 } = docs; const previewPackManifestReady = includesAll(previewManifest, [ 'publication-readiness.md', 'release-notes.md', @@ -664,7 +535,7 @@ function buildRequirements(rootDir, platformReport) { ]); const hermesArtifactsReady = fileExists(rootDir, 'docs/HERMES-SETUP.md') && fileExists(rootDir, 'skills/hermes-imports/SKILL.md'); - const hypergrowthCommandCenterReady = includesAll(hypergrowthCommandCenter, [ + const hypergrowthCommandCenterReady = includesAll(docs.hypergrowthCommandCenter, [ 'harness-native operator system', '$1,728/mo', '$10,000/mo', @@ -678,13 +549,13 @@ function buildRequirements(rootDir, platformReport) { ]); const releaseVideoSuiteReady = scripts['release:video-suite'] === 'node scripts/release-video-suite.js' && fileExists(rootDir, 'scripts/release-video-suite.js') - && includesAll(releaseVideoProduction, [ + && includesAll(docs.releaseVideoProduction, [ 'ECC 2.0 Video Suite Production Manifest', 'Primary launch video', 'Self-Eval Gate', 'timeline', ]) - && includesAll(releaseVideoSuite, [ + && includesAll(docs.releaseVideoSuite, [ 'ecc.release-video-suite.v1', 'video-source-assets-present', 'video-release-artifacts-present', @@ -698,7 +569,7 @@ function buildRequirements(rootDir, platformReport) { 'zero detected black-frame segments', 'primary rough render self-eval passed', ]); - const partnerSponsorTalksReady = includesAll(partnerSponsorTalksPack, [ + const partnerSponsorTalksReady = includesAll(docs.partnerSponsorTalksPack, [ 'Sponsor Outbound', 'Platform Partner DM', 'Consulting Intro', @@ -706,7 +577,7 @@ function buildRequirements(rootDir, platformReport) { 'GitHub Discussion Announcement', 'Do Not Send Or Publish If', ]); - const ownerApprovalPacketReady = includesAll(ownerApprovalPacket, [ + const ownerApprovalPacketReady = includesAll(docs.ownerApprovalPacket, [ 'Owner Approval Packet', 'Decision Register', 'GitHub prerelease', @@ -718,75 +589,99 @@ function buildRequirements(rootDir, platformReport) { 'No outbound email, personal-account post, package publish, plugin tag, or billing announcement is authorized by this packet alone.' ]) && includesAll(previewManifest, ['owner-approval-packet-2026-05-19.md']); - const githubLive = !platformReport.github.skipped && platformReport.github.totals.errors === 0; - const ownerWideOpenPrs = extractLabeledCount(ownerQueueCleanup, 'Owner-wide open PRs after cleanup'); - const ownerWideOpenIssues = extractLabeledCount(ownerQueueCleanup, 'Owner-wide open issues after cleanup'); - const trackedPrQueueCurrent = githubLive - && platformReport.github.totals.openPrs <= platformReport.thresholds.maxOpenPrs; - const trackedIssueQueueCurrent = githubLive - && platformReport.github.totals.openIssues <= platformReport.thresholds.maxOpenIssues; - const ownerPrQueueCurrent = ownerWideOpenPrs === null - || ownerWideOpenPrs <= platformReport.thresholds.maxOpenPrs; - const ownerIssueQueueCurrent = ownerWideOpenIssues === null - || ownerWideOpenIssues <= platformReport.thresholds.maxOpenIssues; - const ownerPrEvidence = ownerWideOpenPrs === null + return { + previewPackManifestReady, + previewPackSmokeReady, + hermesArtifactsReady, + hypergrowthCommandCenterReady, + releaseVideoSuiteReady, + releaseVideoPublishCandidatesReady, + partnerSponsorTalksReady, + ownerApprovalPacketReady, + }; +} + +// PR and issue budgets share one shape: a tracked-repo count from the live +// sweep plus an optional owner-wide count from the queue cleanup ledger. +function queueBudgetRequirement(platformReport, githubLive, spec) { + const { id, requirement, noun, queueNoun, trackedCount, ownerCount, threshold } = spec; + const trackedCurrent = githubLive && trackedCount <= threshold; + const ownerCurrent = ownerCount === null || ownerCount <= threshold; + const ownerEvidence = ownerCount === null ? '' - : `; ${ownerWideOpenPrs} owner-wide open PRs after cleanup`; - const ownerIssueEvidence = ownerWideOpenIssues === null - ? '' - : `; ${ownerWideOpenIssues} owner-wide open issues after cleanup`; + : `; ${ownerCount} owner-wide open ${noun} after cleanup`; + + return buildRequirement( + id, + requirement, + ownerCount === null + ? 'scripts/platform-audit.js live GitHub sweep' + : 'scripts/platform-audit.js live GitHub sweep plus owner-wide queue cleanup ledger', + trackedCurrent && ownerCurrent ? 'current' : 'in_progress', + githubLive + ? `${trackedCount} open ${noun} across ${platformReport.github.repos.length} tracked repos${ownerEvidence}` + : 'live GitHub queue readback was skipped or failed', + trackedCurrent && ownerCurrent + ? 'repeat platform:audit and owner-wide gh search before release' + : `run live platform:audit and owner-wide gh search, then drain ${queueNoun} queue` + ); +} + +function githubQueueRequirements(platformReport, ownerQueueCleanup) { + const { totals } = platformReport.github; + const githubLive = !platformReport.github.skipped && totals.errors === 0; const discussionsCurrent = githubLive - && platformReport.github.totals.discussionsNeedingMaintainerTouch === 0 - && platformReport.github.totals.discussionsMissingAcceptedAnswer === 0; + && totals.discussionsNeedingMaintainerTouch === 0 + && totals.discussionsMissingAcceptedAnswer === 0; return [ - buildRequirement( - 'public-pr-budget', - 'Keep public PRs below 20', - ownerWideOpenPrs === null - ? 'scripts/platform-audit.js live GitHub sweep' - : 'scripts/platform-audit.js live GitHub sweep plus owner-wide queue cleanup ledger', - trackedPrQueueCurrent && ownerPrQueueCurrent ? 'current' : 'in_progress', - githubLive - ? `${platformReport.github.totals.openPrs} open PRs across ${platformReport.github.repos.length} tracked repos${ownerPrEvidence}` - : 'live GitHub queue readback was skipped or failed', - trackedPrQueueCurrent && ownerPrQueueCurrent - ? 'repeat platform:audit and owner-wide gh search before release' - : 'run live platform:audit and owner-wide gh search, then drain PR queue' - ), - buildRequirement( - 'public-issue-budget', - 'Keep public issues below 20', - ownerWideOpenIssues === null - ? 'scripts/platform-audit.js live GitHub sweep' - : 'scripts/platform-audit.js live GitHub sweep plus owner-wide queue cleanup ledger', - trackedIssueQueueCurrent && ownerIssueQueueCurrent ? 'current' : 'in_progress', - githubLive - ? `${platformReport.github.totals.openIssues} open issues across ${platformReport.github.repos.length} tracked repos${ownerIssueEvidence}` - : 'live GitHub queue readback was skipped or failed', - trackedIssueQueueCurrent && ownerIssueQueueCurrent - ? 'repeat platform:audit and owner-wide gh search before release' - : 'run live platform:audit and owner-wide gh search, then drain issue queue' - ), + queueBudgetRequirement(platformReport, githubLive, { + id: 'public-pr-budget', + requirement: 'Keep public PRs below 20', + noun: 'PRs', + queueNoun: 'PR', + trackedCount: totals.openPrs, + ownerCount: extractLabeledCount(ownerQueueCleanup, 'Owner-wide open PRs after cleanup'), + threshold: platformReport.thresholds.maxOpenPrs, + }), + queueBudgetRequirement(platformReport, githubLive, { + id: 'public-issue-budget', + requirement: 'Keep public issues below 20', + noun: 'issues', + queueNoun: 'issue', + trackedCount: totals.openIssues, + ownerCount: extractLabeledCount(ownerQueueCleanup, 'Owner-wide open issues after cleanup'), + threshold: platformReport.thresholds.maxOpenIssues, + }), buildRequirement( 'repository-discussions', 'Respond and manage repository discussions', 'scripts/platform-audit.js discussion summary', discussionsCurrent ? 'current' : 'in_progress', githubLive - ? `${platformReport.github.totals.discussionsNeedingMaintainerTouch} need maintainer touch; ${platformReport.github.totals.discussionsMissingAcceptedAnswer} answerable discussions missing accepted answer` + ? `${totals.discussionsNeedingMaintainerTouch} need maintainer touch; ${totals.discussionsMissingAcceptedAnswer} answerable discussions missing accepted answer` : 'live discussion readback was skipped or failed', discussionsCurrent ? 'repeat before release' : 'respond, answer, or route remaining discussions' ), + ]; +} + +function releasePackRequirements(rootDir, docs, readiness) { + const { scripts } = docs; + const { previewPackManifestReady, previewPackSmokeReady, hermesArtifactsReady } = readiness; + const previewPackReady = previewPackManifestReady && previewPackSmokeReady; + const hermesReady = hermesArtifactsReady && previewPackSmokeReady; + const dashboardScriptReady = scripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js'; + + return [ buildRequirement( 'completion-dashboard', 'Build ITO-44 completion dashboard into a repeatable command', 'npm run operator:dashboard', - scripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js' - && fileExists(rootDir, 'scripts/operator-readiness-dashboard.js') + dashboardScriptReady && fileExists(rootDir, 'scripts/operator-readiness-dashboard.js') ? 'complete' : 'in_progress', - scripts['operator:dashboard'] === 'node scripts/operator-readiness-dashboard.js' + dashboardScriptReady ? 'operator:dashboard package script exists' : 'operator:dashboard package script missing', 'keep generated dashboard attached to publication evidence' @@ -795,13 +690,13 @@ function buildRequirements(rootDir, platformReport) { 'ecc-preview-pack', 'ECC 2.0 preview pack ready', 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md', - previewPackManifestReady && previewPackSmokeReady ? 'current' : previewPackManifestReady ? 'in_progress' : 'not_complete', - previewPackManifestReady && previewPackSmokeReady - ? 'preview pack manifest and deterministic smoke gate are in-tree' - : previewPackManifestReady - ? 'preview pack manifest is in-tree' - : 'preview pack manifest is incomplete', - previewPackManifestReady && previewPackSmokeReady + tiered(previewPackReady, previewPackManifestReady, TIERED_STATUS), + tiered(previewPackReady, previewPackManifestReady, [ + 'preview pack manifest and deterministic smoke gate are in-tree', + 'preview pack manifest is in-tree', + 'preview pack manifest is incomplete', + ]), + previewPackReady ? 'repeat clean-checkout preview-pack smoke before publication' : 'final clean-checkout release approval and publish evidence still pending' ), @@ -809,13 +704,13 @@ function buildRequirements(rootDir, platformReport) { 'hermes-specialized-skills', 'Include Hermes specialized skills safely', 'docs/HERMES-SETUP.md and skills/hermes-imports/SKILL.md', - hermesArtifactsReady && previewPackSmokeReady ? 'current' : hermesArtifactsReady ? 'in_progress' : 'not_complete', - hermesArtifactsReady && previewPackSmokeReady - ? 'Hermes setup/import artifacts are covered by preview-pack smoke' - : hermesArtifactsReady - ? 'Hermes setup and import skill are present' - : 'Hermes setup/import artifacts missing', - hermesArtifactsReady && previewPackSmokeReady + tiered(hermesReady, hermesArtifactsReady, TIERED_STATUS), + tiered(hermesReady, hermesArtifactsReady, [ + 'Hermes setup/import artifacts are covered by preview-pack smoke', + 'Hermes setup and import skill are present', + 'Hermes setup/import artifacts missing', + ]), + hermesReady ? 'repeat preview-pack smoke before release review' : 'final preview-pack smoke and release review pending' ), @@ -823,8 +718,8 @@ function buildRequirements(rootDir, platformReport) { 'naming-and-plugin-publication', 'Prepare name-change, Claude plugin, and Codex plugin paths', 'naming-and-publication-matrix plus release-name-plugin-publication checklist plus publication-readiness', - includesAll(namingMatrix, ['Claude plugin', 'Codex plugin', 'npm package', 'Publication Paths']) - && includesAll(releasePublicationChecklist, [ + includesAll(docs.namingMatrix, ['Claude plugin', 'Codex plugin', 'npm package', 'Publication Paths']) + && includesAll(docs.releasePublicationChecklist, [ 'Ship `v2.0.0-rc.1` as **ECC**', 'affaan-m/ECC', 'ecc-universal', @@ -832,12 +727,20 @@ function buildRequirements(rootDir, platformReport) { 'codex plugin marketplace add', 'Do not rename the npm package until rc.1 is published' ]) - && includesAll(publicationReadiness, ['Claude plugin', 'Codex plugin']) + && includesAll(docs.publicationReadiness, ['Claude plugin', 'Codex plugin']) ? 'in_progress' : 'not_complete', 'naming matrix, release publication checklist, and plugin readiness gates exist', 'real tag/push, marketplace submission, and final channel choice remain approval-gated' ), + ]; +} + +function releaseCopyRequirements(rootDir, docs, readiness) { + const { partnerSponsorTalksReady, ownerApprovalPacketReady } = readiness; + const urlLedgerReady = includesAll(docs.releaseUrlLedger, ['Live Now', 'Approval-Gated URLs', 'Codex marketplace CLI docs']); + + return [ buildRequirement( 'release-notes-and-notifications', 'Prepare release notes, articles, tweets, and push notifications', @@ -847,10 +750,10 @@ function buildRequirements(rootDir, platformReport) { && fileExists(rootDir, 'docs/releases/2.0.0-rc.1/linkedin-post.md') ? 'in_progress' : 'not_complete', - includesAll(releaseUrlLedger, ['Live Now', 'Approval-Gated URLs', 'Codex marketplace CLI docs']) + urlLedgerReady ? 'release notes, X thread, LinkedIn draft, and URL ledger are present' : 'release notes, X thread, and LinkedIn draft are present', - includesAll(releaseUrlLedger, ['Live Now', 'Approval-Gated URLs', 'Codex marketplace CLI docs']) + urlLedgerReady ? 'final live release/npm/plugin/billing URLs and publish approval still pending' : 'URL-backed refresh and publish approval still pending' ), @@ -866,6 +769,26 @@ function buildRequirements(rootDir, platformReport) { ? 'review owner approvals from the final release commit before any publication or outbound action' : 'add the owner decision sheet before publication review' ), + ].concat(growthRequirements(readiness), [ + buildRequirement( + 'partner-sponsor-talks-pack', + 'Prepare sponsor, partner, consulting, podcast, talk, and Discussion copy', + 'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md', + partnerSponsorTalksReady ? 'in_progress' : 'not_complete', + partnerSponsorTalksReady + ? 'sponsor outbound, platform partner DM, consulting intro, talk/podcast pitch, GitHub Discussion announcement, CTA hooks, and do-not-send gate are drafted' + : 'partner, sponsor, consulting, talk, or discussion copy is missing', + partnerSponsorTalksReady + ? 'replace final URLs after publication gates, then get explicit approval before outbound or personal-account posts' + : 'draft the full outbound pack and approval gate' + ), + ]); +} + +function growthRequirements(readiness) { + const { hypergrowthCommandCenterReady, releaseVideoSuiteReady, releaseVideoPublishCandidatesReady } = readiness; + + return [ buildRequirement( 'hypergrowth-command-center', 'Create a second-phase hypergrowth release command center', @@ -882,30 +805,27 @@ function buildRequirements(rootDir, platformReport) { 'release-video-suite', 'Produce the ECC 2.0 release video suite', 'docs/releases/2.0.0-rc.1/video-suite-production.md and npm run release:video-suite', - releaseVideoPublishCandidatesReady ? 'current' : releaseVideoSuiteReady ? 'in_progress' : 'not_complete', - releaseVideoPublishCandidatesReady - ? 'video-suite gate is ready with 15/15 source assets, 13/13 suite artifacts, 12/12 publish candidates, primary self-eval, and zero detected black-frame segments recorded in May 19 evidence' - : releaseVideoSuiteReady - ? 'video production manifest and deterministic video-suite gate are wired for launch video, short clips, captions, timeline, and self-eval evidence' - : 'video production manifest or release:video-suite gate is incomplete', - releaseVideoPublishCandidatesReady - ? 'final owner approval, upload, and public video URLs remain approval-gated' - : releaseVideoSuiteReady - ? 'render final owner-approved MP4s, captions, platform reframes, and editable timeline before posting' - : 'wire release:video-suite and production manifest before final content work' - ), - buildRequirement( - 'partner-sponsor-talks-pack', - 'Prepare sponsor, partner, consulting, podcast, talk, and Discussion copy', - 'docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md', - partnerSponsorTalksReady ? 'in_progress' : 'not_complete', - partnerSponsorTalksReady - ? 'sponsor outbound, platform partner DM, consulting intro, talk/podcast pitch, GitHub Discussion announcement, CTA hooks, and do-not-send gate are drafted' - : 'partner, sponsor, consulting, talk, or discussion copy is missing', - partnerSponsorTalksReady - ? 'replace final URLs after publication gates, then get explicit approval before outbound or personal-account posts' - : 'draft the full outbound pack and approval gate' + tiered(releaseVideoPublishCandidatesReady, releaseVideoSuiteReady, TIERED_STATUS), + tiered(releaseVideoPublishCandidatesReady, releaseVideoSuiteReady, [ + 'video-suite gate is ready with 15/15 source assets, 13/13 suite artifacts, 12/12 publish candidates, primary self-eval, and zero detected black-frame segments recorded in May 19 evidence', + 'video production manifest and deterministic video-suite gate are wired for launch video, short clips, captions, timeline, and self-eval evidence', + 'video production manifest or release:video-suite gate is incomplete', + ]), + tiered(releaseVideoPublishCandidatesReady, releaseVideoSuiteReady, [ + 'final owner approval, upload, and public video URLs remain approval-gated', + 'render final owner-approved MP4s, captions, platform reframes, and editable timeline before posting', + 'wire release:video-suite and production manifest before final content work', + ]) ), + ]; +} + +function programRequirements(rootDir, docs) { + const { roadmap, scripts, progressSync } = docs; + const legacyContext = { stalePrSalvage: docs.stalePrSalvage, legacyInventory: docs.legacyInventory, roadmap }; + const observabilityScriptReady = scripts['observability:ready'] === 'node scripts/observability-readiness.js'; + + return [ buildRequirement( 'agentshield-enterprise-iteration', 'Advance AgentShield enterprise iteration', @@ -946,11 +866,11 @@ function buildRequirements(rootDir, platformReport) { 'observability-for-self-use', 'Provide ECC 2.0 observability for self-use', 'observability readiness gate', - scripts['observability:ready'] === 'node scripts/observability-readiness.js' - && includesAll(observabilityReadiness, ['observability-readiness.js']) + observabilityScriptReady + && includesAll(docs.observabilityReadiness, ['observability-readiness.js']) ? 'complete' : 'in_progress', - scripts['observability:ready'] === 'node scripts/observability-readiness.js' + observabilityScriptReady ? 'observability:ready command and readiness doc exist' : 'observability readiness command missing', 'runtime/dashboard implementation can continue after release gates' @@ -959,8 +879,8 @@ function buildRequirements(rootDir, platformReport) { 'supply-chain-local-protection', 'Keep Mini Shai-Hulud/TanStack protection loop current', 'supply-chain watch plus runbook plus AgentShield package-manager hardening', - includesAll(supplyChainRunbook, ['TanStack', 'Mini Shai-Hulud', 'scan-supply-chain-iocs.js', 'supply-chain-advisory-sources.js']) - && includesAll(supplyChainWorkflow, ['supply-chain-advisory-sources.js', 'supply-chain-advisory-sources.json']) + includesAll(docs.supplyChainRunbook, ['TanStack', 'Mini Shai-Hulud', 'scan-supply-chain-iocs.js', 'supply-chain-advisory-sources.js']) + && includesAll(docs.supplyChainWorkflow, ['supply-chain-advisory-sources.js', 'supply-chain-advisory-sources.json']) && scripts['security:advisory-sources'] === 'node scripts/ci/supply-chain-advisory-sources.js' && fileExists(rootDir, '.github/workflows/supply-chain-watch.yml') ? 'current' @@ -971,6 +891,18 @@ function buildRequirements(rootDir, platformReport) { ]; } +function buildRequirements(rootDir, platformReport) { + const docs = readReleaseDocs(rootDir); + const readiness = assessReadiness(rootDir, docs); + + return [ + ...githubQueueRequirements(platformReport, docs.ownerQueueCleanup), + ...releasePackRequirements(rootDir, docs, readiness), + ...releaseCopyRequirements(rootDir, docs, readiness), + ...programRequirements(rootDir, docs), + ]; +} + function buildReport(options) { const rootDir = path.resolve(options.root); const generatedAt = options.generatedAt || new Date().toISOString(); From 35f57c80e6b7573755fd63136338ca0dc7a0bdb7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 16 Oct 2026 23:14:00 -0400 Subject: [PATCH 17/23] refactor(eval): split the context-profile evaluator run loop runEvaluation is now plan, execute, report: option validation and family resolution, registration pinning, provider and state creation, install preparation, the selection and trial loops, and the report object each sit in their own function. outcomeTrial takes one trial object instead of ten positionals and splits into single-step and stepped paths. The provider adapter separates the guarded provider call from output parsing, and the complex corpus validator checks one task at a time. Report keys, error messages, and call ordering are unchanged. The file is in the implementation digest list, so registration digests move as they do for any edit here. Clears the four complexity warnings and the max-params warning on this file. --- docker/context-profiles/ai-eval-lib.js | 382 ++++++++++++++++--------- 1 file changed, 247 insertions(+), 135 deletions(-) diff --git a/docker/context-profiles/ai-eval-lib.js b/docker/context-profiles/ai-eval-lib.js index c90793cf2..9b5397470 100644 --- a/docker/context-profiles/ai-eval-lib.js +++ b/docker/context-profiles/ai-eval-lib.js @@ -75,6 +75,37 @@ function validateCorpusIds(cases) { // Complex corpora hold a few realistic multi-file tasks with scored hidden graders. Sample gates // are descriptive at this size, so the distinct-task minimum relaxes to the corpus itself. +function invalidComplexStep(step) { + return typeof step.query !== 'string' || !bounded(Buffer.byteLength(step.query), 1, 8192) + || typeof step.check !== 'string' || !bounded(Buffer.byteLength(step.check), 1, 65536) + || (step.checkTimeoutMs !== undefined && !bounded(step.checkTimeoutMs, 1, 120000)) + || (step.manualIds !== undefined && (!Array.isArray(step.manualIds) || step.manualIds.length > 3)); +} + +function validateComplexTaskFiles(task) { + const files = Object.entries(task.files || {}); + if (!Array.isArray(task.manualIds) || task.manualIds.length > 3 || !bounded(files.length, 1, 24) + || files.some(([file, content]) => !safeRelative(file) || typeof content !== 'string' || Buffer.byteLength(content) > 65536)) { + throw new Error('Invalid corpus task'); + } +} + +function validateComplexTask(task) { + if (!/^[a-z][a-z0-9-]{0,63}$/.test(task.id)) throw new Error('Invalid corpus case'); + if (task.steps === undefined + && (typeof task.query !== 'string' || !bounded(Buffer.byteLength(task.query), 1, 8192))) throw new Error('Invalid corpus case'); + validateComplexTaskFiles(task); + if (task.steps !== undefined) { + // Stepped (chained) task: sequential tickets graded in one accumulating workspace. + if (!Array.isArray(task.steps) || !bounded(task.steps.length, 2, 8) || task.steps.some(invalidComplexStep)) { + throw new Error('Invalid corpus task'); + } + } else if (typeof task.check !== 'string' || !bounded(Buffer.byteLength(task.check), 1, 65536) + || (task.checkTimeoutMs !== undefined && !bounded(task.checkTimeoutMs, 1, 120000))) { + throw new Error('Invalid corpus task'); + } +} + function validateComplexCorpus(corpus) { if (!Array.isArray(corpus.selection) || !Array.isArray(corpus.tasks) || !bounded(corpus.selection.length, 0, 50) || !bounded(corpus.tasks.length, 1, 10) @@ -83,29 +114,7 @@ function validateComplexCorpus(corpus) { } validateCorpusIds(corpus.selection); if (new Set(corpus.tasks.map(c => c.id)).size !== corpus.tasks.length) throw new Error('Duplicate corpus ID'); - for (const task of corpus.tasks) { - if (!/^[a-z][a-z0-9-]{0,63}$/.test(task.id)) throw new Error('Invalid corpus case'); - if (task.steps === undefined - && (typeof task.query !== 'string' || !bounded(Buffer.byteLength(task.query), 1, 8192))) throw new Error('Invalid corpus case'); - const files = Object.entries(task.files || {}); - if (!Array.isArray(task.manualIds) || task.manualIds.length > 3 || !bounded(files.length, 1, 24) - || files.some(([file, content]) => !safeRelative(file) || typeof content !== 'string' || Buffer.byteLength(content) > 65536)) { - throw new Error('Invalid corpus task'); - } - if (task.steps !== undefined) { - // Stepped (chained) task: sequential tickets graded in one accumulating workspace. - if (!Array.isArray(task.steps) || !bounded(task.steps.length, 2, 8) - || task.steps.some(step => typeof step.query !== 'string' || !bounded(Buffer.byteLength(step.query), 1, 8192) - || typeof step.check !== 'string' || !bounded(Buffer.byteLength(step.check), 1, 65536) - || (step.checkTimeoutMs !== undefined && !bounded(step.checkTimeoutMs, 1, 120000)) - || (step.manualIds !== undefined && (!Array.isArray(step.manualIds) || step.manualIds.length > 3)))) { - throw new Error('Invalid corpus task'); - } - } else if (typeof task.check !== 'string' || !bounded(Buffer.byteLength(task.check), 1, 65536) - || (task.checkTimeoutMs !== undefined && !bounded(task.checkTimeoutMs, 1, 120000))) { - throw new Error('Invalid corpus task'); - } - } + for (const task of corpus.tasks) validateComplexTask(task); } function sourceSnapshot(repoRoot) { @@ -601,36 +610,50 @@ function launchEnvironment(launch) { TMPDIR: launch.home, LANG: 'C.UTF-8' }; } +// One provider call. A throwing provider still records its elapsed time and +// restores the environment; only source drift propagates as itself. +function callProvider(state, environment, request, started) { + try { + return state.provider(request); + } catch (error) { + state.metrics.push({ phase: request.phase, elapsedMs: Date.now() - started, usage: null }); + if (error?.code === 'source-drift') throw error; + return fail('provider-failed'); + } finally { environment.restore(); } +} + +function parseProviderOutput(state, raw) { + const parsed = state.family === 'claude' ? parseClaudeJson(raw?.stdout) : parseCodexJsonl(raw?.stdout); + const succeeded = raw?.status === 0 && !raw?.error; + return { parsed, succeeded }; +} + +function adapterCall(state, cwd, environment, args, options) { + if (state.calls >= state.maxCalls) fail('call-budget'); + state.assertCurrent(); + environment.verify(); + const remaining = state.deadline - Date.now(); + if (remaining <= 0) fail('deadline'); + const phase = options.phase || (args.includes('read-only') ? 'selection' : 'task'); + state.calls++; + const started = Date.now(); + // Coding tasks outgrow the launcher's interactive default, so the evaluator's own call bound governs them. + const timeoutMs = Math.min(phase === 'task' ? state.callTimeoutMs : options.timeout, state.callTimeoutMs, remaining); + const env = options.env || launchEnvironment(environment.launch); + const raw = callProvider(state, environment, + { phase, input: options.input, cwd, env, timeoutMs, maxBuffer: 1024 * 1024 }, started); + const elapsedMs = Date.now() - started; + const { parsed, succeeded } = parseProviderOutput(state, raw); + state.metrics.push({ phase, elapsedMs, usage: parsed.valid && succeeded ? parsed.usage : null }); + if (Date.now() >= state.deadline || elapsedMs > timeoutMs) fail('deadline'); + state.assertCurrent(); + if (!succeeded) fail('provider-failed'); + if (!parsed.valid) fail(parsed.error ? 'provider-failed' : 'invalid-jsonl'); + return { status: 0, stdout: parsed.text }; +} + function executeAdapter(state, cwd, environment) { - return (_command, args, options) => { - if (state.calls >= state.maxCalls) fail('call-budget'); - state.assertCurrent(); - environment.verify(); - const remaining = state.deadline - Date.now(); - if (remaining <= 0) fail('deadline'); - const phase = options.phase || (args.includes('read-only') ? 'selection' : 'task'); - state.calls++; - const started = Date.now(); - let raw; - // Coding tasks outgrow the launcher's interactive default, so the evaluator's own call bound governs them. - const timeoutMs = Math.min(phase === 'task' ? state.callTimeoutMs : options.timeout, state.callTimeoutMs, remaining); - const env = options.env || launchEnvironment(environment.launch); - try { - raw = state.provider({ phase, input: options.input, cwd, env, timeoutMs, maxBuffer: 1024 * 1024 }); - } catch (error) { - state.metrics.push({ phase, elapsedMs: Date.now() - started, usage: null }); - if (error?.code === 'source-drift') throw error; - fail('provider-failed'); - } finally { environment.restore(); } - const elapsedMs = Date.now() - started; - const parsed = state.family === 'claude' ? parseClaudeJson(raw?.stdout) : parseCodexJsonl(raw?.stdout); - state.metrics.push({ phase, elapsedMs, usage: parsed.valid && raw?.status === 0 && !raw?.error ? parsed.usage : null }); - if (Date.now() >= state.deadline || elapsedMs > timeoutMs) fail('deadline'); - state.assertCurrent(); - if (raw?.status !== 0 || raw?.error) fail('provider-failed'); - if (!parsed.valid) fail(parsed.error ? 'provider-failed' : 'invalid-jsonl'); - return { status: 0, stdout: parsed.text }; - }; + return (_command, args, options) => adapterCall(state, cwd, environment, args, options); } function selectionProbe(item, repoRoot, execute, environment, target) { @@ -661,48 +684,63 @@ function selectionProbe(item, repoRoot, execute, environment, target) { // Full relies on native discovery of the whole install; the Lean arms receive ECC-selected skill bodies; // ecc-legacy runs bare against the pinned pre-scoping skill library; Baseline runs the bare task query. // Stepped tasks run each ticket in the same accumulating workspace, grading after every step. -function outcomeTrial(item, arm, repeat, repoRoot, execute, cwd, environment, target, harvest, metrics = null) { - const launchStep = (query, manualIds) => { - const task = { sessionId: 'ecc-eval', taskId: item.id, revision: 1, phase: 'evaluate', query }; - return launchTaskContext({ repoRoot, execute, nativeEnvironment: environment.launch, target, - bare: arm === 'baseline' || arm === 'ecc-legacy', - task: { ...task, ...(arm === 'manual-lean' && manualIds?.length ? { explicitIds: manualIds } : {}) }, - profileId: arm === 'full' ? 'full@1' : 'lean@1', selectionMode: arm === 'auto-lean' ? 'auto' : 'manual' }); - }; - try { - if (!item.steps) { - const result = launchStep(item.query, item.manualIds); - if (harvest) harvest(arm, item.id, repeat, environment); - const verdict = runScoredCheck(cwd, item.check, item.checkTimeoutMs); - const passed = result.status === 'completed' && verdict.passed && verdict.score >= 0.999; - return { id: item.id, arm, repeat, passed, score: result.status === 'completed' ? verdict.score : 0, - selectedIds: result.selection.selectedIds, failure: passed ? null : 'hidden-check' }; - } - const steps = []; - const selectedIds = []; - for (let index = 0; index < item.steps.length; index++) { - const step = item.steps[index]; - const start = metrics ? metrics.length : 0; - const result = launchStep(step.query, step.manualIds || item.manualIds); - if (harvest) harvest(arm, `${item.id}--step${index + 1}`, repeat, environment); - if (result.status !== 'completed') { - // A failed ticket ends the chain; remaining tickets are unscored. - steps.push({ score: 0, ...(metrics ? metricsSince(metrics, start) : {}) }); - for (let rest = index + 1; rest < item.steps.length; rest++) { - steps.push({ score: 0, ...(metrics ? metricsSince(metrics, metrics.length) : {}) }); - } - break; +// `trial` carries repoRoot, execute, cwd, environment, target, harvest (or null) +// and metrics (or null) for one task trial. +function launchTrialStep(item, arm, trial, query, manualIds) { + const task = { sessionId: 'ecc-eval', taskId: item.id, revision: 1, phase: 'evaluate', query }; + return launchTaskContext({ repoRoot: trial.repoRoot, execute: trial.execute, + nativeEnvironment: trial.environment.launch, target: trial.target, + bare: arm === 'baseline' || arm === 'ecc-legacy', + task: { ...task, ...(arm === 'manual-lean' && manualIds?.length ? { explicitIds: manualIds } : {}) }, + profileId: arm === 'full' ? 'full@1' : 'lean@1', selectionMode: arm === 'auto-lean' ? 'auto' : 'manual' }); +} + +function stepMetrics(metrics, start) { + return metrics ? metricsSince(metrics, start) : {}; +} + +function singleStepTrial(item, arm, repeat, trial) { + const { cwd, environment, harvest } = trial; + const result = launchTrialStep(item, arm, trial, item.query, item.manualIds); + if (harvest) harvest(arm, item.id, repeat, environment); + const verdict = runScoredCheck(cwd, item.check, item.checkTimeoutMs); + const passed = result.status === 'completed' && verdict.passed && verdict.score >= 0.999; + return { id: item.id, arm, repeat, passed, score: result.status === 'completed' ? verdict.score : 0, + selectedIds: result.selection.selectedIds, failure: passed ? null : 'hidden-check' }; +} + +function steppedTrial(item, arm, repeat, trial) { + const { cwd, environment, harvest, metrics } = trial; + const steps = []; + const selectedIds = []; + for (let index = 0; index < item.steps.length; index++) { + const step = item.steps[index]; + const start = metrics ? metrics.length : 0; + const result = launchTrialStep(item, arm, trial, step.query, step.manualIds || item.manualIds); + if (harvest) harvest(arm, `${item.id}--step${index + 1}`, repeat, environment); + if (result.status !== 'completed') { + // A failed ticket ends the chain; remaining tickets are unscored. + steps.push({ score: 0, ...stepMetrics(metrics, start) }); + for (let rest = index + 1; rest < item.steps.length; rest++) { + steps.push({ score: 0, ...stepMetrics(metrics, metrics ? metrics.length : 0) }); } - selectedIds.push(...result.selection.selectedIds); - const verdict = runScoredCheck(cwd, step.check, step.checkTimeoutMs, index + 1); - steps.push({ score: verdict.passed ? verdict.score : 0, ...(metrics ? metricsSince(metrics, start) : {}) }); + break; } - const score = steps.reduce((sum, step) => sum + step.score, 0) / item.steps.length; - const passed = steps.length === item.steps.length && steps.every(step => step.score >= 0.999); - return { id: item.id, arm, repeat, passed, score, selectedIds: [...new Set(selectedIds)], steps, - failure: passed ? null : 'hidden-check' }; + selectedIds.push(...result.selection.selectedIds); + const verdict = runScoredCheck(cwd, step.check, step.checkTimeoutMs, index + 1); + steps.push({ score: verdict.passed ? verdict.score : 0, ...stepMetrics(metrics, start) }); + } + const score = steps.reduce((sum, step) => sum + step.score, 0) / item.steps.length; + const passed = steps.length === item.steps.length && steps.every(step => step.score >= 0.999); + return { id: item.id, arm, repeat, passed, score, selectedIds: [...new Set(selectedIds)], steps, + failure: passed ? null : 'hidden-check' }; +} + +function outcomeTrial(item, arm, repeat, trial) { + try { + return item.steps ? steppedTrial(item, arm, repeat, trial) : singleStepTrial(item, arm, repeat, trial); } catch (error) { - if (harvest) harvest(arm, item.id, repeat, environment); + if (trial.harvest) trial.harvest(arm, item.id, repeat, trial.environment); return { id: item.id, arm, repeat, passed: false, score: 0, selectedIds: [], failure: failureCode(error) }; } } @@ -753,10 +791,10 @@ function createHarvester(artifactDir, envs) { }; } -function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), registration, - repeats = 1, provider, family, allowRealProvider = false, allowCredentialedTools = false, - executable, model, effort, authHome, environments, - arms = undefined, artifactDir = null, maxCalls = 300, deadlineMs = 3600000, callTimeoutMs = 300000 } = {}) { +// Validate the caller's bounds and opt-ins and settle on the provider family. +function resolveEvaluationFamily(options) { + const { provider, allowRealProvider, maxCalls, deadlineMs, callTimeoutMs, registration, + family, executable, effort, allowCredentialedTools } = options; if (!provider && !allowRealProvider) throw new Error('Evaluation requires an injected provider or explicit opt-in'); if (!bounded(maxCalls, 1, 2000) || !bounded(deadlineMs, 1, 8 * 3600000) || !bounded(callTimeoutMs, 1, 600000)) throw new Error('Invalid call or deadline bound'); @@ -766,21 +804,97 @@ function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), re if (!provider && resolvedFamily === 'claude' && !allowCredentialedTools) { throw new Error('Claude task tools can read provider credentials; explicit credentialed-tool opt-in is required'); } + return resolvedFamily; +} + +function pinEvaluation(options, resolvedFamily) { + const { repoRoot, corpus, repeats, model, executable, effort, arms, provider, registration } = options; const pin = preregister({ repoRoot, corpus, repeats, model, executable, effort, arms }); if (!provider && resolvedFamily === 'codex' && pin.arms.includes('ecc-legacy')) { throw new Error('Codex real evaluation requires --arms without ecc-legacy; the pinned legacy skills arm is Claude-only'); } if (registration && !isDeepStrictEqual(registration, pin)) throw new Error('Registration pin mismatch'); - const injected = Boolean(provider); - const liveProvider = provider || (resolvedFamily === 'claude' + return pin; +} + +function createLiveProvider(resolvedFamily, options) { + const { allowRealProvider, allowCredentialedTools, executable, model, effort, authHome, artifactDir } = options; + return resolvedFamily === 'claude' ? createClaudeProvider({ allowRealProvider, allowCredentialedTools, executable, model, persistSessions: Boolean(artifactDir) }) - : createCodexProvider({ allowRealProvider, executable, model, effort, authHome })); - const state = { calls: 0, metrics: [], maxCalls, callTimeoutMs, family: resolvedFamily, + : createCodexProvider({ allowRealProvider, executable, model, effort, authHome }); +} + +function createEvaluationState(options, plan) { + const { repoRoot, corpus, maxCalls, callTimeoutMs, deadlineMs } = options; + const { pin, resolvedFamily, liveProvider } = plan; + return { calls: 0, metrics: [], maxCalls, callTimeoutMs, family: resolvedFamily, deadline: Date.now() + deadlineMs, provider: liveProvider, assertCurrent() { if (digestObject(corpus) !== pin.corpusDigest || sourceSnapshot(repoRoot).sourceDigest !== pin.sourceDigest) fail('source-drift'); } }; +} + +function planEvaluation(options) { + const resolvedFamily = resolveEvaluationFamily(options); + const pin = pinEvaluation(options, resolvedFamily); + const injected = Boolean(options.provider); + const liveProvider = options.provider || createLiveProvider(resolvedFamily, options); + const plan = { resolvedFamily, pin, injected, liveProvider }; + plan.state = createEvaluationState(options, plan); + return plan; +} + +function prepareEvaluationEnvironments(options, plan, installRoot) { + const { repoRoot, executable } = options; + const { injected, resolvedFamily, pin } = plan; + if (injected) return syntheticEnvironments(installRoot); + if (resolvedFamily !== 'claude') return prepareEnvironments({ repoRoot, executable, root: installRoot }); + return prepareClaudeEnvironments({ repoRoot, executable, root: installRoot, + ...(pin.arms.includes('ecc-legacy') + ? { legacySource: exportLegacySource({ repoRoot, destination: path.join(installRoot, 'legacy-source') }) } + : {}) }); +} + +function describeInstalls(envs) { + return Object.fromEntries(Object.entries(envs).map(([name, env]) => [name, + { profileId: env.profileId, skills: env.skills, ...(env.sourceSha ? { sourceSha: env.sourceSha } : {}) }])); +} + +function runSelectionProbes(run, selection) { + const { corpus, repoRoot, state, temp, envs, resolvedFamily } = run; + for (const item of corpus.selection) { + const cwd = path.join(temp, `${item.id}--selection`); + fs.mkdirSync(cwd); + const start = state.metrics.length; + selection.push({ ...selectionProbe(item, repoRoot, executeAdapter(state, cwd, envs.lean), envs.lean, resolvedFamily), + ...metricsSince(state.metrics, start) }); + } +} + +function runOutcomeTrials(run, outcomes) { + const { corpus, pin, repoRoot, state, temp, envs, resolvedFamily, harvest } = run; + for (const scheduled of pin.order) { + const item = corpus.tasks.find(c => c.id === scheduled.id); + for (const arm of scheduled.arms) { + const cwd = path.join(temp, `${item.id}--${arm}--${scheduled.repeat}`); + const environment = envs[['full', 'baseline', 'ecc-legacy'].includes(arm) ? arm : 'lean']; + fs.mkdirSync(cwd); + writeWorkspace(cwd, item.files); + const start = state.metrics.length; + const trial = { repoRoot, execute: executeAdapter(state, cwd, environment), cwd, environment, + target: resolvedFamily, harvest, metrics: state.metrics }; + outcomes.push({ ...outcomeTrial(item, arm, scheduled.repeat, trial), ...metricsSince(state.metrics, start) }); + fs.rmSync(cwd, { recursive: true, force: true }); + } + } +} + +// Prepare installs in a private temp root, run every selection probe and task +// trial, and always remove the root afterwards. +function executeEvaluation(options, plan) { + const { repoRoot, corpus, environments, artifactDir } = options; + const { resolvedFamily, pin, injected, state } = plan; const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-ai-eval-'))); const selection = []; const outcomes = []; @@ -789,52 +903,38 @@ function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), re try { const installRoot = path.join(temp, 'installs'); fs.mkdirSync(installRoot, { mode: 0o700 }); - const envs = environments || (injected ? syntheticEnvironments(installRoot) - : resolvedFamily === 'claude' - ? prepareClaudeEnvironments({ repoRoot, executable, root: installRoot, - ...(pin.arms.includes('ecc-legacy') - ? { legacySource: exportLegacySource({ repoRoot, destination: path.join(installRoot, 'legacy-source') }) } - : {}) }) - : prepareEnvironments({ repoRoot, executable, root: installRoot })); - installs = Object.fromEntries(Object.entries(envs).map(([name, env]) => [name, - { profileId: env.profileId, skills: env.skills, ...(env.sourceSha ? { sourceSha: env.sourceSha } : {}) }])); + const envs = environments || prepareEvaluationEnvironments(options, plan, installRoot); + installs = describeInstalls(envs); harvester = artifactDir && resolvedFamily === 'claude' && !injected ? createHarvester(artifactDir, envs) : null; const harvest = harvester ? (arm, id, repeat, env) => harvester.record(arm, id, repeat, env) : null; - for (const item of corpus.selection) { - const cwd = path.join(temp, `${item.id}--selection`); - fs.mkdirSync(cwd); - const start = state.metrics.length; - selection.push({ ...selectionProbe(item, repoRoot, executeAdapter(state, cwd, envs.lean), envs.lean, resolvedFamily), - ...metricsSince(state.metrics, start) }); - } - for (const scheduled of pin.order) { - const item = corpus.tasks.find(c => c.id === scheduled.id); - for (const arm of scheduled.arms) { - const cwd = path.join(temp, `${item.id}--${arm}--${scheduled.repeat}`); - const environment = envs[['full', 'baseline', 'ecc-legacy'].includes(arm) ? arm : 'lean']; - fs.mkdirSync(cwd); - writeWorkspace(cwd, item.files); - const start = state.metrics.length; - outcomes.push({ ...outcomeTrial(item, arm, scheduled.repeat, repoRoot, - executeAdapter(state, cwd, environment), cwd, environment, resolvedFamily, harvest, state.metrics), - ...metricsSince(state.metrics, start) }); - fs.rmSync(cwd, { recursive: true, force: true }); - } - } + const run = { corpus, pin, repoRoot, state, temp, envs, resolvedFamily, harvest }; + runSelectionProbes(run, selection); + runOutcomeTrials(run, outcomes); if (harvester) harvester.writeIndex(); } finally { if (harvester) harvester.writeIndex(); fs.rmSync(temp, { recursive: true, force: true }); } + return { selection, outcomes, installs, harvester }; +} + +function summarizeSelection(selection) { + const selectionSuccesses = selection.filter(row => row.passed).length; + return { n: selection.length, successes: selectionSuccesses, + categories: [...new Set(selection.map(row => row.category))].map(category => ({ category, + n: selection.filter(row => row.category === category).length, + successes: selection.filter(row => row.category === category && row.passed).length })), + interval: wilson(selectionSuccesses, selection.length), method: 'wilson-95-descriptive-purposive-sample' }; +} + +function buildEvaluationReport(options, plan, run) { + const { maxCalls, deadlineMs, callTimeoutMs } = options; + const { pin, injected, liveProvider, state, resolvedFamily } = plan; + const { selection, outcomes, installs, harvester } = run; const summary = summarize(outcomes, pin.arms); const insufficient = summary.distinctTasks < pin.minimumDistinctTasks || selection.length < pin.minimumDistinctTasks; - const selectionSuccesses = selection.filter(row => row.passed).length; return { schemaVersion: 'ecc.context-eval.v2', registration: pin, evidence: injected ? 'injected-provider' : resolvedFamily === 'claude' ? 'claude-json' : 'codex-jsonl', installs, authentication: injected ? 'injected' : liveProvider.authentication, credentialsRetained: false, calls: state.calls, bounds: { maxCalls, deadlineMs, callTimeoutMs }, selection, outcomes, summary, - selectionSummary: { n: selection.length, successes: selectionSuccesses, - categories: [...new Set(selection.map(row => row.category))].map(category => ({ category, - n: selection.filter(row => row.category === category).length, - successes: selection.filter(row => row.category === category && row.passed).length })), - interval: wilson(selectionSuccesses, selection.length), method: 'wilson-95-descriptive-purposive-sample' }, + selectionSummary: summarizeSelection(selection), gate: { status: insufficient ? 'insufficient-sample' : injected ? 'synthetic-only' : 'review-required', nonInferioritySupported: !insufficient && !injected && summary.pairs.every(p => p.interval[0] >= -pin.nonInferiorityMargin), releaseApproved: false }, nativeInvocation: 'unobserved', @@ -842,6 +942,18 @@ function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), re artifactRetention: harvester ? 'session-jsonl-per-task-trial' : 'none', ...metricsSince(state.metrics, 0) }; } +function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), registration, + repeats = 1, provider, family, allowRealProvider = false, allowCredentialedTools = false, + executable, model, effort, authHome, environments, + arms = undefined, artifactDir = null, maxCalls = 300, deadlineMs = 3600000, callTimeoutMs = 300000 } = {}) { + const options = { repoRoot, corpus, registration, repeats, provider, family, allowRealProvider, + allowCredentialedTools, executable, model, effort, authHome, environments, arms, artifactDir, + maxCalls, deadlineMs, callTimeoutMs }; + const plan = planEvaluation(options); + const run = executeEvaluation(options, plan); + return buildEvaluationReport(options, plan, run); +} + module.exports = { loadCorpus, preregister, runEvaluation, parseCodexJsonl, parseClaudeJson, summarize, wilson, runCheck, runScoredCheck, createAuthLease, createCodexProvider, createClaudeProvider, prepareEnvironments, prepareClaudeEnvironments, exportLegacySource, providerFamily, resolveFamily, readClaudeKeychainToken }; From 26d2010a16878fe3bf91f90178a0402ac87c6576 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 17 Oct 2026 20:41:00 -0400 Subject: [PATCH 18/23] refactor(hooks): split shell-scan into per-token helpers heredocDelimiter, executionRegion and scanShell each carried one long character loop with every quote, escape, heredoc, case and scope rule inline, which is where the three complexity warnings came from. The delimiter reader now has skipDelimiterBlanks, delimiterEscape and readDelimiterWord; the $(...) walker dispatches to regionQuotedEnd, regionSubstitution, openRegionQuote, regionHeredocOperator, regionHeredocBodies, casePhaseStep and closeRegionParen; and the top-level scanner keeps its position and pending word on a scan object that scanQuotedChar, scanEscape, scanSubstitution, openQuote, scanOperator and scanRedirect advance. Every budget.spend call keeps its amount and order, so the work quota tests still land in the same window. The module still has no requires and exports createBudget, scanShell and scanExpansions unchanged; a differential run of the old and new scanner over the hook test corpus plus 58k generated inputs produced identical commands, nested regions and spend totals. --- scripts/hooks/lib/shell-scan.js | 627 +++++++++++++++++++------------- 1 file changed, 380 insertions(+), 247 deletions(-) diff --git a/scripts/hooks/lib/shell-scan.js b/scripts/hooks/lib/shell-scan.js index b887e2789..94a23a32a 100644 --- a/scripts/hooks/lib/shell-scan.js +++ b/scripts/hooks/lib/shell-scan.js @@ -18,13 +18,15 @@ function continuationEnd(input, index) { return index; } -// Delimiters undergo quote removal, not command expansion. Keeping this small -// reader shared with substitution matching prevents body punctuation becoming -// shell syntax while locating the enclosing execution region. -function heredocDelimiter(input, start, budget) { - if (!input.startsWith('<<', start) || input[start + 2] === '<') return null; - const operator = input[start + 2] === '-' ? '<<-' : '<<'; - let i = start + operator.length; +// Skip to the end of a comment. The returned index is the newline or EOF. +function skipComment(input, start, budget) { + let i = start; + while (i < input.length && input[i] !== '\n') { budget.spend(); i++; } + return i; +} + +function skipDelimiterBlanks(input, start, budget) { + let i = start; while (i < input.length) { budget.spend(); if (input[i] === ' ' || input[i] === '\t' || input[i] === '\r') { i++; continue; } @@ -32,37 +34,55 @@ function heredocDelimiter(input, start, budget) { if (continued === i) break; i = continued; } - let value = ''; - let quote = null; - let began = false; - let quoted = false; + return i; +} + +// A backslash in the delimiter word. Returns the index of the last consumed +// character; a continuation is removed without starting the word. +function delimiterEscape(input, i, word) { + const continued = continuationEnd(input, i); + if (continued !== i) return continued - 1; + word.began = true; word.quoted = true; + const next = input[i + 1]; + if (next === undefined) { word.value += '\\'; return i; } + if (word.quote === '"' && !'"$`\\'.includes(next)) word.value += '\\'; + word.value += next; + return i + 1; +} + +function readDelimiterWord(input, start, budget) { + const word = { value: '', quote: null, began: false, quoted: false, end: start }; + let i = start; for (; i < input.length; i++) { budget.spend(); const c = input[i]; - if (quote === "'") { - if (c === "'") quote = null; - else value += c; + if (word.quote === "'") { + if (c === "'") word.quote = null; + else word.value += c; continue; } - if (c === '\\') { - const continued = continuationEnd(input, i); - if (continued !== i) { i = continued - 1; continue; } - began = true; quoted = true; - const next = input[i + 1]; - if (next === undefined) { value += c; continue; } - if (quote === '"' && !'"$`\\'.includes(next)) value += '\\'; - value += next; i++; continue; - } - if (quote === '"') { - if (c === '"') quote = null; - else value += c; + if (c === '\\') { i = delimiterEscape(input, i, word); continue; } + if (word.quote === '"') { + if (c === '"') word.quote = null; + else word.value += c; continue; } - if (c === "'" || c === '"') { quote = c; began = true; quoted = true; continue; } - if (/[\s;|&()<>]/.test(c) || (!began && c === '#')) break; - began = true; value += c; + if (c === "'" || c === '"') { word.quote = c; word.began = true; word.quoted = true; continue; } + if (/[\s;|&()<>]/.test(c) || (!word.began && c === '#')) break; + word.began = true; word.value += c; } - return began ? { operator, word: { value, quoted }, end: i } : null; + word.end = i; + return word; +} + +// Delimiters undergo quote removal, not command expansion. Keeping this small +// reader shared with substitution matching prevents body punctuation becoming +// shell syntax while locating the enclosing execution region. +function heredocDelimiter(input, start, budget) { + if (!input.startsWith('<<', start) || input[start + 2] === '<') return null; + const operator = input[start + 2] === '-' ? '<<-' : '<<'; + const word = readDelimiterWord(input, skipDelimiterBlanks(input, start + operator.length, budget), budget); + return word.began ? { operator, word: { value: word.value, quoted: word.quoted }, end: word.end } : null; } function heredocLine(input, start, joinContinuations, budget) { @@ -129,90 +149,141 @@ function legacyRegion(input, start, budget) { return { text: decoded.join(''), end: i < input.length ? i + 1 : i }; } +// --- $(...) region state --- +// Each helper below takes the innermost frame and the index of the current +// character. Those that consume input return the index of the last consumed +// character, or undefined when the character was not theirs to handle. + +function regionFrame() { + return { quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] }; +} + +function endRegionWord(state) { + if (!state.word) return; + const phase = state.cases[state.cases.length - 1]; + if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop(); + else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject'); + else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in'; + else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern'; + state.commandStart = false; + state.word = ''; state.quotedWord = false; +} + +// Inside '...' or $'...': only the closing quote matters, and an ANSI-C escape +// protects the next character. +function regionQuotedEnd(state, input, i) { + const c = input[i]; + if (state.quote === "$'" && c === '\\' && i + 1 < input.length) return i + 1; + if (c === "'") state.quote = null; + return i; +} + +// Escapes and nested substitutions apply inside double quotes as well. +function regionSubstitution(state, stack, input, i, budget) { + const c = input[i]; + if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; return i + 1; } + if (c === '$' && input[i + 1] === '(') { + state.word += '$()'; state.quotedWord = true; stack.push(regionFrame()); return i + 1; + } + if (c === '`') { + const region = legacyRegion(input, i, budget); + state.word += '`'; state.quotedWord = true; return region.end - 1; + } + return undefined; +} + +function openRegionQuote(state, input, i) { + const c = input[i]; + if (c === '$' && input[i + 1] === "'") { + state.quote = "$'"; state.word += "$'"; state.quotedWord = true; return i + 1; + } + if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; return i; } + return undefined; +} + +// A here-string or heredoc operator ends the current word. A heredoc +// delimiter queues its body for the next newline. +function regionHeredocOperator(state, input, i, budget) { + if (input[i] !== '<') return undefined; + if (input.startsWith('<<<', i)) { endRegionWord(state); return i + 2; } + if (input[i + 1] === '<' && input[i + 2] !== '<') { + endRegionWord(state); + const delimiter = heredocDelimiter(input, i, budget); + if (delimiter) { state.heredocs.push(delimiter); return delimiter.end - 1; } + } + return undefined; +} + +function regionHeredocBodies(state, input, i, budget) { + endRegionWord(state); + let next = i + 1; + for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end; + state.heredocs.length = 0; + state.commandStart = true; + return next - 1; +} + +// A case pattern's closing ')' is not the end of $(...). Only literal +// keyword positions affect this state; quoted or echo operands do not. +function casePhaseStep(state, input, i) { + const c = input[i]; + const phase = state.cases[state.cases.length - 1]; + if (c === ')' && phase === 'pattern') { + state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; return i; + } + if (c === '(' && phase === 'pattern') return i; + if (c === ';' && input[i + 1] === ';' && phase === 'body') { + state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; return i + 1; + } + return undefined; +} + +// Tracks parenthesis depth; true once the outermost region has closed. +function closeRegionParen(stack, state, c) { + if (/[;|&\n]/.test(c)) state.commandStart = true; + if (c === '(') state.depth++; + if (c !== ')') return false; + state.depth--; + if (state.depth !== 0) return false; + stack.pop(); + return stack.length === 0; +} + // Locate a nested execution region without evaluating any supplied text. // Balanced substitutions have independent quote state; malformed regions extend // to EOF and remain conservatively inspectable instead of silently disappearing. function executionRegion(input, start, budget) { if (input[start] === '`') return legacyRegion(input, start, budget); const from = start + 2; - const frame = () => ({ quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] }); - const stack = [frame()]; - function endWord(state) { - if (!state.word) return; - const phase = state.cases[state.cases.length - 1]; - if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop(); - else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject'); - else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in'; - else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern'; - state.commandStart = false; - state.word = ''; state.quotedWord = false; - } + const stack = [regionFrame()]; for (let i = from; i < input.length; i++) { budget.spend(); const state = stack[stack.length - 1]; const c = input[i]; - if (state.quote === "'" || state.quote === "$'") { - if (state.quote === "$'" && c === '\\' && i + 1 < input.length) { i++; continue; } - if (c === "'") state.quote = null; - continue; - } - if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; i++; continue; } - if (c === '$' && input[i + 1] === '(') { - state.word += '$()'; state.quotedWord = true; stack.push(frame()); i++; continue; - } - if (c === '`') { - const region = legacyRegion(input, i, budget); - state.word += '`'; state.quotedWord = true; i = region.end - 1; continue; - } + if (state.quote === "'" || state.quote === "$'") { i = regionQuotedEnd(state, input, i); continue; } + const substituted = regionSubstitution(state, stack, input, i, budget); + if (substituted !== undefined) { i = substituted; continue; } if (state.quote === '"') { if (c === '"') state.quote = null; continue; } - if (c === '$' && input[i + 1] === "'") { - state.quote = "$'"; state.word += "$'"; state.quotedWord = true; i++; continue; - } - if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; continue; } + const opened = openRegionQuote(state, input, i); + if (opened !== undefined) { i = opened; continue; } if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) { - while (i < input.length && input[i] !== '\n') { budget.spend(); i++; } - i--; // Process the newline, including any pending heredoc bodies. + // Stop before the newline so pending heredoc bodies are still processed. + i = skipComment(input, i, budget) - 1; continue; } - if (c === '<' && input.startsWith('<<<', i)) { endWord(state); i += 2; continue; } - if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') { - endWord(state); - const delimiter = heredocDelimiter(input, i, budget); - if (delimiter) { state.heredocs.push(delimiter); i = delimiter.end - 1; continue; } - } - if (c === '\n' && state.heredocs.length) { - endWord(state); - let next = i + 1; - for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end; - state.heredocs.length = 0; - state.commandStart = true; i = next - 1; continue; - } - if (/[\s;|&()]/.test(c)) endWord(state); + const redirected = regionHeredocOperator(state, input, i, budget); + if (redirected !== undefined) { i = redirected; continue; } + if (c === '\n' && state.heredocs.length) { i = regionHeredocBodies(state, input, i, budget); continue; } + if (/[\s;|&()]/.test(c)) endRegionWord(state); else state.word += c; - const phase = state.cases[state.cases.length - 1]; - // A case pattern's closing ')' is not the end of $(...). Only literal - // keyword positions affect this state; quoted or echo operands do not. - if (c === ')' && phase === 'pattern') { - state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; continue; - } - if (c === '(' && phase === 'pattern') continue; - if (c === ';' && input[i + 1] === ';' && phase === 'body') { - state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; i++; continue; - } - if (/[;|&\n]/.test(c)) state.commandStart = true; - if (c === '(') state.depth++; - if (c === ')') { - state.depth--; - if (state.depth === 0) { - stack.pop(); - if (stack.length === 0) { - budget.spend(i - from + 1); - return { text: input.slice(from, i), end: i + 1 }; - } - } + const stepped = casePhaseStep(state, input, i); + if (stepped !== undefined) { i = stepped; continue; } + if (closeRegionParen(stack, state, c)) { + budget.spend(i - from + 1); + return { text: input.slice(from, i), end: i + 1 }; } } budget.spend(input.length - from + 1); @@ -243,164 +314,226 @@ function scanExpansions(input, budget) { return nested; } -function scanShell(input, budget) { - const commands = []; - const nested = []; - const pendingHeredocs = []; +// --- Top-level scan state --- +// scan.i is the read position; every helper advances it past what it consumed. + +const COMPOUND_KEYWORDS = ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function']; + +function newCommand(scan, pipeFrom) { + return { words: [], redirects: [], pipeFrom, nested: [], scope: scan.scope }; +} + +function createScan(input, budget) { const rootScope = { parent: null, isolated: false, conditional: false }; - let scope = rootScope; - const command = pipeFrom => ({ words: [], redirects: [], pipeFrom, nested: [], scope }); - let current = command(null); - let word = null; - let quote = null; - let pendingRedirect = null; - let i = 0; - function begin() { - if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true, dynamic: false }; + const scan = { + input, budget, commands: [], nested: [], pendingHeredocs: [], rootScope, + scope: rootScope, current: null, word: null, quote: null, pendingRedirect: null, i: 0 + }; + scan.current = newCommand(scan, null); + return scan; +} + +function beginWord(scan) { + if (!scan.word) scan.word = { value: '', start: scan.i, end: scan.i, quoted: false, literal: true, dynamic: false }; +} + +function flushWord(scan) { + const { word, current } = scan; + if (!word) return; + word.end = scan.i; + word.raw = scan.input.slice(word.start, scan.i); + if (scan.pendingRedirect) { + const redirect = { operator: scan.pendingRedirect, word, body: '' }; + current.redirects.push(redirect); + if (scan.pendingRedirect === '<<' || scan.pendingRedirect === '<<-') scan.pendingHeredocs.push({ redirect, owner: current }); + scan.pendingRedirect = null; + } else current.words.push(word); + scan.word = null; +} + +function flushCommand(scan, pipe = false, background = false) { + flushWord(scan); + const previous = scan.current; + previous.pipeTo = pipe; + previous.background = background; + if (previous.closedScope && (pipe || background)) { + previous.closedScope.isolated = true; + previous.closedScope.pipelineLast = false; } - function flushWord() { - if (!word) return; - word.end = i; - word.raw = input.slice(word.start, i); - if (pendingRedirect) { - const redirect = { operator: pendingRedirect, word, body: '' }; - current.redirects.push(redirect); - if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push({ redirect, owner: current }); - pendingRedirect = null; - } else current.words.push(word); - word = null; - } - function flushCommand(pipe = false, background = false) { - flushWord(); - const previous = current; - previous.pipeTo = pipe; - previous.background = background; - if (previous.closedScope && (pipe || background)) { - previous.closedScope.isolated = true; - previous.closedScope.pipelineLast = false; + const first = previous.words[0]; + if (first && !first.quoted && COMPOUND_KEYWORDS.includes(first.value)) scan.scope.conditional = true; + if (previous.words.length || previous.redirects.length) scan.commands.push(previous); + scan.current = newCommand(scan, pipe ? previous : null); + scan.pendingRedirect = null; +} + +function consumeHeredocs(scan) { + for (const { redirect, owner } of scan.pendingHeredocs) { + const region = heredocBody(scan.input, scan.i, redirect, scan.budget); + redirect.body = region.body; + scan.i = region.end; + if (!redirect.word.quoted) { + const regions = scanExpansions(redirect.body, scan.budget); + for (const text of regions) { scan.budget.spend(); scan.nested.push(text); owner.nested.push(text); } } - const first = previous.words[0]; - if (first && !first.quoted && ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'].includes(first.value)) scope.conditional = true; - if (previous.words.length || previous.redirects.length) commands.push(previous); - current = command(pipe ? previous : null); - pendingRedirect = null; } - function consumeHeredocs() { - for (const { redirect, owner } of pendingHeredocs) { - const region = heredocBody(input, i, redirect, budget); - redirect.body = region.body; - i = region.end; - if (!redirect.word.quoted) { - const regions = scanExpansions(redirect.body, budget); - for (const text of regions) { budget.spend(); nested.push(text); owner.nested.push(text); } - } - } - pendingHeredocs.length = 0; + scan.pendingHeredocs.length = 0; +} + +// Inside '...' or $'...'. +function scanQuotedChar(scan) { + const c = scan.input[scan.i]; + if (scan.quote === "$'" && c === '\\' && scan.i + 1 < scan.input.length) { + const next = scan.input[scan.i + 1]; + // Preserve boundaries without claiming general ANSI-C escape expansion. + scan.word.value += next === "'" || next === '\\' ? next : c + next; + scan.i += 2; + return; } - while (i < input.length) { + if (c === "'") scan.quote = null; + else scan.word.value += c; + scan.i++; +} + +function scanDoubleQuotedChar(scan, c) { + if (c === '"') scan.quote = null; + else scan.word.value += c; + scan.i++; +} + +function scanEscape(scan) { + beginWord(scan); scan.word.quoted = true; + const next = scan.input[scan.i + 1]; + if (next === undefined) { scan.word.value += '\\'; scan.i++; return; } + if (scan.quote === '"' && !'"$`\\'.includes(next)) scan.word.value += '\\'; + scan.word.value += next; scan.i += 2; +} + +function scanSubstitution(scan) { + beginWord(scan); scan.word.literal = false; scan.word.dynamic = true; + const region = executionRegion(scan.input, scan.i, scan.budget); + scan.nested.push(region.text); scan.current.nested.push(region.text); + scan.word.value += '\u0000'; scan.i = region.end; +} + +// Parameter expansions remain opaque values. Escaped/single/ANSI-C +// quoted dollars have already been consumed as data before this point. +function markParameterExpansion(scan, c) { + if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(scan.input[scan.i + 1] || '')) { + beginWord(scan); scan.word.dynamic = true; + } +} + +function openQuote(scan, c) { + if (c === '$' && scan.input[scan.i + 1] === "'") { + // ANSI-C escaped quotes do not close the word; its contents never expand. + // Numeric/control escapes and generated names remain outside this grammar. + beginWord(scan); scan.word.quoted = true; scan.quote = "$'"; scan.i += 2; + return true; + } + if (c === '"' || c === "'") { beginWord(scan); scan.word.quoted = true; scan.quote = c; scan.i++; return true; } + return false; +} + +function isBraceKeyword(scan, c) { + return (c === '{' || c === '}') && !scan.word && scan.current.words.length === 0 && /[\s;&|]/.test(scan.input[scan.i + 1] || ' '); +} + +function markFunctionDefinition(scan) { + flushWord(scan); + // A function definition does not execute its body. Function grammar + // is unsupported: keep pre-definition alternatives instead of using + // flattened body mutations to certify a later command as safe. + const words = scan.current.words; + if (words.length === 1 && !words[0].quoted && /^[A-Za-z_][A-Za-z0-9_]*$/.test(words[0].value)) scan.scope.conditional = true; +} + +function openScope(scan, c, incomingPipe) { + scan.scope = { parent: scan.scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe }; + scan.current.scope = scan.scope; +} + +function closeScope(scan) { + const closedScope = scan.scope; + scan.scope = closedScope.parent; + scan.current.scope = scan.scope; + scan.current.closedScope = closedScope; + // Ordered metadata only; there is no executable argv in this event. + scan.commands.push({ ...newCommand(scan, null), scopeExit: closedScope }); +} + +function scanOperator(scan, c, braceKeyword) { + const doubled = (c === '&' || c === '|') && scan.input[scan.i + 1] === c; + const pipe = c === '|' && !doubled; + const background = c === '&' && !doubled; + if (doubled) scan.scope.conditional = true; + const incomingPipe = Boolean(scan.current.pipeFrom); + if (c === '(') markFunctionDefinition(scan); + flushCommand(scan, pipe, background); + if (c === '(' || (braceKeyword && c === '{')) openScope(scan, c, incomingPipe); + else if ((c === ')' || (braceKeyword && c === '}')) && scan.scope.parent) closeScope(scan); + scan.i += doubled ? 2 : 1; + if (c === '\n') consumeHeredocs(scan); +} + +// An immediately adjacent numeric word is a descriptor, not an argv word. +function dropDescriptorWord(scan) { + if (scan.word && /^\d+$/.test(scan.word.value)) scan.word = null; +} + +function queueHeredoc(scan, delimiter) { + dropDescriptorWord(scan); + flushWord(scan); + const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' }; + scan.current.redirects.push(redirect); + scan.pendingHeredocs.push({ redirect, owner: scan.current }); + scan.i = delimiter.end; + scan.pendingRedirect = null; +} + +function redirectOperator(input, i, c) { + let operator = c; + if (input[i + 1] === c) operator += c; + if (operator === '<<' && input[i + 2] === '<') operator = '<<<'; + else if (operator === '<<' && input[i + 2] === '-') operator = '<<-'; + else if (input[i + 1] === '&') operator += '&'; + return operator; +} + +function scanRedirect(scan, c) { + const { input } = scan; + if (c === '<' && input[scan.i + 1] === '<' && input[scan.i + 2] !== '<') { + const delimiter = heredocDelimiter(input, scan.i, scan.budget); + if (delimiter) { queueHeredoc(scan, delimiter); return; } + } + dropDescriptorWord(scan); + flushWord(scan); + scan.pendingRedirect = redirectOperator(input, scan.i, c); + scan.i += scan.pendingRedirect.length; +} + +function scanShell(input, budget) { + const scan = createScan(input, budget); + while (scan.i < input.length) { budget.spend(); - const c = input[i]; - if (quote === "'" || quote === "$'") { - if (quote === "$'" && c === '\\' && i + 1 < input.length) { - const next = input[i + 1]; - // Preserve boundaries without claiming general ANSI-C escape expansion. - word.value += next === "'" || next === '\\' ? next : c + next; - i += 2; continue; - } - if (c === "'") quote = null; - else word.value += c; - i++; continue; - } - const continued = continuationEnd(input, i); - if (continued !== i) { i = continued; continue; } - if (c === '\\') { - begin(); word.quoted = true; - const next = input[i + 1]; - if (next === undefined) { word.value += c; i++; continue; } - if (quote === '"' && !'"$`\\'.includes(next)) word.value += '\\'; - word.value += next; i += 2; continue; - } - if (hasExpansion(input, i, quote === null)) { - begin(); word.literal = false; word.dynamic = true; - const region = executionRegion(input, i, budget); - nested.push(region.text); current.nested.push(region.text); word.value += '\u0000'; i = region.end; continue; - } - // Parameter expansions remain opaque values. Escaped/single/ANSI-C - // quoted dollars have already been consumed as data above. - if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(input[i + 1] || '')) { - begin(); word.dynamic = true; - } - if (quote === '"') { - if (c === '"') quote = null; - else word.value += c; - i++; continue; - } - if (c === '$' && input[i + 1] === "'") { - // ANSI-C escaped quotes do not close the word; its contents never expand. - // Numeric/control escapes and generated names remain outside this grammar. - begin(); word.quoted = true; quote = "$'"; i += 2; continue; - } - if (c === '"' || c === "'") { begin(); word.quoted = true; quote = c; i++; continue; } - if (c === '#' && !word) { - while (i < input.length && input[i] !== '\n') { budget.spend(); i++; } - continue; - } - const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' '); - if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) { - const pipe = c === '|' && input[i + 1] !== '|'; - const background = c === '&' && input[i + 1] !== '&'; - if ((c === '&' || c === '|') && input[i + 1] === c) scope.conditional = true; - const incomingPipe = Boolean(current.pipeFrom); - if (c === '(') { - flushWord(); - // A function definition does not execute its body. Function grammar - // is unsupported: keep pre-definition alternatives instead of using - // flattened body mutations to certify a later command as safe. - if (current.words.length === 1 && !current.words[0].quoted && - /^[A-Za-z_][A-Za-z0-9_]*$/.test(current.words[0].value)) scope.conditional = true; - } - flushCommand(pipe, background); - if (c === '(' || (braceKeyword && c === '{')) { - scope = { parent: scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe }; - current.scope = scope; - } else if ((c === ')' || (braceKeyword && c === '}')) && scope.parent) { - const closedScope = scope; - scope = scope.parent; - current.scope = scope; - current.closedScope = closedScope; - // Ordered metadata only; there is no executable argv in this event. - commands.push({ ...command(null), scopeExit: closedScope }); - } - i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1; - if (c === '\n') consumeHeredocs(); - continue; - } - if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') { - const delimiter = heredocDelimiter(input, i, budget); - if (delimiter) { - if (word && /^\d+$/.test(word.value)) word = null; - flushWord(); - const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' }; - current.redirects.push(redirect); pendingHeredocs.push({ redirect, owner: current }); - i = delimiter.end; pendingRedirect = null; continue; - } - } - if (c === '<' || c === '>') { - // An immediately adjacent numeric word is a descriptor, not an argv word. - if (word && /^\d+$/.test(word.value)) word = null; - flushWord(); - let operator = c; - if (input[i + 1] === c) operator += c; - if (operator === '<<' && input[i + 2] === '<') operator = '<<<'; - else if (operator === '<<' && input[i + 2] === '-') operator = '<<-'; - else if (input[i + 1] === '&') operator += '&'; - pendingRedirect = operator; i += operator.length; continue; - } - if (/\s/.test(c)) { flushWord(); i++; continue; } - begin(); word.value += c; i++; + if (scan.quote === "'" || scan.quote === "$'") { scanQuotedChar(scan); continue; } + const continued = continuationEnd(input, scan.i); + if (continued !== scan.i) { scan.i = continued; continue; } + const c = input[scan.i]; + if (c === '\\') { scanEscape(scan); continue; } + if (hasExpansion(input, scan.i, scan.quote === null)) { scanSubstitution(scan); continue; } + markParameterExpansion(scan, c); + if (scan.quote === '"') { scanDoubleQuotedChar(scan, c); continue; } + if (openQuote(scan, c)) continue; + if (c === '#' && !scan.word) { scan.i = skipComment(input, scan.i, budget); continue; } + const braceKeyword = isBraceKeyword(scan, c); + if (braceKeyword || '\n;&|()'.includes(c)) { scanOperator(scan, c, braceKeyword); continue; } + if (c === '<' || c === '>') { scanRedirect(scan, c); continue; } + if (/\s/.test(c)) { flushWord(scan); scan.i++; continue; } + beginWord(scan); scan.word.value += c; scan.i++; } - flushCommand(); - return { commands, nested, rootScope }; + flushCommand(scan); + return { commands: scan.commands, nested: scan.nested, rootScope: scan.rootScope }; } module.exports = { createBudget, scanShell, scanExpansions }; From 87aaef56cbfe70d310f76f12aa1c3ff38940a8aa Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 17 Oct 2026 21:29:00 -0400 Subject: [PATCH 19/23] refactor(lib): split the gateguard code lexer by token class lexCodeLines ran every state (code, line comment, block comment, string) through one loop body with three closures over its locals, and shCodeLines and psCodeLines each inlined their escape, quote and comment readers. The C-family lexer now keeps its partial line on a small lx record and dispatches by state to lexNewline, lexLineComment, lexBlockComment, lexString and lexCode, with lexCommentStart, startsJsx and lexStringStart under lexCode. The shell lexers share pushShellLine, readEscapedPair, readQuotedSpan and readLineComment; shCodeLines looks its readers up in a table and psCodeLines splits into psBlockComment and psToken. Every reader returns the index of the last character it consumed, so the loops advance exactly as before, and the sentinel for an unlexable edit is still null from the C-family path and a null result from the shell paths. Exports are unchanged. A differential run against the previous version over the unit test strings plus 40k generated inputs, across all nine spec shapes change-profile builds, produced identical lines, normalized signatures and isTrivialEdit verdicts. --- scripts/lib/gateguard-code-lexer.js | 493 ++++++++++++++++------------ 1 file changed, 274 insertions(+), 219 deletions(-) diff --git a/scripts/lib/gateguard-code-lexer.js b/scripts/lib/gateguard-code-lexer.js index f89c9e101..c45d7e53b 100644 --- a/scripts/lib/gateguard-code-lexer.js +++ b/scripts/lib/gateguard-code-lexer.js @@ -110,141 +110,170 @@ function stringSpecials(quote) { return pattern; } +// --- lexCodeLines state --- +// lx carries the text, spec and partial line. Each step reads from index i and +// returns the index of the last consumed character, or null when the text is +// not lexable and the edit cannot be trivial. + +function appendCode(lx, piece) { + lx.code += piece; + lx.tail = (lx.tail + piece).slice(-2); +} + +function endComment(lx, end) { + const marker = directiveMarker(lx.text.slice(lx.commentStart, end), lx.spec); + if (marker) appendCode(lx, marker); +} + +function endLine(lx) { + if (endsWithContinuation(lx.code)) return false; + lx.lines.push(lx.code); + lx.code = ''; + lx.tail = ''; + return true; +} + +function lexNewline(lx, i) { + const { text } = lx; + if (lx.state === 'string') return null; + if (lx.state === 'line') { + if (text.slice(lx.commentStart, i).trimEnd().endsWith('\\')) return null; + endComment(lx, i); + lx.state = 'code'; + } + if (lx.state === 'block') { + lx.lines.push(lx.code); + lx.code = ''; + lx.tail = ''; + } else if (!endLine(lx)) { + return null; + } + return text[i] === '\r' && text[i + 1] === '\n' ? i + 1 : i; +} + +function lexLineComment(lx, i) { + return nextSpecial(LINE_BREAK, lx.text, i) - 1; +} + +function lexBlockComment(lx, i) { + const { text } = lx; + const ch = text[i]; + if (ch !== '*' && ch !== '/') return nextSpecial(BLOCK_SPECIALS, text, i + 1) - 1; + if (ch === '*' && text[i + 1] === '/') { + endComment(lx, i); + lx.state = 'code'; + appendCode(lx, ' '); + return i + 1; + } + if (ch === '/' && text[i + 1] === '*') return null; + return i; +} + +function lexString(lx, i) { + const { text, quote } = lx; + const ch = text[i]; + if (ch !== '\\' && ch !== quote) { + const end = nextSpecial(stringSpecials(quote), text, i + 1); + const run = text.slice(i, end); + appendCode(lx, run); + lx.stringBody += run; + return end - 1; + } + appendCode(lx, ch); + if (ch === '\\') { + const next = text[i + 1]; + if (next === undefined || next === '\n' || next === '\r') return null; + appendCode(lx, next); + return i + 1; + } + if (lx.spec.noDollarInStrings && lx.stringBody.includes('$')) return null; + lx.state = 'code'; + return i; +} + +function lexCommentStart(lx, i) { + const { text, spec } = lx; + const ch = text[i]; + if (spec.lineComment === '//' && ch === '/' && text[i + 1] === '/') { + lx.state = 'line'; + lx.commentStart = i + 2; + return i + 1; + } + if (spec.lineComment === '#' && ch === '#') { + lx.state = 'line'; + lx.commentStart = i + 1; + return i; + } + if (spec.blockComment && ch === '/' && text[i + 1] === '*') { + lx.state = 'block'; + lx.commentStart = i + 2; + return i + 1; + } + return undefined; +} + +function startsJsx(spec, text, i) { + const ch = text[i]; + const next = text[i + 1]; + if (spec.jsx && ch === '<') return isLetter(next) || next === '/' || next === '>' || next === '!'; + return spec.jsx !== undefined && ch === '-' && next === '-' && text[i + 2] === '>'; +} + +function lexStringStart(lx, i) { + const { text, spec } = lx; + const ch = text[i]; + if (spec.tripleQuotes && text[i + 1] === ch && text[i + 2] === ch) return null; + if (stringPrefixIsUnsafe(lx.tail, spec)) return null; + lx.state = 'string'; + lx.quote = ch; + lx.stringBody = ''; + appendCode(lx, ch); + return i; +} + +function lexCode(lx, i) { + const { text, spec } = lx; + const ch = text[i]; + if (!lx.specials.chars.has(ch)) { + const end = nextSpecial(lx.specials.pattern, text, i + 1); + appendCode(lx, text.slice(i, end)); + return end - 1; + } + const comment = lexCommentStart(lx, i); + if (comment !== undefined) return comment; + if (spec.forbiddenCode && spec.forbiddenCode.includes(ch)) return null; + if (startsJsx(spec, text, i)) return null; + if (ch === "'" && spec.rustChars) { + const end = rustCharEnd(text, i); + appendCode(lx, end === -1 ? ch : text.slice(i, end + 1)); + return end === -1 ? i : end; + } + if (spec.quotes.includes(ch)) return lexStringStart(lx, i); + appendCode(lx, ch); + return i; +} + +const LEX_STEPS = { code: lexCode, line: lexLineComment, block: lexBlockComment, string: lexString }; + +function finishLex(lx) { + if (lx.state === 'string' || lx.state === 'block') return null; + if (lx.state === 'line') { + if (lx.text.slice(lx.commentStart).trimEnd().endsWith('\\')) return null; + endComment(lx, lx.text.length); + } + return endLine(lx) ? lx.lines : null; +} + function lexCodeLines(text, spec) { - const specials = codeSpecials(spec); - const lines = []; - let code = ''; - let state = 'code'; - let quote = ''; - let stringBody = ''; - let commentStart = 0; - let tail = ''; - const append = piece => { - code += piece; - tail = (tail + piece).slice(-2); - }; - const endComment = end => { - const marker = directiveMarker(text.slice(commentStart, end), spec); - if (marker) append(marker); - }; - const endLine = () => { - if (endsWithContinuation(code)) return false; - lines.push(code); - code = ''; - tail = ''; - return true; - }; - if (spec.jsx !== undefined && text.startsWith('#!')) state = 'line'; + const lx = { text, spec, specials: codeSpecials(spec), lines: [], code: '', state: 'code', quote: '', stringBody: '', commentStart: 0, tail: '' }; + if (spec.jsx !== undefined && text.startsWith('#!')) lx.state = 'line'; for (let i = 0; i < text.length; i++) { const ch = text[i]; - if (ch === '\n' || ch === '\r') { - if (state === 'string') return null; - if (state === 'line') { - if (text.slice(commentStart, i).trimEnd().endsWith('\\')) return null; - endComment(i); - state = 'code'; - } - if (state === 'block') { - lines.push(code); - code = ''; - tail = ''; - } else if (!endLine()) { - return null; - } - if (ch === '\r' && text[i + 1] === '\n') i++; - continue; - } - if (state === 'line') { - i = nextSpecial(LINE_BREAK, text, i) - 1; - continue; - } - if (state === 'block') { - if (ch !== '*' && ch !== '/') { - i = nextSpecial(BLOCK_SPECIALS, text, i + 1) - 1; - continue; - } - if (ch === '*' && text[i + 1] === '/') { - endComment(i); - state = 'code'; - append(' '); - i++; - } else if (ch === '/' && text[i + 1] === '*') { - return null; - } - continue; - } - if (state === 'string') { - if (ch !== '\\' && ch !== quote) { - const end = nextSpecial(stringSpecials(quote), text, i + 1); - const run = text.slice(i, end); - append(run); - stringBody += run; - i = end - 1; - continue; - } - append(ch); - if (ch === '\\') { - const next = text[i + 1]; - if (next === undefined || next === '\n' || next === '\r') return null; - append(next); - i++; - } else if (ch === quote) { - if (spec.noDollarInStrings && stringBody.includes('$')) return null; - state = 'code'; - } else { - stringBody += ch; - } - continue; - } - if (!specials.chars.has(ch)) { - const end = nextSpecial(specials.pattern, text, i + 1); - append(text.slice(i, end)); - i = end - 1; - continue; - } - if (spec.lineComment === '//' && ch === '/' && text[i + 1] === '/') { - state = 'line'; - commentStart = i + 2; - i++; - continue; - } - if (spec.lineComment === '#' && ch === '#') { - state = 'line'; - commentStart = i + 1; - continue; - } - if (spec.blockComment && ch === '/' && text[i + 1] === '*') { - state = 'block'; - commentStart = i + 2; - i++; - continue; - } - if (spec.forbiddenCode && spec.forbiddenCode.includes(ch)) return null; - if (spec.jsx && ch === '<' && (isLetter(text[i + 1]) || text[i + 1] === '/' || text[i + 1] === '>' || text[i + 1] === '!')) return null; - if (spec.jsx !== undefined && ch === '-' && text[i + 1] === '-' && text[i + 2] === '>') return null; - if (ch === "'" && spec.rustChars) { - const end = rustCharEnd(text, i); - append(end === -1 ? ch : text.slice(i, end + 1)); - if (end !== -1) i = end; - continue; - } - if (spec.quotes.includes(ch)) { - if (spec.tripleQuotes && text[i + 1] === ch && text[i + 2] === ch) return null; - if (stringPrefixIsUnsafe(tail, spec)) return null; - state = 'string'; - quote = ch; - stringBody = ''; - append(ch); - continue; - } - append(ch); + const next = ch === '\n' || ch === '\r' ? lexNewline(lx, i) : LEX_STEPS[lx.state](lx, i); + if (next === null) return null; + i = next; } - if (state === 'string' || state === 'block') return null; - if (state === 'line') { - if (text.slice(commentStart).trimEnd().endsWith('\\')) return null; - endComment(text.length); - } - return endLine() ? lines : null; + return finishLex(lx); } function normalizeCodeLine(line, spec) { @@ -364,56 +393,86 @@ function isBlank(ch) { return ch === ' ' || ch === '\t'; } +// --- Shell and PowerShell readers --- +// Each reader appends what it consumed to lx.code and returns the index of the +// last consumed character, or -1 when the text is not lexable. + +function pushShellLine(lx, i) { + lx.lines.push(lx.code); + lx.code = ''; + return lx.text[i] === '\r' && lx.text[i + 1] === '\n' ? i + 1 : i; +} + +function readEscapedPair(lx, i) { + const next = lx.text[i + 1]; + if (next === undefined || next === '\n' || next === '\r') return -1; + lx.code += lx.text[i] + next; + return i + 1; +} + +function readQuotedSpan(lx, i, end) { + if (end === -1) return -1; + lx.code += lx.text.slice(i, end + 1); + return end; +} + +function lineCommentEnd(text, i) { + while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++; + return i; +} + +function readLineComment(lx, i) { + const end = lineCommentEnd(lx.text, i); + lx.code += directiveMarker(lx.text.slice(i + 1, end + 1), SHELL_SPEC); + return end; +} + +function shSingleQuoted(lx, i) { + const { text } = lx; + if (text[i - 1] === '$') return -1; + const end = text.indexOf("'", i + 1); + if (end === -1) return -1; + const body = text.slice(i, end + 1); + if (body.includes('\n') || body.includes('\r')) return -1; + lx.code += body; + return end; +} + +function shDoubleQuoted(lx, i) { + return readQuotedSpan(lx, i, shDoubleQuoteEnd(lx.text, i)); +} + +function shHash(lx, i) { + const starts = hashStartsComment(lx.text, i); + if (starts === null) return -1; + if (!starts) { + lx.code += '#'; + return i; + } + return readLineComment(lx, i); +} + +const SH_READERS = new Map([['\\', readEscapedPair], ["'", shSingleQuoted], ['"', shDoubleQuoted], ['#', shHash]]); + function shCodeLines(text) { if (text.includes('<<') || text.includes('`') || hasLoneCarriageReturn(text)) return null; - const lines = []; - let code = ''; + const lx = { text, code: '', lines: [] }; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '\n' || ch === '\r') { - lines.push(code); - code = ''; - if (ch === '\r' && text[i + 1] === '\n') i++; + i = pushShellLine(lx, i); continue; } - if (ch === '\\') { - const next = text[i + 1]; - if (next === undefined || next === '\n' || next === '\r') return null; - code += ch + next; - i++; + const reader = SH_READERS.get(ch); + if (!reader) { + lx.code += ch; continue; } - if (ch === "'") { - if (text[i - 1] === '$') return null; - const end = text.indexOf("'", i + 1); - if (end === -1) return null; - const body = text.slice(i, end + 1); - if (body.includes('\n') || body.includes('\r')) return null; - code += body; - i = end; - continue; - } - if (ch === '"') { - const end = shDoubleQuoteEnd(text, i); - if (end === -1) return null; - code += text.slice(i, end + 1); - i = end; - continue; - } - if (ch === '#') { - const starts = hashStartsComment(text, i); - if (starts === null) return null; - if (starts) { - const start = i + 1; - while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++; - code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC); - continue; - } - } - code += ch; + i = reader(lx, i); + if (i === -1) return null; } - lines.push(code); - return lines; + lx.lines.push(lx.code); + return lx.lines; } const PS_SMART_QUOTES = /[\u2018\u2019\u201a\u201b\u201c\u201d\u201e]/; @@ -442,62 +501,58 @@ function psQuoteEnd(text, i) { return -1; } +function psQuoted(lx, i) { + return readQuotedSpan(lx, i, psQuoteEnd(lx.text, i)); +} + +function psBlockStart(lx, i) { + if (!psHashStartsComment(lx.text, i)) return -1; + lx.block = true; + lx.blockStart = i + 2; + return i + 1; +} + +function psHash(lx, i) { + if (!psHashStartsComment(lx.text, i)) return -1; + return readLineComment(lx, i); +} + +function psBlockComment(lx, i) { + const { text } = lx; + if (text[i] === '<' && text[i + 1] === '#') return -1; + if (text[i] === '#' && text[i + 1] === '>') { + lx.block = false; + lx.code += directiveMarker(text.slice(lx.blockStart, i), SHELL_SPEC) || ' '; + return i + 1; + } + return i; +} + +function psToken(lx, i) { + const ch = lx.text[i]; + if (ch === '`') return readEscapedPair(lx, i); + if (ch === "'" || ch === '"') return psQuoted(lx, i); + if (ch === '<' && lx.text[i + 1] === '#') return psBlockStart(lx, i); + if (ch === '#') return psHash(lx, i); + lx.code += ch; + return i; +} + function psCodeLines(text) { if (text.includes('@"') || text.includes("@'") || PS_SMART_QUOTES.test(text)) return null; - const lines = []; - let code = ''; - let block = false; - let blockStart = 0; + const lx = { text, code: '', lines: [], block: false, blockStart: 0 }; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '\n' || ch === '\r') { - lines.push(code); - code = ''; - if (ch === '\r' && text[i + 1] === '\n') i++; + i = pushShellLine(lx, i); continue; } - if (block) { - if (ch === '<' && text[i + 1] === '#') return null; - if (ch === '#' && text[i + 1] === '>') { - block = false; - code += directiveMarker(text.slice(blockStart, i), SHELL_SPEC) || ' '; - i++; - } - continue; - } - if (ch === '`') { - const next = text[i + 1]; - if (next === undefined || next === '\n' || next === '\r') return null; - code += ch + next; - i++; - continue; - } - if (ch === "'" || ch === '"') { - const end = psQuoteEnd(text, i); - if (end === -1) return null; - code += text.slice(i, end + 1); - i = end; - continue; - } - if (ch === '<' && text[i + 1] === '#') { - if (!psHashStartsComment(text, i)) return null; - block = true; - blockStart = i + 2; - i++; - continue; - } - if (ch === '#') { - if (!psHashStartsComment(text, i)) return null; - const start = i + 1; - while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++; - code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC); - continue; - } - code += ch; + i = lx.block ? psBlockComment(lx, i) : psToken(lx, i); + if (i === -1) return null; } - if (block) return null; - lines.push(code); - return lines; + if (lx.block) return null; + lx.lines.push(lx.code); + return lx.lines; } function batchCodeLines(text) { From 5c507e0704d4444cd2503f3e583da47b0c1394c5 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 17 Oct 2026 22:16:00 -0400 Subject: [PATCH 20/23] refactor(lib): split search evidence by tool and credit path searchEvidence built the Glob, Grep and LS evidence inline before falling through to the shell path, and findCreditingSearch and findClosestMiss each nested the per-search and per-item loops with the stem and directory checks in one body. The tool builders are now globEvidence, grepEvidence and lsEvidence behind a small Map, the item loop of findCreditingSearch is creditingItem, and findClosestMiss hands the miss reasons to missReason and the Read scan to considerReads. searchHasMessage replaces the repeated messageId check. Results are unchanged: the same first crediting search wins, the same lowest-ranked miss is reported, and errors still resolve to null inside the two public functions. Checked by running the old and new factory side by side over 40k generated scans and targets with identical output. --- scripts/lib/gateguard-search-evidence.js | 134 ++++++++++++++--------- 1 file changed, 80 insertions(+), 54 deletions(-) diff --git a/scripts/lib/gateguard-search-evidence.js b/scripts/lib/gateguard-search-evidence.js index 567d41f0d..c20a594bc 100644 --- a/scripts/lib/gateguard-search-evidence.js +++ b/scripts/lib/gateguard-search-evidence.js @@ -304,42 +304,68 @@ const MISS_RANK = Object.freeze({ 'same-batch': 0, lookup: 1, excluded: 2, 'out-of-scope': 3, 'stdin-only': 4, 'not-a-search': 5, 'generic-stem': 6 }); +// --- Tool search evidence --- + +function explicitPath(input) { + return typeof input.path === 'string' && input.path ? input.path : ''; +} + +function globEvidence(input) { + if (typeof input.pattern !== 'string' || !input.pattern) return []; + const explicit = explicitPath(input); + const prefix = globLiteralPrefix(input.pattern); + const scope = [explicit || '.', prefix || '.']; + if (!GLOB_CHARS_PATTERN.test(input.pattern)) { + const lookup = [explicit || '.', input.pattern]; + return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }]; + } + const dirs = prefix || explicit ? [scope] : []; + // see docs/gateguard/design-notes.md#path-patterns + const pathPattern = pathPatternFor([explicit || '.'], input.pattern); + return [{ texts: [input.pattern], dirs, scope, detail: input.pattern, pathPattern }]; +} + +function grepEvidence(input) { + if (typeof input.pattern !== 'string' || !input.pattern) return []; + const explicit = explicitPath(input); + const detail = grepDetail(input); + const filters = grepToolFilters(input.glob, input.type); + return [{ + texts: stringsOf(input.pattern, ...filters.positives), + dirs: explicit ? [[explicit]] : [], + scope: [explicit || '.'], + detail, + pathIncludes: true, + ...filters + }]; +} + +function lsEvidence(input) { + return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir })); +} + +const TOOL_EVIDENCE = new Map([['Glob', globEvidence], ['Grep', grepEvidence], ['LS', lsEvidence]]); + +function searchHasMessage(search) { + return typeof search.messageId === 'string' && Boolean(search.messageId); +} + +/** First evidence item that credits the target by stem or, when allowed, by directory. */ +function creditingItem(items, ctx, stem, allowDirMatch) { + for (const item of items) { + if (!evidenceInScope(item, ctx) || !filtersAdmitTarget(item, ctx, stem)) continue; + const byStem = stem !== null && stemCredits(item, ctx, stem); + const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx); + if (byStem || byDir) return item; + } + return null; +} /** Build the prior-search matcher around the hook's shell segmenter. */ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS }) { function searchEvidence(search, shellDirsTrusted, misses) { - const input = search.input; - if (search.name === 'Glob') { - if (typeof input.pattern !== 'string' || !input.pattern) return []; - const explicit = typeof input.path === 'string' && input.path ? input.path : ''; - const prefix = globLiteralPrefix(input.pattern); - const scope = [explicit || '.', prefix || '.']; - if (!GLOB_CHARS_PATTERN.test(input.pattern)) { - const lookup = [explicit || '.', input.pattern]; - return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }]; - } - const dirs = prefix || explicit ? [scope] : []; - // see docs/gateguard/design-notes.md#path-patterns - const pathPattern = pathPatternFor([explicit || '.'], input.pattern); - return [{ texts: [input.pattern], dirs, scope, detail: input.pattern, pathPattern }]; - } - if (search.name === 'Grep') { - if (typeof input.pattern !== 'string' || !input.pattern) return []; - const explicit = typeof input.path === 'string' && input.path ? input.path : ''; - const detail = grepDetail(input); - const filters = grepToolFilters(input.glob, input.type); - return [{ - texts: stringsOf(input.pattern, ...filters.positives), - dirs: explicit ? [[explicit]] : [], - scope: [explicit || '.'], - detail, - pathIncludes: true, - ...filters - }]; - } - if (search.name === 'LS') { - return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir })); - } - return shellSearchEvidence(input.command, search.name, shellDirsTrusted, misses); + const byTool = TOOL_EVIDENCE.get(search.name); + if (byTool) return byTool(search.input); + return shellSearchEvidence(search.input.command, search.name, shellDirsTrusted, misses); } function segmentPipeFlags(input) { @@ -493,17 +519,11 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME const excluded = excludedBatchId(scan, data); let shellDirsTrusted = null; for (const search of scan.searches) { - if (typeof search.messageId !== 'string' || !search.messageId || search.messageId === excluded) continue; + if (!searchHasMessage(search) || search.messageId === excluded) continue; if (!allowDirMatch && !inputMayMention(search.input, eligible)) continue; if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan); - for (const item of cachedEvidence(search, shellDirsTrusted).items) { - if (!evidenceInScope(item, ctx) || !filtersAdmitTarget(item, ctx, stem)) continue; - const byStem = stem !== null && stemCredits(item, ctx, stem); - const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx); - if (byStem || byDir) { - return { name: search.name, detail: item.detail, callsAgo: search.callsAgo }; - } - } + const item = creditingItem(cachedEvidence(search, shellDirsTrusted).items, ctx, stem, allowDirMatch); + if (item) return { name: search.name, detail: item.detail, callsAgo: search.callsAgo }; } return null; } catch (_) { @@ -534,6 +554,19 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME return 'excluded'; } + function missReason(miss, stem, rawStem) { + if (!mentionsTarget(miss.texts, stem, rawStem)) return null; + return stem === null ? 'generic-stem' : miss.reason; + } + + function considerReads(scan, ctx, stem, consider) { + for (const read of Array.isArray(scan.reads) ? scan.reads : []) { + if (!read || typeof read.path !== 'string' || !read.path) continue; + const readsTarget = ctx.resolveDir(read.path) === ctx.targetKey || mentionsTarget([read.path], stem, null); + if (readsTarget) consider('Read', read.path, 'not-a-search'); + } + } + /** Closest search of the current turn that did not credit the target, with a reason code, else null. */ function findClosestMiss(scan, targetPath, allowDirMatch, data) { try { @@ -551,22 +584,15 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME if (reason && (best === null || MISS_RANK[reason] < MISS_RANK[best.reason])) best = { name, detail, reason }; }; for (const search of scan.searches) { - if (!search || typeof search.messageId !== 'string' || !search.messageId) continue; + if (!search || !searchHasMessage(search)) continue; if (!allowDirMatch && (!bare || !inputMayMention(search.input, bare))) continue; if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan); const { items, misses } = cachedEvidence(search, shellDirsTrusted); - for (const item of items) { - consider(search.name, item.detail, itemMiss(item, ctx, stem, rawStem, allowDirMatch, search.messageId === excluded)); - } - for (const miss of misses) { - if (mentionsTarget(miss.texts, stem, rawStem)) consider(search.name, miss.detail, stem === null ? 'generic-stem' : miss.reason); - } - } - for (const read of Array.isArray(scan.reads) ? scan.reads : []) { - if (!read || typeof read.path !== 'string' || !read.path) continue; - const readsTarget = ctx.resolveDir(read.path) === ctx.targetKey || mentionsTarget([read.path], stem, null); - if (readsTarget) consider('Read', read.path, 'not-a-search'); + const sameBatch = search.messageId === excluded; + for (const item of items) consider(search.name, item.detail, itemMiss(item, ctx, stem, rawStem, allowDirMatch, sameBatch)); + for (const miss of misses) consider(search.name, miss.detail, missReason(miss, stem, rawStem)); } + considerReads(scan, ctx, stem, consider); return best; } catch (_) { return null; From 6f1881aeafa3c48f7b603cc4be4b2e4e70daad59 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 17 Oct 2026 23:02:00 -0400 Subject: [PATCH 21/23] refactor(lib): split nasiko install, uninstall and tar extraction extractQualifiedTarGzip walked the archive, validated the terminator, read each header and classified the entry in one loop; installNasiko and uninstallNasiko each resolved the release and paths, then ran the whole download or staging sequence inside one try with rollback flags. The extractor now uses gunzipArchive, assertTarTerminator, readTarEntry, isBinaryEntry and isSidecarEntry. The two lifecycle calls share qualifiedReleaseFor and installPathsFor; install fetches through fetchVerifiedBinary and publishes through publishBinary, and uninstall stages and deletes through removeInstalledFiles with removeOrRetain. The rollback rules are the same: a failed publish removes only the files this call created, a failed rename puts the staged file back, and a failed delete keeps the tombstone and reports it in cleanupPending. The plan still takes its version from the requested release, not from releaseOverride, and every dependency hook is called in the same order. Exports are unchanged, and a run of the old and new extractor over 20k generated archives covering sidecars, bad padding, bad size fields, truncation and trailing data produced identical results and messages. --- scripts/lib/nasiko-release.js | 222 +++++++++++++++++++++------------- 1 file changed, 139 insertions(+), 83 deletions(-) diff --git a/scripts/lib/nasiko-release.js b/scripts/lib/nasiko-release.js index 6e5391768..a8fb93a49 100644 --- a/scripts/lib/nasiko-release.js +++ b/scripts/lib/nasiko-release.js @@ -88,47 +88,68 @@ function readTarOctal(block, offset, length) { return size; } -function extractQualifiedTarGzip(archiveBytes, expectedName) { - let tar; - try { tar = zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); } +function gunzipArchive(archiveBytes) { + try { return zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); } catch (_error) { throw new Error('Nasiko archive is invalid or exceeds the decompressed size limit.'); } +} + +function isZeroBlock(bytes) { + return bytes.every(byte => byte === 0); +} + +function assertTarTerminator(tar, offset) { + const terminatorEnd = offset + 1024; + if (terminatorEnd > tar.length || !isZeroBlock(tar.subarray(offset + 512, terminatorEnd)) || !isZeroBlock(tar.subarray(terminatorEnd))) { + throw new Error('Unsafe Nasiko archive: incomplete terminator or nonzero trailing data.'); + } +} + +function readTarEntry(tar, offset) { + const header = tar.subarray(offset, offset + 512); + const name = readTarString(header, 0, 100); + const prefix = readTarString(header, 345, 155); + const type = String.fromCharCode(header[156] || 48); + const size = readTarOctal(header, 124, 12); + const start = offset + 512; + const end = start + size; + const paddedEnd = start + Math.ceil(size / 512) * 512; + if (!Number.isSafeInteger(end) || paddedEnd > tar.length) throw new Error('Nasiko archive is truncated.'); + const payload = tar.subarray(start, end); + if (!isZeroBlock(tar.subarray(end, paddedEnd))) throw new Error('Unsafe Nasiko archive: nonzero tar padding.'); + return { name, prefix, type, size, payload, paddedEnd }; +} + +function isBinaryEntry(entry, expectedName) { + return !entry.prefix && entry.name === expectedName && (entry.type === '0' || entry.type === '\0'); +} + +// macOS tar may add an AppleDouble fork and a pax header beside the binary. +// Both are tolerated only when small and when the pax header renames nothing. +function isSidecarEntry(entry, expectedName) { + if (entry.prefix) return false; + if (entry.name === `._${expectedName}`) return entry.type === '0' && entry.size <= 1024 * 1024; + if (entry.name === `PaxHeader/${expectedName}`) { + return entry.type === 'x' && entry.size <= 64 * 1024 && !/(?:^|\n)(?:path|linkpath)=/i.test(entry.payload.toString('utf8')); + } + return false; +} + +function extractQualifiedTarGzip(archiveBytes, expectedName) { + const tar = gunzipArchive(archiveBytes); let offset = 0; let binary = null; let terminated = false; while (offset < tar.length) { if (offset + 512 > tar.length) throw new Error('Unsafe Nasiko archive: truncated tar header.'); - const header = tar.subarray(offset, offset + 512); - if (header.every(byte => byte === 0)) { - const terminatorEnd = offset + 1024; - if ( - terminatorEnd > tar.length - || !tar.subarray(offset + 512, terminatorEnd).every(byte => byte === 0) - || !tar.subarray(terminatorEnd).every(byte => byte === 0) - ) { - throw new Error('Unsafe Nasiko archive: incomplete terminator or nonzero trailing data.'); - } + if (isZeroBlock(tar.subarray(offset, offset + 512))) { + assertTarTerminator(tar, offset); terminated = true; break; } - const name = readTarString(header, 0, 100); - const prefix = readTarString(header, 345, 155); - const type = String.fromCharCode(header[156] || 48); - const size = readTarOctal(header, 124, 12); - const start = offset + 512; - const end = start + size; - const paddedEnd = start + Math.ceil(size / 512) * 512; - if (!Number.isSafeInteger(end) || paddedEnd > tar.length) throw new Error('Nasiko archive is truncated.'); - const payload = tar.subarray(start, end); - if (!tar.subarray(end, paddedEnd).every(byte => byte === 0)) { - throw new Error('Unsafe Nasiko archive: nonzero tar padding.'); - } - const isBinary = !prefix && name === expectedName && (type === '0' || type === '\0'); - const isAppleDouble = !prefix && name === `._${expectedName}` && type === '0' && size <= 1024 * 1024; - const isPaxMetadata = !prefix && name === `PaxHeader/${expectedName}` && type === 'x' && size <= 64 * 1024 - && !/(?:^|\n)(?:path|linkpath)=/i.test(payload.toString('utf8')); - if (isBinary && !binary && size > 0 && size <= MAX_BINARY_BYTES) binary = Buffer.from(payload); - else if (!isAppleDouble && !isPaxMetadata) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); - offset = paddedEnd; + const entry = readTarEntry(tar, offset); + if (isBinaryEntry(entry, expectedName) && !binary && entry.size > 0 && entry.size <= MAX_BINARY_BYTES) binary = Buffer.from(entry.payload); + else if (!isSidecarEntry(entry, expectedName)) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); + offset = entry.paddedEnd; } if (!terminated) throw new Error('Unsafe Nasiko archive: missing complete tar terminator.'); if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); @@ -376,45 +397,48 @@ function acquireLifecycleLock(installDirectory, fileSystem = fs, options = {}) { } } -async function installNasiko(options = {}, dependencies = {}) { - const version = options.version || 'v0.1.0'; - const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch); - const release = dependencies.releaseOverride ? { ...base, ...dependencies.releaseOverride } : base; +function qualifiedReleaseFor(options, dependencies) { + return getQualifiedRelease(options.version || 'v0.1.0', dependencies.platform || process.platform, dependencies.arch || process.arch); +} + +function installPathsFor(options, dependencies, release) { const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir())); - const destination = path.join(installDirectory, release.binaryName); - const plan = { dryRun: Boolean(options.dryRun), version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, binaryDigest: release.binaryDigest, registryOrigin: REGISTRY_ORIGIN, destination, license: release.license, sourceUrl: release.sourceUrl }; - if (options.dryRun) return plan; - if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.'); - fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 }); - assertPrivateInstallDirectory(installDirectory); - const releaseLock = acquireLifecycleLock(installDirectory); + return { installDirectory, destination: path.join(installDirectory, release.binaryName) }; +} + +async function fetchVerifiedBinary(release, dependencies) { + const retrieve = dependencies.fetchBytes || fetchBytes; + const manifestBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`, { accept: 'application/vnd.oci.image.manifest.v1+json', maxBytes: MAX_MANIFEST_BYTES }); + assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest'); + const layer = validateManifest(manifestBytes); + const archiveBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`, { maxBytes: MAX_ARCHIVE_BYTES }); + if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.'); + assertDigest(archiveBytes, layer.digest, 'Nasiko archive'); + const binary = (dependencies.extractBinary || extractQualifiedTarGzip)(archiveBytes, release.binaryName); + assertDigest(binary, release.binaryDigest, 'Nasiko binary'); + return { binary, layer }; +} + +function writeBinary(descriptor, binary) { + try { + fs.writeFileSync(descriptor, binary); + fs.fsyncSync(descriptor); + if (fs.fstatSync(descriptor).size !== binary.length) throw new Error('Published Nasiko binary size mismatch.'); + } finally { fs.closeSync(descriptor); } +} + +// Publish the binary and its receipt exclusively; a failure after either file +// is created removes what this call owns and nothing else. +function publishBinary(plan, release, layer, binary, dependencies) { + const { destination } = plan; const metadataPath = metadataPathFor(destination); let destinationOwned = false; let metadataOwned = false; try { - const existing = inspectInstalledNasiko(destination); - if (existing.installed) { - if (existing.qualified && existing.version === version) return { ...plan, dryRun: false, installed: true, reused: true }; - throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.'); - } - const retrieve = dependencies.fetchBytes || fetchBytes; - const manifestBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`, { accept: 'application/vnd.oci.image.manifest.v1+json', maxBytes: MAX_MANIFEST_BYTES }); - assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest'); - const layer = validateManifest(manifestBytes); - const archiveBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`, { maxBytes: MAX_ARCHIVE_BYTES }); - if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.'); - assertDigest(archiveBytes, layer.digest, 'Nasiko archive'); - const binary = (dependencies.extractBinary || extractQualifiedTarGzip)(archiveBytes, release.binaryName); - assertDigest(binary, release.binaryDigest, 'Nasiko binary'); - if (dependencies.beforePublish) dependencies.beforePublish(destination); const descriptor = fs.openSync(destination, 'wx', 0o700); destinationOwned = true; - try { - fs.writeFileSync(descriptor, binary); - fs.fsyncSync(descriptor); - if (fs.fstatSync(descriptor).size !== binary.length) throw new Error('Published Nasiko binary size mismatch.'); - } finally { fs.closeSync(descriptor); } - const metadata = { version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl }; + writeBinary(descriptor, binary); + const metadata = { version: plan.version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl }; (dependencies.writeMetadata || writeMetadataExclusive)(metadataPath, metadata); metadataOwned = true; return { ...plan, dryRun: false, installed: true, reused: false, artifactDigest: layer.digest }; @@ -422,51 +446,83 @@ async function installNasiko(options = {}, dependencies = {}) { if (metadataOwned) fs.rmSync(metadataPath, { force: true }); if (destinationOwned) fs.rmSync(destination, { force: true }); throw error; + } +} + +async function installNasiko(options = {}, dependencies = {}) { + const base = qualifiedReleaseFor(options, dependencies); + const release = dependencies.releaseOverride ? { ...base, ...dependencies.releaseOverride } : base; + const { installDirectory, destination } = installPathsFor(options, dependencies, release); + const plan = { dryRun: Boolean(options.dryRun), version: base.version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, binaryDigest: release.binaryDigest, registryOrigin: REGISTRY_ORIGIN, destination, license: release.license, sourceUrl: release.sourceUrl }; + if (options.dryRun) return plan; + if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.'); + fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 }); + assertPrivateInstallDirectory(installDirectory); + const releaseLock = acquireLifecycleLock(installDirectory); + try { + const existing = inspectInstalledNasiko(destination); + if (existing.installed) { + if (existing.qualified && existing.version === plan.version) return { ...plan, dryRun: false, installed: true, reused: true }; + throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.'); + } + const { binary, layer } = await fetchVerifiedBinary(release, dependencies); + if (dependencies.beforePublish) dependencies.beforePublish(destination); + return publishBinary(plan, release, layer, binary, dependencies); } finally { releaseLock(); } } -function uninstallNasiko(options = {}, dependencies = {}) { - const version = options.version || 'v0.1.0'; - const release = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch); - const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir())); - const destination = path.join(installDirectory, release.binaryName); - const plan = { dryRun: Boolean(options.dryRun), version, destination }; - if (options.dryRun) return plan; - if (!options.yes) throw new Error('Nasiko uninstall requires explicit --yes consent.'); - if (!fs.existsSync(installDirectory)) return { ...plan, dryRun: false, removed: false }; - assertPrivateInstallDirectory(installDirectory); - const releaseLock = acquireLifecycleLock(installDirectory); +function removeOrRetain(remove, target, retained) { + try { remove(target); } catch (_error) { retained.push(target); } +} + +// Rename both files aside before deleting them so a failed rename restores +// the install, while a failed delete leaves a named tombstone for the operator. +function removeInstalledFiles(destination, dependencies) { const metadataPath = metadataPathFor(destination); const suffix = `${process.pid}-${crypto.randomBytes(6).toString('hex')}`; const binaryTombstone = `${destination}.remove-${suffix}`; const metadataTombstone = `${metadataPath}.remove-${suffix}`; - let binaryStaged = false; - let metadataStaged = false; const rename = dependencies.rename || fs.renameSync; const remove = dependencies.remove || (target => fs.rmSync(target)); + let binaryStaged = false; + let metadataStaged = false; try { - const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination); - if (!status.installed) return { ...plan, dryRun: false, removed: false }; - if (!status.qualified || status.version !== version) throw new Error('Refusing to remove an unqualified or modified Nasiko executable.'); rename(destination, binaryTombstone); binaryStaged = true; rename(metadataPath, metadataTombstone); metadataStaged = true; const cleanupPending = []; - try { remove(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); } + removeOrRetain(remove, metadataTombstone, cleanupPending); metadataStaged = false; - try { remove(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); } + removeOrRetain(remove, binaryTombstone, cleanupPending); binaryStaged = false; if (cleanupPending.length > 0) { const cleanupError = new Error(`Nasiko uninstall is incomplete; retained staged file(s): ${cleanupPending.join(', ')}. Remove these files before reinstalling.`); cleanupError.cleanupPending = cleanupPending; throw cleanupError; } - return { ...plan, dryRun: false, removed: true, cleanupPending: [] }; } catch (error) { if (metadataStaged && !fs.existsSync(metadataPath)) rename(metadataTombstone, metadataPath); if (binaryStaged && !fs.existsSync(destination)) rename(binaryTombstone, destination); throw error; + } +} + +function uninstallNasiko(options = {}, dependencies = {}) { + const release = qualifiedReleaseFor(options, dependencies); + const { installDirectory, destination } = installPathsFor(options, dependencies, release); + const plan = { dryRun: Boolean(options.dryRun), version: release.version, destination }; + if (options.dryRun) return plan; + if (!options.yes) throw new Error('Nasiko uninstall requires explicit --yes consent.'); + if (!fs.existsSync(installDirectory)) return { ...plan, dryRun: false, removed: false }; + assertPrivateInstallDirectory(installDirectory); + const releaseLock = acquireLifecycleLock(installDirectory); + try { + const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination); + if (!status.installed) return { ...plan, dryRun: false, removed: false }; + if (!status.qualified || status.version !== release.version) throw new Error('Refusing to remove an unqualified or modified Nasiko executable.'); + removeInstalledFiles(destination, dependencies); + return { ...plan, dryRun: false, removed: true, cleanupPending: [] }; } finally { releaseLock(); } } From 9471ef4724ddcdf8f3de302f08c1ee38c8ef7721 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 18 Oct 2026 21:20:00 -0400 Subject: [PATCH 22/23] test(runner): add a worker pool and an ECC_TEST_ONLY filter to run-all The runner executed 422 files one process at a time, which is most of the 12 to 40 minutes a CI lane spends in "Run tests". ECC_TEST_JOBS above 1 now runs files through a pool of that many processes; output is still buffered and printed per file, so logs read the same. The default stays 1 and that path is the original synchronous spawnSync loop, so the VM-driven runner test and the piped-output tests are unchanged. ECC_TEST_ONLY is the mirror of ECC_TEST_SKIP: a comma-separated list of path substrings, and only matching files run. CI uses it to give the package-manager lanes just the suites that read the package-manager variable instead of the whole suite. Two real-process tests cover the pool (grouped output, counted failure, summary last) and the ONLY filter. --- CLAUDE.md | 6 ++ tests/ci/run-all.test.js | 91 ++++++++++++++++++++++ tests/run-all.js | 161 ++++++++++++++++++++++++++++++--------- 3 files changed, 224 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d5649ac50..f514243e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,12 @@ This is a **Claude Code plugin** - a collection of production-ready agents, skil # Run all tests node tests/run-all.js +# Run files through a 4-process pool, or only files whose path contains a pattern +ECC_TEST_JOBS=4 node tests/run-all.js +ECC_TEST_ONLY=package-manager,hooks/hooks.test node tests/run-all.js +# Suites that rebuild or back up .opencode/dist, or assert a time budget, must run after the pool, one at a time +ECC_TEST_JOBS=4 ECC_TEST_SERIAL=$(grep -rlE --include='*.test.js' 'build-opencode|\.backup-|dist\.backup|time budget' tests | sed 's#^tests/##' | paste -sd, -) node tests/run-all.js + # Run individual test files node tests/lib/utils.test.js node tests/lib/package-manager.test.js diff --git a/tests/ci/run-all.test.js b/tests/ci/run-all.test.js index 32e016977..69008181b 100644 --- a/tests/ci/run-all.test.js +++ b/tests/ci/run-all.test.js @@ -177,6 +177,97 @@ const tests = [ fs.rmSync(root, { recursive: true, force: true }); } }]), + ['worker pool runs every file, keeps per-file output grouped, and keeps the summary last', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-pool-')); + try { + const testsDir = path.join(root, 'tests'); + fs.mkdirSync(testsDir); + fs.writeFileSync(path.join(testsDir, 'run-all.js'), source); + for (const [name, failures] of [['a', 0], ['b', 1], ['c', 0]]) { + fs.writeFileSync(path.join(testsDir, `${name}.test.js`), [ + `console.log('BEGIN ${name}');`, + `console.log('Passed: 2, Failed: ${failures}');`, + `console.log('END ${name}');`, + ].join('\n')); + } + const env = { ...process.env, ECC_TEST_JOBS: '3', PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}` }; + delete env.ECC_TEST_SKIP; + delete env.ECC_TEST_ONLY; + const result = spawnSync(process.execPath, [path.join(testsDir, 'run-all.js')], { encoding: 'utf8', env, timeout: 15000 }); + assert.ifError(result.error); + assert.strictEqual(result.status, 1, result.stderr); + assert.match(result.stdout, /Running 3 files with 3 parallel jobs/); + for (const name of ['a', 'b', 'c']) { + assert.match(result.stdout, new RegExp(`━━━ Running ${name}\\.test\\.js ━━━\\n(?:.*\\n)*?BEGIN ${name}\\n[^━]*END ${name}`), `output for ${name} stays grouped`); + } + assert.match(result.stdout, /✗ b\.test\.js reported 1 failed tests/); + assert.match(result.stdout, /Files: 2 passed, 1 failed/); + assert.ok(result.stdout.trimEnd().endsWith('Passed: 6, Failed: 1')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }], + ['ECC_TEST_SERIAL files run one at a time after the pool drains', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-serial-')); + try { + const testsDir = path.join(root, 'tests'); + fs.mkdirSync(testsDir); + fs.writeFileSync(path.join(testsDir, 'run-all.js'), source); + const marker = path.join(root, 'serial-marker'); + // Pool files sleep briefly; the serial file asserts nothing else is still running by + // checking that both pool files already wrote their markers. + for (const name of ['pool-a', 'pool-b']) { + fs.writeFileSync(path.join(testsDir, `${name}.test.js`), [ + "setTimeout(() => {", + ` require('fs').writeFileSync(${JSON.stringify(path.join(root, `${name}.done`))}, '1');`, + " console.log('Passed: 1, Failed: 0');", + '}, 150);', + ].join('\n')); + } + fs.writeFileSync(path.join(testsDir, 'opencode-dist.test.js'), [ + "const fs = require('fs');", + `const done = ['pool-a', 'pool-b'].filter(n => fs.existsSync(${JSON.stringify(root)} + '/' + n + '.done')).length;`, + `fs.writeFileSync(${JSON.stringify(marker)}, String(done));`, + "console.log(done === 2 ? 'Passed: 1, Failed: 0' : 'Passed: 0, Failed: 1');", + 'process.exitCode = done === 2 ? 0 : 1;', + ].join('\n')); + const env = { ...process.env, ECC_TEST_JOBS: '3', ECC_TEST_SERIAL: 'opencode-dist', PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}` }; + delete env.ECC_TEST_SKIP; + delete env.ECC_TEST_ONLY; + const result = spawnSync(process.execPath, [path.join(testsDir, 'run-all.js')], { encoding: 'utf8', env, timeout: 15000 }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stdout); + assert.match(result.stdout, /serial after the pool: opencode-dist/); + assert.match(result.stdout, /Running 1 serial files after the pool/); + assert.strictEqual(fs.readFileSync(marker, 'utf8'), '2'); + assert.match(result.stdout, /Files: 3 passed, 0 failed/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }], + ['ECC_TEST_ONLY keeps just the files whose path contains a pattern', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-only-')); + try { + const testsDir = path.join(root, 'tests'); + fs.mkdirSync(path.join(testsDir, 'lib'), { recursive: true }); + fs.mkdirSync(path.join(testsDir, 'hooks'), { recursive: true }); + fs.writeFileSync(path.join(testsDir, 'run-all.js'), source); + fs.writeFileSync(path.join(testsDir, 'lib', 'package-manager.test.js'), "console.log('Passed: 1, Failed: 0');"); + fs.writeFileSync(path.join(testsDir, 'hooks', 'hooks.test.js'), "console.log('Passed: 1, Failed: 0');"); + fs.writeFileSync(path.join(testsDir, 'lib', 'other.test.js'), "console.log('Passed: 0, Failed: 1'); process.exitCode = 1;"); + const env = { ...process.env, ECC_TEST_ONLY: 'package-manager, hooks/hooks.test', PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}` }; + delete env.ECC_TEST_SKIP; + delete env.ECC_TEST_JOBS; + const result = spawnSync(process.execPath, [path.join(testsDir, 'run-all.js')], { encoding: 'utf8', env, timeout: 15000 }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stdout); + assert.match(result.stdout, /Only patterns: package-manager, hooks\/hooks\.test/); + assert.ok(!result.stdout.includes('other.test.js'), 'unmatched file must not run'); + assert.match(result.stdout, /Files: 2 passed, 0 failed/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }], ]; let failed = 0; diff --git a/tests/run-all.js b/tests/run-all.js index d03efc67c..5e436c4fa 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -const { spawnSync } = require('child_process'); +const { spawn, spawnSync } = require('child_process'); const path = require('path'); const fs = require('fs'); @@ -8,10 +8,23 @@ const testsDir = __dirname; const repoRoot = path.resolve(testsDir, '..'); const TEST_GLOB = 'tests/**/*.test.js'; const filter = (((process.argv || [])[2]) || '').split(path.sep).join('/'); -const skipPatterns = (process.env.ECC_TEST_SKIP || '') +const splitPatterns = value => (value || '') .split(',') .map(s => s.trim()) .filter(Boolean); +// ECC_TEST_SKIP drops files whose path contains any pattern; ECC_TEST_ONLY keeps +// just the files whose path contains one. CI's package-manager lanes use ONLY to +// run the handful of suites that read CLAUDE_CODE_PACKAGE_MANAGER. +const skipPatterns = splitPatterns(process.env.ECC_TEST_SKIP); +const onlyPatterns = splitPatterns(process.env.ECC_TEST_ONLY); +// ECC_TEST_SERIAL names suites that mutate the shared checkout (for example +// the opencode dist build and the installers that copy from it). The pool +// holds them back and runs them one at a time after every other file. +const serialPatterns = splitPatterns(process.env.ECC_TEST_SERIAL); +// ECC_TEST_JOBS above 1 runs files through a worker pool of that many +// processes. The default is 1: the original synchronous loop, unchanged. +// Output stays grouped per file whatever the pool size. +const jobs = Math.max(1, Math.floor(Number(process.env.ECC_TEST_JOBS)) || 1); function matchesTestGlob(relativePath) { const normalized = relativePath.split(path.sep).join('/'); @@ -21,6 +34,9 @@ function matchesTestGlob(relativePath) { if (skipPatterns.some(pattern => normalized.includes(pattern))) { return false; } + if (onlyPatterns.length > 0 && !onlyPatterns.some(pattern => normalized.includes(pattern))) { + return false; + } if (typeof path.matchesGlob === 'function') { return path.matchesGlob(normalized, TEST_GLOB); } @@ -114,6 +130,14 @@ if (skipPatterns.length > 0) { console.log(`Skipped patterns: ${skipPatterns.join(', ')}`); console.log(); } +if (onlyPatterns.length > 0) { + console.log(`Only patterns: ${onlyPatterns.join(', ')}`); + console.log(); +} +if (jobs > 1) { + console.log(`Running ${testFiles.length} files with ${jobs} parallel jobs${serialPatterns.length ? `, serial after the pool: ${serialPatterns.join(', ')}` : ''}`); + console.log(); +} if (testFiles.length === 0) { console.log(`✗ No test files matched ${TEST_GLOB}${filter ? ` with filter "${filter}"` : ''}`); @@ -126,31 +150,54 @@ let filesPassed = 0; let filesFailed = 0; const failedFiles = []; -for (const testFile of testFiles) { - const testPath = path.join(testsDir, testFile); - const displayPath = testFile.split(path.sep).join('/'); - - if (!fs.existsSync(testPath)) { - console.log(`WARNING Skipping ${displayPath} (file not found)`); - continue; - } - - console.log(`\n━━━ Running ${displayPath} ━━━`); - +function childEnvFor() { const childEnv = { ...process.env }; - for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) { + // Git context would leak into fixtures; ONLY and JOBS are runner controls and + // must not reach suites that spawn the runner themselves. + for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX', 'ECC_TEST_ONLY', 'ECC_TEST_JOBS', 'ECC_TEST_SERIAL']) { delete childEnv[key]; } + return childEnv; +} - const result = spawnSync('node', [testPath], { +function runTestFileSync(testFile) { + return spawnSync('node', [path.join(testsDir, testFile)], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], - env: childEnv + env: childEnvFor() }); +} +function runTestFile(testFile) { + const testPath = path.join(testsDir, testFile); + const childEnv = childEnvFor(); + return new Promise(resolve => { + let stdout = ''; + let stderr = ''; + let child; + try { + child = spawn('node', [testPath], { stdio: ['pipe', 'pipe', 'pipe'], env: childEnv }); + } catch (error) { + resolve({ error, status: null, signal: null, stdout, stderr }); + return; + } + // spawnSync with piped stdio and no input hands the child a closed stdin; + // spawn leaves it open, and a suite that reads stdin would wait forever. + child.stdin.end(); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', error => resolve({ error, status: null, signal: null, stdout, stderr })); + child.on('close', (status, signal) => resolve({ error: null, status, signal, stdout, stderr })); + }); +} + +function reportTestFile(displayPath, result) { const stdout = result.stdout || ''; const stderr = result.stderr || ''; + console.log(`\n━━━ Running ${displayPath} ━━━`); if (stdout) console.log(stdout); if (stderr) console.log(stderr); @@ -188,25 +235,71 @@ for (const testFile of testFiles) { } } -const totalTests = totalPassed + totalFailed; +function pendingTestFiles() { + return testFiles.filter(testFile => { + if (fs.existsSync(path.join(testsDir, testFile))) return true; + console.log(`WARNING Skipping ${testFile.split(path.sep).join('/')} (file not found)`); + return false; + }); +} -console.log('\n╔' + '═'.repeat(BOX_W) + '╗'); -console.log(boxLine(' Final Results')); -console.log('╠' + '═'.repeat(BOX_W) + '╣'); -console.log(boxLine(` Total Tests: ${String(totalTests).padStart(4)}`)); -console.log(boxLine(` Passed: ${String(totalPassed).padStart(4)} ✓`)); -console.log(boxLine(` Failed: ${String(totalFailed).padStart(4)} ${totalFailed > 0 ? '✗' : ' '}`)); -console.log(boxLine(` Files: ${filesPassed} passed, ${filesFailed} failed`)); -if (failedFiles.length > 0) { - console.log('╠' + '═'.repeat(BOX_W) + '╣'); - for (const name of failedFiles.slice(0, 10)) { - console.log(boxLine(` ✗ ${name}`.slice(0, BOX_W))); - } - if (failedFiles.length > 10) { - console.log(boxLine(` ... and ${failedFiles.length - 10} more`)); +function runAllSync() { + for (const testFile of pendingTestFiles()) { + reportTestFile(testFile.split(path.sep).join('/'), runTestFileSync(testFile)); } } -console.log('╚' + '═'.repeat(BOX_W) + '╝'); -console.log(`\nPassed: ${totalPassed}, Failed: ${totalFailed}`); -process.exitCode = totalFailed > 0 ? 1 : 0; +function isSerial(testFile) { + const normalized = testFile.split(path.sep).join('/'); + return serialPatterns.some(pattern => normalized.includes(pattern)); +} + +async function runAllParallel() { + const pending = pendingTestFiles(); + const queue = pending.filter(testFile => !isSerial(testFile)); + const serial = pending.filter(isSerial); + const worker = async () => { + while (queue.length > 0) { + const testFile = queue.shift(); + const result = await runTestFile(testFile); + reportTestFile(testFile.split(path.sep).join('/'), result); + } + }; + await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker)); + if (serial.length > 0) console.log(`\nRunning ${serial.length} serial files after the pool`); + for (const testFile of serial) { + reportTestFile(testFile.split(path.sep).join('/'), await runTestFile(testFile)); + } +} + +function finish() { + const totalTests = totalPassed + totalFailed; + + console.log('\n╔' + '═'.repeat(BOX_W) + '╗'); + console.log(boxLine(' Final Results')); + console.log('╠' + '═'.repeat(BOX_W) + '╣'); + console.log(boxLine(` Total Tests: ${String(totalTests).padStart(4)}`)); + console.log(boxLine(` Passed: ${String(totalPassed).padStart(4)} ✓`)); + console.log(boxLine(` Failed: ${String(totalFailed).padStart(4)} ${totalFailed > 0 ? '✗' : ' '}`)); + console.log(boxLine(` Files: ${filesPassed} passed, ${filesFailed} failed`)); + if (failedFiles.length > 0) { + console.log('╠' + '═'.repeat(BOX_W) + '╣'); + for (const name of failedFiles.slice(0, 10)) { + console.log(boxLine(` ✗ ${name}`.slice(0, BOX_W))); + } + if (failedFiles.length > 10) { + console.log(boxLine(` ... and ${failedFiles.length - 10} more`)); + } + } + console.log('╚' + '═'.repeat(BOX_W) + '╝'); + console.log(`\nPassed: ${totalPassed}, Failed: ${totalFailed}`); + + process.exitCode = totalFailed > 0 ? 1 : 0; +} + +if (jobs > 1) { + runAllParallel().then(finish); +} else { + runAllSync(); + finish(); +} From 07971588f8ac7a60080ba8c1b7920c9e8e733e7d Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 18 Oct 2026 22:45:00 -0400 Subject: [PATCH 23/23] ci: replace the 33-way test matrix with full and package-manager lanes The test job ran the entire suite on 3 OSes x 3 Node lines x 4 package managers, 33 full runs per push. Windows lanes took 28 to 39 minutes and macOS 14 to 19, so one push queued about 1.5 hours of wall clock behind runner concurrency, and timing-sensitive tests got 33 chances to flake. The package-manager axis only ever changed one environment variable that nine suites read. Now there are 13 lanes. Five run the full suite: Linux on Node 18, 20 and 22, plus Windows and macOS on Node 22. Eight are package-manager lanes on Node 22 that run only the suites which read CLAUDE_CODE_PACKAGE_MANAGER or CLAUDE_PACKAGE_MANAGER; the file list is grepped at run time so it cannot drift as tests are added. Full lanes use the runner's 4-process pool on Linux and macOS; Windows stays serial because its process spawn and temp-dir cleanup are the flaky part. --- .github/workflows/ci.yml | 58 ++++++++++++++++++----- tests/docker/plugin-setup-harness.test.js | 7 ++- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 575981b06..cfedaa4a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,22 +18,35 @@ permissions: jobs: test: - name: Test (${{ matrix.os }}, Node ${{ matrix.node }}, ${{ matrix.pm }}) + name: Test (${{ matrix.os }}, Node ${{ matrix.node }}, ${{ matrix.pm }}, ${{ matrix.scope }}) runs-on: ${{ matrix.os }} - # The complete cross-platform suite reaches its final installer cases at - # 30 minutes on hosted Windows Node 18; retain a bounded completion window. + # The serial Windows full lane reaches its final installer cases at about + # 30 minutes; retain a bounded completion window. timeout-minutes: 45 strategy: fail-fast: false + # Two kinds of lane. `full` runs the whole suite; `pm` runs only the suites + # that read CLAUDE_CODE_PACKAGE_MANAGER, which is all the package-manager + # axis ever changed. The old 33-way cross product ran the full suite 33 + # times per push, with Windows lanes at 30 to 40 minutes each. matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - node: ['18.x', '20.x', '22.x'] - pm: [npm, pnpm, yarn, bun] - exclude: - # Bun has limited Windows support - - os: windows-latest - pm: bun + include: + # Full suite: Linux on every supported Node line, current LTS on Windows and macOS. + - { os: ubuntu-latest, node: '18.x', pm: npm, scope: full } + - { os: ubuntu-latest, node: '20.x', pm: npm, scope: full } + - { os: ubuntu-latest, node: '22.x', pm: npm, scope: full } + - { os: windows-latest, node: '22.x', pm: npm, scope: full } + - { os: macos-latest, node: '22.x', pm: npm, scope: full } + # Package-manager lanes on current LTS. Bun has limited Windows support. + - { os: ubuntu-latest, node: '22.x', pm: pnpm, scope: pm } + - { os: ubuntu-latest, node: '22.x', pm: yarn, scope: pm } + - { os: ubuntu-latest, node: '22.x', pm: bun, scope: pm } + - { os: windows-latest, node: '22.x', pm: pnpm, scope: pm } + - { os: windows-latest, node: '22.x', pm: yarn, scope: pm } + - { os: macos-latest, node: '22.x', pm: pnpm, scope: pm } + - { os: macos-latest, node: '22.x', pm: yarn, scope: pm } + - { os: macos-latest, node: '22.x', pm: bun, scope: pm } steps: - name: Checkout @@ -125,18 +138,37 @@ jobs: *) echo "Unsupported package manager: ${{ matrix.pm }}" && exit 1 ;; esac - # Run tests + # Run tests. Full lanes run every file through a 4-process pool on Linux + # and macOS; Windows stays serial because its process spawn and temp-dir + # cleanup are the flaky part. pm lanes derive their file list from the + # suites that actually read the package-manager variable, so the lane + # cannot drift out of date as tests are added. - name: Run tests - run: node tests/run-all.js + shell: bash + run: | + if [ "${{ matrix.scope }}" = "pm" ]; then + ECC_TEST_ONLY=$(grep -rlE --include='*.test.js' 'CLAUDE(_CODE)?_PACKAGE_MANAGER' tests | sed 's#^tests/##' | sort | paste -sd, -) + export ECC_TEST_ONLY + echo "pm lane files: $ECC_TEST_ONLY" + fi + # Suites that run scripts/build-opencode.js, or rename a checkout path + # to a .backup- copy and restore it, mutate the shared checkout; + # suites that assert a wall-clock budget cannot share cores with the + # pool. Both groups run last, one at a time. + ECC_TEST_SERIAL=$(grep -rlE --include='*.test.js' 'build-opencode|\.backup-|dist\.backup|time budget' tests | sed 's#^tests/##' | sort | paste -sd, -) + export ECC_TEST_SERIAL + echo "serial after the pool: $ECC_TEST_SERIAL" + node tests/run-all.js env: CLAUDE_CODE_PACKAGE_MANAGER: ${{ matrix.pm }} + ECC_TEST_JOBS: ${{ runner.os == 'Windows' && '1' || '4' }} # Upload test artifacts on failure - name: Upload test artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-results-${{ matrix.os }}-node${{ matrix.node }}-${{ matrix.pm }} + name: test-results-${{ matrix.os }}-node${{ matrix.node }}-${{ matrix.pm }}-${{ matrix.scope }} path: | tests/ !tests/node_modules/ diff --git a/tests/docker/plugin-setup-harness.test.js b/tests/docker/plugin-setup-harness.test.js index 3e7ee7221..c1decd7ac 100644 --- a/tests/docker/plugin-setup-harness.test.js +++ b/tests/docker/plugin-setup-harness.test.js @@ -274,10 +274,9 @@ test('uses one shell-free focused runner across Linux, macOS, and Windows', () = const packageJson = read(files.packageJson); const platformRunner = read(files.platformRunner); - assert.match( - ci, - /os:\s*\[ubuntu-latest,\s*windows-latest,\s*macos-latest\]/ - ); + for (const os of ['ubuntu-latest', 'windows-latest', 'macos-latest']) { + assert.match(ci, new RegExp(`os:\\s*${os},[^}]*scope:\\s*full`), `${os} must run the full suite`); + } assert.match( packageJson, /"test:plugin-setup-platform":\s*"node docker\/plugin-setup\/run-platform-tests\.js"/