Merge branch 'main' into fix/managed-hook-config-bom

This commit is contained in:
haelyra authored and GitHub committed 2026-10-20 15:18:22 -04:00
commit 224f99c059
54 files changed
+7068 -4693

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
# 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 |
| --- | --- | --- |
| 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, 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
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.
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. 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.
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.
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.
Closing PRs cannot reset burst counts. Rapid closing and resubmission still
count as creations within the same rolling window.
## 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.
- 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 `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
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. 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
- [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)
+8
View File
@@ -0,0 +1,8 @@
{
"windowMinutes": 20,
"closeAbove": 5,
"blockAbove": 10,
"maxOpen": 10,
"deniedUserIDs": [108231307],
"notify": ["affaan-m", "haelyra"]
}
+45 -13
View File
@@ -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-<pid> 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/
+39
View File
@@ -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
+6
View File
@@ -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
+17
View File
@@ -433,6 +433,23 @@ 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.
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.
### 1. PR Title Format
```
+2 -2
View File
@@ -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: <role prompt path>
<TASK>
Requirement: <enhanced requirement (or $ARGUMENTS if not enhanced)>
@@ -53,7 +53,7 @@ EOF",
# Resume session call
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend codex resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement (or $ARGUMENTS if not enhanced)>
+3 -3
View File
@@ -29,7 +29,7 @@ $ARGUMENTS
```
# Resume session call (recommended) - Implementation Prototype
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|antigravity> resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|antigravity> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <task description>
@@ -44,7 +44,7 @@ EOF",
# New session call - Implementation Prototype
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|antigravity> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|antigravity> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <task description>
@@ -62,7 +62,7 @@ EOF",
```
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|antigravity> resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|antigravity> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Scope: Audit the final code changes.
+2 -2
View File
@@ -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: <role prompt path>
<TASK>
Requirement: <enhanced requirement (or $ARGUMENTS if not enhanced)>
@@ -53,7 +53,7 @@ EOF",
# Resume session call
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend antigravity resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend antigravity resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement (or $ARGUMENTS if not enhanced)>
+1 -1
View File
@@ -28,7 +28,7 @@ $ARGUMENTS
```
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|antigravity> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|antigravity> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement>
+2 -2
View File
@@ -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 <codex|antigravity> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|antigravity> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement (or $ARGUMENTS if not enhanced)>
@@ -57,7 +57,7 @@ EOF",
# Resume session call
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|antigravity> resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|antigravity> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement (or $ARGUMENTS if not enhanced)>
+247 -135
View File
@@ -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 };
+2 -2
View File
@@ -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: <ロールプロンプトパス>
<TASK>
Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)>
@@ -49,7 +49,7 @@ EOF",
# セッション再開呼び出し
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend codex resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <ロールプロンプトパス>
<TASK>
Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)>
+4 -4
View File
@@ -25,7 +25,7 @@ $ARGUMENTS
```
# セッション再開呼び出し(推奨) - 実装プロトタイプ
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <ロールプロンプトパス>
<TASK>
Requirement: <タスクの説明>
@@ -40,7 +40,7 @@ EOF",
# 新規セッション呼び出し - 実装プロトタイプ
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <ロールプロンプトパス>
<TASK>
Requirement: <タスクの説明>
@@ -58,7 +58,7 @@ EOF",
```
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <ロールプロンプトパス>
<TASK>
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の場合は空文字列を使用
**ロールプロンプト**:
+2 -2
View File
@@ -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: <ロールプロンプトパス>
<TASK>
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 <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <ロールプロンプトパス>
<TASK>
Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)>
+2 -2
View File
@@ -24,7 +24,7 @@ $ARGUMENTS
```
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <ロールプロンプトパス>
<TASK>
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の場合は空文字列を使用
**ロールプロンプト**:
+3 -3
View File
@@ -38,7 +38,7 @@
```
# 新規セッション呼び出し
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <ロールプロンプトパス>
<TASK>
Requirement: <強化された要件(または強化されていない場合は$ARGUMENTS)>
@@ -53,7 +53,7 @@ EOF",
# セッション再開呼び出し
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <ロールプロンプトパス>
<TASK>
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の場合は空文字列を使用
**ロールプロンプト**:
+2 -2
View File
@@ -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: <role prompt path>
<TASK>
Requirement: <enhanced requirement (veya enhance edilmediyse $ARGUMENTS)>
@@ -49,7 +49,7 @@ EOF",
# Session devam ettirme çağrısı
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend codex resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement (veya enhance edilmediyse $ARGUMENTS)>
+4 -4
View File
@@ -25,7 +25,7 @@ $ARGUMENTS
```
# Session devam ettirme çağrısı (önerilen) - Implementation Prototype
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <task description>
@@ -40,7 +40,7 @@ EOF",
# Yeni session çağrısı - Implementation Prototype
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <task description>
@@ -58,7 +58,7 @@ EOF",
```
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
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**:
+2 -2
View File
@@ -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: <role prompt path>
<TASK>
Requirement: <enhanced requirement (veya enhance edilmediyse $ARGUMENTS)>
@@ -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 <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement (veya enhance edilmediyse $ARGUMENTS)>
+2 -2
View File
@@ -24,7 +24,7 @@ $ARGUMENTS
```
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced 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**:
+3 -3
View File
@@ -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 <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement (veya enhance edilmediyse $ARGUMENTS)>
@@ -53,7 +53,7 @@ EOF",
# Session devam ettirme çağrısı
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced requirement (veya enhance edilmediyse $ARGUMENTS)>
@@ -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**:
+2 -2
View File
@@ -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: <角色提示路径>
<TASK>
需求: <增强后的需求(若未增强则为 $ARGUMENTS)>
@@ -50,7 +50,7 @@ EOF",
# 恢复会话调用
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend codex resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend codex resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <角色提示路径>
<TASK>
需求: <增强后的需求(若未增强则为 $ARGUMENTS)>
+4 -4
View File
@@ -25,7 +25,7 @@ $ARGUMENTS
```
# 恢复会话调用(推荐)- 实现原型
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <task description>
@@ -40,7 +40,7 @@ EOF",
# 新建会话调用 - 实现原型
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <task description>
@@ -58,7 +58,7 @@ EOF",
```
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
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 使用空字符串
**角色提示**:
+2 -2
View File
@@ -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: <角色提示文件路径>
<TASK>
需求: <增强后的需求(若未增强则为$ARGUMENTS)>
@@ -50,7 +50,7 @@ EOF",
# 恢复会话调用
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend gemini --gemini-model gemini-3-pro-preview resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend gemini --gemini-model gemini-3-pro-preview resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <角色提示文件路径>
<TASK>
需求: <增强后的需求(若未增强则为$ARGUMENTS)>
+2 -2
View File
@@ -24,7 +24,7 @@ $ARGUMENTS
```
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <enhanced 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 使用空字符串
**角色提示**:
+3 -3
View File
@@ -39,7 +39,7 @@
```
# 新会话调用
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> - \"$PWD\" <<'EOF'
ROLE_FILE: <角色提示文件路径>
<TASK>
需求: <增强后的需求(如未增强则为$ARGUMENTS)>
@@ -54,7 +54,7 @@ EOF",
# 恢复会话调用
Bash({
command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
command: "~/.claude/bin/codeagent-wrapper --backend <codex|gemini> resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <角色提示文件路径>
<TASK>
需求: <增强后的需求(如未增强则为$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 使用空字符串
**角色提示词**:
+24 -13
View File
@@ -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
);
+233
View File
@@ -0,0 +1,233 @@
#!/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', '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');
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()] };
}
/** 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 = '<!-- ecc-pr-open-cap -->';
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 {
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 = `<!-- ecc-pr-burst:${current.user.id}:${result.until.slice(0, 10)} -->`;
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 = '<!-- ecc-pr-burst:block-threshold -->';
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' };
// 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) 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;
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);
}
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, capExcess };
+281 -167
View File
@@ -82,6 +82,93 @@ 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');
}
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.
* @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(unescapeInlineJs(nodeEMatch[1]));
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 +192,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 +201,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 +279,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 +300,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 +498,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 +519,7 @@ function validateHooks() {
console.log(`Updated fingerprints in ${METADATA_FILENAME}`);
}
console.log(`Validated ${totalMatchers} hook matchers`);
console.log(`Validated ${result.totalMatchers} hook matchers`);
}
validateHooks();
+403 -252
View File
@@ -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) {
File diff suppressed because it is too large. Load diff
+380 -247
View File
@@ -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 };
+182 -159
View File
@@ -18,6 +18,61 @@ const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
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);
+274 -219
View File
@@ -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) {
+379 -248
View File
@@ -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) {
+80 -54
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+139 -83
View File
@@ -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(); }
}
File diff suppressed because it is too large. Load diff
+193 -318
View File
@@ -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));
}
}
+299 -367
View File
@@ -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 <github-login> --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 <github-login> --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 <github-login> --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 <github-login> --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();
+88
View File
@@ -0,0 +1,88 @@
#!/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 (/\/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);
}
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 });
}
});
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { evaluate, moderate, githubAPI, listPages, strongestWindow, capExcess } = require('../../scripts/ci/pr-burst-guard');
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 });
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.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('/comments') && method === 'POST') 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')));
});
// 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/);
});
+91
View File
@@ -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;
+15
View File
@@ -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');
@@ -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}`);
+3 -4
View File
@@ -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"/
+125
View File
@@ -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);
});
+77
View File
@@ -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');
});
+80
View File
@@ -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)));
}
});
+112
View File
@@ -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());
});
+106
View File
@@ -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' }));
});
+127 -34
View File
@@ -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();
}