mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
Merge branch 'main' into fix/managed-hook-config-bom
This commit is contained in:
54 files changed
+7068
-4693
No files matched your search
@@ -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)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"windowMinutes": 20,
|
||||
"closeAbove": 5,
|
||||
"blockAbove": 10,
|
||||
"maxOpen": 10,
|
||||
"deniedUserIDs": [108231307],
|
||||
"notify": ["affaan-m", "haelyra"]
|
||||
}
|
||||
+45
-13
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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)>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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)>
|
||||
|
||||
@@ -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の場合は空文字列を使用
|
||||
|
||||
**ロールプロンプト**:
|
||||
|
||||
|
||||
@@ -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)>
|
||||
|
||||
@@ -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の場合は空文字列を使用
|
||||
|
||||
**ロールプロンプト**:
|
||||
|
||||
|
||||
@@ -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の場合は空文字列を使用
|
||||
|
||||
**ロールプロンプト**:
|
||||
|
||||
|
||||
@@ -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)>
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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)>
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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)>
|
||||
|
||||
@@ -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 使用空字符串
|
||||
|
||||
**角色提示**:
|
||||
|
||||
|
||||
@@ -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)>
|
||||
|
||||
@@ -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 使用空字符串
|
||||
|
||||
**角色提示**:
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+803
-836
File diff suppressed because it is too large.
Load diff
+139
-83
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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"/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user