@opengsd/gsd-core 1.20.1 → 1.21.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/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +234 -126
- package/commands/gsd/autonomous.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +450 -753
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +80 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +13 -11
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +11 -19
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +107 -31
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +91 -41
- package/gsd-core/bin/lib/state.cjs +446 -61
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +97 -17
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +3 -2
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +8 -5
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +97 -17
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -80,6 +80,59 @@
|
|
|
80
80
|
* mentions above — a false violation reds a correct tree; a missed
|
|
81
81
|
* ambiguous mention only loses one detection channel.
|
|
82
82
|
*
|
|
83
|
+
* ## Gitignored-path refinement (#3677)
|
|
84
|
+
*
|
|
85
|
+
* #3677 deleted `.gsd/phase/.../40-design.md` and `50-test-matrix.md`, files
|
|
86
|
+
* force-committed despite `.gitignore`. Tracked comments cite OTHER,
|
|
87
|
+
* untracked local design docs that carry the same basenames, so the bare
|
|
88
|
+
* basename was misattributed to the deletion (MEASURED: 40 false violations
|
|
89
|
+
* at the pre-fix commit 31782c1fd2 against origin/next, with the built
|
|
90
|
+
* gsd-core/bin/lib copied in).
|
|
91
|
+
*
|
|
92
|
+
* A gitignored path was never meant to be tracked, and its basename is
|
|
93
|
+
* shared by untracked local files that tracked comments cite — so a
|
|
94
|
+
* bare-basename mention cannot be attributed to the deleted file. For each
|
|
95
|
+
* deleted path that `git check-ignore --no-index` reports as ignored,
|
|
96
|
+
* matching switches from the bare basename to the full repo-relative path,
|
|
97
|
+
* the same switch the #3907 collision rule makes (one shared predicate,
|
|
98
|
+
* {@link matchesByFullPath}), in BOTH the live-consumer and tests/ arms. A
|
|
99
|
+
* genuine full-path reference to the deleted file still fails.
|
|
100
|
+
*
|
|
101
|
+
* The verdict must not be steerable by the change under review or depend on
|
|
102
|
+
* the machine, so:
|
|
103
|
+
*
|
|
104
|
+
* - Only ignore rules from TRACKED `.gitignore` files that are unchanged from
|
|
105
|
+
* the base (committed diff AND local edits) count. The source of
|
|
106
|
+
* each match (`check-ignore -v`) must be a repo-relative path whose
|
|
107
|
+
* basename is `.gitignore` AND that `git ls-files` lists; `.git/info/exclude`,
|
|
108
|
+
* a global `core.excludesFile`, an untracked `.gitignore` and negated (`!`)
|
|
109
|
+
* patterns are never honored.
|
|
110
|
+
* - If the diff `<base>...HEAD` touches ANY `.gitignore` (any status), the
|
|
111
|
+
* refinement is off entirely: the post-diff tree's ignore rules are
|
|
112
|
+
* PR-controlled, and a PR that deleted `x.cjs` and ignored `x.cjs` in the
|
|
113
|
+
* same diff would otherwise disable its own basename check.
|
|
114
|
+
* - Every git read of paths is NUL-delimited (`-z`), so `core.quotePath`
|
|
115
|
+
* C-quoting of non-ASCII paths cannot break the lookups.
|
|
116
|
+
* - Degrade, never skip: any failure of the ignore query (non-repo, timeout,
|
|
117
|
+
* signal, exit 128, output overflow) yields an empty set — the stricter
|
|
118
|
+
* basename matching — instead of aborting the whole lint.
|
|
119
|
+
*
|
|
120
|
+
* ## Tracked-but-gitignored files, base resolution, echo hygiene (#3677)
|
|
121
|
+
*
|
|
122
|
+
* - Force-adding a file despite `.gitignore` is what created #3677, so the
|
|
123
|
+
* lint also fails when any tracked file is ignored by a tracked
|
|
124
|
+
* `.gitignore` (`git ls-files -z -ci --exclude-per-directory=.gitignore`;
|
|
125
|
+
* deliberately NOT `--exclude-standard`, which would read `.git/info/exclude`
|
|
126
|
+
* and the global excludes file). A failure of that query is a hard failure.
|
|
127
|
+
* - The deleted-file check is skipped ONLY when the base ref (or its merge
|
|
128
|
+
* base) is unresolvable. Any other git failure (timeout, signal, output
|
|
129
|
+
* overflow) exits non-zero. `--no-renames` keeps a deletion paired with a
|
|
130
|
+
* near-copy a `D`, whatever `diff.renames` says. Under CI (`CI` or
|
|
131
|
+
* `GITHUB_ACTIONS` non-empty and not "false"/"0") an unresolvable base is a
|
|
132
|
+
* failure, not a skip — otherwise a shallow checkout would silently pass.
|
|
133
|
+
* - Every path echoed into lint output goes through `sanitizeEcho`, since
|
|
134
|
+
* `-z` lets a path carry newlines and escape sequences.
|
|
135
|
+
*
|
|
83
136
|
* ## Historical-record exemption (#3942)
|
|
84
137
|
*
|
|
85
138
|
* `docs/adr/**` and `docs/research/**` are excluded from the `docs/` scan.
|
|
@@ -96,6 +149,7 @@
|
|
|
96
149
|
*/
|
|
97
150
|
|
|
98
151
|
const fs = require('node:fs');
|
|
152
|
+
const os = require('node:os');
|
|
99
153
|
const path = require('node:path');
|
|
100
154
|
const cp = require('node:child_process');
|
|
101
155
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
@@ -106,6 +160,19 @@ const ROOT = path.join(__dirname, '..');
|
|
|
106
160
|
const SCAN_ROOTS = ['.github/workflows', 'gsd-core', 'docs'];
|
|
107
161
|
const EXTRA_FILES = ['package.json'];
|
|
108
162
|
const TESTS_ROOT = 'tests';
|
|
163
|
+
// 16 MiB: a large PR's NUL-delimited path lists must not overflow execFileSync's
|
|
164
|
+
// default 1 MiB buffer.
|
|
165
|
+
const DEFAULT_GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The git output buffer cap. `GSD_REMOVED_BUT_NEEDED_GIT_MAX_BUFFER` (bytes)
|
|
169
|
+
* is a test seam that lets a test provoke a real ENOBUFS on a RESOLVABLE base.
|
|
170
|
+
* @returns {number}
|
|
171
|
+
*/
|
|
172
|
+
function gitMaxBuffer() {
|
|
173
|
+
const n = Number.parseInt(process.env.GSD_REMOVED_BUT_NEEDED_GIT_MAX_BUFFER || '', 10);
|
|
174
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_GIT_MAX_BUFFER;
|
|
175
|
+
}
|
|
109
176
|
|
|
110
177
|
// Skip these when walking SCAN_ROOTS — binary/generated content that can
|
|
111
178
|
// never carry a meaningful basename reference, and is often large.
|
|
@@ -186,6 +253,42 @@ function walk(dir) {
|
|
|
186
253
|
return out;
|
|
187
254
|
}
|
|
188
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Pure: the ONE predicate both scanning arms use to decide whether a deleted
|
|
258
|
+
* file is matched by its full repo-relative path instead of its bare basename:
|
|
259
|
+
* a surviving file shares the basename (#3907) or the deleted path is
|
|
260
|
+
* gitignored (#3677).
|
|
261
|
+
* @param {string} deletedFile - repo-relative deleted path
|
|
262
|
+
* @param {Set<string>} survivingBasenames
|
|
263
|
+
* @param {Set<string>} ignoredDeletions
|
|
264
|
+
* @returns {boolean}
|
|
265
|
+
*/
|
|
266
|
+
function matchesByFullPath(deletedFile, survivingBasenames, ignoredDeletions) {
|
|
267
|
+
return survivingBasenames.has(path.basename(deletedFile)) || ignoredDeletions.has(deletedFile);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Pure: the violation reason for a basename-or-path match (#3907, #3677).
|
|
272
|
+
* @param {string} deletedFile
|
|
273
|
+
* @param {string} basename
|
|
274
|
+
* @param {boolean} collides - a surviving file shares the basename (#3907)
|
|
275
|
+
* @param {boolean} fullPathOnly - matching was by full path (collision or gitignored)
|
|
276
|
+
* @returns {string}
|
|
277
|
+
*/
|
|
278
|
+
function fullPathReason(deletedFile, basename, collides, fullPathOnly) {
|
|
279
|
+
// Paths come from NUL-delimited git output and may hold control characters;
|
|
280
|
+
// this text is echoed into lint output that AI agents read as trusted.
|
|
281
|
+
const file = sanitizeEcho(deletedFile);
|
|
282
|
+
const base = sanitizeEcho(basename);
|
|
283
|
+
if (collides) {
|
|
284
|
+
return `full path '${file}' still referenced (basename '${base}' also belongs to a surviving file, so matched by path — #3907)`;
|
|
285
|
+
}
|
|
286
|
+
if (fullPathOnly) {
|
|
287
|
+
return `full path '${file}' still referenced (the deleted path is gitignored, so matched by path — other untracked files share basename '${base}' — #3677)`;
|
|
288
|
+
}
|
|
289
|
+
return `basename '${base}' still referenced`;
|
|
290
|
+
}
|
|
291
|
+
|
|
189
292
|
/**
|
|
190
293
|
* Pure: given a list of deleted basenames and a `{ file, content }[]` corpus
|
|
191
294
|
* of the post-diff tree, find every surviving reference.
|
|
@@ -194,22 +297,23 @@ function walk(dir) {
|
|
|
194
297
|
* @param {Set<string>} [survivingBasenames] - basenames still present in the
|
|
195
298
|
* post-diff tree (#3907); when a deleted file's basename is in this set,
|
|
196
299
|
* matching switches from basename to the deleted file's full path
|
|
300
|
+
* @param {Set<string>} [ignoredDeletions] - deleted paths that are gitignored
|
|
301
|
+
* (#3677); matched by full path, same switch as a basename collision
|
|
197
302
|
* @returns {{ deletedFile: string, referencedIn: string, reason: string }[]}
|
|
198
303
|
*/
|
|
199
|
-
function findSurvivingReferences(deletedFiles, corpus, survivingBasenames = new Set()) {
|
|
304
|
+
function findSurvivingReferences(deletedFiles, corpus, survivingBasenames = new Set(), ignoredDeletions = new Set()) {
|
|
200
305
|
const violations = [];
|
|
201
306
|
for (const deletedFile of deletedFiles) {
|
|
202
307
|
const basename = path.basename(deletedFile);
|
|
203
308
|
const collides = survivingBasenames.has(basename);
|
|
309
|
+
const fullPathOnly = matchesByFullPath(deletedFile, survivingBasenames, ignoredDeletions);
|
|
204
310
|
for (const { file, content } of corpus) {
|
|
205
|
-
const matched =
|
|
311
|
+
const matched = fullPathOnly ? referencesPath(content, deletedFile) : referencesBasename(content, basename);
|
|
206
312
|
if (matched) {
|
|
207
313
|
violations.push({
|
|
208
314
|
deletedFile,
|
|
209
315
|
referencedIn: file,
|
|
210
|
-
reason: collides
|
|
211
|
-
? `full path '${deletedFile}' still referenced (basename '${basename}' also belongs to a surviving file, so matched by path — #3907)`
|
|
212
|
-
: `basename '${basename}' still referenced`,
|
|
316
|
+
reason: fullPathReason(deletedFile, basename, collides, fullPathOnly),
|
|
213
317
|
});
|
|
214
318
|
}
|
|
215
319
|
}
|
|
@@ -283,18 +387,20 @@ function classifyTestReference(line, basename, matchTarget = basename) {
|
|
|
283
387
|
* matching switches from basename to the deleted file's full path, same
|
|
284
388
|
* refinement as {@link findSurvivingReferences} so the two scanners
|
|
285
389
|
* cannot disagree about what "a reference" means
|
|
390
|
+
* @param {Set<string>} [ignoredDeletions] - deleted paths that are gitignored
|
|
391
|
+
* (#3677); same full-path switch, same predicate as findSurvivingReferences
|
|
286
392
|
* @returns {{ deletedFile: string, referencedIn: string, reason: string }[]}
|
|
287
393
|
*/
|
|
288
|
-
function findSurvivingTestReferences(deletedFiles, testsCorpus, survivingBasenames = new Set()) {
|
|
394
|
+
function findSurvivingTestReferences(deletedFiles, testsCorpus, survivingBasenames = new Set(), ignoredDeletions = new Set()) {
|
|
289
395
|
const violations = [];
|
|
290
396
|
for (const deletedFile of deletedFiles) {
|
|
291
397
|
const basename = path.basename(deletedFile);
|
|
292
|
-
const
|
|
293
|
-
const matchTarget =
|
|
294
|
-
// The left-boundary class matches referencesPath's when
|
|
398
|
+
const fullPathOnly = matchesByFullPath(deletedFile, survivingBasenames, ignoredDeletions);
|
|
399
|
+
const matchTarget = fullPathOnly ? deletedFile : basename;
|
|
400
|
+
// The left-boundary class matches referencesPath's when fullPathOnly
|
|
295
401
|
// (excludes '/' so a survivor's own longer path isn't mistaken for a
|
|
296
402
|
// reference to the deleted file it merely shares a basename with).
|
|
297
|
-
const refRe =
|
|
403
|
+
const refRe = fullPathOnly
|
|
298
404
|
? new RegExp(`(^|[^\\w./-])${escapeRegex(matchTarget)}($|[^\\w.-])`)
|
|
299
405
|
: new RegExp(`(^|[^\\w.-])${escapeRegex(matchTarget)}($|[^\\w.-])`);
|
|
300
406
|
for (const { file, content } of testsCorpus) {
|
|
@@ -307,9 +413,9 @@ function findSurvivingTestReferences(deletedFiles, testsCorpus, survivingBasenam
|
|
|
307
413
|
referencedIn: file,
|
|
308
414
|
// control-char-stripped + capped: this text is echoed into lint
|
|
309
415
|
// output that AI agents read as trusted instructions
|
|
310
|
-
reason:
|
|
311
|
-
? `pins-existence: test depends on deleted path '${matchTarget}' — ${sanitizeEcho(line.trim())}`
|
|
312
|
-
: `pins-existence: test depends on deleted basename '${basename}' — ${sanitizeEcho(line.trim())}`,
|
|
416
|
+
reason: fullPathOnly
|
|
417
|
+
? `pins-existence: test depends on deleted path '${sanitizeEcho(matchTarget)}' — ${sanitizeEcho(line.trim())}`
|
|
418
|
+
: `pins-existence: test depends on deleted basename '${sanitizeEcho(basename)}' — ${sanitizeEcho(line.trim())}`,
|
|
313
419
|
});
|
|
314
420
|
}
|
|
315
421
|
// 'asserts-absence' is correct post-deletion state; null is a
|
|
@@ -320,24 +426,285 @@ function findSurvivingTestReferences(deletedFiles, testsCorpus, survivingBasenam
|
|
|
320
426
|
return violations;
|
|
321
427
|
}
|
|
322
428
|
|
|
323
|
-
function getDeletedFiles(root, baseRef) {
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
429
|
+
function getDeletedFiles(root, baseRef, entries = null) {
|
|
430
|
+
// A git failure propagates as a plain Error. main() has already skipped an
|
|
431
|
+
// UNRESOLVABLE base (isBaseResolvable), so anything thrown here — timeout,
|
|
432
|
+
// signal, output overflow — is a hard failure, never a silent pass.
|
|
433
|
+
return (entries || getDiffEntries(root, baseRef))
|
|
434
|
+
.filter((entry) => entry.status === 'D')
|
|
435
|
+
.map((entry) => entry.paths[0]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* NUL-delimited `git diff --name-status` of `<baseRef>...HEAD`. `-z` keeps
|
|
440
|
+
* non-ASCII paths verbatim (no `core.quotePath` C-quoting).
|
|
441
|
+
* @param {string} root
|
|
442
|
+
* @param {string} baseRef
|
|
443
|
+
* @param {boolean} [quiet] - discard git's stderr (callers that degrade silently)
|
|
444
|
+
* @returns {string}
|
|
445
|
+
*/
|
|
446
|
+
function gitDiffNameStatus(root, baseRef, quiet = false) {
|
|
447
|
+
return cp.execFileSync('git', ['diff', '--name-status', '-z', '--no-renames', `${baseRef}...HEAD`], {
|
|
331
448
|
cwd: root,
|
|
332
449
|
encoding: 'utf8',
|
|
333
450
|
timeout: 15000,
|
|
451
|
+
maxBuffer: gitMaxBuffer(),
|
|
452
|
+
...(quiet ? { stdio: ['ignore', 'pipe', 'ignore'] } : {}),
|
|
334
453
|
});
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The parsed `git diff --name-status -z <baseRef>...HEAD`, run once per scan
|
|
458
|
+
* and shared by getDeletedFiles and getIgnoredDeletions.
|
|
459
|
+
* @param {string} root
|
|
460
|
+
* @param {string} baseRef
|
|
461
|
+
* @param {boolean} [quiet]
|
|
462
|
+
* @returns {{ status: string, paths: string[] }[]}
|
|
463
|
+
*/
|
|
464
|
+
function getDiffEntries(root, baseRef, quiet = false) {
|
|
465
|
+
return parseNameStatusZ(gitDiffNameStatus(root, baseRef, quiet));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Pure: parse `git diff --name-status -z` output. Records are
|
|
470
|
+
* `STATUS NUL path NUL`, except rename/copy (`R<score>`/`C<score>`) which
|
|
471
|
+
* carry TWO paths (`STATUS NUL old NUL new NUL`).
|
|
472
|
+
* @param {string} out
|
|
473
|
+
* @returns {{ status: string, paths: string[] }[]}
|
|
474
|
+
*/
|
|
475
|
+
function parseNameStatusZ(out) {
|
|
476
|
+
const tokens = out.split('\0');
|
|
477
|
+
const entries = [];
|
|
478
|
+
let i = 0;
|
|
479
|
+
while (i < tokens.length) {
|
|
480
|
+
const status = tokens[i];
|
|
481
|
+
if (!status) {
|
|
482
|
+
i += 1;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const pathCount = status.startsWith('R') || status.startsWith('C') ? 2 : 1;
|
|
486
|
+
entries.push({ status, paths: tokens.slice(i + 1, i + 1 + pathCount) });
|
|
487
|
+
i += 1 + pathCount;
|
|
488
|
+
}
|
|
489
|
+
return entries;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Pure: does `source` (the ignore-rule file `git check-ignore -v` reported)
|
|
494
|
+
* look like a repo-relative `.gitignore` path? Rejects `.git/info/exclude`,
|
|
495
|
+
* absolute paths (a global `core.excludesFile`) and any other basename.
|
|
496
|
+
* @param {string} source
|
|
497
|
+
* @returns {boolean}
|
|
498
|
+
*/
|
|
499
|
+
function isRepoRelativeGitignore(source) {
|
|
500
|
+
if (!source || path.isAbsolute(source) || source.startsWith('/')) return false;
|
|
501
|
+
return source.split('/').pop() === '.gitignore';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Pure: parse `git check-ignore -v -z` output — records of four
|
|
506
|
+
* NUL-terminated fields `source linenum pattern pathname` — into the matches
|
|
507
|
+
* whose rule is a non-negated pattern from a repo-relative `.gitignore`.
|
|
508
|
+
* @param {string} out
|
|
509
|
+
* @returns {{ source: string, pathname: string }[]}
|
|
510
|
+
*/
|
|
511
|
+
function parseCheckIgnoreZ(out) {
|
|
512
|
+
const fields = out.split('\0');
|
|
513
|
+
const matches = [];
|
|
514
|
+
for (let i = 0; i + 3 < fields.length; i += 4) {
|
|
515
|
+
const [source, , pattern, pathname] = fields.slice(i, i + 4);
|
|
516
|
+
if (!isRepoRelativeGitignore(source) || pattern.startsWith('!')) continue;
|
|
517
|
+
matches.push({ source, pathname });
|
|
518
|
+
}
|
|
519
|
+
return matches;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Tracked `.gitignore` files with local (staged or unstaged) edits relative to
|
|
524
|
+
* HEAD. Throws if git fails.
|
|
525
|
+
* @param {string} root
|
|
526
|
+
* @returns {string[]} repo-relative paths
|
|
527
|
+
*/
|
|
528
|
+
function getDirtyGitignores(root) {
|
|
529
|
+
return cp.execFileSync(
|
|
530
|
+
'git',
|
|
531
|
+
['diff', '--name-only', '-z', 'HEAD', '--', ':(glob)**/.gitignore'],
|
|
532
|
+
{ cwd: root, encoding: 'utf8', timeout: 15000, maxBuffer: gitMaxBuffer(), stdio: ['ignore', 'pipe', 'ignore'] },
|
|
533
|
+
).split('\0').filter(Boolean);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Pure: may `rel` (a repo-relative, forward-slash path) be materialized under
|
|
538
|
+
* a sandbox directory? Rejects empty / `.` / `..` segments, backslashes and
|
|
539
|
+
* colons (Windows separators, drive letters, ADS), and any `.git` segment
|
|
540
|
+
* (case-insensitive).
|
|
541
|
+
* @param {string} rel
|
|
542
|
+
* @returns {boolean}
|
|
543
|
+
*/
|
|
544
|
+
function isSafeRepoPath(rel) {
|
|
545
|
+
if (!rel) return false;
|
|
546
|
+
return rel.split('/').every((seg) => {
|
|
547
|
+
if (seg === '' || seg === '.' || seg === '..' || seg.includes('\\') || seg.includes(':')) return false;
|
|
548
|
+
// Windows/NTFS aliases of `.git`: trailing dots/spaces are dropped, and
|
|
549
|
+
// `GIT~1` is its 8.3 short name.
|
|
550
|
+
const folded = seg.replace(/[. ]+$/, '').toLowerCase();
|
|
551
|
+
return folded !== '' && folded !== '.git' && !/^git~\d+$/.test(folded);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* The environment for git commands run in the scratch repo: the parent's with
|
|
557
|
+
* every variable that could redirect git to another repository, index,
|
|
558
|
+
* object store or config removed.
|
|
559
|
+
* @returns {NodeJS.ProcessEnv}
|
|
560
|
+
*/
|
|
561
|
+
function isolatedGitEnv(scratchDir = null) {
|
|
562
|
+
const env = { ...process.env };
|
|
563
|
+
for (const key of Object.keys(env)) {
|
|
564
|
+
if (/^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CEILING_DIRECTORIES|TEMPLATE_DIR|EXEC_PATH|ATTR_.*|CONFIG.*)$/.test(key)) {
|
|
565
|
+
delete env[key];
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// No system or global git config at all (and no per-user config home).
|
|
569
|
+
env.GIT_CONFIG_NOSYSTEM = '1';
|
|
570
|
+
env.GIT_CONFIG_GLOBAL = os.devNull;
|
|
571
|
+
if (scratchDir) {
|
|
572
|
+
env.HOME = scratchDir;
|
|
573
|
+
env.XDG_CONFIG_HOME = scratchDir;
|
|
574
|
+
}
|
|
575
|
+
return env;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* `git init` of the scratch repo at `dir/.git` with NO template (`--template=`
|
|
580
|
+
* empty), so neither `init.templateDir` nor `GIT_TEMPLATE_DIR` can seed hooks
|
|
581
|
+
* or an `info/exclude`. Exported as a seam so a test can prove the flag.
|
|
582
|
+
* @param {string} dir
|
|
583
|
+
* @param {NodeJS.ProcessEnv} [env] - defaults to {@link isolatedGitEnv}()
|
|
584
|
+
* @returns {string} the git dir
|
|
585
|
+
*/
|
|
586
|
+
function initScratchRepo(dir, env = isolatedGitEnv(dir)) {
|
|
587
|
+
const gitDir = path.join(dir, '.git');
|
|
588
|
+
cp.execFileSync('git', ['--git-dir', gitDir, '--work-tree', dir, 'init', '-q', '--template='], {
|
|
589
|
+
cwd: dir,
|
|
590
|
+
env,
|
|
591
|
+
timeout: 15000,
|
|
592
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
593
|
+
});
|
|
594
|
+
return gitDir;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Which of `paths` are ignored by the TRACKED `.gitignore` files alone, at
|
|
599
|
+
* their HEAD versions. `check-ignore -v` reports only the highest-precedence
|
|
600
|
+
* rule, so evaluating in the working tree lets an untracked or locally edited
|
|
601
|
+
* nested `.gitignore` mask a tracked rule. Instead every `.gitignore` at HEAD
|
|
602
|
+
* is materialized into a fresh empty repo (no `info/exclude`, global excludes
|
|
603
|
+
* disabled) and `git check-ignore --no-index` runs there; the paths need not
|
|
604
|
+
* exist. The temp dir is always removed. Throws if git fails.
|
|
605
|
+
* @param {string} root
|
|
606
|
+
* @param {string[]} paths - repo-relative, forward-slash separated
|
|
607
|
+
* @returns {Set<string>} the subset ignored by a non-negated tracked rule
|
|
608
|
+
*/
|
|
609
|
+
function ignoredByTrackedRules(root, paths) {
|
|
610
|
+
if (paths.length === 0) return new Set();
|
|
611
|
+
// `ls-tree -r -z HEAD` records: `<mode> <type> <oid>\t<path>`. Only regular
|
|
612
|
+
// blobs count — a symlinked .gitignore is not read by git >= 2.32.
|
|
613
|
+
const gitignores = [];
|
|
614
|
+
for (const record of cp.execFileSync('git', ['ls-tree', '-r', '-z', 'HEAD'], {
|
|
615
|
+
cwd: root,
|
|
616
|
+
encoding: 'utf8',
|
|
617
|
+
timeout: 15000,
|
|
618
|
+
maxBuffer: gitMaxBuffer(),
|
|
619
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
620
|
+
}).split('\0').filter(Boolean)) {
|
|
621
|
+
const tab = record.indexOf('\t');
|
|
622
|
+
const [mode, type] = record.slice(0, tab).split(' ');
|
|
623
|
+
const rel = record.slice(tab + 1);
|
|
624
|
+
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) continue;
|
|
625
|
+
if (rel.split('/').pop() !== '.gitignore') continue;
|
|
626
|
+
if (!isSafeRepoPath(rel)) throw new Error(`unsafe tracked .gitignore path ${sanitizeEcho(rel)}`);
|
|
627
|
+
gitignores.push(rel);
|
|
628
|
+
}
|
|
629
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rbn-ignore-'));
|
|
630
|
+
const gitDir = path.join(tmp, '.git');
|
|
631
|
+
const isolated = { env: isolatedGitEnv(tmp), cwd: tmp };
|
|
632
|
+
try {
|
|
633
|
+
initScratchRepo(tmp, isolated.env);
|
|
634
|
+
const tmpPrefix = path.resolve(tmp) + path.sep;
|
|
635
|
+
for (const rel of gitignores) {
|
|
636
|
+
const dest = path.resolve(tmp, ...rel.split('/'));
|
|
637
|
+
if (!dest.startsWith(tmpPrefix)) throw new Error(`tracked .gitignore path escapes the sandbox: ${sanitizeEcho(rel)}`);
|
|
638
|
+
const blob = cp.execFileSync('git', ['show', `HEAD:${rel}`], {
|
|
639
|
+
cwd: root,
|
|
640
|
+
timeout: 15000,
|
|
641
|
+
maxBuffer: gitMaxBuffer(),
|
|
642
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
643
|
+
});
|
|
644
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
645
|
+
fs.writeFileSync(dest, blob);
|
|
646
|
+
}
|
|
647
|
+
let out;
|
|
648
|
+
try {
|
|
649
|
+
out = cp.execFileSync(
|
|
650
|
+
'git',
|
|
651
|
+
['--git-dir', gitDir, '--work-tree', tmp, '-c', 'core.excludesFile=', 'check-ignore', '--no-index', '-v', '-z', '--stdin'],
|
|
652
|
+
{
|
|
653
|
+
...isolated,
|
|
654
|
+
encoding: 'utf8',
|
|
655
|
+
// `check-ignore` rejects literal-pathspec mode, so a leading "./"
|
|
656
|
+
// is what keeps a name like `:(top)x` from being read as pathspec
|
|
657
|
+
// magic; it is stripped from the reported names below.
|
|
658
|
+
input: paths.map((f) => `./${f}\0`).join(''),
|
|
659
|
+
timeout: 15000,
|
|
660
|
+
maxBuffer: gitMaxBuffer(),
|
|
661
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
662
|
+
},
|
|
663
|
+
);
|
|
664
|
+
} catch (e) {
|
|
665
|
+
if (e && e.status === 1) return new Set(); // none ignored
|
|
666
|
+
throw e;
|
|
667
|
+
}
|
|
668
|
+
return new Set(parseCheckIgnoreZ(out).map((m) => (m.pathname.startsWith('./') ? m.pathname.slice(2) : m.pathname)));
|
|
669
|
+
} finally {
|
|
670
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Which of the deleted paths are gitignored (#3677). Only base-controlled
|
|
676
|
+
* rules count (see the header's "Gitignored-path refinement"):
|
|
677
|
+
*
|
|
678
|
+
* - if the diff `<baseRef>...HEAD` touches any `.gitignore`, nothing is
|
|
679
|
+
* refined (the post-diff ignore rules are PR-controlled);
|
|
680
|
+
* - the rules are the tracked `.gitignore` files at HEAD only, evaluated in an
|
|
681
|
+
* isolated temp repo ({@link ignoredByTrackedRules}) so untracked files,
|
|
682
|
+
* `.git/info/exclude`, global excludes and negated patterns cannot matter;
|
|
683
|
+
* - `-z` throughout, so non-ASCII paths are not C-quoted.
|
|
684
|
+
*
|
|
685
|
+
* Degrades, never throws: exit 1 ("none ignored") and every other failure
|
|
686
|
+
* (non-repo, timeout, signal, 128, buffer overflow) yield an empty Set, which
|
|
687
|
+
* leaves the stricter bare-basename matching in force.
|
|
688
|
+
* @param {string} root
|
|
689
|
+
* @param {string[]} deletedFiles - repo-relative deleted paths
|
|
690
|
+
* @param {string} baseRef - the ref the diff is taken against
|
|
691
|
+
* @returns {Set<string>} the subset that is gitignored, forward-slash separated
|
|
692
|
+
*/
|
|
693
|
+
function getIgnoredDeletions(root, deletedFiles, baseRef, entries = null) {
|
|
694
|
+
if (deletedFiles.length === 0) return new Set();
|
|
695
|
+
try {
|
|
696
|
+
const diffEntries = entries || getDiffEntries(root, baseRef, true);
|
|
697
|
+
const touchesGitignore = diffEntries
|
|
698
|
+
.some((entry) => entry.paths.some((p) => p.split('/').pop() === '.gitignore'));
|
|
699
|
+
if (touchesGitignore) return new Set();
|
|
700
|
+
// A local (staged or unstaged) edit to a tracked .gitignore is just as
|
|
701
|
+
// un-base-controlled as a committed one.
|
|
702
|
+
if (getDirtyGitignores(root).length > 0) return new Set();
|
|
703
|
+
return ignoredByTrackedRules(root, deletedFiles);
|
|
704
|
+
} catch {
|
|
705
|
+
// Any failure: no refinement.
|
|
706
|
+
return new Set();
|
|
707
|
+
}
|
|
341
708
|
}
|
|
342
709
|
|
|
343
710
|
/**
|
|
@@ -350,16 +717,13 @@ function getDeletedFiles(root, baseRef) {
|
|
|
350
717
|
* @returns {string[]} repo-relative paths, forward-slash separated
|
|
351
718
|
*/
|
|
352
719
|
function getSurvivingFiles(root) {
|
|
353
|
-
const out = cp.execFileSync('git', ['ls-files'], {
|
|
720
|
+
const out = cp.execFileSync('git', ['ls-files', '-z'], {
|
|
354
721
|
cwd: root,
|
|
355
722
|
encoding: 'utf8',
|
|
356
723
|
timeout: 15000,
|
|
724
|
+
maxBuffer: gitMaxBuffer(),
|
|
357
725
|
});
|
|
358
|
-
return out
|
|
359
|
-
.trim()
|
|
360
|
-
.split('\n')
|
|
361
|
-
.filter(Boolean)
|
|
362
|
-
.map((f) => f.replace(/\\/g, '/'));
|
|
726
|
+
return out.split('\0').filter(Boolean);
|
|
363
727
|
}
|
|
364
728
|
|
|
365
729
|
// #3942: docs/adr/** and docs/research/** are historical records — an ADR's
|
|
@@ -403,8 +767,10 @@ function buildCorpus(root) {
|
|
|
403
767
|
return corpus;
|
|
404
768
|
}
|
|
405
769
|
|
|
406
|
-
function scan(root, baseRef) {
|
|
407
|
-
|
|
770
|
+
function scan(root, baseRef, trackedFiles = null) {
|
|
771
|
+
// One `git diff --name-status -z` per scan, shared by both consumers.
|
|
772
|
+
const diffEntries = getDiffEntries(root, baseRef);
|
|
773
|
+
const deletedFiles = getDeletedFiles(root, baseRef, diffEntries);
|
|
408
774
|
if (deletedFiles.length === 0) return [];
|
|
409
775
|
const corpus = buildCorpus(root);
|
|
410
776
|
// tests/ arm (#3565): same deletions, discriminated references. A bare
|
|
@@ -430,40 +796,174 @@ function scan(root, baseRef) {
|
|
|
430
796
|
// already filesystem-walked corpus/testsCorpus file lists, which do see
|
|
431
797
|
// them.
|
|
432
798
|
const survivingBasenames = buildSurvivingBasenames([
|
|
433
|
-
...getSurvivingFiles(root),
|
|
799
|
+
...(trackedFiles || getSurvivingFiles(root)),
|
|
434
800
|
...corpus.map((c) => c.file),
|
|
435
801
|
...testsCorpus.map((t) => t.file),
|
|
436
802
|
]);
|
|
437
|
-
|
|
438
|
-
|
|
803
|
+
// #3677: a gitignored deleted path is matched by full path too.
|
|
804
|
+
const ignoredDeletions = getIgnoredDeletions(root, deletedFiles, baseRef, diffEntries);
|
|
805
|
+
const violations = findSurvivingReferences(deletedFiles, corpus, survivingBasenames, ignoredDeletions);
|
|
806
|
+
violations.push(...findSurvivingTestReferences(deletedFiles, testsCorpus, survivingBasenames, ignoredDeletions));
|
|
439
807
|
return violations;
|
|
440
808
|
}
|
|
441
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Tracked files that a tracked `.gitignore` ignores — force-added despite the
|
|
812
|
+
* ignore rule (#3677). `--exclude-per-directory=.gitignore` reads ONLY the
|
|
813
|
+
* per-directory `.gitignore` files; `--exclude-standard` would also read
|
|
814
|
+
* `.git/info/exclude` and the global excludes file, a machine-dependent verdict.
|
|
815
|
+
* A failure of the query throws (a hard failure, never a silent pass).
|
|
816
|
+
* @param {string} root
|
|
817
|
+
* @returns {string[]} repo-relative paths
|
|
818
|
+
*/
|
|
819
|
+
function getTrackedIgnoredFiles(root, trackedFiles = null) {
|
|
820
|
+
// Every tracked file is judged against the tracked .gitignore files at HEAD
|
|
821
|
+
// only (see ignoredByTrackedRules), so local/untracked rules cannot mask or
|
|
822
|
+
// add a finding.
|
|
823
|
+
const ignored = ignoredByTrackedRules(root, trackedFiles || getSurvivingFiles(root));
|
|
824
|
+
return [...ignored];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Can `baseRef` be diffed against HEAD here? Unresolvable (no such ref, or no
|
|
829
|
+
* merge base — e.g. a shallow clone) is the ONLY condition the lint skips on;
|
|
830
|
+
* a timeout/signal/spawn error is thrown so it can never read as a pass.
|
|
831
|
+
* @param {string} root
|
|
832
|
+
* @param {string} baseRef
|
|
833
|
+
* @returns {boolean}
|
|
834
|
+
*/
|
|
835
|
+
function isBaseResolvable(root, baseRef) {
|
|
836
|
+
const run = (args) => cp.spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 15000 });
|
|
837
|
+
const verify = run(['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
|
|
838
|
+
if (verify.error || verify.signal) throw verify.error || new Error(`git rev-parse killed by ${verify.signal}`);
|
|
839
|
+
if (verify.status !== 0) return false;
|
|
840
|
+
const mergeBase = run(['merge-base', baseRef, 'HEAD']);
|
|
841
|
+
if (mergeBase.error || mergeBase.signal) throw mergeBase.error || new Error(`git merge-base killed by ${mergeBase.signal}`);
|
|
842
|
+
return mergeBase.status === 0;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Is this a CI run? `CI` or `GITHUB_ACTIONS` set to a non-empty value other
|
|
847
|
+
* than "false"/"0".
|
|
848
|
+
* @returns {boolean}
|
|
849
|
+
*/
|
|
850
|
+
function isCiEnvironment() {
|
|
851
|
+
return ['CI', 'GITHUB_ACTIONS'].some((k) => {
|
|
852
|
+
const v = (process.env[k] || '').trim().toLowerCase();
|
|
853
|
+
return v !== '' && v !== 'false' && v !== '0';
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const NAME_DENY_RE = /[;|&$`'"<>(){}!\s]/;
|
|
858
|
+
const MERGE_QUEUE_REF_RE = /^refs\/heads\/gh-readonly-queue\/(.+)\/pr-\d+-[0-9a-f]+$/;
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* The branch to diff against (without the `origin/` prefix), in order:
|
|
862
|
+
* `GSD_REMOVED_BUT_NEEDED_BASE`; `GITHUB_BASE_REF` (pull_request); for a
|
|
863
|
+
* `merge_group` event (GITHUB_BASE_REF is empty there) the base encoded in
|
|
864
|
+
* `GITHUB_REF` = `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>`, validated
|
|
865
|
+
* against a safe branch-name pattern; otherwise the default `next` (this is
|
|
866
|
+
* also what a `push` or local run gets).
|
|
867
|
+
* @param {NodeJS.ProcessEnv} env
|
|
868
|
+
* @returns {string}
|
|
869
|
+
*/
|
|
870
|
+
function resolveBaseBranch(env) {
|
|
871
|
+
for (const key of ['GSD_REMOVED_BUT_NEEDED_BASE', 'GITHUB_BASE_REF']) {
|
|
872
|
+
const value = env[key];
|
|
873
|
+
if (!value) continue;
|
|
874
|
+
// An explicit base that is not a safe branch name is a hard failure, never
|
|
875
|
+
// a silent fallback to a different base.
|
|
876
|
+
if (!isSafeBranchName(value)) {
|
|
877
|
+
throw new ExitError(1, `lint-removed-but-needed: invalid base branch in ${key}: '${sanitizeEcho(value)}'`);
|
|
878
|
+
}
|
|
879
|
+
return value;
|
|
880
|
+
}
|
|
881
|
+
if (env.GITHUB_EVENT_NAME === 'merge_group') {
|
|
882
|
+
const m = MERGE_QUEUE_REF_RE.exec(env.GITHUB_REF || '');
|
|
883
|
+
if (m && isSafeBranchName(m[1])) return m[1];
|
|
884
|
+
}
|
|
885
|
+
return 'next';
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Pure: is `name` a safe branch name for `origin/<name>` — safe-character
|
|
890
|
+
* pattern, no `..` anywhere, no empty segment (leading/trailing/double `/`).
|
|
891
|
+
* @param {string} name
|
|
892
|
+
* @returns {boolean}
|
|
893
|
+
*/
|
|
894
|
+
function isSafeBranchName(name) {
|
|
895
|
+
// A leading '-' would read as an option; shell metacharacters are refused
|
|
896
|
+
// as defence in depth even though git is always spawned without a shell.
|
|
897
|
+
// A bare `@` is valid ref syntax but is git's alias for HEAD.
|
|
898
|
+
if (!name || name === '@' || name.startsWith('-') || NAME_DENY_RE.test(name)) return false;
|
|
899
|
+
// git's own rule (control characters, spaces, `..`, `//`, trailing `/` or
|
|
900
|
+
// `.lock`, `@{`, `~^:?*[\`, ...); legal names like release+hotfix pass.
|
|
901
|
+
// Syntax-only form (`refs/heads/<name>`, no --branch): `--branch` would
|
|
902
|
+
// expand `@` / `@{-N}` against whatever repo the cwd is in.
|
|
903
|
+
const r = cp.spawnSync('git', ['check-ref-format', `refs/heads/${name}`], {
|
|
904
|
+
cwd: os.tmpdir(),
|
|
905
|
+
encoding: 'utf8',
|
|
906
|
+
timeout: 15000,
|
|
907
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
908
|
+
});
|
|
909
|
+
if (r.error || r.signal) throw r.error || new Error(`git check-ref-format killed by ${r.signal}`);
|
|
910
|
+
return r.status === 0;
|
|
911
|
+
}
|
|
912
|
+
|
|
442
913
|
function main() {
|
|
443
|
-
const baseRef = `origin/${process.env
|
|
444
|
-
|
|
914
|
+
const baseRef = `origin/${resolveBaseBranch(process.env)}`;
|
|
915
|
+
const failures = [];
|
|
916
|
+
let trackedIgnored;
|
|
917
|
+
let violations = [];
|
|
918
|
+
let skipped = false;
|
|
445
919
|
try {
|
|
446
|
-
|
|
920
|
+
const trackedFiles = getSurvivingFiles(ROOT); // one `git ls-files`, two consumers
|
|
921
|
+
trackedIgnored = getTrackedIgnoredFiles(ROOT, trackedFiles);
|
|
922
|
+
if (isBaseResolvable(ROOT, baseRef)) violations = scan(ROOT, baseRef, trackedFiles);
|
|
923
|
+
else skipped = true;
|
|
447
924
|
} catch (e) {
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
925
|
+
// A git failure on a resolvable base is NOT "base unreachable": exiting 0
|
|
926
|
+
// here would let a timeout or output overflow pass the whole lint.
|
|
927
|
+
throw new ExitError(
|
|
928
|
+
1,
|
|
929
|
+
`lint-removed-but-needed: git failed, so the check could not run (${sanitizeEcho(e.message)})`,
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
if (skipped && isCiEnvironment()) {
|
|
933
|
+
// In CI a silent skip is a silent pass: the gate would never run on a
|
|
934
|
+
// shallow checkout. Fail loudly so the workflow fetches the base.
|
|
935
|
+
throw new ExitError(
|
|
936
|
+
1,
|
|
937
|
+
`lint-removed-but-needed: could not resolve ${sanitizeEcho(baseRef)} in CI, so the deleted-file check cannot run `
|
|
938
|
+
+ '(fetch the base ref, e.g. a checkout with fetch-depth 0 or an explicit `git fetch origin <base>`)',
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
if (skipped) {
|
|
942
|
+
// origin/<base> unreachable on a local machine (e.g. a shallow clone with
|
|
943
|
+
// no matching remote-tracking ref) — skip the deleted-file check rather
|
|
944
|
+
// than fail it, matching lint-fix-has-regression-tests.cjs.
|
|
945
|
+
console.log(`lint-removed-but-needed: could not resolve ${sanitizeEcho(baseRef)}, skipping the deleted-file check`);
|
|
453
946
|
}
|
|
454
947
|
if (violations.length > 0) {
|
|
455
948
|
const detail = violations
|
|
456
|
-
.map((v) => ` ${v.deletedFile} deleted, but still referenced in ${v.referencedIn}: ${v.reason}`)
|
|
949
|
+
.map((v) => ` ${sanitizeEcho(v.deletedFile)} deleted, but still referenced in ${sanitizeEcho(v.referencedIn)}: ${v.reason}`)
|
|
457
950
|
.join('\n');
|
|
458
|
-
|
|
459
|
-
1,
|
|
951
|
+
failures.push(
|
|
460
952
|
'lint-removed-but-needed: a deleted file is still referenced by a live consumer\n'
|
|
461
953
|
+ '(DEFECT.REMOVED-BUT-NEEDED). Either restore the file or update every consumer in the\n'
|
|
462
954
|
+ 'same commit — do not paper over with a workaround that loses reproducibility:\n'
|
|
463
955
|
+ detail,
|
|
464
956
|
);
|
|
465
957
|
}
|
|
466
|
-
|
|
958
|
+
if (trackedIgnored.length > 0) {
|
|
959
|
+
failures.push(
|
|
960
|
+
'lint-removed-but-needed: tracked file(s) are gitignored by a tracked .gitignore (#3677) —\n'
|
|
961
|
+
+ 'force-added despite the ignore rule. `git rm --cached` them (or drop the ignore rule):\n'
|
|
962
|
+
+ trackedIgnored.map((f) => ` ${sanitizeEcho(f)} is tracked but gitignored`).join('\n'),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
if (failures.length > 0) throw new ExitError(1, failures.join('\n'));
|
|
966
|
+
if (!skipped) console.log('ok lint-removed-but-needed: no deleted file has a surviving reference');
|
|
467
967
|
}
|
|
468
968
|
|
|
469
969
|
module.exports = {
|
|
@@ -476,6 +976,18 @@ module.exports = {
|
|
|
476
976
|
findSurvivingTestReferences,
|
|
477
977
|
getDeletedFiles,
|
|
478
978
|
getSurvivingFiles,
|
|
979
|
+
getIgnoredDeletions,
|
|
980
|
+
matchesByFullPath,
|
|
981
|
+
getTrackedIgnoredFiles,
|
|
982
|
+
ignoredByTrackedRules,
|
|
983
|
+
isolatedGitEnv,
|
|
984
|
+
initScratchRepo,
|
|
985
|
+
isSafeRepoPath,
|
|
986
|
+
resolveBaseBranch,
|
|
987
|
+
isBaseResolvable,
|
|
988
|
+
parseNameStatusZ,
|
|
989
|
+
parseCheckIgnoreZ,
|
|
990
|
+
isRepoRelativeGitignore,
|
|
479
991
|
buildCorpus,
|
|
480
992
|
isDocsHistoricalRecord,
|
|
481
993
|
scan,
|