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,665 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-test-git-config-target.mjs — census of state-MUTATING `git` invocations
|
|
4
|
+
* in `tests/**` that name no explicit target.
|
|
5
|
+
*
|
|
6
|
+
* ## The defect class (2026-08-19 incident)
|
|
7
|
+
*
|
|
8
|
+
* A `git` command that mutates repository state and passes neither `-C <path>`
|
|
9
|
+
* nor a `cwd:` option resolves its target from AMBIENT state: the process's
|
|
10
|
+
* working directory, or an inherited `GIT_DIR`/`GIT_WORK_TREE`. In a test that
|
|
11
|
+
* is invisible and usually harmless — vitest's cwd is the repo root, so the
|
|
12
|
+
* fixture "just works" — right up until the mutation lands in the developer's
|
|
13
|
+
* REAL repository.
|
|
14
|
+
*
|
|
15
|
+
* That happened. A coordinator exported `GIT_DIR="$PWD/.git"` for a hook
|
|
16
|
+
* diagnostic; the export survived into the test run, and because `GIT_DIR`
|
|
17
|
+
* outranks `-C`, fixture helpers retargeted at the real repo: a detached HEAD,
|
|
18
|
+
* three fixture commits, and three entries in `.git/config` (a fixture remote,
|
|
19
|
+
* a fixture `user.email`/`user.name`, `commit.gpgsign=false`). The config
|
|
20
|
+
* entries are invisible to `git status`; two commits reached GitLab AND the
|
|
21
|
+
* public GitHub mirror with the wrong author before anyone noticed.
|
|
22
|
+
*
|
|
23
|
+
* `scripts/lib/git-config-drift.mjs` is the runtime symptom probe for that
|
|
24
|
+
* incident. THIS check is the static half: it finds the call sites where a
|
|
25
|
+
* mutation's destination is decided by ambient state rather than by the call.
|
|
26
|
+
*
|
|
27
|
+
* ## What it does NOT catch — read before quoting a clean run
|
|
28
|
+
*
|
|
29
|
+
* **The incident's own two call sites pass this check.** Measured
|
|
30
|
+
* 2026-08-19: `tests/skills/claude-md-drift-check/checker.test.mjs:355` and
|
|
31
|
+
* `:399` run `spawnSync('git', ['remote','add','gitlab', <fixture url>], { cwd: vault })`
|
|
32
|
+
* — an explicit, correct `cwd`. They still wrote into the real repository,
|
|
33
|
+
* because an inherited `GIT_DIR` beats `cwd` and `-C` alike.
|
|
34
|
+
*
|
|
35
|
+
* So this check addresses a REAL but DIFFERENT subclass (a mutation with no
|
|
36
|
+
* declared destination at all) and is not, on its own, the root-cause gate for
|
|
37
|
+
* the incident. The root-cause gate for the `GIT_DIR` half is environmental —
|
|
38
|
+
* scrubbing `GIT_DIR`/`GIT_WORK_TREE` once, centrally, before any test runs —
|
|
39
|
+
* and lives outside this file. The `gitDirInheritable` counter in the summary
|
|
40
|
+
* measures that second population so the gap is visible in the OUTPUT, not
|
|
41
|
+
* only in this comment.
|
|
42
|
+
*
|
|
43
|
+
* Further named gaps:
|
|
44
|
+
* - **A non-literal argv array is never judged.** `execFileSync('git', args, …)`
|
|
45
|
+
* cannot be classified from the call site; counted as `unresolvedArgv`.
|
|
46
|
+
* - **A spread before the subcommand** (`['-C', dir, ...args]`) leaves the
|
|
47
|
+
* subcommand unknown; the target IS resolvable, so such sites are simply
|
|
48
|
+
* not findings.
|
|
49
|
+
* - **The options object is matched textually** inside the call expression: a
|
|
50
|
+
* `cwd:` anywhere in it counts as an explicit target. A `cwd` passed as an
|
|
51
|
+
* opaque variable (`execFileSync('git', [...], opts)`) is counted as
|
|
52
|
+
* `unresolvedOptions` and never reported.
|
|
53
|
+
* - **Only `.mjs`/`.js`/`.cjs` under `tests/` are scanned**, and files are
|
|
54
|
+
* read with `readFileSync`, never a `grep` spawn — one NUL byte makes a
|
|
55
|
+
* text file invisible to grep-based audits (see
|
|
56
|
+
* `.claude/rules/anti-pattern-a-nul-byte-in-a-tracked-production-file-....md`).
|
|
57
|
+
* - **Comment lines are skipped**, so a documented counter-example in a
|
|
58
|
+
* docblock is not a finding.
|
|
59
|
+
* - **A match inside a string literal is fixture TEXT, not a call site.**
|
|
60
|
+
* Without that filter this check reported 8 findings in its OWN test file,
|
|
61
|
+
* whose positive cases are the anti-pattern written as strings; 23 such
|
|
62
|
+
* matches exist across `tests/` (2026-08-19). See {@link insideStringLiteral}
|
|
63
|
+
* for the residual multi-line-template limit.
|
|
64
|
+
*
|
|
65
|
+
* ## Mode: WARN, never blocking
|
|
66
|
+
*
|
|
67
|
+
* Findings print as `WARN:` and the runner returns 0. `FAIL:` is reserved for
|
|
68
|
+
* the tool-error path. This is load-bearing, not stylistic:
|
|
69
|
+
* `scripts/validate-plugin.mjs` tallies `^[ ]{2}FAIL:` lines from EVERY
|
|
70
|
+
* sub-check into a module-wide counter and exits 1 when it is non-zero — the
|
|
71
|
+
* sub-check's own exit code is discarded for WARN-only checks. A single
|
|
72
|
+
* `FAIL:` line here would red the whole validator, and with it the spawner
|
|
73
|
+
* tests that run it. Same posture, and same reason, as
|
|
74
|
+
* `check-vcs-repo-flag.mjs` and `check-unwired-features.mjs`.
|
|
75
|
+
*
|
|
76
|
+
* Import-safety: importing this module exposes the inspector and runner only;
|
|
77
|
+
* the CLI path is guarded at the bottom of the file.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
81
|
+
import path from 'node:path';
|
|
82
|
+
import { pathToFileURL } from 'node:url';
|
|
83
|
+
|
|
84
|
+
/** Only this directory is scanned — a mutation in `scripts/` is production intent. */
|
|
85
|
+
const SCAN_DIR = 'tests';
|
|
86
|
+
|
|
87
|
+
/** Extensions scanned. `.md` fixtures under tests/ are prose, not call sites. */
|
|
88
|
+
const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Subcommands that MUTATE repository state, and the argument shapes that make
|
|
92
|
+
* an otherwise-mutating subcommand read-only.
|
|
93
|
+
*
|
|
94
|
+
* The set is the one named in the incident report — `config`, `remote`,
|
|
95
|
+
* `init`, `commit`, `checkout`, `reset`, `add`, `tag` — plus the four that
|
|
96
|
+
* mutate through the same door and appear in this repo's fixtures (`rm`, `mv`,
|
|
97
|
+
* `stash`, `branch`). It is deliberately NOT every mutating git subcommand:
|
|
98
|
+
* a wider set buys no additional true positive here (measured: the same
|
|
99
|
+
* finding list) and each extra entry is an extra false-positive surface.
|
|
100
|
+
*
|
|
101
|
+
* `readOnlyWhen` is consulted against the argv AFTER the subcommand.
|
|
102
|
+
*
|
|
103
|
+
* @type {ReadonlyMap<string, {readOnlyWhen?: RegExp[]}>}
|
|
104
|
+
*/
|
|
105
|
+
const MUTATING_SUBCOMMANDS = new Map([
|
|
106
|
+
// `git config --get/--list/--get-all/--get-regexp` reads; anything else writes.
|
|
107
|
+
['config', { readOnlyWhen: [/^--(get|get-all|get-regexp|get-urlmatch|list|l)$/, /^-l$/] }],
|
|
108
|
+
// `git remote` alone (or `-v`/`show`/`get-url`) reads; add/set-url/remove/rename write.
|
|
109
|
+
['remote', { readOnlyWhen: [/^(-v|--verbose|show|get-url)$/] }],
|
|
110
|
+
['init', {}],
|
|
111
|
+
['commit', {}],
|
|
112
|
+
['checkout', {}],
|
|
113
|
+
['reset', {}],
|
|
114
|
+
['add', {}],
|
|
115
|
+
// `git tag -l/--list` reads.
|
|
116
|
+
['tag', { readOnlyWhen: [/^(-l|--list|--contains|--points-at)$/] }],
|
|
117
|
+
['rm', {}],
|
|
118
|
+
['mv', {}],
|
|
119
|
+
['stash', {}],
|
|
120
|
+
// `git branch` alone / `-l` / `--list` / `-a` reads; -d/-D/-m/-M/<name> writes.
|
|
121
|
+
['branch', { readOnlyWhen: [/^(-l|--list|-a|--all|-r|--remotes|--show-current|-v|--verbose)$/] }],
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Subcommands whose own POSITIONAL argument names the repository, so the call
|
|
126
|
+
* is explicitly targeted without `-C` or `cwd:`.
|
|
127
|
+
*
|
|
128
|
+
* This is not a refinement invented at the desk — it is the entire first
|
|
129
|
+
* measurement. The v1 rule (no `-C`, no `cwd:`) reported **11 findings against
|
|
130
|
+
* `tests/` on 2026-08-19, and all 11 were `git init [-q] <dir>`**: a 100%
|
|
131
|
+
* false-positive rate on a shape that is not merely acceptable but the
|
|
132
|
+
* canonical way to create a fixture repo.
|
|
133
|
+
*
|
|
134
|
+
* `clone` is deliberately ABSENT: it creates a new repository rather than
|
|
135
|
+
* mutating an existing one, so it is not in {@link MUTATING_SUBCOMMANDS} to
|
|
136
|
+
* begin with.
|
|
137
|
+
*
|
|
138
|
+
* Maps the subcommand to the flags that CONSUME the following token, so
|
|
139
|
+
* `git init -b main` does not read `main` as the target directory.
|
|
140
|
+
*
|
|
141
|
+
* @type {ReadonlyMap<string, string[]>}
|
|
142
|
+
*/
|
|
143
|
+
const POSITIONAL_TARGET_SUBCOMMANDS = new Map([
|
|
144
|
+
['init', ['-b', '--initial-branch', '--template', '--separate-git-dir', '--object-format', '--ref-format', '--shared']],
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `git config` scope flags that move the write OFF the repository entirely.
|
|
149
|
+
* `--global` writes `~/.gitconfig`, `--system` the machine config, `--file`
|
|
150
|
+
* a named file — none of them needs a repo target, so none is a finding.
|
|
151
|
+
*/
|
|
152
|
+
const CONFIG_NON_REPO_SCOPES = Object.freeze(['--global', '--system', '--file', '--blob']);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `git` global options that consume the NEXT argv token. Needed so the
|
|
156
|
+
* subcommand scan does not mistake an option's VALUE for the subcommand.
|
|
157
|
+
*/
|
|
158
|
+
const VALUE_TAKING_GLOBALS = Object.freeze(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
|
|
159
|
+
|
|
160
|
+
/** `git` global options that declare an explicit repository target. */
|
|
161
|
+
const TARGET_DECLARING_GLOBALS = Object.freeze(['-C', '--git-dir', '--work-tree']);
|
|
162
|
+
|
|
163
|
+
/** Call shapes that hand an argv ARRAY to a `git` binary. */
|
|
164
|
+
const ARGV_CALL_RE = /\b(?:execFileSync|execFile|spawnSync|spawn)\s*\(\s*['"]git['"]\s*,\s*/g;
|
|
165
|
+
|
|
166
|
+
/** Call shapes that hand a SHELL STRING opening with `git` to a shell. */
|
|
167
|
+
const SHELL_CALL_RE = /\b(?:execSync|exec)\s*\(\s*(['"`])\s*git\s/g;
|
|
168
|
+
|
|
169
|
+
/** Matches a `cwd:` property in an options object. */
|
|
170
|
+
const CWD_OPTION_RE = /\bcwd\s*:/;
|
|
171
|
+
|
|
172
|
+
/** Matches a `GIT_DIR`/`GIT_WORK_TREE` mention (an explicit env target). */
|
|
173
|
+
const GIT_ENV_TARGET_RE = /\bGIT_(?:DIR|WORK_TREE|COMMON_DIR)\b/;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Matches an options-object argument that is an opaque identifier rather than
|
|
177
|
+
* a literal — `execFileSync('git', [...], opts)`. Such a call cannot be judged.
|
|
178
|
+
*/
|
|
179
|
+
const OPAQUE_OPTIONS_RE = /^\s*,\s*[A-Za-z_$][\w$]*\s*\)/;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Recursively collect scannable files under `dir`. Returns `[]` for a missing
|
|
183
|
+
* directory (the caller reports that as a tool error via an empty corpus).
|
|
184
|
+
*
|
|
185
|
+
* @param {string} dir
|
|
186
|
+
* @returns {string[]}
|
|
187
|
+
*/
|
|
188
|
+
export function walk(dir) {
|
|
189
|
+
/** @type {string[]} */
|
|
190
|
+
const out = [];
|
|
191
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
192
|
+
let entries;
|
|
193
|
+
try {
|
|
194
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
195
|
+
} catch {
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
const full = path.join(dir, entry.name);
|
|
200
|
+
if (entry.isDirectory()) out.push(...walk(full));
|
|
201
|
+
else if (entry.isFile() && CODE_EXTENSIONS.includes(path.extname(entry.name))) out.push(full);
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Slice the balanced-bracket region starting at `open` in `text`.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} text
|
|
210
|
+
* @param {number} open index of the opening bracket
|
|
211
|
+
* @param {string} openChar
|
|
212
|
+
* @param {string} closeChar
|
|
213
|
+
* @returns {number} index of the matching close bracket, or -1
|
|
214
|
+
*/
|
|
215
|
+
function matchBracket(text, open, openChar, closeChar) {
|
|
216
|
+
let depth = 0;
|
|
217
|
+
for (let i = open; i < text.length; i += 1) {
|
|
218
|
+
const ch = text[i];
|
|
219
|
+
if (ch === openChar) depth += 1;
|
|
220
|
+
else if (ch === closeChar) {
|
|
221
|
+
depth -= 1;
|
|
222
|
+
if (depth === 0) return i;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return -1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @typedef {{t:'lit', v:string} | {t:'spread'} | {t:'expr'}} ArgvToken
|
|
230
|
+
* `lit` a string literal · `spread` a `...rest` element · `expr` any other
|
|
231
|
+
* expression (an identifier, a member access, an interpolated template).
|
|
232
|
+
*
|
|
233
|
+
* The `spread`/`expr` split is load-bearing, not cosmetic: `['init','-q',dir]`
|
|
234
|
+
* carries its target in an `expr` positional, while `['-C',dir,...args]`
|
|
235
|
+
* carries an unknowable tail in a `spread`. Folding both to "opaque" is what
|
|
236
|
+
* produced the first measurement's 11 false positives.
|
|
237
|
+
*/
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Tokenize a literal argv array body into ordered tokens.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} inner text between `[` and `]`
|
|
243
|
+
* @returns {ArgvToken[]}
|
|
244
|
+
*/
|
|
245
|
+
export function tokenizeArgv(inner) {
|
|
246
|
+
/** @type {ArgvToken[]} */
|
|
247
|
+
const tokens = [];
|
|
248
|
+
const re = /(['"])((?:\\.|(?!\1)[^\\])*)\1|`([^`$]*)`|(\.\.\.)[\w$.[\]]*|([A-Za-z_$][\w$.[\]]*|`[^`]*`)/g;
|
|
249
|
+
/** @type {RegExpExecArray|null} */
|
|
250
|
+
let m;
|
|
251
|
+
while ((m = re.exec(inner)) !== null) {
|
|
252
|
+
if (m[2] !== undefined) tokens.push({ t: 'lit', v: m[2] });
|
|
253
|
+
else if (m[3] !== undefined) tokens.push({ t: 'lit', v: m[3] });
|
|
254
|
+
else if (m[4] !== undefined) tokens.push({ t: 'spread' });
|
|
255
|
+
else tokens.push({ t: 'expr' });
|
|
256
|
+
}
|
|
257
|
+
return tokens;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Classify a tokenized `git` argv.
|
|
262
|
+
*
|
|
263
|
+
* @param {ArgvToken[]} tokens argv AFTER the `git` binary name
|
|
264
|
+
* @returns {{hasArgvTarget: boolean, subcommand: string|null, rest: ArgvToken[]}}
|
|
265
|
+
*/
|
|
266
|
+
export function classifyArgv(tokens) {
|
|
267
|
+
let hasArgvTarget = false;
|
|
268
|
+
let index = 0;
|
|
269
|
+
while (index < tokens.length) {
|
|
270
|
+
const token = tokens[index];
|
|
271
|
+
// A non-literal in leading-flag position makes the subcommand unknowable;
|
|
272
|
+
// never guess past it.
|
|
273
|
+
if (token.t !== 'lit') return { hasArgvTarget, subcommand: null, rest: [] };
|
|
274
|
+
if (!token.v.startsWith('-')) break;
|
|
275
|
+
|
|
276
|
+
const eq = token.v.indexOf('=');
|
|
277
|
+
const bare = eq === -1 ? token.v : token.v.slice(0, eq);
|
|
278
|
+
if (TARGET_DECLARING_GLOBALS.includes(bare)) hasArgvTarget = true;
|
|
279
|
+
if (eq === -1 && VALUE_TAKING_GLOBALS.includes(bare)) index += 2;
|
|
280
|
+
else index += 1;
|
|
281
|
+
}
|
|
282
|
+
if (index >= tokens.length) return { hasArgvTarget, subcommand: null, rest: [] };
|
|
283
|
+
const head = tokens[index];
|
|
284
|
+
return {
|
|
285
|
+
hasArgvTarget,
|
|
286
|
+
subcommand: head.t === 'lit' ? head.v : null,
|
|
287
|
+
rest: tokens.slice(index + 1),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Decide whether the subcommand's OWN arguments already name the destination:
|
|
293
|
+
* a `git init <dir>` positional, or a `git config --global|--system|--file`
|
|
294
|
+
* scope that does not touch the repository at all.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} subcommand
|
|
297
|
+
* @param {ArgvToken[]} rest argv after the subcommand
|
|
298
|
+
* @returns {boolean}
|
|
299
|
+
*/
|
|
300
|
+
export function hasSubcommandTarget(subcommand, rest) {
|
|
301
|
+
if (subcommand === 'config') {
|
|
302
|
+
return rest.some((token) => {
|
|
303
|
+
if (token.t !== 'lit') return false;
|
|
304
|
+
const eq = token.v.indexOf('=');
|
|
305
|
+
return CONFIG_NON_REPO_SCOPES.includes(eq === -1 ? token.v : token.v.slice(0, eq));
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const valueFlags = POSITIONAL_TARGET_SUBCOMMANDS.get(subcommand);
|
|
309
|
+
if (!valueFlags) return false;
|
|
310
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
311
|
+
const token = rest[index];
|
|
312
|
+
// A spread hides an unknown number of elements — refuse to guess.
|
|
313
|
+
if (token.t === 'spread') return false;
|
|
314
|
+
// An `expr` in positional position is the target directory variable, the
|
|
315
|
+
// dominant fixture shape (`git init -q dir`).
|
|
316
|
+
if (token.t === 'expr') return true;
|
|
317
|
+
if (!token.v.startsWith('-')) return true;
|
|
318
|
+
if (token.v.indexOf('=') === -1 && valueFlags.includes(token.v)) index += 1;
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* @param {string|null} subcommand
|
|
325
|
+
* @param {ArgvToken[]} rest argv after the subcommand
|
|
326
|
+
* @returns {boolean} true when this invocation mutates repository state
|
|
327
|
+
*/
|
|
328
|
+
export function isMutating(subcommand, rest) {
|
|
329
|
+
if (typeof subcommand !== 'string') return false;
|
|
330
|
+
const spec = MUTATING_SUBCOMMANDS.get(subcommand);
|
|
331
|
+
if (!spec) return false;
|
|
332
|
+
if (!spec.readOnlyWhen) return true;
|
|
333
|
+
// `git remote` / `git branch` / `git tag` with NO further argument is a
|
|
334
|
+
// listing form, which is read-only.
|
|
335
|
+
const args = rest.filter((token) => token.t === 'lit').map((token) => token.v);
|
|
336
|
+
if (subcommand !== 'config' && args.length === 0) return false;
|
|
337
|
+
return !args.some((arg) => spec.readOnlyWhen.some((re) => re.test(arg)));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Split a shell command string into tokens, dropping interpolation holes.
|
|
342
|
+
* `git add ${JSON.stringify(rel)}` → `['git','add']` — enough to resolve the
|
|
343
|
+
* subcommand, which is all this check needs.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} command
|
|
346
|
+
* @returns {string[]}
|
|
347
|
+
*/
|
|
348
|
+
export function tokenizeShellCommand(command) {
|
|
349
|
+
return String(command ?? '')
|
|
350
|
+
.replace(/\$\{[^}]*\}/g, ' ')
|
|
351
|
+
.split(/\s+/)
|
|
352
|
+
.filter((token) => token !== '')
|
|
353
|
+
.map((token) => token.replace(/^['"]|['"]$/g, ''));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Compute the 1-based line number of `index` in `body`.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} body
|
|
360
|
+
* @param {number} index
|
|
361
|
+
* @returns {number}
|
|
362
|
+
*/
|
|
363
|
+
function lineOf(body, index) {
|
|
364
|
+
let line = 1;
|
|
365
|
+
for (let i = 0; i < index && i < body.length; i += 1) if (body[i] === '\n') line += 1;
|
|
366
|
+
return line;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @param {string} body
|
|
371
|
+
* @param {number} index
|
|
372
|
+
* @returns {boolean} true when the line containing `index` is a comment line
|
|
373
|
+
*/
|
|
374
|
+
function inCommentLine(body, index) {
|
|
375
|
+
const start = body.lastIndexOf('\n', index) + 1;
|
|
376
|
+
return /^\s*(\/\/|\/\*|\*)/.test(body.slice(start, index + 1));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* True when `index` falls INSIDE a string literal on its own line.
|
|
381
|
+
*
|
|
382
|
+
* Necessary, not defensive: a test that documents this very anti-pattern
|
|
383
|
+
* writes the offending call as a STRING (`"execFileSync('git', ['config', …])"`),
|
|
384
|
+
* and without this scanner the check reports its own fixtures — 8 findings in
|
|
385
|
+
* its own test file, measured 2026-08-19. A check that is red on its own
|
|
386
|
+
* regression suite is the shape that gets switched off.
|
|
387
|
+
*
|
|
388
|
+
* A per-line scanner (rather than whole-file) because a multi-line template
|
|
389
|
+
* literal would otherwise poison every subsequent line's quote state, and a
|
|
390
|
+
* call is always matched at the token that OPENS it — which is on one line.
|
|
391
|
+
*
|
|
392
|
+
* Known limit: a template literal that spans lines and contains a `git` call is
|
|
393
|
+
* judged by the line it sits on, so an interpolated multi-line command string
|
|
394
|
+
* can be judged as code. Measured 0 occurrences in `tests/` on 2026-08-19.
|
|
395
|
+
*
|
|
396
|
+
* @param {string} body
|
|
397
|
+
* @param {number} index
|
|
398
|
+
* @returns {boolean}
|
|
399
|
+
*/
|
|
400
|
+
export function insideStringLiteral(body, index) {
|
|
401
|
+
const start = body.lastIndexOf('\n', index) + 1;
|
|
402
|
+
/** @type {string|null} */
|
|
403
|
+
let quote = null;
|
|
404
|
+
for (let i = start; i < index; i += 1) {
|
|
405
|
+
const ch = body[i];
|
|
406
|
+
if (ch === '\\') {
|
|
407
|
+
i += 1;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (quote === null) {
|
|
411
|
+
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
|
|
412
|
+
} else if (ch === quote) {
|
|
413
|
+
quote = null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return quote !== null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Scan one file for `git` invocations.
|
|
421
|
+
*
|
|
422
|
+
* @param {string} relative repo-relative path
|
|
423
|
+
* @param {string} body file content
|
|
424
|
+
* @param {{applicable: number, targeted: number, readOnly: number, unresolvedArgv: number, unresolvedOptions: number, gitDirInheritable: number, insideStringLiteral: number}} tally mutated in place
|
|
425
|
+
* @returns {Array<{kind: string, file: string, line: number, form: string, command: string, message: string}>}
|
|
426
|
+
*/
|
|
427
|
+
export function scanFile(relative, body, tally) {
|
|
428
|
+
/** @type {Array<{kind: string, file: string, line: number, form: string, command: string, message: string}>} */
|
|
429
|
+
const findings = [];
|
|
430
|
+
|
|
431
|
+
/** @type {Array<{index: number, form: 'argv'|'shell', tokens: Array<string|null>, tail: string}>} */
|
|
432
|
+
const calls = [];
|
|
433
|
+
|
|
434
|
+
ARGV_CALL_RE.lastIndex = 0;
|
|
435
|
+
/** @type {RegExpExecArray|null} */
|
|
436
|
+
let m;
|
|
437
|
+
while ((m = ARGV_CALL_RE.exec(body)) !== null) {
|
|
438
|
+
const afterIndex = m.index + m[0].length;
|
|
439
|
+
const after = body.slice(afterIndex);
|
|
440
|
+
if (!after.startsWith('[')) {
|
|
441
|
+
tally.unresolvedArgv += 1;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const close = matchBracket(after, 0, '[', ']');
|
|
445
|
+
if (close === -1) {
|
|
446
|
+
tally.unresolvedArgv += 1;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
calls.push({
|
|
450
|
+
index: m.index,
|
|
451
|
+
form: 'argv',
|
|
452
|
+
tokens: tokenizeArgv(after.slice(1, close)),
|
|
453
|
+
tail: after.slice(close + 1),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
SHELL_CALL_RE.lastIndex = 0;
|
|
458
|
+
while ((m = SHELL_CALL_RE.exec(body)) !== null) {
|
|
459
|
+
const quote = m[1];
|
|
460
|
+
const openIndex = body.indexOf(quote, m.index);
|
|
461
|
+
const closeIndex = body.indexOf(quote, openIndex + 1);
|
|
462
|
+
if (closeIndex === -1) continue;
|
|
463
|
+
const command = body.slice(openIndex + 1, closeIndex);
|
|
464
|
+
const tokens = tokenizeShellCommand(command);
|
|
465
|
+
calls.push({
|
|
466
|
+
index: m.index,
|
|
467
|
+
form: 'shell',
|
|
468
|
+
// Drop the leading `git`, then lift every word into the `ArgvToken`
|
|
469
|
+
// shape so both forms share `classifyArgv`. An interpolation hole was
|
|
470
|
+
// already erased by `tokenizeShellCommand`, so what survives is literal.
|
|
471
|
+
tokens: tokens.slice(1).map((v) => ({ t: /** @type {const} */ ('lit'), v })),
|
|
472
|
+
tail: body.slice(closeIndex + 1),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
for (const call of calls) {
|
|
477
|
+
if (inCommentLine(body, call.index)) continue;
|
|
478
|
+
if (insideStringLiteral(body, call.index)) {
|
|
479
|
+
tally.insideStringLiteral += 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const { hasArgvTarget, subcommand, rest } = classifyArgv(call.tokens);
|
|
484
|
+
if (!isMutating(subcommand, rest)) {
|
|
485
|
+
if (subcommand !== null) tally.readOnly += 1;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
tally.applicable += 1;
|
|
489
|
+
|
|
490
|
+
// The options object: everything up to the end of the call expression.
|
|
491
|
+
const callEnd = matchBracket(call.tail, call.tail.indexOf('('), '(', ')');
|
|
492
|
+
const optionsText = callEnd === -1 ? call.tail.slice(0, 400) : call.tail.slice(0, callEnd);
|
|
493
|
+
const opaqueOptions = OPAQUE_OPTIONS_RE.test(call.tail);
|
|
494
|
+
const hasCwd = CWD_OPTION_RE.test(optionsText);
|
|
495
|
+
const hasEnvTarget = GIT_ENV_TARGET_RE.test(optionsText);
|
|
496
|
+
const hasTarget =
|
|
497
|
+
hasArgvTarget || hasCwd || hasEnvTarget || hasSubcommandTarget(subcommand, rest);
|
|
498
|
+
|
|
499
|
+
if (hasTarget) {
|
|
500
|
+
tally.targeted += 1;
|
|
501
|
+
// The second, larger population: a correct target that an inherited
|
|
502
|
+
// GIT_DIR still outranks. Counted, never reported per-site — see header.
|
|
503
|
+
if (!hasEnvTarget) tally.gitDirInheritable += 1;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (opaqueOptions) {
|
|
507
|
+
tally.unresolvedOptions += 1;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const printable = [subcommand, ...rest.map((t) => (t.t === 'lit' ? t.v : '<expr>'))]
|
|
512
|
+
.slice(0, 4)
|
|
513
|
+
.join(' ');
|
|
514
|
+
findings.push({
|
|
515
|
+
kind: 'no-target',
|
|
516
|
+
file: relative,
|
|
517
|
+
line: lineOf(body, call.index),
|
|
518
|
+
form: call.form,
|
|
519
|
+
command: `git ${printable}`,
|
|
520
|
+
message:
|
|
521
|
+
`\`git ${printable}\` mutiert Repo-Zustand ohne explizites Ziel ` +
|
|
522
|
+
`(weder \`-C <pfad>\` noch \`cwd:\`) — das Ziel entscheidet die ambiente cwd.`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return findings;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Run the full census.
|
|
531
|
+
*
|
|
532
|
+
* @param {string} pluginRoot absolute plugin root
|
|
533
|
+
* @returns {{ok: boolean, summary: {filesScanned: number, applicable: number, targeted: number, readOnly: number, unresolvedArgv: number, unresolvedOptions: number, gitDirInheritable: number, insideStringLiteral: number, findings: number}, findings: Array<object>, toolError: boolean}}
|
|
534
|
+
*/
|
|
535
|
+
export function inspectTestGitConfigTarget(pluginRoot) {
|
|
536
|
+
const tally = {
|
|
537
|
+
applicable: 0,
|
|
538
|
+
targeted: 0,
|
|
539
|
+
readOnly: 0,
|
|
540
|
+
unresolvedArgv: 0,
|
|
541
|
+
unresolvedOptions: 0,
|
|
542
|
+
gitDirInheritable: 0,
|
|
543
|
+
insideStringLiteral: 0,
|
|
544
|
+
};
|
|
545
|
+
/** @type {Array<object>} */
|
|
546
|
+
const findings = [];
|
|
547
|
+
const result = {
|
|
548
|
+
ok: false,
|
|
549
|
+
summary: { filesScanned: 0, ...tally, findings: 0 },
|
|
550
|
+
findings,
|
|
551
|
+
toolError: false,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const scanRoot = path.join(pluginRoot, SCAN_DIR);
|
|
555
|
+
try {
|
|
556
|
+
statSync(scanRoot);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
result.toolError = true;
|
|
559
|
+
findings.push({
|
|
560
|
+
kind: 'tool-error',
|
|
561
|
+
file: SCAN_DIR,
|
|
562
|
+
line: 0,
|
|
563
|
+
message: `cannot stat the scan root: ${error instanceof Error ? error.message : String(error)}`,
|
|
564
|
+
});
|
|
565
|
+
return result;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const files = walk(scanRoot).sort();
|
|
569
|
+
for (const absolute of files) {
|
|
570
|
+
const relative = path.relative(pluginRoot, absolute);
|
|
571
|
+
/** @type {string} */
|
|
572
|
+
let body;
|
|
573
|
+
try {
|
|
574
|
+
body = readFileSync(absolute, 'utf8');
|
|
575
|
+
} catch (error) {
|
|
576
|
+
result.toolError = true;
|
|
577
|
+
findings.push({
|
|
578
|
+
kind: 'tool-error',
|
|
579
|
+
file: relative,
|
|
580
|
+
line: 0,
|
|
581
|
+
message: `cannot read: ${error instanceof Error ? error.message : String(error)}`,
|
|
582
|
+
});
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
585
|
+
if (!/['"`]\s*git[\s'"`]/.test(body)) continue;
|
|
586
|
+
findings.push(...scanFile(relative, body, tally));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
findings.sort((a, b) => String(a.file).localeCompare(String(b.file)) || Number(a.line) - Number(b.line));
|
|
590
|
+
result.summary = { filesScanned: files.length, ...tally, findings: findings.length };
|
|
591
|
+
result.ok = findings.length === 0;
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Run the human-readable validator CLI.
|
|
597
|
+
*
|
|
598
|
+
* WARN-ONLY: findings print as WARN and still return 0. `FAIL:` is emitted
|
|
599
|
+
* only on the tool-error path — see § Mode in the header.
|
|
600
|
+
*
|
|
601
|
+
* @param {string} pluginRoot absolute plugin root
|
|
602
|
+
* @returns {number} 0 = census completed, 2 = tool error
|
|
603
|
+
*/
|
|
604
|
+
export function runCheckTestGitConfigTarget(pluginRoot) {
|
|
605
|
+
console.log('--- Check: state-mutating git calls in tests/ without an explicit target (WARN-only) ---');
|
|
606
|
+
const inspection = inspectTestGitConfigTarget(pluginRoot);
|
|
607
|
+
|
|
608
|
+
if (inspection.toolError) {
|
|
609
|
+
for (const item of inspection.findings) console.log(` FAIL: ${item.file} — ${item.message}`);
|
|
610
|
+
console.log('');
|
|
611
|
+
console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
|
|
612
|
+
return 2;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
for (const item of inspection.findings) {
|
|
616
|
+
console.log(` WARN: [${item.kind}] ${item.file}:${item.line} — ${item.message}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const s = inspection.summary;
|
|
620
|
+
console.log(
|
|
621
|
+
` PASS: censused ${s.filesScanned} test file(s) — ${s.applicable} state-mutating git ` +
|
|
622
|
+
`invocation(s), ${s.targeted} name an explicit target, ${s.findings} do not; ` +
|
|
623
|
+
`${s.readOnly} read-only invocation(s) skipped, ${s.unresolvedArgv} variable argv ` +
|
|
624
|
+
`array(s) and ${s.unresolvedOptions} opaque options object(s) unjudged; ` +
|
|
625
|
+
`${s.gitDirInheritable} targeted call(s) would still be outranked by an inherited GIT_DIR; ` +
|
|
626
|
+
`${s.insideStringLiteral} match(es) inside a string literal treated as fixture text`,
|
|
627
|
+
);
|
|
628
|
+
console.log('');
|
|
629
|
+
console.log('Results: 1 passed, 0 failed');
|
|
630
|
+
return 0;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
|
|
634
|
+
if (isMain) {
|
|
635
|
+
const argv = process.argv.slice(2);
|
|
636
|
+
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
|
637
|
+
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
638
|
+
const usage =
|
|
639
|
+
'Usage: check-test-git-config-target.mjs [<plugin-root>] [--json]\n' +
|
|
640
|
+
' --json emit the inspection envelope as a single JSON object on stdout\n' +
|
|
641
|
+
'Exit: 0 census completed (findings are WARN-only) · 1 usage error · 2 tool error';
|
|
642
|
+
|
|
643
|
+
if (flags.has('--help')) {
|
|
644
|
+
console.log(usage);
|
|
645
|
+
process.exitCode = 0;
|
|
646
|
+
} else {
|
|
647
|
+
const unknown = [...flags].filter((f) => f !== '--json' && f !== '--help');
|
|
648
|
+
if (unknown.length > 0) {
|
|
649
|
+
console.error(`Unknown flag(s): ${unknown.join(', ')}\n${usage}`);
|
|
650
|
+
process.exitCode = 1;
|
|
651
|
+
} else {
|
|
652
|
+
const pluginRoot = path.resolve(positional[0] ?? process.cwd());
|
|
653
|
+
if (flags.has('--json')) {
|
|
654
|
+
const inspection = inspectTestGitConfigTarget(pluginRoot);
|
|
655
|
+
console.log(JSON.stringify(inspection, null, 2));
|
|
656
|
+
process.exitCode = inspection.toolError ? 2 : 0;
|
|
657
|
+
} else {
|
|
658
|
+
process.exitCode = runCheckTestGitConfigTarget(pluginRoot);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// Deliberately NOT `process.exit()`: on a pipe, exiting discards stdout
|
|
663
|
+
// writes still queued in the async write buffer (see
|
|
664
|
+
// `.claude/rules/anti-pattern-console-log-process-exit-drops-stdout-....md`).
|
|
665
|
+
}
|
|
@@ -42,8 +42,28 @@ import { pathToFileURL } from 'node:url';
|
|
|
42
42
|
// (ported from check-owner-leakage.mjs, wrapped as pure exported helpers)
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Text-scan extension allow-list.
|
|
47
|
+
*
|
|
48
|
+
* '.html' (#1080 Finding C). This set is a SECOND, independent copy of the same
|
|
49
|
+
* concept check-owner-leakage.mjs maintains, and the two had DRIFTED: that scanner
|
|
50
|
+
* gained '.html' while this one kept the older list. The consequence was specific,
|
|
51
|
+
* not cosmetic — this validator exists for text "consumed by an LLM" (see the file
|
|
52
|
+
* header), and site/ ships four .html pages carrying JSON-LD directly beside
|
|
53
|
+
* site/llms.txt and site/llms-full.txt, i.e. exactly the consumer circle it was built
|
|
54
|
+
* for. Measured before adding: an identical U+202E + U+200B payload produced 2
|
|
55
|
+
* dangerous-invisible findings in a .md file and NOTHING in a .html file.
|
|
56
|
+
*
|
|
57
|
+
* DELIBERATELY NOT added to STRICT_EXTS below. STRICT flags every non-curated emoji,
|
|
58
|
+
* and .html is a prose/markup medium. Today that carve-out costs no detection — the
|
|
59
|
+
* strict variant was measured and produced the SAME single finding as the lenient one,
|
|
60
|
+
* so no emoji false positive exists in the tracked pages right now — but page copy is
|
|
61
|
+
* precisely where a future non-curated emoji legitimately lands, and a CI landmine
|
|
62
|
+
* armed by ordinary copywriting is not worth the marginal gain. The actual attack
|
|
63
|
+
* surface (invisibles, bidi overrides, tag-block smuggling) is flagged in BOTH
|
|
64
|
+
* contexts, so nothing security-relevant rides on the strict/lenient choice here.
|
|
65
|
+
*/
|
|
66
|
+
export const TEXT_EXTS = new Set(['.md', '.mjs', '.js', '.ts', '.json', '.yml', '.yaml', '.sh', '.txt', '.html']);
|
|
47
67
|
|
|
48
68
|
/** STRICT-context extensions (emoji + homoglyphs flagged, not just invisibles). */
|
|
49
69
|
const STRICT_EXTS = new Set(['.mjs', '.js', '.ts', '.sh', '.json', '.yml', '.yaml']);
|