session-orchestrator 3.20.0 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-vcs-repo-flag.mjs — census of `gh`/`glab` invocations that omit `--repo`/`-R`.
|
|
4
|
+
*
|
|
5
|
+
* ## The defect class (issue #971)
|
|
6
|
+
*
|
|
7
|
+
* `gh` and `glab` resolve their target repository from the AMBIENT git remote of
|
|
8
|
+
* the process's cwd whenever `--repo`/`-R` is absent. That is invisible and
|
|
9
|
+
* correct in an operator's own checkout, and wrong everywhere the cwd is not the
|
|
10
|
+
* repo the command means: a sibling worktree, an `/autopilot` child process, a
|
|
11
|
+
* dispatched wave-agent, a consumer repo that copied a template, or any host
|
|
12
|
+
* whose `origin` points at a fork. The failure is silent — the command succeeds
|
|
13
|
+
* against the WRONG project. `scripts/lib/vcs-repo-spec.mjs` exists precisely to
|
|
14
|
+
* produce the spec such a call site should pass; this check finds the call sites
|
|
15
|
+
* that never learned to.
|
|
16
|
+
*
|
|
17
|
+
* ## Mode: WARN, never blocking
|
|
18
|
+
*
|
|
19
|
+
* Findings print as `WARN:` and the runner returns 0. `FAIL:` is reserved for
|
|
20
|
+
* the tool-error path (an unreadable tree). This is load-bearing, not
|
|
21
|
+
* stylistic: `scripts/validate-plugin.mjs` tallies `^[ ]{2}FAIL:` lines from
|
|
22
|
+
* EVERY sub-check into a module-wide counter and exits 1 if that counter is
|
|
23
|
+
* non-zero — the sub-check's own exit code is deliberately discarded for
|
|
24
|
+
* WARN-only checks. A single `FAIL:` line here would therefore red the whole
|
|
25
|
+
* validator (and with it three CI-reachable test files) on a census that was
|
|
26
|
+
* ~40 findings on the day it landed. Warn first, ratchet later once the Wave-3
|
|
27
|
+
* sweep has drained the backlog.
|
|
28
|
+
*
|
|
29
|
+
* ## Classification, and why the naive grep is >50% noise
|
|
30
|
+
*
|
|
31
|
+
* A broad `grep -n 'gh\|glab'` over this repo returns ~244 line matches; only a
|
|
32
|
+
* minority are invocations. Four filters do the separating, each measured:
|
|
33
|
+
*
|
|
34
|
+
* 1. **Markdown: a fence, or it is prose.** A CommonMark fence state machine
|
|
35
|
+
* (both ``` and ~~~, nesting-aware via marker length) gates every markdown
|
|
36
|
+
* match. Only ```bash / ```sh / ```shell / ```console fences are candidates.
|
|
37
|
+
* A match with no fence at all — a table cell, a bullet, running text — is
|
|
38
|
+
* prose. A non-shell fence (```js, ```yaml) is prose.
|
|
39
|
+
* 2. **Shell comments inside a shell fence are prose** (`^\s*#`).
|
|
40
|
+
* 3. **JS/MJS: comment lines are prose** (`^\s*(//|/*|*)`), and a match inside
|
|
41
|
+
* a string literal counts only when the command STARTS the literal (after
|
|
42
|
+
* optional whitespace) AND the literal carries no `${` interpolation. That
|
|
43
|
+
* second half is what separates
|
|
44
|
+
* `' glab ci status --pipeline-id LATEST'` (a real command handed to a
|
|
45
|
+
* shell) from `` `glab note add spawn error: ${err}` `` (a log message).
|
|
46
|
+
* 4. **The first token after the CLI must be a REAL top-level subcommand** of
|
|
47
|
+
* that CLI, measured from `gh --help` / `glab --help` (gh 2.86.0,
|
|
48
|
+
* glab 1.91.0, probed 2026-08-14). This alone kills the prose residue that
|
|
49
|
+
* survives filters 1–3 — `glab or gh`, `glab output could`, `glab exited`,
|
|
50
|
+
* `glab not found`, `glab is`, `glab then` were all live matches in the raw
|
|
51
|
+
* census.
|
|
52
|
+
*
|
|
53
|
+
* ## D-NA — where `--repo` does not exist, so its absence is not a defect
|
|
54
|
+
*
|
|
55
|
+
* Every entry below was probed against the installed binaries on 2026-08-14
|
|
56
|
+
* (gh 2.86.0, glab 1.91.0) by grepping the subcommand's own `--help` for
|
|
57
|
+
* `-R,? --repo` and, for the disputed cases, by executing the flag:
|
|
58
|
+
*
|
|
59
|
+
* - `gh api` / `glab api` — neither documents `--repo`; both document
|
|
60
|
+
* `--hostname`. `gh api -R x /user` → `unknown shorthand flag: 'R'`.
|
|
61
|
+
* `glab` accepts `-R` here only because it is an inherited root flag, and it
|
|
62
|
+
* changes nothing: the endpoint path IS the target. The instrument for these
|
|
63
|
+
* is `resolveRepoHost()` — see `scripts/lib/vcs-repo-spec.mjs` § hostname.
|
|
64
|
+
* - `gh repo <*>` — `gh repo view -R cli/cli` → `unknown shorthand flag: 'R'`.
|
|
65
|
+
* The `repo` group takes a POSITIONAL `[<repository>]`.
|
|
66
|
+
* - `gh auth`/`gh config`/`gh extension`/`gh alias`/`gh completion`/`gh status`
|
|
67
|
+
* and `glab auth`/`glab config`/`glab group`/`glab completion`/`glab alias`/
|
|
68
|
+
* `glab check-update`/`glab version`/`glab help` — host- or client-scoped,
|
|
69
|
+
* no repo concept (`gh auth status -R x` → `unknown shorthand flag: 'R'`).
|
|
70
|
+
*
|
|
71
|
+
* **`glab repo <*>` is deliberately NOT D-NA, and the asymmetry with `gh repo`
|
|
72
|
+
* is real.** `glab repo view -R gitlab-org/cli` is ACCEPTED (it produced a
|
|
73
|
+
* `404 Not Found` from the configured host, i.e. the flag resolved the target)
|
|
74
|
+
* because `-R/--repo` is a persistent root flag on `glab` — it does not appear
|
|
75
|
+
* in `glab repo view --help`'s own FLAGS block, so a help-text-only census
|
|
76
|
+
* would get this backwards.
|
|
77
|
+
*
|
|
78
|
+
* ## Conditional `--repo` — where the flag exists but REQUIRES a positional
|
|
79
|
+
*
|
|
80
|
+
* A third class sits between "applicable" and D-NA: subcommands whose positional
|
|
81
|
+
* is OPTIONAL (it defaults to the current branch's PR) but becomes MANDATORY the
|
|
82
|
+
* moment `-R` appears. Adding `-R` to such a call without also pinning a
|
|
83
|
+
* PR/branch does not harden it — it BREAKS it at runtime. Reporting these is
|
|
84
|
+
* therefore worse than useless: the "fix" is the regression.
|
|
85
|
+
*
|
|
86
|
+
* Measured 2026-08-14 against gh 2.86.0, one probe per subcommand, `cwd=/tmp`
|
|
87
|
+
* (no git remote, so nothing ambient can mask the result):
|
|
88
|
+
*
|
|
89
|
+
* gh pr checks -R cli/cli → argument required when using the `--repo` flag
|
|
90
|
+
* gh pr checks -R cli/cli trunk → no pull requests found for branch "trunk" (flag accepted)
|
|
91
|
+
* gh pr view -R cli/cli → argument required when using the --repo flag
|
|
92
|
+
* gh pr diff -R cli/cli → argument required when using the `--repo` flag
|
|
93
|
+
* gh pr ready -R cli/cli → argument required when using the --repo flag
|
|
94
|
+
* gh pr merge -R cli/cli → argument required when using the --repo flag
|
|
95
|
+
* gh pr comment -R cli/cli → argument required when using the --repo flag
|
|
96
|
+
*
|
|
97
|
+
* The rule is POSITIONAL-DEPENDENT, never subcommand-blanket: `gh pr checks 123`
|
|
98
|
+
* and `gh pr view 4711` both accept `-R` happily, so a bare one of those is
|
|
99
|
+
* still a genuine finding. Only the no-positional form is excluded.
|
|
100
|
+
*
|
|
101
|
+
* Three neighbouring shapes were probed and deliberately EXCLUDED from the table
|
|
102
|
+
* because their error is Cobra arity, not a `--repo` interaction — the positional
|
|
103
|
+
* is mandatory there with or without the flag, so no `-R` advice is being
|
|
104
|
+
* suppressed: `gh pr close`/`reopen` (`cannot close pull request: number, url,
|
|
105
|
+
* or branch required` / `accepts 1 arg(s), received 0`), every `gh issue <*>`
|
|
106
|
+
* (`gh issue view -R cli/cli` → `accepts 1 arg(s), received 0`), and
|
|
107
|
+
* `gh release delete`/`edit` (same arity message). `gh pr list`, `gh pr status`,
|
|
108
|
+
* `gh issue list` and `gh release view`/`list` all RAN against `cli/cli` with
|
|
109
|
+
* `-R` and no positional — those stay fully applicable.
|
|
110
|
+
*
|
|
111
|
+
* **The glab side of this table is empty by measurement, not by omission.**
|
|
112
|
+
* `glab mr view -R gitlab-org/cli` and `glab ci status -R gitlab-org/cli` (no
|
|
113
|
+
* positional, glab 1.91.0, 2026-08-14) were both ACCEPTED by the CLI and failed
|
|
114
|
+
* downstream at the configured host (exit 1, `ERROR` block), never at flag
|
|
115
|
+
* parsing. glab has no equivalent of gh's conditional-positional check.
|
|
116
|
+
*
|
|
117
|
+
* Note what a help-text-derived rule would have concluded here: `gh pr checks
|
|
118
|
+
* --help` lists `-R, --repo` under INHERITED FLAGS with no caveat whatsoever.
|
|
119
|
+
* Reading the help would have marked this call site as a plain finding. Only
|
|
120
|
+
* executing the binary shows the constraint — same lesson as the `glab repo`
|
|
121
|
+
* asymmetry above, in the opposite direction.
|
|
122
|
+
*
|
|
123
|
+
* ## Blind spot — a `-R` the CLI will REJECT
|
|
124
|
+
*
|
|
125
|
+
* Every entry in the gap list below is an OMISSION: a call site the census does
|
|
126
|
+
* not see. This one is not, so it is named apart from them.
|
|
127
|
+
* `repoFlagBlockedByMissingPositional()` is consulted only AFTER the
|
|
128
|
+
* `--repo`-present branch (see its own docstring for why), so the
|
|
129
|
+
* conditional-positional rule can only ever describe calls that LACK the flag —
|
|
130
|
+
* the harmless half of that table. The harmful half is invisible:
|
|
131
|
+
* `gh pr checks -R o/r --watch` carries `-R` with no positional, which gh 2.86.0
|
|
132
|
+
* rejects at parse time (`argument required when using the --repo flag`, exit 1,
|
|
133
|
+
* probed 2026-08-14). This census counts it as `withFlag` — as already hardened.
|
|
134
|
+
*
|
|
135
|
+
* Measured 2026-08-14 against this tree: **0 such call sites**. A probe copy of
|
|
136
|
+
* this file, patched to skip the repo flag's own value before scanning the
|
|
137
|
+
* remaining tokens for a positional, ran `inspectVcsRepoFlag(repoRoot)` and
|
|
138
|
+
* reported an empty list — so the blind spot is inert here today, and detecting
|
|
139
|
+
* it is ~8 lines.
|
|
140
|
+
*
|
|
141
|
+
* It is nonetheless NOT reported, for a contract reason rather than a detection
|
|
142
|
+
* one. A finding here means "add `--repo`"; that call's fix is the opposite
|
|
143
|
+
* ("name the PR, or drop the flag"), and the `kind` enum, the WARN message text
|
|
144
|
+
* and the PASS line's "N bare" all speak only the first language. Folding a
|
|
145
|
+
* loud, exit-1 failure into a census built for SILENT wrong-repo failures would
|
|
146
|
+
* hand the sweep a worklist whose entries prescribe opposite edits, and would
|
|
147
|
+
* buy nothing the command's own first run does not already say. That class wants
|
|
148
|
+
* its own check; it is routed as a follow-up rather than patched in here.
|
|
149
|
+
*
|
|
150
|
+
* ## Named coverage gaps (v1) — read these before quoting the count
|
|
151
|
+
*
|
|
152
|
+
* - **Variable argv arrays are not resolved.** `spawnSync('glab', glabArgs)`
|
|
153
|
+
* cannot be judged from the call site; only a LITERAL array is inspected.
|
|
154
|
+
* Measured 2026-08-14: 5 direct `execFile*`/`spawn*` sites naming `gh`/`glab`
|
|
155
|
+
* in `scripts/`+`hooks/`, 4 of them variable-args. The count is reported as
|
|
156
|
+
* `unresolvedArgv` in the summary so the hole is visible in the output, not
|
|
157
|
+
* only in this comment.
|
|
158
|
+
* - **Interpolated command templates are skipped** (filter 3). A genuine
|
|
159
|
+
* `` `glab issue view ${n}` `` is invisible. The alternative — treating every
|
|
160
|
+
* interpolated string as a command — readmits the four log-message templates
|
|
161
|
+
* this repo actually has, which is the worse trade at v1. The mirror hole:
|
|
162
|
+
* an INTERPOLATION-FREE log string that happens to open with a real
|
|
163
|
+
* subcommand (`log('glab issue list failed')`) would be reported as a call
|
|
164
|
+
* site. This repo has none today; filter 4 catches the common shapes
|
|
165
|
+
* (`glab note add …` is not a real subcommand).
|
|
166
|
+
* - **Backtick-command bullets in skill prose are classified as prose**, per
|
|
167
|
+
* filter 1. `skills/discovery/SKILL.md` and `skills/plan/SKILL.md` both carry
|
|
168
|
+
* list items whose inline-code command the coordinator copies verbatim —
|
|
169
|
+
* formally prose, operationally an instruction. Calling them prose is a
|
|
170
|
+
* DELIBERATE choice: the alternative (treating inline code as a command)
|
|
171
|
+
* matched every `` `glab` `` mention in every rule file. The Wave-3 sweep
|
|
172
|
+
* should read those two files by hand.
|
|
173
|
+
* - **A shell fence NESTED inside a wider (4+ backtick) fence is content, not a
|
|
174
|
+
* fence** — CommonMark-correct, but it means its commands are invisible.
|
|
175
|
+
* Measured 2026-08-14: `grep -rn '^\s*\x60\x60\x60\x60' skills docs agents
|
|
176
|
+
* commands .claude templates` → 0 matches, so the gap is inert today.
|
|
177
|
+
* - **`tests/` is excluded from the corpus.** A test that asserts on a command
|
|
178
|
+
* string is not a sweep target; including them added 42 raw matches of pure
|
|
179
|
+
* noise.
|
|
180
|
+
* - **Only `.md` and `.mjs`/`.js`/`.cjs` are scanned.** `.sh`, `.yml`, and
|
|
181
|
+
* `.gitlab-ci.yml` are outside v1 (raw census: 0 matches there today).
|
|
182
|
+
* - **A leading flag before the subcommand is not resolved.** `glab -R x issue
|
|
183
|
+
* list` yields no subcommand token and is counted as `skippedLeadingFlag`,
|
|
184
|
+
* never as a finding. Measured 0 occurrences in this repo.
|
|
185
|
+
* - Files are read with `readFileSync`, never a `grep` spawn: one NUL byte
|
|
186
|
+
* makes a text file invisible to grep-based audits (see
|
|
187
|
+
* `.claude/rules/anti-pattern-a-nul-byte-in-a-tracked-production-file-...md`),
|
|
188
|
+
* which would silently drop call sites and understate the census.
|
|
189
|
+
*
|
|
190
|
+
* Import-safety: importing this module exposes the inspector and runner only;
|
|
191
|
+
* the CLI path is guarded at the bottom of the file.
|
|
192
|
+
*/
|
|
193
|
+
|
|
194
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
195
|
+
import path from 'node:path';
|
|
196
|
+
import { pathToFileURL } from 'node:url';
|
|
197
|
+
|
|
198
|
+
/** Directories whose content is scanned. Root-level `*.md` is added separately. */
|
|
199
|
+
const SCAN_DIRS = Object.freeze([
|
|
200
|
+
'.claude',
|
|
201
|
+
'agents',
|
|
202
|
+
'commands',
|
|
203
|
+
'docs',
|
|
204
|
+
'hooks',
|
|
205
|
+
'scripts',
|
|
206
|
+
'skills',
|
|
207
|
+
'templates',
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
/** Directory names excluded at any depth. `tests` — see § Named coverage gaps. */
|
|
211
|
+
const EXCLUDED_DIRS = Object.freeze([
|
|
212
|
+
'node_modules',
|
|
213
|
+
'.git',
|
|
214
|
+
'tests',
|
|
215
|
+
'test',
|
|
216
|
+
'__tests__',
|
|
217
|
+
'coverage',
|
|
218
|
+
'dist',
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
/** Markdown extensions (fence state machine applies). */
|
|
222
|
+
const DOC_EXTENSIONS = Object.freeze(['.md']);
|
|
223
|
+
|
|
224
|
+
/** Code extensions (comment + string-literal rules apply). */
|
|
225
|
+
const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
|
|
226
|
+
|
|
227
|
+
/** Fence languages whose body is shell. Anything else is prose. */
|
|
228
|
+
const SHELL_LANGS = Object.freeze(new Set(['bash', 'sh', 'shell', 'console', 'zsh']));
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Real top-level subcommands, harvested from `gh --help` / `glab --help`
|
|
232
|
+
* (gh 2.86.0 / glab 1.91.0, 2026-08-14). Filter 4: a token that is not in this
|
|
233
|
+
* set is prose, not a subcommand.
|
|
234
|
+
*/
|
|
235
|
+
const SUBCOMMANDS = Object.freeze({
|
|
236
|
+
gh: Object.freeze(
|
|
237
|
+
new Set([
|
|
238
|
+
'actions', 'agent-task', 'alias', 'api', 'attestation', 'auth', 'browse', 'cache',
|
|
239
|
+
'codespace', 'completion', 'config', 'copilot', 'environment', 'extension', 'gist',
|
|
240
|
+
'gpg-key', 'issue', 'label', 'org', 'pr', 'project', 'release', 'repo', 'ruleset',
|
|
241
|
+
'run', 'search', 'secret', 'ssh-key', 'status', 'variable', 'workflow',
|
|
242
|
+
]),
|
|
243
|
+
),
|
|
244
|
+
glab: Object.freeze(
|
|
245
|
+
new Set([
|
|
246
|
+
'alias', 'api', 'attestation', 'auth', 'changelog', 'check-update', 'ci', 'cluster',
|
|
247
|
+
'completion', 'config', 'deploy-key', 'duo', 'gpg-key', 'help', 'incident', 'issue',
|
|
248
|
+
'iteration', 'job', 'label', 'mcp', 'milestone', 'mr', 'opentofu', 'pipeline',
|
|
249
|
+
'release', 'repo', 'runner', 'runner-controller', 'schedule', 'securefile', 'snippet',
|
|
250
|
+
'ssh-key', 'stack', 'token', 'user', 'variable', 'version', 'work-items',
|
|
251
|
+
]),
|
|
252
|
+
),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Subcommand groups where `--repo`/`-R` does not apply. See § D-NA in the header
|
|
257
|
+
* for the per-entry probe evidence. Keyed on the FIRST subcommand token.
|
|
258
|
+
*/
|
|
259
|
+
const NOT_APPLICABLE = Object.freeze({
|
|
260
|
+
gh: Object.freeze(
|
|
261
|
+
new Set(['api', 'repo', 'auth', 'config', 'extension', 'alias', 'completion', 'status']),
|
|
262
|
+
),
|
|
263
|
+
glab: Object.freeze(
|
|
264
|
+
new Set(['api', 'auth', 'config', 'group', 'completion', 'alias', 'check-update', 'version', 'help']),
|
|
265
|
+
),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Subcommand groups whose SECOND positional argument IS the repository, so an
|
|
270
|
+
* explicit positional makes the invocation unambiguous without `--repo`.
|
|
271
|
+
* `glab repo view group/project` is as explicit as `glab repo view -R
|
|
272
|
+
* group/project`; `glab repo view --output json` is not. (`gh repo` is D-NA —
|
|
273
|
+
* it has no `-R` at all — so this table only ever needs the glab side.)
|
|
274
|
+
*/
|
|
275
|
+
const POSITIONAL_REPO_GROUPS = Object.freeze({
|
|
276
|
+
gh: Object.freeze(new Set()),
|
|
277
|
+
glab: Object.freeze(new Set(['repo'])),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* `<group> <subcommand>` pairs where `--repo`/`-R` is CONDITIONAL: legal with a
|
|
282
|
+
* positional, rejected without one. Keyed `cli → group → Set(subcommand)`. See
|
|
283
|
+
* § Conditional `--repo` in the header for the per-entry probe transcript; the
|
|
284
|
+
* glab side is empty by measurement.
|
|
285
|
+
*/
|
|
286
|
+
const REPO_FLAG_NEEDS_POSITIONAL = Object.freeze({
|
|
287
|
+
gh: Object.freeze({
|
|
288
|
+
pr: Object.freeze(new Set(['checks', 'view', 'diff', 'ready', 'merge', 'comment'])),
|
|
289
|
+
}),
|
|
290
|
+
glab: Object.freeze({}),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Does this token stand in for a positional argument? A flag does not, and
|
|
295
|
+
* neither does a shell redirect — `gh pr checks 2>&1` names no PR, so reading
|
|
296
|
+
* `2>&1` as one would both hide a real finding here and manufacture a phantom
|
|
297
|
+
* `explicitPositional` for `glab repo view 2>&1`.
|
|
298
|
+
*
|
|
299
|
+
* @param {string | undefined} token
|
|
300
|
+
* @returns {boolean}
|
|
301
|
+
*/
|
|
302
|
+
function looksPositional(token) {
|
|
303
|
+
return Boolean(token) && !token.startsWith('-') && !/^\d*[<>]/.test(token);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Is `-R` unavailable here because the conditional-positional rule bites — i.e.
|
|
308
|
+
* would adding the flag BREAK this call rather than harden it?
|
|
309
|
+
*
|
|
310
|
+
* Two deliberate conservatisms, both erring toward reporting rather than
|
|
311
|
+
* silence, because a wrongly-suppressed call site is invisible forever while a
|
|
312
|
+
* wrongly-reported one is one sweep read away:
|
|
313
|
+
*
|
|
314
|
+
* 1. Only consulted AFTER the `--repo`-present branch. A call that already
|
|
315
|
+
* carries the flag is never this function's business, and asking here would
|
|
316
|
+
* mean parsing which flags consume a value — `gh pr merge -R o/r 123` puts
|
|
317
|
+
* its positional at index 4, behind `-R`'s argument. What that ordering
|
|
318
|
+
* costs is named in § Blind spot at the top of this file: the `-R`-present,
|
|
319
|
+
* positional-absent call — the one the CLI actually rejects — is invisible.
|
|
320
|
+
* 2. ANY positional-looking token past the subcommand disarms the rule. A flag
|
|
321
|
+
* VALUE (`--json state` → `state`) can be mistaken for a positional; that
|
|
322
|
+
* mistake yields a finding, never a suppression.
|
|
323
|
+
*
|
|
324
|
+
* @param {'gh'|'glab'} cli
|
|
325
|
+
* @param {string} group first subcommand token (`pr`)
|
|
326
|
+
* @param {string | undefined} sub second token (`checks`)
|
|
327
|
+
* @param {string[]} rest every token past the subcommand pair
|
|
328
|
+
* @returns {boolean}
|
|
329
|
+
*/
|
|
330
|
+
function repoFlagBlockedByMissingPositional(cli, group, sub, rest) {
|
|
331
|
+
const subs = REPO_FLAG_NEEDS_POSITIONAL[cli][group];
|
|
332
|
+
if (!subs || !sub || !subs.has(sub)) return false;
|
|
333
|
+
return !rest.some(looksPositional);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A `--repo`/`-R` occurrence. `--repo=x`, `-R x` and `-R"$SPEC"` all count;
|
|
338
|
+
* `--repository` does not (the `\b` blocks it) and neither does a `-R` glued to
|
|
339
|
+
* a bare word (`-Rfoo`), which no call site in this repo uses.
|
|
340
|
+
*/
|
|
341
|
+
const REPO_FLAG_RE = /(?<![\w-])(?:--repo\b|--repo=|-R\b|-R(?=["'$]))/;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Command position: start of line, or after a shell separator / substitution
|
|
345
|
+
* opener, optionally preceded by `VAR=value` assignments.
|
|
346
|
+
*/
|
|
347
|
+
const COMMAND_RE = /(?:^|[|;&(]|\$\()\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(gh|glab)(?=\s)/g;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Slice the text following a matched `gh`/`glab` token into this invocation's
|
|
351
|
+
* segment, and project out the part that is the OUTER command's own argv.
|
|
352
|
+
*
|
|
353
|
+
* Two jobs in one walk, because they share the concept a character-class stop
|
|
354
|
+
* regex cannot express — where a COMMAND SUBSTITUTION begins and ends:
|
|
355
|
+
*
|
|
356
|
+
* 1. **Where the invocation ends.** `|`, `;`, `||`, `&&` and `)` end a command
|
|
357
|
+
* only at substitution depth 0 (`&` alone never does — `2>&1`). A `)`-blind
|
|
358
|
+
* stop cuts `glab issue create --title "$(date)" -R g/p` before its flag and
|
|
359
|
+
* reports an already-correct call site.
|
|
360
|
+
* 2. **Whose `-R` is this.** Everything inside `$( … )` or backticks is a
|
|
361
|
+
* DIFFERENT command's argv. `glab issue list $(grep -R pattern src)` carries
|
|
362
|
+
* no repo flag at all — the `-R` is grep's (`cp`, `ls`, `rsync`, `chmod`
|
|
363
|
+
* spell it the same way). Crediting it to the outer call is the one failure
|
|
364
|
+
* direction this census must not have: unlike every gap named in the header,
|
|
365
|
+
* which merely omits a call site, this reports a BARE invocation as already
|
|
366
|
+
* hardened — and the `0 bare` that results is the number a sweep declares
|
|
367
|
+
* itself finished on. A `-R` that sits OUTSIDE the substitution is still the
|
|
368
|
+
* outer command's own, substitution-valued or not: `-R $(cat repo.txt)`
|
|
369
|
+
* counts.
|
|
370
|
+
*
|
|
371
|
+
* The outer projection replaces each substitution with a single space rather
|
|
372
|
+
* than deleting it, so that closing the gap in `-$(x)R` cannot weld a `-R` into
|
|
373
|
+
* existence.
|
|
374
|
+
*
|
|
375
|
+
* Two named ceilings, both landing on today's verdict so neither is a
|
|
376
|
+
* regression; revisit if a call site in this corpus ever takes either shape.
|
|
377
|
+
* `$((` arithmetic expansion reads as a substitution plus a plain paren, so a
|
|
378
|
+
* segment containing one is cut at its second `)` — exactly where the previous
|
|
379
|
+
* `)`-stop cut it. Quote state is not tracked: `'$(x)'` is a literal string to a
|
|
380
|
+
* real shell and a substitution to this walk, which reaches the same verdict
|
|
381
|
+
* (the `-R` inside is not this command's flag) by a more generous route.
|
|
382
|
+
*
|
|
383
|
+
* @param {string} rest text following the matched CLI token
|
|
384
|
+
* @returns {{segment: string, outer: string}} full segment, and its depth-0 projection
|
|
385
|
+
*/
|
|
386
|
+
function sliceInvocation(rest) {
|
|
387
|
+
let segment = '';
|
|
388
|
+
let outer = '';
|
|
389
|
+
let depth = 0;
|
|
390
|
+
let inBacktick = false;
|
|
391
|
+
|
|
392
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
393
|
+
const char = rest[index];
|
|
394
|
+
const pair = rest.slice(index, index + 2);
|
|
395
|
+
|
|
396
|
+
if (depth === 0 && !inBacktick) {
|
|
397
|
+
if (pair === '||' || pair === '&&' || char === '|' || char === ';' || char === ')') break;
|
|
398
|
+
if (pair === '$(') {
|
|
399
|
+
depth = 1;
|
|
400
|
+
segment += pair;
|
|
401
|
+
outer += ' ';
|
|
402
|
+
index += 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (char === '`') {
|
|
406
|
+
inBacktick = true;
|
|
407
|
+
segment += char;
|
|
408
|
+
outer += ' ';
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
segment += char;
|
|
412
|
+
outer += char;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
segment += char;
|
|
417
|
+
if (inBacktick) {
|
|
418
|
+
if (char === '`') inBacktick = false;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (pair === '$(') {
|
|
422
|
+
depth += 1;
|
|
423
|
+
segment += '(';
|
|
424
|
+
index += 1;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (char === ')') depth -= 1;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { segment, outer };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Snippet budget — see § stdout budget at the CLI guard. */
|
|
434
|
+
const SNIPPET_MAX = 200;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @typedef {{
|
|
438
|
+
* kind: 'missing-repo-flag-doc' | 'missing-repo-flag-code' | 'tool-error',
|
|
439
|
+
* file: string,
|
|
440
|
+
* line: number,
|
|
441
|
+
* cli: 'gh' | 'glab' | '-',
|
|
442
|
+
* command: string,
|
|
443
|
+
* message: string,
|
|
444
|
+
* }} Finding
|
|
445
|
+
*/
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* @typedef {{
|
|
449
|
+
* applicable: number,
|
|
450
|
+
* notApplicable: number,
|
|
451
|
+
* positionalRequired: number,
|
|
452
|
+
* withFlag: number,
|
|
453
|
+
* explicitPositional: number,
|
|
454
|
+
* unknownSubcommand: number,
|
|
455
|
+
* skippedLeadingFlag: number,
|
|
456
|
+
* }} Tally
|
|
457
|
+
*/
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Recursively collect scannable files, skipping symlinks and excluded dirs.
|
|
461
|
+
*
|
|
462
|
+
* @param {string} directory absolute directory path
|
|
463
|
+
* @param {string[]} [acc]
|
|
464
|
+
* @returns {string[]} absolute file paths
|
|
465
|
+
*/
|
|
466
|
+
function walk(directory, acc = []) {
|
|
467
|
+
if (!existsSync(directory)) return acc;
|
|
468
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
469
|
+
if (entry.isSymbolicLink()) continue;
|
|
470
|
+
if (EXCLUDED_DIRS.includes(entry.name)) continue;
|
|
471
|
+
const fullPath = path.join(directory, entry.name);
|
|
472
|
+
if (entry.isDirectory()) {
|
|
473
|
+
walk(fullPath, acc);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!entry.isFile()) continue;
|
|
477
|
+
const extension = path.extname(entry.name);
|
|
478
|
+
if (DOC_EXTENSIONS.includes(extension) || CODE_EXTENSIONS.includes(extension)) acc.push(fullPath);
|
|
479
|
+
}
|
|
480
|
+
return acc;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** @param {string} text @returns {string} clamped, single-line snippet */
|
|
484
|
+
function clampSnippet(text) {
|
|
485
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
486
|
+
return flat.length > SNIPPET_MAX ? `${flat.slice(0, SNIPPET_MAX - 1)}…` : flat;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Extract every `gh`/`glab` invocation from one logical (continuation-joined)
|
|
491
|
+
* command line, classifying each against the subcommand + D-NA tables.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} text the logical command line
|
|
494
|
+
* @param {Tally} tally mutated in place
|
|
495
|
+
* @returns {{cli: 'gh'|'glab', command: string, segment: string}[]} findings-worthy invocations
|
|
496
|
+
*/
|
|
497
|
+
export function extractBareInvocations(text, tally) {
|
|
498
|
+
/** @type {{cli: 'gh'|'glab', command: string, segment: string}[]} */
|
|
499
|
+
const bare = [];
|
|
500
|
+
COMMAND_RE.lastIndex = 0;
|
|
501
|
+
/** @type {RegExpExecArray | null} */
|
|
502
|
+
let matched;
|
|
503
|
+
while ((matched = COMMAND_RE.exec(text)) !== null) {
|
|
504
|
+
const cli = /** @type {'gh'|'glab'} */ (matched[1]);
|
|
505
|
+
const { segment, outer } = sliceInvocation(text.slice(matched.index + matched[0].length));
|
|
506
|
+
|
|
507
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
508
|
+
if (tokens.length === 0) continue;
|
|
509
|
+
if (tokens[0].startsWith('-')) {
|
|
510
|
+
tally.skippedLeadingFlag += 1;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const head = tokens[0];
|
|
514
|
+
if (!SUBCOMMANDS[cli].has(head)) {
|
|
515
|
+
tally.unknownSubcommand += 1;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (NOT_APPLICABLE[cli].has(head)) {
|
|
519
|
+
tally.notApplicable += 1;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
tally.applicable += 1;
|
|
524
|
+
// `outer`, never `segment`: a `-R` inside a command substitution belongs to
|
|
525
|
+
// the inner command — see `sliceInvocation` job 2.
|
|
526
|
+
if (REPO_FLAG_RE.test(outer)) {
|
|
527
|
+
tally.withFlag += 1;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (repoFlagBlockedByMissingPositional(cli, head, tokens[1], tokens.slice(2))) {
|
|
531
|
+
tally.positionalRequired += 1;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (POSITIONAL_REPO_GROUPS[cli].has(head) && looksPositional(tokens[2])) {
|
|
535
|
+
tally.explicitPositional += 1;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const second = tokens[1] && !tokens[1].startsWith('-') ? ` ${tokens[1]}` : '';
|
|
539
|
+
bare.push({ cli, command: `${cli} ${head}${second}`, segment });
|
|
540
|
+
}
|
|
541
|
+
return bare;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Join `\`-continuation lines into logical lines, keeping the START line number.
|
|
546
|
+
*
|
|
547
|
+
* @param {{line: number, text: string}[]} entries
|
|
548
|
+
* @returns {{line: number, text: string}[]}
|
|
549
|
+
*/
|
|
550
|
+
function joinContinuations(entries) {
|
|
551
|
+
/** @type {{line: number, text: string}[]} */
|
|
552
|
+
const joined = [];
|
|
553
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
554
|
+
const { line } = entries[index];
|
|
555
|
+
let { text } = entries[index];
|
|
556
|
+
while (/\\\s*$/.test(text) && index + 1 < entries.length && entries[index + 1].line === entries[index].line + 1) {
|
|
557
|
+
index += 1;
|
|
558
|
+
text = `${text.replace(/\\\s*$/, '')} ${entries[index].text.trim()}`;
|
|
559
|
+
}
|
|
560
|
+
joined.push({ line, text });
|
|
561
|
+
}
|
|
562
|
+
return joined;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Scan a markdown file: fence state machine → shell fences only → drop shell
|
|
567
|
+
* comments → join continuations → classify.
|
|
568
|
+
*
|
|
569
|
+
* @param {string} relative repo-relative path
|
|
570
|
+
* @param {string} body file content
|
|
571
|
+
* @param {Tally} tally mutated in place
|
|
572
|
+
* @returns {Finding[]}
|
|
573
|
+
*/
|
|
574
|
+
export function scanMarkdown(relative, body, tally) {
|
|
575
|
+
/** @type {Finding[]} */
|
|
576
|
+
const findings = [];
|
|
577
|
+
/** @type {{line: number, text: string}[]} */
|
|
578
|
+
const shellLines = [];
|
|
579
|
+
/** @type {{marker: string, length: number, shell: boolean} | null} */
|
|
580
|
+
let fence = null;
|
|
581
|
+
|
|
582
|
+
const lines = body.split('\n');
|
|
583
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
584
|
+
const raw = lines[index];
|
|
585
|
+
const fenceMatch = raw.match(/^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/);
|
|
586
|
+
if (fenceMatch) {
|
|
587
|
+
const marker = fenceMatch[1][0];
|
|
588
|
+
const length = fenceMatch[1].length;
|
|
589
|
+
const lang = fenceMatch[2].toLowerCase();
|
|
590
|
+
if (fence === null) {
|
|
591
|
+
fence = { marker, length, shell: SHELL_LANGS.has(lang) };
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
// A closing fence uses the same char, is at least as long, and has no info string.
|
|
595
|
+
if (marker === fence.marker && length >= fence.length && lang === '') {
|
|
596
|
+
fence = null;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
// Otherwise it is fence content (a nested fence inside a wider one).
|
|
600
|
+
}
|
|
601
|
+
if (fence === null || !fence.shell) continue;
|
|
602
|
+
const stripped = raw.replace(/^\s*[$❯>]\s+/, '');
|
|
603
|
+
if (/^\s*#/.test(stripped)) continue;
|
|
604
|
+
shellLines.push({ line: index + 1, text: stripped });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const entry of joinContinuations(shellLines)) {
|
|
608
|
+
for (const hit of extractBareInvocations(entry.text, tally)) {
|
|
609
|
+
findings.push({
|
|
610
|
+
kind: 'missing-repo-flag-doc',
|
|
611
|
+
file: relative,
|
|
612
|
+
line: entry.line,
|
|
613
|
+
cli: hit.cli,
|
|
614
|
+
command: hit.command,
|
|
615
|
+
message:
|
|
616
|
+
`\`${hit.command}\` runs without --repo/-R — the target project comes from the ambient ` +
|
|
617
|
+
`cwd remote: ${clampSnippet(`${hit.cli}${hit.segment}`)}`,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return findings;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* A `gh`/`glab` token that OPENS a string literal (quote, optional whitespace,
|
|
626
|
+
* then the CLI name). Filter 3: only such a literal is a shell command handed to
|
|
627
|
+
* something; a CLI name in the middle of a string is a message about a command.
|
|
628
|
+
*/
|
|
629
|
+
const CODE_STRING_CMD_RE = /(['"`])\s*(?=(?:gh|glab)\s)/g;
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Scan a literal `execFile*`/`spawn*` argv array. A non-literal args argument is
|
|
633
|
+
* counted as `unresolvedArgv` and never judged.
|
|
634
|
+
*
|
|
635
|
+
* @param {string} body file content
|
|
636
|
+
* @returns {{literals: {index: number, cli: 'gh'|'glab', args: string[]}[], unresolved: number}}
|
|
637
|
+
*/
|
|
638
|
+
export function scanArgvCalls(body) {
|
|
639
|
+
const callRe = /\b(?:execFileSync|execFile|spawnSync|spawn)\s*\(\s*['"](gh|glab)['"]\s*,\s*/g;
|
|
640
|
+
/** @type {{index: number, cli: 'gh'|'glab', args: string[]}[]} */
|
|
641
|
+
const literals = [];
|
|
642
|
+
let unresolved = 0;
|
|
643
|
+
/** @type {RegExpExecArray | null} */
|
|
644
|
+
let matched;
|
|
645
|
+
while ((matched = callRe.exec(body)) !== null) {
|
|
646
|
+
const after = body.slice(matched.index + matched[0].length);
|
|
647
|
+
if (!after.startsWith('[')) {
|
|
648
|
+
unresolved += 1;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
let depth = 0;
|
|
652
|
+
let end = -1;
|
|
653
|
+
for (let index = 0; index < after.length; index += 1) {
|
|
654
|
+
const char = after[index];
|
|
655
|
+
if (char === '[') depth += 1;
|
|
656
|
+
else if (char === ']') {
|
|
657
|
+
depth -= 1;
|
|
658
|
+
if (depth === 0) {
|
|
659
|
+
end = index;
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (end === -1) {
|
|
665
|
+
unresolved += 1;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const inner = after.slice(1, end);
|
|
669
|
+
if (inner.includes('...') || /\$\{/.test(inner)) {
|
|
670
|
+
unresolved += 1;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const args = [...inner.matchAll(/['"]([^'"]*)['"]/g)].map((entry) => entry[1]);
|
|
674
|
+
literals.push({ index: matched.index, cli: /** @type {'gh'|'glab'} */ (matched[1]), args });
|
|
675
|
+
}
|
|
676
|
+
return { literals, unresolved };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Scan a JS/MJS file: comment lines are prose; a match counts only when it
|
|
681
|
+
* starts an interpolation-free string literal. Literal argv arrays are judged
|
|
682
|
+
* separately.
|
|
683
|
+
*
|
|
684
|
+
* @param {string} relative repo-relative path
|
|
685
|
+
* @param {string} body file content
|
|
686
|
+
* @param {Tally} tally mutated in place
|
|
687
|
+
* @returns {{findings: Finding[], unresolvedArgv: number}}
|
|
688
|
+
*/
|
|
689
|
+
export function scanCode(relative, body, tally) {
|
|
690
|
+
/** @type {Finding[]} */
|
|
691
|
+
const findings = [];
|
|
692
|
+
const lines = body.split('\n');
|
|
693
|
+
|
|
694
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
695
|
+
const raw = lines[index];
|
|
696
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(raw)) continue;
|
|
697
|
+
CODE_STRING_CMD_RE.lastIndex = 0;
|
|
698
|
+
/** @type {RegExpExecArray | null} */
|
|
699
|
+
let matched;
|
|
700
|
+
while ((matched = CODE_STRING_CMD_RE.exec(raw)) !== null) {
|
|
701
|
+
const quote = matched[1];
|
|
702
|
+
const contentStart = matched.index + matched[0].length;
|
|
703
|
+
const closeAt = raw.indexOf(quote, contentStart);
|
|
704
|
+
const literal = closeAt === -1 ? raw.slice(contentStart) : raw.slice(contentStart, closeAt);
|
|
705
|
+
if (literal.includes('${')) continue;
|
|
706
|
+
for (const hit of extractBareInvocations(literal.trim(), tally)) {
|
|
707
|
+
findings.push({
|
|
708
|
+
kind: 'missing-repo-flag-code',
|
|
709
|
+
file: relative,
|
|
710
|
+
line: index + 1,
|
|
711
|
+
cli: hit.cli,
|
|
712
|
+
command: hit.command,
|
|
713
|
+
message:
|
|
714
|
+
`\`${hit.command}\` in a shell-command string runs without --repo/-R — the target project ` +
|
|
715
|
+
`comes from the ambient cwd remote: ${clampSnippet(`${hit.cli}${hit.segment}`)}`,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const { literals, unresolved } = scanArgvCalls(body);
|
|
722
|
+
for (const call of literals) {
|
|
723
|
+
const head = call.args[0];
|
|
724
|
+
if (!head || head.startsWith('-')) {
|
|
725
|
+
tally.skippedLeadingFlag += 1;
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (!SUBCOMMANDS[call.cli].has(head)) {
|
|
729
|
+
tally.unknownSubcommand += 1;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (NOT_APPLICABLE[call.cli].has(head)) {
|
|
733
|
+
tally.notApplicable += 1;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
tally.applicable += 1;
|
|
737
|
+
if (call.args.some((arg) => arg === '--repo' || arg === '-R' || arg.startsWith('--repo='))) {
|
|
738
|
+
tally.withFlag += 1;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (repoFlagBlockedByMissingPositional(call.cli, head, call.args[1], call.args.slice(2))) {
|
|
742
|
+
tally.positionalRequired += 1;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (POSITIONAL_REPO_GROUPS[call.cli].has(head) && looksPositional(call.args[2])) {
|
|
746
|
+
tally.explicitPositional += 1;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const line = body.slice(0, call.index).split('\n').length;
|
|
750
|
+
const second = call.args[1] && !call.args[1].startsWith('-') ? ` ${call.args[1]}` : '';
|
|
751
|
+
findings.push({
|
|
752
|
+
kind: 'missing-repo-flag-code',
|
|
753
|
+
file: relative,
|
|
754
|
+
line,
|
|
755
|
+
cli: call.cli,
|
|
756
|
+
command: `${call.cli} ${head}${second}`,
|
|
757
|
+
message:
|
|
758
|
+
`\`${call.cli} ${head}${second}\` argv array carries no --repo/-R — the target project comes ` +
|
|
759
|
+
`from the ambient cwd remote: ${clampSnippet(call.args.join(' '))}`,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return { findings, unresolvedArgv: unresolved };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Run the full census.
|
|
768
|
+
*
|
|
769
|
+
* @param {string} pluginRoot absolute plugin root
|
|
770
|
+
* @returns {{
|
|
771
|
+
* ok: boolean,
|
|
772
|
+
* summary: {
|
|
773
|
+
* filesScanned: number, docFiles: number, codeFiles: number,
|
|
774
|
+
* applicable: number, notApplicable: number, positionalRequired: number, withFlag: number,
|
|
775
|
+
* explicitPositional: number, unknownSubcommand: number, skippedLeadingFlag: number,
|
|
776
|
+
* unresolvedArgv: number, findings: number,
|
|
777
|
+
* byKind: Record<string, number>,
|
|
778
|
+
* },
|
|
779
|
+
* findings: Finding[],
|
|
780
|
+
* toolError: boolean,
|
|
781
|
+
* }}
|
|
782
|
+
*/
|
|
783
|
+
export function inspectVcsRepoFlag(pluginRoot) {
|
|
784
|
+
/** @type {Finding[]} */
|
|
785
|
+
const findings = [];
|
|
786
|
+
/** @type {Tally} */
|
|
787
|
+
const tally = {
|
|
788
|
+
applicable: 0,
|
|
789
|
+
notApplicable: 0,
|
|
790
|
+
positionalRequired: 0,
|
|
791
|
+
withFlag: 0,
|
|
792
|
+
explicitPositional: 0,
|
|
793
|
+
unknownSubcommand: 0,
|
|
794
|
+
skippedLeadingFlag: 0,
|
|
795
|
+
};
|
|
796
|
+
const result = {
|
|
797
|
+
ok: false,
|
|
798
|
+
summary: {
|
|
799
|
+
filesScanned: 0,
|
|
800
|
+
docFiles: 0,
|
|
801
|
+
codeFiles: 0,
|
|
802
|
+
...tally,
|
|
803
|
+
unresolvedArgv: 0,
|
|
804
|
+
findings: 0,
|
|
805
|
+
/** @type {Record<string, number>} */
|
|
806
|
+
byKind: {},
|
|
807
|
+
},
|
|
808
|
+
findings,
|
|
809
|
+
toolError: false,
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
/** @type {string[]} */
|
|
813
|
+
let files;
|
|
814
|
+
try {
|
|
815
|
+
files = SCAN_DIRS.flatMap((dir) => walk(path.join(pluginRoot, dir)));
|
|
816
|
+
for (const entry of readdirSync(pluginRoot, { withFileTypes: true })) {
|
|
817
|
+
if (entry.isFile() && DOC_EXTENSIONS.includes(path.extname(entry.name))) {
|
|
818
|
+
files.push(path.join(pluginRoot, entry.name));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
files.sort();
|
|
822
|
+
} catch (error) {
|
|
823
|
+
result.toolError = true;
|
|
824
|
+
findings.push({
|
|
825
|
+
kind: 'tool-error',
|
|
826
|
+
file: '-',
|
|
827
|
+
line: 0,
|
|
828
|
+
cli: '-',
|
|
829
|
+
command: '-',
|
|
830
|
+
message: `cannot enumerate the scan corpus: ${error instanceof Error ? error.message : String(error)}`,
|
|
831
|
+
});
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
let unresolvedArgv = 0;
|
|
836
|
+
let docFiles = 0;
|
|
837
|
+
let codeFiles = 0;
|
|
838
|
+
|
|
839
|
+
for (const absolute of files) {
|
|
840
|
+
const relative = path.relative(pluginRoot, absolute);
|
|
841
|
+
/** @type {string} */
|
|
842
|
+
let body;
|
|
843
|
+
try {
|
|
844
|
+
body = readFileSync(absolute, 'utf8');
|
|
845
|
+
} catch (error) {
|
|
846
|
+
result.toolError = true;
|
|
847
|
+
findings.push({
|
|
848
|
+
kind: 'tool-error',
|
|
849
|
+
file: relative,
|
|
850
|
+
line: 0,
|
|
851
|
+
cli: '-',
|
|
852
|
+
command: '-',
|
|
853
|
+
message: `cannot read: ${error instanceof Error ? error.message : String(error)}`,
|
|
854
|
+
});
|
|
855
|
+
return result;
|
|
856
|
+
}
|
|
857
|
+
if (!/\b(?:gh|glab)\b/.test(body)) {
|
|
858
|
+
if (DOC_EXTENSIONS.includes(path.extname(absolute))) docFiles += 1;
|
|
859
|
+
else codeFiles += 1;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (DOC_EXTENSIONS.includes(path.extname(absolute))) {
|
|
863
|
+
docFiles += 1;
|
|
864
|
+
findings.push(...scanMarkdown(relative, body, tally));
|
|
865
|
+
} else {
|
|
866
|
+
codeFiles += 1;
|
|
867
|
+
const scanned = scanCode(relative, body, tally);
|
|
868
|
+
findings.push(...scanned.findings);
|
|
869
|
+
unresolvedArgv += scanned.unresolvedArgv;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
874
|
+
|
|
875
|
+
/** @type {Record<string, number>} */
|
|
876
|
+
const byKind = {};
|
|
877
|
+
for (const item of findings) byKind[item.kind] = (byKind[item.kind] ?? 0) + 1;
|
|
878
|
+
|
|
879
|
+
result.summary = {
|
|
880
|
+
filesScanned: files.length,
|
|
881
|
+
docFiles,
|
|
882
|
+
codeFiles,
|
|
883
|
+
...tally,
|
|
884
|
+
unresolvedArgv,
|
|
885
|
+
findings: findings.length,
|
|
886
|
+
byKind,
|
|
887
|
+
};
|
|
888
|
+
result.ok = findings.length === 0;
|
|
889
|
+
return result;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Run the human-readable validator CLI.
|
|
894
|
+
*
|
|
895
|
+
* WARN-ONLY: findings print as WARN and still return 0. `FAIL:` is emitted only
|
|
896
|
+
* on the tool-error path — see § Mode in the header for why a single stray
|
|
897
|
+
* `FAIL:` line would red the whole `validate-plugin.mjs` gate.
|
|
898
|
+
*
|
|
899
|
+
* @param {string} pluginRoot absolute plugin root
|
|
900
|
+
* @returns {number} 0 = census completed (with or without findings), 2 = tool error
|
|
901
|
+
*/
|
|
902
|
+
export function runCheckVcsRepoFlag(pluginRoot) {
|
|
903
|
+
console.log('--- Check: gh/glab invocations without --repo/-R (WARN-only) ---');
|
|
904
|
+
const inspection = inspectVcsRepoFlag(pluginRoot);
|
|
905
|
+
|
|
906
|
+
if (inspection.toolError) {
|
|
907
|
+
for (const item of inspection.findings) console.log(` FAIL: ${item.file} — ${item.message}`);
|
|
908
|
+
console.log('');
|
|
909
|
+
console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
|
|
910
|
+
return 2;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
for (const item of inspection.findings) {
|
|
914
|
+
console.log(` WARN: [${item.kind}] ${item.file}:${item.line} — ${item.message}`);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const s = inspection.summary;
|
|
918
|
+
console.log(
|
|
919
|
+
` PASS: censused ${s.filesScanned} file(s) (${s.docFiles} doc + ${s.codeFiles} code) — ` +
|
|
920
|
+
`${s.applicable} repo-scoped invocation(s), ${s.withFlag} already pass --repo/-R, ` +
|
|
921
|
+
`${s.explicitPositional} name the repo positionally, ${s.findings} bare; ` +
|
|
922
|
+
`${s.notApplicable} where --repo does not apply, ${s.positionalRequired} where --repo needs a ` +
|
|
923
|
+
`positional the call omits, ${s.unknownSubcommand} prose match(es) dropped, ` +
|
|
924
|
+
`${s.unresolvedArgv} variable argv array(s) unjudged`,
|
|
925
|
+
);
|
|
926
|
+
console.log('');
|
|
927
|
+
console.log('Results: 1 passed, 0 failed');
|
|
928
|
+
return 0;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
|
|
932
|
+
if (isMain) {
|
|
933
|
+
const argv = process.argv.slice(2);
|
|
934
|
+
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
|
935
|
+
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
936
|
+
const usage =
|
|
937
|
+
'Usage: check-vcs-repo-flag.mjs [<plugin-root>] [--json]\n' +
|
|
938
|
+
' --json emit the inspection envelope as a single JSON object on stdout\n' +
|
|
939
|
+
'Exit: 0 census completed (findings are WARN-only) · 1 usage error · 2 tool error';
|
|
940
|
+
|
|
941
|
+
if (flags.has('--help')) {
|
|
942
|
+
console.log(usage);
|
|
943
|
+
process.exitCode = 0;
|
|
944
|
+
} else {
|
|
945
|
+
const unknown = [...flags].filter((f) => f !== '--json' && f !== '--help');
|
|
946
|
+
if (unknown.length > 0) {
|
|
947
|
+
console.error(`Unknown flag(s): ${unknown.join(', ')}\n${usage}`);
|
|
948
|
+
process.exitCode = 1;
|
|
949
|
+
} else {
|
|
950
|
+
const pluginRoot = path.resolve(positional[0] ?? process.cwd());
|
|
951
|
+
if (flags.has('--json')) {
|
|
952
|
+
const inspection = inspectVcsRepoFlag(pluginRoot);
|
|
953
|
+
// Data on stdout, diagnostics on stderr (cli-design.md).
|
|
954
|
+
console.log(JSON.stringify(inspection, null, 2));
|
|
955
|
+
process.exitCode = inspection.toolError ? 2 : 0;
|
|
956
|
+
} else {
|
|
957
|
+
process.exitCode = runCheckVcsRepoFlag(pluginRoot);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
// Deliberately NOT `process.exit()`: on a pipe, exiting discards stdout writes
|
|
962
|
+
// still queued in the async write buffer, and the `--json` envelope of this
|
|
963
|
+
// census can outgrow the ~64 KiB pipe capacity. Setting exitCode lets the
|
|
964
|
+
// writes drain first. Snippets are clamped to SNIPPET_MAX for the same reason.
|
|
965
|
+
}
|