@opengsd/gsd-core 1.9.0 → 1.10.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 (223) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -21,6 +21,13 @@
21
21
  * here would be a MODULE_NOT_FOUND in the published package. The duplication is bounded
22
22
  * by a parity test — `tests/emitted-attribution.test.cjs` runs both surfaces over one
23
23
  * corpus and fails if they ever disagree about what is schema-valid.
24
+ *
25
+ * #2914: the single shared `tests/emitted-drift-ack.json` is replaced by per-PR
26
+ * fragments under `tests/emitted-drift-acks/` (kept alongside the legacy file, which is
27
+ * still honored). This validator now checks BOTH: every physical source is run through
28
+ * the same schema/policy rules below, and — because two sources are never allowed to
29
+ * name the same path (silent last-wins would resurrect exactly the silent-drift class
30
+ * the ack seam exists to end) — a cross-source duplicate key is ALSO a hard failure.
24
31
  */
25
32
 
26
33
  const fs = require('node:fs');
@@ -28,10 +35,36 @@ const path = require('node:path');
28
35
 
29
36
  const ACK_VERSION = 1;
30
37
  const ACK_REPO_PATH = 'tests/emitted-drift-ack.json';
38
+ const ACK_DIR_REPO_PATH = 'tests/emitted-drift-acks';
31
39
  const REPO_ROOT = path.join(__dirname, '..');
32
40
 
41
+ /**
42
+ * Upper bound on how many fragment files `listFragmentFiles` may return in one
43
+ * `readdirSync` pass. Mirrors `MAX_ACK_FRAGMENTS` in `tests/helpers/emitted-diff.cjs` —
44
+ * DUPLICATED rather than imported, because `scripts/` ships in the npm package and
45
+ * `tests/` does not (requiring across that line would be MODULE_NOT_FOUND once
46
+ * published; see this file's top-of-file comment). The two are held to the same value
47
+ * by the schema-parity test in `tests/emitted-attribution.test.cjs`.
48
+ *
49
+ * Exceeding it throws rather than truncating: a truncated listing would silently drop
50
+ * acknowledgments from consideration, which is exactly the class of silent failure this
51
+ * whole ack seam exists to prevent.
52
+ */
53
+ const MAX_ACK_FRAGMENTS = 500;
54
+
33
55
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
34
56
 
57
+ /**
58
+ * Key names that can never be a legitimate emitted path or bare workflow/agent filename
59
+ * (`__proto__`, `constructor`, `prototype`). Duplicated (not imported) in
60
+ * `tests/helpers/emitted-diff.cjs`'s `parseAck` for the same reason every other constant
61
+ * here is duplicated rather than required — `scripts/` ships, `tests/` does not. Held to
62
+ * the same set by the schema-parity test in `tests/emitted-attribution.test.cjs`, which
63
+ * must see BOTH surfaces reject a document naming one of these, never one silently
64
+ * accepting what the other errors on (#2914 review).
65
+ */
66
+ const RESERVED_ACK_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
67
+
35
68
  /**
36
69
  * Validate an ack document's raw text.
37
70
  *
@@ -42,9 +75,13 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
42
75
  * rather than left behind.
43
76
  *
44
77
  * @param {string|null} raw file contents, or null when the file is absent
78
+ * @param {object} [opts]
79
+ * @param {string} [opts.source] the path used in error messages (default: the legacy
80
+ * file). Generalized (#2914) so the same rules apply verbatim to a fragment under
81
+ * `ACK_DIR_REPO_PATH` — one definition of "valid", named per the file it is checking.
45
82
  * @returns {{ schemaErrors: string[], policyErrors: string[], ok: boolean }}
46
83
  */
47
- function validateAckText(raw) {
84
+ function validateAckText(raw, { source = ACK_REPO_PATH } = {}) {
48
85
  const schemaErrors = [];
49
86
  const policyErrors = [];
50
87
  const done = () => ({ schemaErrors, policyErrors, ok: schemaErrors.length === 0 && policyErrors.length === 0 });
@@ -52,7 +89,7 @@ function validateAckText(raw) {
52
89
  if (raw === null) return done(); // absent is the healthy steady state
53
90
 
54
91
  if (raw.trim() === '') {
55
- schemaErrors.push(`${ACK_REPO_PATH} is present but empty`);
92
+ schemaErrors.push(`${source} is present but empty`);
56
93
  return done();
57
94
  }
58
95
 
@@ -60,7 +97,7 @@ function validateAckText(raw) {
60
97
  try {
61
98
  doc = JSON.parse(raw);
62
99
  } catch (err) {
63
- schemaErrors.push(`${ACK_REPO_PATH} is not valid JSON: ${err.message}`);
100
+ schemaErrors.push(`${source} is not valid JSON: ${err.message}`);
64
101
  return done();
65
102
  }
66
103
 
@@ -70,7 +107,7 @@ function validateAckText(raw) {
70
107
  // it declares nothing, so the remedy is the same as an entryless document.
71
108
  if (doc === null) {
72
109
  policyErrors.push(
73
- `${ACK_REPO_PATH} contains "null" and declares no acknowledgments. Delete the file — `
110
+ `${source} contains "null" and declares no acknowledgments. Delete the file — `
74
111
  + 'the healthy steady state is no file at all.',
75
112
  );
76
113
  return done();
@@ -78,28 +115,41 @@ function validateAckText(raw) {
78
115
 
79
116
  if (!isPlainObject(doc)) {
80
117
  schemaErrors.push(
81
- `${ACK_REPO_PATH}: must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`,
118
+ `${source}: must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`,
82
119
  );
83
120
  return done();
84
121
  }
85
122
 
86
123
  if (doc.version !== undefined && doc.version !== ACK_VERSION) {
87
124
  schemaErrors.push(
88
- `${ACK_REPO_PATH}: unsupported version ${JSON.stringify(doc.version)} (expected ${ACK_VERSION})`,
125
+ `${source}: unsupported version ${JSON.stringify(doc.version)} (expected ${ACK_VERSION})`,
89
126
  );
90
127
  }
91
128
 
92
129
  const paths = doc.paths;
93
130
  if (paths !== undefined && !isPlainObject(paths)) {
94
- schemaErrors.push(`${ACK_REPO_PATH}: "paths" must be an object of <emitted path> -> { reason }`);
131
+ schemaErrors.push(`${source}: "paths" must be an object of <emitted path> -> { reason }`);
95
132
  return done();
96
133
  }
97
134
 
98
135
  const entries = paths === undefined ? [] : Object.entries(paths);
99
136
  for (const [rel, value] of entries) {
137
+ if (RESERVED_ACK_KEYS.has(rel)) {
138
+ // Reject loudly rather than silently filter. Previously this key was excluded
139
+ // only from `declaredKeys`'s duplicate-detection view, so a document naming it
140
+ // passed validation here while the gate's `parseAck` (fed the JSON.parse'd
141
+ // document, where such a key is a genuine own property) either mishandled it or
142
+ // disagreed silently — two surfaces reaching different verdicts on the same
143
+ // document (#2914 review). Recognizably the same finding as `parseAck`'s.
144
+ schemaErrors.push(
145
+ `${source}: ack key "${rel}" is reserved and can never be a valid emitted path `
146
+ + 'or workflow/agent filename — remove it',
147
+ );
148
+ continue;
149
+ }
100
150
  const reason = isPlainObject(value) ? value.reason : value;
101
151
  if (typeof reason !== 'string' || reason.trim() === '') {
102
- schemaErrors.push(`${ACK_REPO_PATH}: ack for "${rel}" has no non-empty "reason"`);
152
+ schemaErrors.push(`${source}: ack for "${rel}" has no non-empty "reason"`);
103
153
  }
104
154
  }
105
155
 
@@ -108,7 +158,7 @@ function validateAckText(raw) {
108
158
  // behind after removing the last entry by hand.
109
159
  if (entries.length === 0) {
110
160
  policyErrors.push(
111
- `${ACK_REPO_PATH} is present but declares no acknowledgments. Delete the file — an `
161
+ `${source} is present but declares no acknowledgments. Delete the file — an `
112
162
  + 'empty one signals nothing, and the healthy steady state is no file at all.',
113
163
  );
114
164
  }
@@ -120,30 +170,175 @@ function readIfPresent(file) {
120
170
  return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
121
171
  }
122
172
 
173
+ /**
174
+ * Fragment filenames under `dir`, sorted. Absent directory == zero fragments.
175
+ *
176
+ * Fails loudly, naming `dir`, the cap, and the actual count, when the directory holds
177
+ * more than `MAX_ACK_FRAGMENTS` entries — never silently truncates the listing.
178
+ */
179
+ function listFragmentFiles(dir) {
180
+ if (!fs.existsSync(dir)) return [];
181
+ const names = fs.readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
182
+ if (names.length > MAX_ACK_FRAGMENTS) {
183
+ throw new Error(
184
+ `lint-emitted-drift-ack: ${dir} contains ${names.length} ack fragments, exceeding `
185
+ + `the cap of ${MAX_ACK_FRAGMENTS}. Refusing to read only some of them — a truncated `
186
+ + 'read would silently drop acknowledgments. Prune spent fragments from this directory.',
187
+ );
188
+ }
189
+ return names;
190
+ }
191
+
192
+ /**
193
+ * The path keys a document declares, for cross-source collision detection — but ONLY
194
+ * when the document is itself trustworthy. A document that failed its own schema check
195
+ * must not also seed a bogus "collision" derived from garbage; its own error already
196
+ * blocks the merge, and reporting a fabricated collision on top would confuse rather
197
+ * than clarify. `RESERVED_ACK_KEYS` are also excluded here — they can never be a
198
+ * legitimate duplicate, since they can never be a legitimate key at all — but this is
199
+ * belt-and-suspenders, not the enforcement point: `validateAckText` above now rejects any
200
+ * document naming one outright, so `main()` only ever calls this on a document whose
201
+ * schema already checked out, making the exclusion below unreachable in practice.
202
+ */
203
+ function declaredKeys(raw) {
204
+ if (raw === null) return [];
205
+ let doc;
206
+ try {
207
+ doc = JSON.parse(raw);
208
+ } catch {
209
+ return [];
210
+ }
211
+ if (!isPlainObject(doc)) return [];
212
+ const paths = doc.paths;
213
+ if (paths === undefined) return [];
214
+ if (!isPlainObject(paths)) return [];
215
+ return Object.keys(paths).filter((k) => !RESERVED_ACK_KEYS.has(k));
216
+ }
217
+
218
+ /**
219
+ * assertAbsentOnNext — the `next`-lane guard (#2914), invoked only by the
220
+ * `guard-no-ack-on-next` workflow job on push to `next`, never in `lint:ci`.
221
+ *
222
+ * `validateAckText` lints SHAPE, because a PR's own working tree may legitimately carry
223
+ * a live, well-formed ack — that is the normal case a PR-lane check must allow. This
224
+ * function instead rejects PRESENCE outright, valid or not: per the ack-lifecycle law
225
+ * (#2789, `RULESET.EMITTED_ATTRIBUTION`), an entry already at the base is spent the
226
+ * moment it merges, so a document surviving on `next` is inert cruft by definition, not
227
+ * a thing to schema-check.
228
+ *
229
+ * This MUST NOT run as a PR-lane check comparing a PR against `next` — that is the #2768
230
+ * shape #2789 exists to prevent (a spent-but-present base ack would red every open PR the
231
+ * instant one landed). It is safe only because it runs on `next` itself, asserting a fact
232
+ * about `next`'s own tree, never about any PR's diff against it.
233
+ *
234
+ * @param {boolean} present whether ACK_REPO_PATH exists in the tree being checked
235
+ * @returns {{ ok: boolean, message: string }}
236
+ */
237
+ function assertAbsentOnNext(present) {
238
+ if (!present) {
239
+ return { ok: true, message: `ok guard-no-ack-on-next: ${ACK_REPO_PATH} is absent (the healthy steady state)` };
240
+ }
241
+ return {
242
+ ok: false,
243
+ message: [
244
+ `guard-no-ack-on-next: ${ACK_REPO_PATH} exists on next.`,
245
+ '',
246
+ 'Every entry in this file is scoped to the diff that introduced it (#2789). Once merged '
247
+ + 'to next it is, by definition, already at the base -- spent and inert, regardless of '
248
+ + 'whether it is otherwise well-formed.',
249
+ '',
250
+ '#2914: acks now go in per-PR fragments under tests/emitted-drift-acks/, one file per '
251
+ + 'PR, never this single shared file -- a persistent fragment there is harmless (every '
252
+ + 'fragment is independently named, so it cannot conflict with any other PR), which is '
253
+ + 'why only THIS legacy file is guarded here, never the fragment directory.',
254
+ '',
255
+ 'CONTRIBUTING.md: "When you remove the last entry from tests/emitted-drift-ack.json, '
256
+ + 'delete the file too -- its presence is the alarm."',
257
+ '',
258
+ `Remedy: git rm ${ACK_REPO_PATH}`,
259
+ ].join('\n'),
260
+ };
261
+ }
262
+
123
263
  function main() {
124
- const file = path.join(REPO_ROOT, ...ACK_REPO_PATH.split('/'));
125
- const result = validateAckText(readIfPresent(file));
126
- const all = [...result.schemaErrors, ...result.policyErrors];
264
+ const legacyFile = path.join(REPO_ROOT, ...ACK_REPO_PATH.split('/'));
265
+
266
+ if (process.argv.includes('--guard-next')) {
267
+ const result = assertAbsentOnNext(fs.existsSync(legacyFile));
268
+ console.log(result.message);
269
+ if (!result.ok) process.exitCode = 1;
270
+ return;
271
+ }
272
+
273
+ const fragmentsDir = path.join(REPO_ROOT, ...ACK_DIR_REPO_PATH.split('/'));
274
+ const sources = [
275
+ { label: ACK_REPO_PATH, raw: readIfPresent(legacyFile) },
276
+ ...listFragmentFiles(fragmentsDir).map((name) => ({
277
+ label: `${ACK_DIR_REPO_PATH}/${name}`,
278
+ raw: readIfPresent(path.join(fragmentsDir, name)),
279
+ })),
280
+ ];
281
+
282
+ const problems = [];
283
+ const owner = new Map(); // path key -> the source label that already claimed it
284
+ let anyPresent = false;
285
+
286
+ for (const { label, raw } of sources) {
287
+ if (raw !== null) anyPresent = true;
288
+
289
+ // `validateAckText` already prefixes every message with `source` (== `label`), so
290
+ // these are pushed verbatim rather than re-prefixed — a second prefix would read as
291
+ // "tests/emitted-drift-acks/x.json: tests/emitted-drift-acks/x.json is not valid
292
+ // JSON", naming the same file twice for no reason.
293
+ const result = validateAckText(raw, { source: label });
294
+ problems.push(...result.schemaErrors, ...result.policyErrors);
295
+
296
+ // Only chase collisions across documents whose OWN schema already checked out —
297
+ // a document we could not trust must not also seed a fabricated collision.
298
+ if (result.schemaErrors.length === 0) {
299
+ for (const key of declaredKeys(raw)) {
300
+ if (owner.has(key)) {
301
+ problems.push(
302
+ `duplicate ack for "${key}": declared in both ${owner.get(key)} and ${label}. `
303
+ + 'Two ack sources (fragments, or a fragment and the legacy file) may never '
304
+ + 'name the same path — rename or merge them.',
305
+ );
306
+ continue;
307
+ }
308
+ owner.set(key, label);
309
+ }
310
+ }
311
+ }
127
312
 
128
- if (all.length) {
129
- console.error(`lint-emitted-drift-ack: ${all.length} problem(s) in ${ACK_REPO_PATH}\n`);
130
- for (const e of all) console.error(` - ${e}`);
313
+ if (problems.length) {
314
+ console.error(`lint-emitted-drift-ack: ${problems.length} problem(s)\n`);
315
+ for (const e of problems) console.error(` - ${e}`);
131
316
  console.error(
132
317
  '\nThis blocks the merge on purpose. The base-side reader fails loudly on a document '
133
318
  + 'it cannot parse, so a broken one on the base branch reds every PR that carries an '
134
- + 'acknowledgment. Fix or delete the file here, where it is cheap.',
319
+ + 'acknowledgment, and a duplicate across two sources is exactly the silent-drift class '
320
+ + 'the ack seam exists to end. Fix or delete the offending source(s) here, where it is cheap.',
135
321
  );
136
322
  process.exitCode = 1;
137
323
  return;
138
324
  }
139
325
 
140
326
  console.log(
141
- fs.existsSync(file)
142
- ? `ok lint-emitted-drift-ack: ${ACK_REPO_PATH} is well-formed`
143
- : `ok lint-emitted-drift-ack: ${ACK_REPO_PATH} absent (the healthy steady state)`,
327
+ anyPresent
328
+ ? 'ok lint-emitted-drift-ack: all acknowledgment sources are well-formed'
329
+ : 'ok lint-emitted-drift-ack: no acknowledgment sources present (the healthy steady state)',
144
330
  );
145
331
  }
146
332
 
147
333
  if (require.main === module) main();
148
334
 
149
- module.exports = { validateAckText, ACK_VERSION, ACK_REPO_PATH };
335
+ module.exports = {
336
+ validateAckText,
337
+ assertAbsentOnNext,
338
+ declaredKeys,
339
+ listFragmentFiles,
340
+ ACK_VERSION,
341
+ ACK_REPO_PATH,
342
+ ACK_DIR_REPO_PATH,
343
+ MAX_ACK_FRAGMENTS,
344
+ };