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,925 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-untracked-test-deps.mjs — STATIC guard against the "a test depends on a
|
|
3
|
+
// path that is not in the repository" class.
|
|
4
|
+
//
|
|
5
|
+
// THE CLASS. A test reads a path that exists on the developer's disk but is not
|
|
6
|
+
// tracked by git (gitignored, or simply never `git add`-ed). Locally it is green;
|
|
7
|
+
// on CI — which starts from a fresh clone — the path is absent, so the test is
|
|
8
|
+
// either red (fatal read) or silently degraded to zero coverage. Third recorded
|
|
9
|
+
// instance in this repo (learnings `fcfd01dc` conf 0.80, `ea56c14b` conf 0.75).
|
|
10
|
+
//
|
|
11
|
+
// WHY THE OBVIOUS SCAN DOES NOT WORK. The instance that turned CI red —
|
|
12
|
+
// `tests/scripts/site-numbers.test.mjs` — never names `.orchestrator` at all.
|
|
13
|
+
// The dependency is TRANSITIVE: the test calls `collect(REPO_ROOT)`, and
|
|
14
|
+
// `scripts/site-numbers.mjs` does `join(root, '.orchestrator', 'metrics',
|
|
15
|
+
// 'sessions.jsonl')` with `root` as a runtime parameter. A scan over `tests/**`
|
|
16
|
+
// alone scores zero on exactly this incident. The scan must follow the IMPORT
|
|
17
|
+
// CLOSURE.
|
|
18
|
+
//
|
|
19
|
+
// THE TWO RULES. Four rule variants were measured against the ground truth
|
|
20
|
+
// (582 files / 14,039 tests, tracked tree vs tracked+store; 27 status diffs in
|
|
21
|
+
// 2 files):
|
|
22
|
+
//
|
|
23
|
+
// R0 closure names an ignored path 227 hits, 2 TP, 225 FP
|
|
24
|
+
// R1 R0 + test contains a repo-root expression 68 hits, 1 TP, 67 FP
|
|
25
|
+
// R3 test contains a bare ignored path literal 48 hits, 1 TP, 47 FP
|
|
26
|
+
// R2 ← implemented 1 hit, 1 TP, 0 FP
|
|
27
|
+
// R4 ← implemented 1 hit, 1 TP, 0 FP
|
|
28
|
+
//
|
|
29
|
+
// MEASURED DIVERGENCE, recorded rather than smoothed over: this implementation
|
|
30
|
+
// of R2 finds TWO test files against the live tree, not one. The second —
|
|
31
|
+
// `tests/lib/validate/check-learning-provenance.test.mjs` — reaches the same
|
|
32
|
+
// gitignored store and is a fully documented accommodation (it branches on the
|
|
33
|
+
// store's absence). It is invisible to the differential measurement above by
|
|
34
|
+
// construction: that measurement asks "does the test's STATUS change with and
|
|
35
|
+
// without the store", and a test that accommodates correctly changes status in
|
|
36
|
+
// neither direction. The two measurements are both right about different
|
|
37
|
+
// populations. The static scan is the wider one; the ignore marker is how an
|
|
38
|
+
// accommodation is declared.
|
|
39
|
+
//
|
|
40
|
+
// R2: the test passes a STATICALLY RESOLVABLE REAL repo root
|
|
41
|
+
// (`fileURLToPath(new URL('../../', import.meta.url))`, `resolve(__dirname,
|
|
42
|
+
// '../..')`, `process.cwd()`) as an argument to a LOCALLY IMPORTED
|
|
43
|
+
// function whose import closure names an untracked path.
|
|
44
|
+
// R4: the test reads an untracked path CWD-RELATIVE — `readFileSync`/
|
|
45
|
+
// `existsSync`/… on a bare literal, or on an identifier bound to a bare
|
|
46
|
+
// literal, with NO `join`/`resolve` wrapping.
|
|
47
|
+
//
|
|
48
|
+
// The false-positive avoidance falls out of the RULE FORM, it is not a
|
|
49
|
+
// heuristic: R2 demands a STATICALLY resolvable real root, which a
|
|
50
|
+
// `mkdtempSync(...)` root can never be; R4 demands the ABSENCE of any
|
|
51
|
+
// `join`/`resolve` wrapping, which every tmp-fixture target has.
|
|
52
|
+
// R1/R3, at 98-99% false positives, are precisely the shape of check this repo
|
|
53
|
+
// has had to demote to WARN-only three times — they are deliberately NOT built.
|
|
54
|
+
//
|
|
55
|
+
// ORACLE. "Untracked" is `git check-ignore` OR (exists on disk AND absent from
|
|
56
|
+
// `git ls-files`). `check-ignore` alone misses a file that is simply never
|
|
57
|
+
// `git add`-ed. MEASURED PITFALL: `git check-ignore --stdin` aborts with `fatal`
|
|
58
|
+
// on the first path beginning with `../` and DISCARDS THE REST OF THE STREAM —
|
|
59
|
+
// in one intermediate run that swallowed 44 of 45 hits, and it recurred here
|
|
60
|
+
// with a JSON fixture blob that merely happened to contain a slash. Two layers,
|
|
61
|
+
// because a charset filter alone is an arms race: escaping/absolute/exotic specs
|
|
62
|
+
// are filtered out before the batch (`normalizeCandidate`), AND the batch
|
|
63
|
+
// BISECTS on a fatal (`checkIgnoreBatch`), so one refused spec costs one dropped
|
|
64
|
+
// candidate instead of the whole result set.
|
|
65
|
+
//
|
|
66
|
+
// WHY STATIC (termination, not cost). `scripts/validate-plugin.mjs` runs in NO
|
|
67
|
+
// CI job and NOT in the pre-push gate; it is reached only via two tests that
|
|
68
|
+
// spawn it (`tests/scripts/orchestrators-e2e.test.mjs`,
|
|
69
|
+
// `tests/agents/persona-reviewers.test.mjs`). A check that spawned `vitest`
|
|
70
|
+
// would run from a vitest test that spawns validate-plugin that spawns the
|
|
71
|
+
// check: unbounded recursion. This check spawns `git` only.
|
|
72
|
+
//
|
|
73
|
+
// ESCAPE HATCH. A line carrying the inline marker `check-untracked-test-deps:ignore`
|
|
74
|
+
// is exempt (same shape as `check-dead-bridge:ignore`). Use it only for a
|
|
75
|
+
// DOCUMENTED accommodation, and always with a rationale beside it.
|
|
76
|
+
//
|
|
77
|
+
// NAMED CEILING (BV-004) — what R2+R4 provably does NOT catch:
|
|
78
|
+
// 1. SUBPROCESS ARGV — `spawnSync(node, [SCRIPT, REPO_ROOT])`. This is the
|
|
79
|
+
// MOST LIKELY form of the next instance; in the current incident it only
|
|
80
|
+
// narrowly failed to be the trigger.
|
|
81
|
+
// 2. MODULE-SCOPE ROOT — the module resolves `process.cwd()` itself, so the
|
|
82
|
+
// test passes no root argument at all and R2's conjunct never arms.
|
|
83
|
+
// 3. COMPOSED FILENAMES — `join(root, '.orchestrator', 'metrics',
|
|
84
|
+
// `${name}.jsonl`)`: the directory itself is tracked/unignored, only
|
|
85
|
+
// `*.jsonl` inside it is ignored, so the assembled candidate misses.
|
|
86
|
+
// 4. NON-STATIC IMPORTS — `require()`, `import(variable)`: not in the closure.
|
|
87
|
+
// 5. ENVIRONMENT DEPENDENCIES — `$HOME`, the vault dir, `.env.local`: not a
|
|
88
|
+
// repo-relative path, so the oracle cannot judge them.
|
|
89
|
+
// 6. UNTRACKED DIRECTORIES that no ignore rule names. The oracle's
|
|
90
|
+
// exists-but-untracked half is FILES ONLY (see `resolveUntrackedOracle`),
|
|
91
|
+
// because `git ls-files` lists no directories at all — judging directories
|
|
92
|
+
// by it condemns `.git`. So a closure naming a bare `.orchestrator/metrics`
|
|
93
|
+
// is a miss, while `.orchestrator/metrics/sessions.jsonl` is caught.
|
|
94
|
+
// Additionally, code inside a TEMPLATE LITERAL is masked as data (this is what
|
|
95
|
+
// keeps a test that embeds fixture source from flagging itself), so a
|
|
96
|
+
// genuine `readFileSync(`${d}/x`)` is a miss; and a candidate `git check-ignore`
|
|
97
|
+
// refuses outright is dropped by the bisect rather than guessed at.
|
|
98
|
+
// REVISIT TRIGGER: any further instance of the class that slips through R2+R4.
|
|
99
|
+
//
|
|
100
|
+
// Usage: check-untracked-test-deps.mjs <repo-root>
|
|
101
|
+
// Output: ` PASS: …` / ` FAIL: …` lines (exactly two leading spaces), then
|
|
102
|
+
// `Results: N passed, M failed`. Exit 0 = clean, 1 = finding(s), 2 = tool error.
|
|
103
|
+
//
|
|
104
|
+
// Import-safety: importing this module MUST NOT execute anything — the isMain
|
|
105
|
+
// guard at the bottom is the only side-effecting path.
|
|
106
|
+
|
|
107
|
+
import { pathToFileURL } from 'node:url';
|
|
108
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
109
|
+
import { spawnSync } from 'node:child_process';
|
|
110
|
+
import path from 'node:path';
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Constants
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/** Inline exemption marker — mirrors `check-dead-bridge:ignore`. */
|
|
117
|
+
export const IGNORE_MARKER = 'check-untracked-test-deps:ignore';
|
|
118
|
+
|
|
119
|
+
/** Directory (repo-relative) walked for test files. */
|
|
120
|
+
const TESTS_DIR = 'tests';
|
|
121
|
+
|
|
122
|
+
/** Test-file suffix. */
|
|
123
|
+
const TEST_SUFFIX = '.test.mjs';
|
|
124
|
+
|
|
125
|
+
/** Extensions a local import may resolve to. */
|
|
126
|
+
const MODULE_EXTS = ['.mjs', '.js', '.cjs'];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Vitest `resolve.alias` entries that point INTO this repo. Kept as a tiny
|
|
130
|
+
* literal map rather than parsing `vitest.config.mjs`: there is exactly one
|
|
131
|
+
* alias, and a parser for a config that has never had a second entry is the
|
|
132
|
+
* speculative-abstraction anti-pattern (`build-value.md` BV-001).
|
|
133
|
+
*/
|
|
134
|
+
const IMPORT_ALIASES = { '@lib': 'scripts/lib' };
|
|
135
|
+
|
|
136
|
+
/** Depth cap for the import-closure walk (cycles are also visited-guarded). */
|
|
137
|
+
const MAX_CLOSURE_DEPTH = 6;
|
|
138
|
+
|
|
139
|
+
/** Callees whose string-literal arguments assemble into a path (R2 closure). */
|
|
140
|
+
const PATH_JOIN_FNS = new Set(['join', 'resolve', 'path.join', 'path.resolve']);
|
|
141
|
+
|
|
142
|
+
/** Filesystem readers whose FIRST argument is a path (R4). */
|
|
143
|
+
const FS_READ_FNS = new Set([
|
|
144
|
+
'readFileSync', 'readdirSync', 'existsSync', 'statSync', 'lstatSync',
|
|
145
|
+
'openSync', 'accessSync', 'realpathSync', 'readlinkSync', 'createReadStream',
|
|
146
|
+
'readFile', 'readdir', 'stat', 'lstat', 'access', 'open',
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
/** An expression that resolves to a REAL, static location (R2 numerator). */
|
|
150
|
+
const REAL_ROOT_EXPR_RE = /import\.meta\.url|__dirname|process\s*\.\s*cwd\s*\(/;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* …unless it is a throwaway temp directory. This single negation is what makes
|
|
154
|
+
* R2's zero-false-positive property structural rather than tuned.
|
|
155
|
+
*/
|
|
156
|
+
const TMP_ROOT_RE = /mkdtemp|tmpdir/;
|
|
157
|
+
|
|
158
|
+
/** Upper bound on a plausible repo-relative path. */
|
|
159
|
+
const MAX_CANDIDATE_LEN = 200;
|
|
160
|
+
|
|
161
|
+
/** Characters a path in THIS repo can be built from (positive allowlist). */
|
|
162
|
+
const SANE_PATH_RE = /^[A-Za-z0-9._@+\-/ ]+$/;
|
|
163
|
+
|
|
164
|
+
/** Words that look like a call but are not one. */
|
|
165
|
+
const NON_CALLEES = new Set([
|
|
166
|
+
'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
|
|
167
|
+
'await', 'new', 'do', 'else', 'in', 'of', 'delete', 'void', 'yield', 'import',
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Source masking — string/template/comment/regex interiors become spaces
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Blank out the INTERIOR of every string literal, template literal, comment and
|
|
176
|
+
* regex literal, preserving byte offsets and newlines. Delimiters are kept so
|
|
177
|
+
* paren/comma balance and identifier boundaries survive.
|
|
178
|
+
*
|
|
179
|
+
* This is what separates CODE from DATA. A test that embeds fixture source in a
|
|
180
|
+
* template literal (this check's own test file does) must not be scanned as
|
|
181
|
+
* though that fixture were its own body — masking makes that structural instead
|
|
182
|
+
* of relying on a self-exemption marker.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} text
|
|
185
|
+
* @returns {string} same-length masked source
|
|
186
|
+
*/
|
|
187
|
+
export function maskSource(text) {
|
|
188
|
+
const out = text.split('');
|
|
189
|
+
const n = out.length;
|
|
190
|
+
let i = 0;
|
|
191
|
+
// Last significant (non-space) char before `i`, used for the regex/division
|
|
192
|
+
// disambiguation below.
|
|
193
|
+
let prev = '';
|
|
194
|
+
|
|
195
|
+
const blank = (from, to) => {
|
|
196
|
+
for (let k = from; k < to && k < n; k++) if (out[k] !== '\n') out[k] = ' ';
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
while (i < n) {
|
|
200
|
+
const c = text[i];
|
|
201
|
+
const next = text[i + 1];
|
|
202
|
+
|
|
203
|
+
// Line comment.
|
|
204
|
+
if (c === '/' && next === '/') {
|
|
205
|
+
let j = i;
|
|
206
|
+
while (j < n && text[j] !== '\n') j++;
|
|
207
|
+
blank(i, j);
|
|
208
|
+
i = j;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// Block comment.
|
|
212
|
+
if (c === '/' && next === '*') {
|
|
213
|
+
let j = i + 2;
|
|
214
|
+
while (j < n && !(text[j] === '*' && text[j + 1] === '/')) j++;
|
|
215
|
+
blank(i, Math.min(j + 2, n));
|
|
216
|
+
i = Math.min(j + 2, n);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Single/double-quoted string — keep the quotes, blank the interior.
|
|
220
|
+
if (c === "'" || c === '"') {
|
|
221
|
+
let j = i + 1;
|
|
222
|
+
while (j < n && text[j] !== c) {
|
|
223
|
+
if (text[j] === '\\') j++;
|
|
224
|
+
if (text[j] === '\n') break; // unterminated — bail at EOL
|
|
225
|
+
j++;
|
|
226
|
+
}
|
|
227
|
+
blank(i + 1, j);
|
|
228
|
+
i = Math.min(j + 1, n);
|
|
229
|
+
prev = c;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
// Template literal — blank everything including `${…}` substitutions.
|
|
233
|
+
if (c === '`') {
|
|
234
|
+
let j = i + 1;
|
|
235
|
+
while (j < n && text[j] !== '`') {
|
|
236
|
+
if (text[j] === '\\') j++;
|
|
237
|
+
j++;
|
|
238
|
+
}
|
|
239
|
+
blank(i + 1, j);
|
|
240
|
+
i = Math.min(j + 1, n);
|
|
241
|
+
prev = c;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Regex literal — only when the previous significant char cannot end an
|
|
245
|
+
// expression, AND a closing unescaped `/` exists on the SAME line. The
|
|
246
|
+
// same-line bound keeps a misread division from swallowing the file.
|
|
247
|
+
if (c === '/' && (prev === '' || '(,=:[!&|?{};+-*%~^<>'.includes(prev))) {
|
|
248
|
+
let j = i + 1;
|
|
249
|
+
let inClass = false;
|
|
250
|
+
let closed = -1;
|
|
251
|
+
while (j < n && text[j] !== '\n') {
|
|
252
|
+
if (text[j] === '\\') { j += 2; continue; }
|
|
253
|
+
if (text[j] === '[') inClass = true;
|
|
254
|
+
else if (text[j] === ']') inClass = false;
|
|
255
|
+
else if (text[j] === '/' && !inClass) { closed = j; break; }
|
|
256
|
+
j++;
|
|
257
|
+
}
|
|
258
|
+
if (closed > i) {
|
|
259
|
+
blank(i + 1, closed);
|
|
260
|
+
i = closed + 1;
|
|
261
|
+
prev = '/';
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!/\s/.test(c)) prev = c;
|
|
267
|
+
i++;
|
|
268
|
+
}
|
|
269
|
+
return out.join('');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Tiny source scanners (offset-aligned against the masked text)
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build a line-number lookup for a source text.
|
|
278
|
+
* @param {string} text
|
|
279
|
+
* @returns {(index: number) => number} 1-based line number for a char offset
|
|
280
|
+
*/
|
|
281
|
+
function lineLookup(text) {
|
|
282
|
+
const starts = [0];
|
|
283
|
+
for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
|
|
284
|
+
return (index) => {
|
|
285
|
+
let lo = 0;
|
|
286
|
+
let hi = starts.length - 1;
|
|
287
|
+
while (lo < hi) {
|
|
288
|
+
const mid = (lo + hi + 1) >> 1;
|
|
289
|
+
if (starts[mid] <= index) lo = mid;
|
|
290
|
+
else hi = mid - 1;
|
|
291
|
+
}
|
|
292
|
+
return lo + 1;
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Split the argument list of the call whose `(` sits at `openIdx`. Operates on
|
|
298
|
+
* MASKED text (string interiors already blanked), so nothing inside a literal
|
|
299
|
+
* can affect the paren/comma balance.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} masked
|
|
302
|
+
* @param {number} openIdx index of the opening `(`
|
|
303
|
+
* @returns {Array<{start: number, end: number}> | null} null when unbalanced
|
|
304
|
+
*/
|
|
305
|
+
function argRanges(masked, openIdx) {
|
|
306
|
+
const ranges = [];
|
|
307
|
+
let depth = 0;
|
|
308
|
+
let start = openIdx + 1;
|
|
309
|
+
for (let i = openIdx; i < masked.length; i++) {
|
|
310
|
+
const c = masked[i];
|
|
311
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
312
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
313
|
+
depth--;
|
|
314
|
+
if (depth === 0) {
|
|
315
|
+
if (i > start || ranges.length > 0) ranges.push({ start, end: i });
|
|
316
|
+
return ranges;
|
|
317
|
+
}
|
|
318
|
+
} else if (c === ',' && depth === 1) {
|
|
319
|
+
ranges.push({ start, end: i });
|
|
320
|
+
start = i + 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const CALL_RE = /(?<![\w$.])((?:[A-Za-z_$][\w$]*\s*\.\s*)?[A-Za-z_$][\w$]*)\s*\(/g;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Enumerate every call expression in a source file.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} original raw source (argument VALUES are sliced from here)
|
|
332
|
+
* @param {string} masked masked source (call STRUCTURE is found here)
|
|
333
|
+
* @returns {Array<{callee: string, args: string[], index: number}>}
|
|
334
|
+
*/
|
|
335
|
+
function scanCalls(original, masked) {
|
|
336
|
+
const calls = [];
|
|
337
|
+
CALL_RE.lastIndex = 0;
|
|
338
|
+
let m;
|
|
339
|
+
while ((m = CALL_RE.exec(masked)) !== null) {
|
|
340
|
+
const callee = m[1].replace(/\s+/g, '');
|
|
341
|
+
if (NON_CALLEES.has(callee)) continue;
|
|
342
|
+
const openIdx = m.index + m[0].length - 1;
|
|
343
|
+
const ranges = argRanges(masked, openIdx);
|
|
344
|
+
if (!ranges) continue;
|
|
345
|
+
calls.push({
|
|
346
|
+
callee,
|
|
347
|
+
args: ranges.map((r) => original.slice(r.start, r.end).trim()),
|
|
348
|
+
index: m.index,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return calls;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** A bare single/double-quoted string literal, whole-argument. */
|
|
355
|
+
const WHOLE_STRING_RE = /^(['"])((?:[^'"\\]|\\.)*)\1$/;
|
|
356
|
+
|
|
357
|
+
/** A bare identifier, whole-argument. */
|
|
358
|
+
const WHOLE_IDENT_RE = /^[A-Za-z_$][\w$]*$/;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {string} arg trimmed argument source
|
|
362
|
+
* @returns {string | null} the literal's value, or null when not a bare literal
|
|
363
|
+
*/
|
|
364
|
+
function asStringLiteral(arg) {
|
|
365
|
+
const m = WHOLE_STRING_RE.exec(arg);
|
|
366
|
+
return m ? m[2] : null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Path-candidate extraction
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Normalize a raw path candidate to a repo-relative form, or return null when
|
|
375
|
+
* it cannot be one.
|
|
376
|
+
*
|
|
377
|
+
* A candidate must LOOK repo-relative: it either contains a `/` or begins with
|
|
378
|
+
* a `.` (a dotfile/dotdir). A single bare segment such as `package.json` or
|
|
379
|
+
* `node_modules` is rejected — joined onto an unknown base it is a filename,
|
|
380
|
+
* not a repo-relative path, and attributing it to the repo root is what would
|
|
381
|
+
* manufacture false positives.
|
|
382
|
+
*
|
|
383
|
+
* @param {string} raw
|
|
384
|
+
* @returns {string | null}
|
|
385
|
+
*/
|
|
386
|
+
export function normalizeCandidate(raw) {
|
|
387
|
+
if (typeof raw !== 'string') return null;
|
|
388
|
+
let p = raw.trim();
|
|
389
|
+
if (!p || p.length > MAX_CANDIDATE_LEN) return null;
|
|
390
|
+
// Positive charset, not a blocklist. A test corpus contains string literals
|
|
391
|
+
// that merely HAPPEN to carry a slash — a JSON fixture, a prose sentence, a
|
|
392
|
+
// regex source. One such literal reaching `git check-ignore --stdin` aborts
|
|
393
|
+
// the batch and discards the rest of the stream (measured: a `[{"number":63,
|
|
394
|
+
// …}]` fixture blob did exactly that here). Anything outside this charset is
|
|
395
|
+
// not a path this repo could contain, so it never enters the batch.
|
|
396
|
+
if (!SANE_PATH_RE.test(p)) return null;
|
|
397
|
+
if (p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)) return null;
|
|
398
|
+
if (p.startsWith('~')) return null;
|
|
399
|
+
p = p.replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/\/+$/, '');
|
|
400
|
+
while (p.startsWith('./')) p = p.slice(2);
|
|
401
|
+
if (!p || p === '.') return null;
|
|
402
|
+
if (p.split('/').some((seg) => seg === '..')) return null; // check-ignore fatal
|
|
403
|
+
if (!p.includes('/') && !p.startsWith('.')) return null;
|
|
404
|
+
return p;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Extract every path candidate a module NAMES: `join(...)`/`resolve(...)`
|
|
409
|
+
* assembled from string-literal segments, plus bare path-shaped literals.
|
|
410
|
+
*
|
|
411
|
+
* @param {string} original
|
|
412
|
+
* @param {string} masked
|
|
413
|
+
* @returns {Array<{candidate: string, line: number}>}
|
|
414
|
+
*/
|
|
415
|
+
function extractPathCandidates(original, masked) {
|
|
416
|
+
const lineOf = lineLookup(original);
|
|
417
|
+
/** @type {Array<{candidate: string, line: number}>} */
|
|
418
|
+
const out = [];
|
|
419
|
+
const seen = new Set();
|
|
420
|
+
const push = (raw, index) => {
|
|
421
|
+
const candidate = normalizeCandidate(raw);
|
|
422
|
+
if (!candidate) return;
|
|
423
|
+
const line = lineOf(index);
|
|
424
|
+
const key = `${candidate}@${line}`;
|
|
425
|
+
if (seen.has(key)) return;
|
|
426
|
+
seen.add(key);
|
|
427
|
+
out.push({ candidate, line });
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// (a) join()/resolve() assembled from the ordered string-literal arguments.
|
|
431
|
+
for (const call of scanCalls(original, masked)) {
|
|
432
|
+
if (!PATH_JOIN_FNS.has(call.callee)) continue;
|
|
433
|
+
const segs = call.args.map(asStringLiteral).filter((s) => s !== null);
|
|
434
|
+
if (segs.length === 0) continue;
|
|
435
|
+
push(segs.join('/'), call.index);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// (b) bare path-shaped string literals anywhere in the module.
|
|
439
|
+
const litRe = /(['"])((?:[^'"\\\n]|\\.)*)\1/g;
|
|
440
|
+
let m;
|
|
441
|
+
while ((m = litRe.exec(masked)) !== null) {
|
|
442
|
+
// masked blanks the interior — read the value from the original at the
|
|
443
|
+
// same offsets.
|
|
444
|
+
const value = original.slice(m.index + 1, m.index + m[0].length - 1);
|
|
445
|
+
push(value, m.index);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Extract ONLY the bare path-shaped string literals of a source file (no
|
|
453
|
+
* join/resolve assembly). Exported so the false-positive regression test can
|
|
454
|
+
* build the R3 candidate corpus mechanically instead of hand-listing it.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} original
|
|
457
|
+
* @param {string} masked
|
|
458
|
+
* @returns {Array<{candidate: string, line: number}>}
|
|
459
|
+
*/
|
|
460
|
+
export function extractLiteralCandidates(original, masked) {
|
|
461
|
+
const lineOf = lineLookup(original);
|
|
462
|
+
const out = [];
|
|
463
|
+
const seen = new Set();
|
|
464
|
+
const litRe = /(['"])((?:[^'"\\\n]|\\.)*)\1/g;
|
|
465
|
+
let m;
|
|
466
|
+
while ((m = litRe.exec(masked)) !== null) {
|
|
467
|
+
const value = original.slice(m.index + 1, m.index + m[0].length - 1);
|
|
468
|
+
const candidate = normalizeCandidate(value);
|
|
469
|
+
if (!candidate || seen.has(candidate)) continue;
|
|
470
|
+
seen.add(candidate);
|
|
471
|
+
out.push({ candidate, line: lineOf(m.index) });
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// Import resolution + closure
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
const IMPORT_RE = /import\s+([\s\S]*?)\s+from\s+(['"])([^'"]+)\2/g;
|
|
481
|
+
const BARE_IMPORT_RE = /import\s+(['"])([^'"]+)\1/g;
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Resolve an import specifier to an absolute file path INSIDE the repo, or null
|
|
485
|
+
* for bare/external/unresolvable specifiers.
|
|
486
|
+
*
|
|
487
|
+
* @param {string} repoRoot
|
|
488
|
+
* @param {string} fromFile absolute path of the importing file
|
|
489
|
+
* @param {string} spec
|
|
490
|
+
* @returns {string | null}
|
|
491
|
+
*/
|
|
492
|
+
function resolveLocalImport(repoRoot, fromFile, spec) {
|
|
493
|
+
let base = null;
|
|
494
|
+
if (spec.startsWith('./') || spec.startsWith('../')) {
|
|
495
|
+
base = path.resolve(path.dirname(fromFile), spec);
|
|
496
|
+
} else {
|
|
497
|
+
for (const [alias, target] of Object.entries(IMPORT_ALIASES)) {
|
|
498
|
+
if (spec === alias || spec.startsWith(`${alias}/`)) {
|
|
499
|
+
base = path.resolve(repoRoot, target, spec.slice(alias.length + 1));
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (!base) return null;
|
|
505
|
+
const rel = path.relative(repoRoot, base);
|
|
506
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
507
|
+
|
|
508
|
+
const tryPaths = [base, ...MODULE_EXTS.map((e) => base + e),
|
|
509
|
+
...MODULE_EXTS.map((e) => path.join(base, `index${e}`))];
|
|
510
|
+
for (const p of tryPaths) {
|
|
511
|
+
try {
|
|
512
|
+
if (existsSync(p) && statSync(p).isFile()) return p;
|
|
513
|
+
} catch { /* unreadable — treat as unresolvable */ }
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Parse the static imports of a source file into
|
|
520
|
+
* `{ bindings: Map<name, absPath>, modules: Set<absPath> }`.
|
|
521
|
+
*
|
|
522
|
+
* Structure is matched on the MASKED text (so an `import` word inside a comment
|
|
523
|
+
* or a fixture template literal is invisible), but the specifier VALUE is
|
|
524
|
+
* sliced from the ORIGINAL at the same offsets — masking blanks string
|
|
525
|
+
* interiors, and the specifier is a string interior.
|
|
526
|
+
*
|
|
527
|
+
* @param {string} repoRoot
|
|
528
|
+
* @param {string} file absolute path of the importing file
|
|
529
|
+
* @param {string} original
|
|
530
|
+
* @param {string} masked
|
|
531
|
+
* @returns {{bindings: Map<string, string>, modules: Set<string>}}
|
|
532
|
+
*/
|
|
533
|
+
function parseImports(repoRoot, file, original, masked) {
|
|
534
|
+
const bindings = new Map();
|
|
535
|
+
const modules = new Set();
|
|
536
|
+
|
|
537
|
+
const addModule = (spec) => {
|
|
538
|
+
const abs = resolveLocalImport(repoRoot, file, spec);
|
|
539
|
+
if (abs) modules.add(abs);
|
|
540
|
+
return abs;
|
|
541
|
+
};
|
|
542
|
+
/** Slice the specifier value out of the original using the masked match. */
|
|
543
|
+
const specOf = (m, group) => {
|
|
544
|
+
const end = m.index + m[0].length - 1; // closing quote
|
|
545
|
+
return original.slice(end - m[group].length, end);
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
IMPORT_RE.lastIndex = 0;
|
|
549
|
+
let m;
|
|
550
|
+
while ((m = IMPORT_RE.exec(masked)) !== null) {
|
|
551
|
+
const clause = m[1];
|
|
552
|
+
const abs = addModule(specOf(m, 3));
|
|
553
|
+
if (!abs) continue;
|
|
554
|
+
// `{ a, b as c }` — named; `* as ns` — namespace; `d` — default.
|
|
555
|
+
const named = clause.match(/\{([\s\S]*?)\}/);
|
|
556
|
+
if (named) {
|
|
557
|
+
for (const part of named[1].split(',')) {
|
|
558
|
+
const t = part.trim();
|
|
559
|
+
if (!t) continue;
|
|
560
|
+
const asMatch = t.match(/^([\w$]+)\s+as\s+([\w$]+)$/);
|
|
561
|
+
bindings.set(asMatch ? asMatch[2] : t.replace(/\s.*$/, ''), abs);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const ns = clause.match(/\*\s+as\s+([\w$]+)/);
|
|
565
|
+
if (ns) bindings.set(ns[1], abs);
|
|
566
|
+
const head = clause.replace(/\{[\s\S]*?\}/g, '').replace(/\*\s+as\s+[\w$]+/g, '')
|
|
567
|
+
.split(',')[0].trim();
|
|
568
|
+
if (head && /^[\w$]+$/.test(head)) bindings.set(head, abs);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
BARE_IMPORT_RE.lastIndex = 0;
|
|
572
|
+
while ((m = BARE_IMPORT_RE.exec(masked)) !== null) addModule(specOf(m, 2));
|
|
573
|
+
|
|
574
|
+
return { bindings, modules };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Breadth-first import closure of a file, with the shortest chain to each
|
|
579
|
+
* reached module.
|
|
580
|
+
*
|
|
581
|
+
* @param {string} repoRoot
|
|
582
|
+
* @param {string} entry absolute path
|
|
583
|
+
* @param {(abs: string) => {original: string, masked: string} | null} readSource
|
|
584
|
+
* @returns {Map<string, string[]>} absPath → chain of absPaths from entry
|
|
585
|
+
*/
|
|
586
|
+
function importClosure(repoRoot, entry, readSource) {
|
|
587
|
+
/** @type {Map<string, string[]>} */
|
|
588
|
+
const reached = new Map();
|
|
589
|
+
let frontier = [{ file: entry, chain: [entry] }];
|
|
590
|
+
const visited = new Set([entry]);
|
|
591
|
+
|
|
592
|
+
for (let depth = 0; depth < MAX_CLOSURE_DEPTH && frontier.length > 0; depth++) {
|
|
593
|
+
const nextFrontier = [];
|
|
594
|
+
for (const { file, chain } of frontier) {
|
|
595
|
+
const src = readSource(file);
|
|
596
|
+
if (!src) continue;
|
|
597
|
+
const { modules } = parseImports(repoRoot, file, src.original, src.masked);
|
|
598
|
+
for (const mod of modules) {
|
|
599
|
+
if (visited.has(mod)) continue;
|
|
600
|
+
visited.add(mod);
|
|
601
|
+
const nextChain = [...chain, mod];
|
|
602
|
+
reached.set(mod, nextChain);
|
|
603
|
+
nextFrontier.push({ file: mod, chain: nextChain });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
frontier = nextFrontier;
|
|
607
|
+
}
|
|
608
|
+
return reached;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// Oracle — "untracked" = ignored OR (exists on disk AND not in git ls-files)
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Ask `git check-ignore` which of `specs` are ignored, adding them to `out`.
|
|
617
|
+
*
|
|
618
|
+
* BISECTING, on purpose. `git check-ignore --stdin` aborts with `fatal` on the
|
|
619
|
+
* first spec it refuses (one escaping `../`, one over-long or exotic string) and
|
|
620
|
+
* DISCARDS THE REST OF THE STREAM — in one intermediate run that swallowed 44 of
|
|
621
|
+
* 45 hits, and it recurred here with a JSON fixture blob. A charset filter alone
|
|
622
|
+
* is an arms race against whatever literal the next test file contains; halving
|
|
623
|
+
* the batch on a fatal isolates the offender in O(log n) spawns and costs at
|
|
624
|
+
* most one dropped candidate instead of the whole result set.
|
|
625
|
+
*
|
|
626
|
+
* @param {string} repoRoot
|
|
627
|
+
* @param {string[]} specs
|
|
628
|
+
* @param {Set<string>} out
|
|
629
|
+
*/
|
|
630
|
+
function checkIgnoreBatch(repoRoot, specs, out) {
|
|
631
|
+
if (specs.length === 0) return;
|
|
632
|
+
const ci = spawnSync('git', ['check-ignore', '--stdin'], {
|
|
633
|
+
cwd: repoRoot, input: specs.join('\n'), encoding: 'utf8',
|
|
634
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
635
|
+
});
|
|
636
|
+
// 0 = at least one ignored (listed on stdout); 1 = none ignored; else fatal.
|
|
637
|
+
if (ci.status === 0) {
|
|
638
|
+
for (const line of (ci.stdout || '').split('\n')) {
|
|
639
|
+
const p = line.trim();
|
|
640
|
+
if (p) out.add(p);
|
|
641
|
+
}
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (ci.status === 1) return;
|
|
645
|
+
if (specs.length === 1) {
|
|
646
|
+
process.stderr.write(
|
|
647
|
+
` WARN: git check-ignore rejected "${specs[0]}" — candidate dropped (${(ci.stderr || '').trim().split('\n')[0]})\n`,
|
|
648
|
+
);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const mid = specs.length >> 1;
|
|
652
|
+
checkIgnoreBatch(repoRoot, specs.slice(0, mid), out);
|
|
653
|
+
checkIgnoreBatch(repoRoot, specs.slice(mid), out);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Batch-classify repo-relative candidate paths as untracked.
|
|
658
|
+
*
|
|
659
|
+
* MEASURED PITFALL: `git check-ignore --stdin` aborts with `fatal` on the first
|
|
660
|
+
* spec beginning with `../` and DISCARDS the remainder of the stream (44 of 45
|
|
661
|
+
* hits swallowed in one intermediate run). `normalizeCandidate` already rejects
|
|
662
|
+
* `..` segments and absolute specs; this function re-filters defensively so a
|
|
663
|
+
* caller that bypasses normalization cannot re-open the hole.
|
|
664
|
+
*
|
|
665
|
+
* @param {string} repoRoot
|
|
666
|
+
* @param {Iterable<string>} candidates
|
|
667
|
+
* @returns {{untracked: Set<string>, error: string | null}}
|
|
668
|
+
*/
|
|
669
|
+
export function resolveUntrackedOracle(repoRoot, candidates) {
|
|
670
|
+
const all = [...new Set(candidates)].filter(
|
|
671
|
+
(c) => typeof c === 'string' && c && !c.startsWith('/') && !c.startsWith('-')
|
|
672
|
+
&& !c.includes('\0') && !c.split('/').some((s) => s === '..'),
|
|
673
|
+
);
|
|
674
|
+
const untracked = new Set();
|
|
675
|
+
if (all.length === 0) return { untracked, error: null };
|
|
676
|
+
|
|
677
|
+
const ls = spawnSync('git', ['ls-files', '-z'], {
|
|
678
|
+
cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
679
|
+
});
|
|
680
|
+
if (ls.status !== 0) {
|
|
681
|
+
return { untracked, error: `git ls-files failed: ${(ls.stderr || '').trim()}` };
|
|
682
|
+
}
|
|
683
|
+
const tracked = new Set((ls.stdout || '').split('\0').filter(Boolean));
|
|
684
|
+
|
|
685
|
+
checkIgnoreBatch(repoRoot, all, untracked);
|
|
686
|
+
|
|
687
|
+
// Second half of the oracle: a FILE that exists on disk but is absent from
|
|
688
|
+
// `git ls-files` — the never-`git add`-ed case `check-ignore` cannot see.
|
|
689
|
+
//
|
|
690
|
+
// Deliberately FILES ONLY. `git ls-files` enumerates files, so every
|
|
691
|
+
// DIRECTORY is trivially "absent" from it — judging directories by this
|
|
692
|
+
// branch condemns `.git` (present in every clone by construction), plus every
|
|
693
|
+
// directory whose tracked children happen to sit one level deeper. Measured:
|
|
694
|
+
// that variant produced 31 findings across 8 test files, of which the
|
|
695
|
+
// directory candidates (`.git`, `.claude`, `docs/prd`, `skills/bootstrap`, …)
|
|
696
|
+
// were all false. A directory can therefore only be condemned by
|
|
697
|
+
// `check-ignore`, which does judge directories correctly.
|
|
698
|
+
for (const c of all) {
|
|
699
|
+
if (untracked.has(c)) continue;
|
|
700
|
+
if (tracked.has(c)) continue;
|
|
701
|
+
try {
|
|
702
|
+
const abs = path.join(repoRoot, c);
|
|
703
|
+
if (existsSync(abs) && statSync(abs).isFile()) untracked.add(c);
|
|
704
|
+
} catch { /* unreadable — not classifiable */ }
|
|
705
|
+
}
|
|
706
|
+
return { untracked, error: null };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// Scan
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Recursively collect `*.test.mjs` under `absDir`.
|
|
715
|
+
* @param {string} absDir
|
|
716
|
+
* @returns {string[]}
|
|
717
|
+
*/
|
|
718
|
+
function walkTests(absDir) {
|
|
719
|
+
const out = [];
|
|
720
|
+
let entries;
|
|
721
|
+
try {
|
|
722
|
+
if (!existsSync(absDir) || !statSync(absDir).isDirectory()) return out;
|
|
723
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
724
|
+
} catch { return out; }
|
|
725
|
+
for (const ent of entries) {
|
|
726
|
+
const full = path.join(absDir, ent.name);
|
|
727
|
+
try {
|
|
728
|
+
if (ent.isDirectory()) out.push(...walkTests(full));
|
|
729
|
+
else if (ent.isFile() && full.endsWith(TEST_SUFFIX)) out.push(full);
|
|
730
|
+
} catch { /* skip unreadable entry */ }
|
|
731
|
+
}
|
|
732
|
+
return out.sort();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Scan a repository and return every R2/R4 finding.
|
|
737
|
+
*
|
|
738
|
+
* @param {string} repoRoot absolute repo root
|
|
739
|
+
* @returns {{findings: Array<{rule: 'R2'|'R4', file: string, line: number, candidate: string, message: string}>, error: string | null}}
|
|
740
|
+
*/
|
|
741
|
+
export function scanUntrackedTestDeps(repoRoot) {
|
|
742
|
+
/** @type {Map<string, {original: string, masked: string, lines: string[]}>} */
|
|
743
|
+
const cache = new Map();
|
|
744
|
+
const readSource = (abs) => {
|
|
745
|
+
if (cache.has(abs)) return cache.get(abs);
|
|
746
|
+
let original;
|
|
747
|
+
try { original = readFileSync(abs, 'utf8'); } catch { return null; }
|
|
748
|
+
const entry = { original, masked: maskSource(original), lines: original.split(/\r?\n/) };
|
|
749
|
+
cache.set(abs, entry);
|
|
750
|
+
return entry;
|
|
751
|
+
};
|
|
752
|
+
const rel = (abs) => path.relative(repoRoot, abs).split(path.sep).join('/');
|
|
753
|
+
|
|
754
|
+
const testFiles = walkTests(path.join(repoRoot, TESTS_DIR));
|
|
755
|
+
/** @type {Array<{rule: 'R2'|'R4', file: string, line: number, candidate: string, message: string}>} */
|
|
756
|
+
const raw = [];
|
|
757
|
+
|
|
758
|
+
for (const testFile of testFiles) {
|
|
759
|
+
const src = readSource(testFile);
|
|
760
|
+
if (!src) continue;
|
|
761
|
+
const lineOf = lineLookup(src.original);
|
|
762
|
+
const calls = scanCalls(src.original, src.masked);
|
|
763
|
+
|
|
764
|
+
// ---- R4: cwd-relative read from a bare literal (or an identifier bound
|
|
765
|
+
// to one), with NO join/resolve wrapping. ------------------------------
|
|
766
|
+
/** @type {Map<string, {value: string, line: number}>} */
|
|
767
|
+
const literalBindings = new Map();
|
|
768
|
+
const bindRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(['"])((?:[^'"\\\n]|\\.)*)\2/g;
|
|
769
|
+
let bm;
|
|
770
|
+
while ((bm = bindRe.exec(src.masked)) !== null) {
|
|
771
|
+
const valueStart = bm.index + bm[0].length - bm[3].length - 1;
|
|
772
|
+
const value = src.original.slice(valueStart, valueStart + bm[3].length);
|
|
773
|
+
literalBindings.set(bm[1], { value, line: lineOf(bm.index) });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
for (const call of calls) {
|
|
777
|
+
const bare = call.callee.includes('.') ? call.callee.split('.').pop() : call.callee;
|
|
778
|
+
if (!FS_READ_FNS.has(bare)) continue;
|
|
779
|
+
const first = call.args[0];
|
|
780
|
+
if (!first) continue;
|
|
781
|
+
// The absence of any wrapping IS the rule: a `(` in the argument means a
|
|
782
|
+
// join/resolve/anything-else wrapper, which every tmp fixture target has.
|
|
783
|
+
if (first.includes('(')) continue;
|
|
784
|
+
let value = asStringLiteral(first);
|
|
785
|
+
let bindLine = null;
|
|
786
|
+
if (value === null && WHOLE_IDENT_RE.test(first) && literalBindings.has(first)) {
|
|
787
|
+
value = literalBindings.get(first).value;
|
|
788
|
+
bindLine = literalBindings.get(first).line;
|
|
789
|
+
}
|
|
790
|
+
const candidate = normalizeCandidate(value ?? '');
|
|
791
|
+
if (!candidate) continue;
|
|
792
|
+
const line = lineOf(call.index);
|
|
793
|
+
// Marker on the read line OR on the binding line exempts the finding.
|
|
794
|
+
if (hasMarker(src.lines, line) || (bindLine && hasMarker(src.lines, bindLine))) continue;
|
|
795
|
+
raw.push({
|
|
796
|
+
rule: 'R4',
|
|
797
|
+
file: testFile,
|
|
798
|
+
line,
|
|
799
|
+
candidate,
|
|
800
|
+
message: `${rel(testFile)}:${line} — reads "${candidate}" CWD-RELATIVE via ${bare}() (no join/resolve): the path is NOT in the repository, so this is green locally and absent on a fresh CI clone`,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---- R2: a statically resolvable REAL root passed to a locally imported
|
|
805
|
+
// function whose import closure names an untracked path. ---------------
|
|
806
|
+
const rootVars = new Set();
|
|
807
|
+
const rootBindRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n;]+)/g;
|
|
808
|
+
let rm;
|
|
809
|
+
while ((rm = rootBindRe.exec(src.masked)) !== null) {
|
|
810
|
+
const rhsStart = rm.index + rm[0].length - rm[2].length;
|
|
811
|
+
const rhs = src.original.slice(rhsStart, rhsStart + rm[2].length);
|
|
812
|
+
if (REAL_ROOT_EXPR_RE.test(rhs) && !TMP_ROOT_RE.test(rhs)) rootVars.add(rm[1]);
|
|
813
|
+
}
|
|
814
|
+
const isRootArg = (arg) => {
|
|
815
|
+
const t = arg.trim();
|
|
816
|
+
if (!t) return false;
|
|
817
|
+
if (WHOLE_IDENT_RE.test(t)) return rootVars.has(t);
|
|
818
|
+
return REAL_ROOT_EXPR_RE.test(t) && !TMP_ROOT_RE.test(t);
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const { bindings } = parseImports(repoRoot, testFile, src.original, src.masked);
|
|
822
|
+
/** @type {Array<{callee: string, line: number}>} */
|
|
823
|
+
const armed = [];
|
|
824
|
+
for (const call of calls) {
|
|
825
|
+
const head = call.callee.includes('.') ? call.callee.split('.')[0] : call.callee;
|
|
826
|
+
if (!bindings.has(head)) continue;
|
|
827
|
+
if (!call.args.some(isRootArg)) continue;
|
|
828
|
+
armed.push({ callee: call.callee, line: lineOf(call.index) });
|
|
829
|
+
}
|
|
830
|
+
if (armed.length === 0) continue;
|
|
831
|
+
|
|
832
|
+
const closure = importClosure(repoRoot, testFile, readSource);
|
|
833
|
+
/** @type {Set<string>} */
|
|
834
|
+
const reported = new Set();
|
|
835
|
+
for (const [mod, chain] of closure) {
|
|
836
|
+
const modSrc = readSource(mod);
|
|
837
|
+
if (!modSrc) continue;
|
|
838
|
+
for (const { candidate, line: modLine } of extractPathCandidates(modSrc.original, modSrc.masked)) {
|
|
839
|
+
if (reported.has(candidate)) continue;
|
|
840
|
+
const site = armed.find((a) => !hasMarker(src.lines, a.line));
|
|
841
|
+
if (!site) continue;
|
|
842
|
+
reported.add(candidate);
|
|
843
|
+
const chainStr = chain.map(rel).join(' → ');
|
|
844
|
+
raw.push({
|
|
845
|
+
rule: 'R2',
|
|
846
|
+
file: testFile,
|
|
847
|
+
line: site.line,
|
|
848
|
+
candidate,
|
|
849
|
+
message: `${rel(testFile)}:${site.line} — passes a real repo root to \`${site.callee}()\`; its import closure reaches ${rel(mod)}:${modLine}, which names "${candidate}" — a path that is NOT in the repository (chain: ${chainStr})`,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const { untracked, error } = resolveUntrackedOracle(repoRoot, raw.map((f) => f.candidate));
|
|
856
|
+
return { findings: raw.filter((f) => untracked.has(f.candidate)), error };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* @param {string[]} lines
|
|
861
|
+
* @param {number} lineNo 1-based
|
|
862
|
+
* @returns {boolean}
|
|
863
|
+
*/
|
|
864
|
+
function hasMarker(lines, lineNo) {
|
|
865
|
+
const l = lines[lineNo - 1];
|
|
866
|
+
return typeof l === 'string' && l.includes(IGNORE_MARKER);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
// CLI
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Run the check against a repo root, printing the validate-plugin line
|
|
875
|
+
* vocabulary.
|
|
876
|
+
*
|
|
877
|
+
* @param {string} repoRoot
|
|
878
|
+
* @returns {number} 0 = clean, 1 = finding(s), 2 = tool error
|
|
879
|
+
*/
|
|
880
|
+
export function runCheckUntrackedTestDeps(repoRoot) {
|
|
881
|
+
console.log('--- Check: untracked test dependencies (R2 transitive / R4 cwd-relative) ---');
|
|
882
|
+
|
|
883
|
+
const gitCheck = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
884
|
+
cwd: repoRoot, encoding: 'utf8',
|
|
885
|
+
});
|
|
886
|
+
if (gitCheck.status !== 0) {
|
|
887
|
+
console.log(` FAIL: not a git repository: ${repoRoot} — the untracked oracle needs git`);
|
|
888
|
+
console.log('');
|
|
889
|
+
console.log('Results: 0 passed, 1 failed');
|
|
890
|
+
return 2;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const { findings, error } = scanUntrackedTestDeps(repoRoot);
|
|
894
|
+
if (error) {
|
|
895
|
+
console.log(` FAIL: oracle error — ${error}`);
|
|
896
|
+
console.log('');
|
|
897
|
+
console.log('Results: 0 passed, 1 failed');
|
|
898
|
+
return 2;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const rules = ['R2', 'R4'];
|
|
902
|
+
const hit = new Set(findings.map((f) => f.rule));
|
|
903
|
+
for (const r of rules) {
|
|
904
|
+
if (hit.has(r)) continue;
|
|
905
|
+
const what = r === 'R2'
|
|
906
|
+
? 'no test passes a real repo root into an import closure that names an untracked path'
|
|
907
|
+
: 'no test reads an untracked path cwd-relative';
|
|
908
|
+
console.log(` PASS: ${r} — ${what}`);
|
|
909
|
+
}
|
|
910
|
+
for (const f of findings) console.log(` FAIL: [${f.rule}] ${f.message}`);
|
|
911
|
+
|
|
912
|
+
console.log('');
|
|
913
|
+
console.log(`Results: ${rules.length - hit.size} passed, ${findings.length} failed`);
|
|
914
|
+
return findings.length > 0 ? 1 : 0;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
|
|
918
|
+
if (isMain) {
|
|
919
|
+
const root = process.argv[2];
|
|
920
|
+
if (!root) {
|
|
921
|
+
console.error('Usage: check-untracked-test-deps.mjs <repo-root>');
|
|
922
|
+
process.exit(2);
|
|
923
|
+
}
|
|
924
|
+
process.exit(runCheckUntrackedTestDeps(path.resolve(root)));
|
|
925
|
+
}
|