@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.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. 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 = collides ? referencesPath(content, deletedFile) : referencesBasename(content, basename);
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 collides = survivingBasenames.has(basename);
293
- const matchTarget = collides ? deletedFile : basename;
294
- // The left-boundary class matches referencesPath's when collides
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 = collides
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: collides
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
- // Deliberately let a git failure (unresolvable ref, no merge base, etc.)
325
- // propagate as a plain Error — main() treats ANY scan() failure as "cannot
326
- // resolve this base ref in this environment" and degrades to a skip,
327
- // matching lint-fix-has-regression-tests.cjs. There is no failure mode here
328
- // that should hard-exit non-zero; a real drift is only ever reported once
329
- // the diff succeeds and findSurvivingReferences finds a violation.
330
- const out = cp.execFileSync('git', ['diff', '--name-status', `${baseRef}...HEAD`], {
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
- return out
336
- .trim()
337
- .split('\n')
338
- .filter(Boolean)
339
- .filter((line) => line.startsWith('D\t'))
340
- .map((line) => line.slice(2));
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
- const deletedFiles = getDeletedFiles(root, baseRef);
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
- const violations = findSurvivingReferences(deletedFiles, corpus, survivingBasenames);
438
- violations.push(...findSurvivingTestReferences(deletedFiles, testsCorpus, survivingBasenames));
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.GSD_REMOVED_BUT_NEEDED_BASE || process.env.GITHUB_BASE_REF || 'next'}`;
444
- let violations;
914
+ const baseRef = `origin/${resolveBaseBranch(process.env)}`;
915
+ const failures = [];
916
+ let trackedIgnored;
917
+ let violations = [];
918
+ let skipped = false;
445
919
  try {
446
- violations = scan(ROOT, baseRef);
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
- // origin/<base> unreachable in this environment (e.g. a shallow local
449
- // clone with no matching remote-tracking ref) — degrade to a skip rather
450
- // than a false failure, matching lint-fix-has-regression-tests.cjs.
451
- console.log(`lint-removed-but-needed: could not resolve ${baseRef}, skipping (${e.message})`);
452
- return;
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
- throw new ExitError(
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
- console.log('ok lint-removed-but-needed: no deleted file has a surviving reference');
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,