@opengsd/gsd-core 1.13.0 → 1.14.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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +85 -0
  4. package/agents/gsd-ai-researcher.compact.md +96 -0
  5. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  6. package/agents/gsd-code-fixer.compact.md +458 -0
  7. package/agents/gsd-code-fixer.md +5 -5
  8. package/agents/gsd-code-reviewer.compact.md +269 -0
  9. package/agents/gsd-code-reviewer.md +15 -3
  10. package/agents/gsd-codebase-mapper.compact.md +760 -0
  11. package/agents/gsd-debug-session-manager.compact.md +345 -0
  12. package/agents/gsd-doc-classifier.compact.md +192 -0
  13. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  14. package/agents/gsd-doc-verifier.compact.md +143 -0
  15. package/agents/gsd-doc-writer.compact.md +440 -0
  16. package/agents/gsd-dom-verifier.compact.md +138 -0
  17. package/agents/gsd-domain-researcher.compact.md +141 -0
  18. package/agents/gsd-eval-auditor.compact.md +160 -0
  19. package/agents/gsd-eval-planner.compact.md +137 -0
  20. package/agents/gsd-framework-selector.compact.md +82 -0
  21. package/agents/gsd-integration-checker.compact.md +245 -0
  22. package/agents/gsd-intel-updater.compact.md +226 -0
  23. package/agents/gsd-mempalace-curator.compact.md +45 -0
  24. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  25. package/agents/gsd-pattern-mapper.compact.md +275 -0
  26. package/agents/gsd-project-researcher.compact.md +587 -0
  27. package/agents/gsd-research-synthesizer.compact.md +212 -0
  28. package/agents/gsd-roadmapper.compact.md +454 -0
  29. package/agents/gsd-roadmapper.md +13 -0
  30. package/agents/gsd-security-auditor.compact.md +162 -0
  31. package/agents/gsd-ui-auditor.compact.md +404 -0
  32. package/agents/gsd-ui-checker.compact.md +277 -0
  33. package/agents/gsd-ui-researcher.compact.md +282 -0
  34. package/agents/gsd-user-profiler.compact.md +108 -0
  35. package/bin/install.js +206 -68
  36. package/commands/gsd/cleanup.md +1 -0
  37. package/commands/gsd/code-review.md +2 -1
  38. package/commands/gsd/complete-milestone.md +1 -0
  39. package/commands/gsd/config.md +1 -0
  40. package/commands/gsd/debug.md +1 -0
  41. package/commands/gsd/graphify.md +1 -0
  42. package/commands/gsd/health.md +1 -0
  43. package/commands/gsd/mempalace-capture.md +1 -0
  44. package/commands/gsd/mempalace-recall.md +1 -0
  45. package/commands/gsd/new-milestone.md +1 -0
  46. package/commands/gsd/new-project.md +1 -0
  47. package/commands/gsd/next.md +1 -0
  48. package/commands/gsd/pause-work.md +1 -0
  49. package/commands/gsd/phase.md +1 -0
  50. package/commands/gsd/pr-branch.md +1 -0
  51. package/commands/gsd/resume-work.md +1 -0
  52. package/commands/gsd/review-backlog.md +1 -0
  53. package/commands/gsd/settings.md +2 -1
  54. package/commands/gsd/stats.md +1 -0
  55. package/commands/gsd/thread.md +1 -0
  56. package/commands/gsd/workspace.md +1 -0
  57. package/commands/gsd/workstreams.md +1 -0
  58. package/gsd-core/bin/check-latest-version.cjs +8 -3
  59. package/gsd-core/bin/gsd-tools.cjs +338 -125
  60. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  61. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  62. package/gsd-core/bin/lib/audit.cjs +39 -22
  63. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  64. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  65. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  66. package/gsd-core/bin/lib/capability-registry.cjs +79 -67
  67. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  68. package/gsd-core/bin/lib/capability-validator.cjs +14 -1
  69. package/gsd-core/bin/lib/check-command-router.cjs +113 -36
  70. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  71. package/gsd-core/bin/lib/commands.cjs +650 -72
  72. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  73. package/gsd-core/bin/lib/config.cjs +153 -38
  74. package/gsd-core/bin/lib/coverage.cjs +1 -1
  75. package/gsd-core/bin/lib/decisions.cjs +137 -34
  76. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  77. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  78. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
  79. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  80. package/gsd-core/bin/lib/init.cjs +409 -47
  81. package/gsd-core/bin/lib/install-engine.cjs +16 -3
  82. package/gsd-core/bin/lib/install-profiles.cjs +14 -0
  83. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  84. package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
  85. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  86. package/gsd-core/bin/lib/milestone.cjs +19 -8
  87. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  88. package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +161 -22
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  91. package/gsd-core/bin/lib/phase.cjs +167 -63
  92. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  93. package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
  94. package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
  95. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  96. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  97. package/gsd-core/bin/lib/quick-batch.cjs +1 -1
  98. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  99. package/gsd-core/bin/lib/research-store.cjs +11 -12
  100. package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
  101. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  102. package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
  103. package/gsd-core/bin/lib/roadmap.cjs +108 -14
  104. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
  105. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
  106. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
  107. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
  108. package/gsd-core/bin/lib/security.cjs +126 -7
  109. package/gsd-core/bin/lib/state-document.cjs +130 -28
  110. package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
  111. package/gsd-core/bin/lib/state-transition.cjs +142 -28
  112. package/gsd-core/bin/lib/state.cjs +223 -27
  113. package/gsd-core/bin/lib/surface.cjs +60 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  115. package/gsd-core/bin/lib/uat.cjs +1 -1
  116. package/gsd-core/bin/lib/update-context.cjs +30 -24
  117. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  118. package/gsd-core/bin/lib/verification.cjs +47 -15
  119. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  120. package/gsd-core/bin/lib/verify.cjs +188 -23
  121. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
  122. package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
  123. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  124. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  125. package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
  126. package/gsd-core/references/compact-content-gate.md +66 -0
  127. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  128. package/gsd-core/references/model-profiles.md +12 -3
  129. package/gsd-core/references/planning-config.md +3 -0
  130. package/gsd-core/references/tdd.md +5 -2
  131. package/gsd-core/references/thinking-models-planning.md +18 -2
  132. package/gsd-core/references/verification-patterns.md +17 -4
  133. package/gsd-core/references/worktree-path-safety.md +112 -2
  134. package/gsd-core/templates/README.md +7 -1
  135. package/gsd-core/templates/state.md +6 -3
  136. package/gsd-core/templates/summary.compact.md +212 -0
  137. package/gsd-core/templates/user-setup.compact.md +199 -0
  138. package/gsd-core/templates/user-setup.md +0 -9
  139. package/gsd-core/workflows/add-todo.md +3 -2
  140. package/gsd-core/workflows/autonomous.md +13 -10
  141. package/gsd-core/workflows/check-todos.md +4 -2
  142. package/gsd-core/workflows/cleanup.md +3 -1
  143. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
  144. package/gsd-core/workflows/code-review-fix.md +3 -3
  145. package/gsd-core/workflows/code-review.md +156 -30
  146. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  147. package/gsd-core/workflows/complete-milestone.md +39 -262
  148. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  149. package/gsd-core/workflows/docs-update.md +14 -155
  150. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  151. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
  152. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  153. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
  154. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  155. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  156. package/gsd-core/workflows/execute-phase.md +53 -152
  157. package/gsd-core/workflows/execute-plan.md +20 -7
  158. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  159. package/gsd-core/workflows/help.md +1 -1
  160. package/gsd-core/workflows/map-codebase.md +50 -3
  161. package/gsd-core/workflows/new-milestone.md +54 -12
  162. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  163. package/gsd-core/workflows/new-project.md +32 -202
  164. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  165. package/gsd-core/workflows/plan-phase.md +22 -181
  166. package/gsd-core/workflows/pr-branch.md +19 -7
  167. package/gsd-core/workflows/quick.md +8 -1
  168. package/gsd-core/workflows/reapply-patches.md +77 -3
  169. package/gsd-core/workflows/settings.md +18 -5
  170. package/gsd-core/workflows/update.md +7 -5
  171. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  172. package/gsd-core/workflows/verify-work.md +20 -180
  173. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  174. package/hooks/dist/gsd-context-monitor.js +88 -15
  175. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  176. package/hooks/dist/gsd-secret-read-guard.js +44 -18
  177. package/hooks/dist/gsd-statusline.js +11 -7
  178. package/hooks/dist/gsd-validate-commit.sh +34 -4
  179. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  180. package/hooks/dist/gsd-write-guard.js +46 -1
  181. package/hooks/dist/lib/dispatch-identity.js +187 -0
  182. package/hooks/dist/lib/filename-classification.js +64 -0
  183. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  184. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  185. package/hooks/gsd-agent-isolation-guard.js +42 -16
  186. package/hooks/gsd-context-monitor.js +88 -15
  187. package/hooks/gsd-cursor-subagent-start.js +34 -14
  188. package/hooks/gsd-secret-read-guard.js +44 -18
  189. package/hooks/gsd-statusline.js +11 -7
  190. package/hooks/gsd-validate-commit.sh +34 -4
  191. package/hooks/gsd-worktree-path-guard.js +25 -14
  192. package/hooks/gsd-write-guard.js +46 -1
  193. package/hooks/lib/dispatch-identity.js +187 -0
  194. package/hooks/lib/filename-classification.js +64 -0
  195. package/hooks/lib/isolation-deny-reason.js +53 -1
  196. package/hooks/lib/isolation-sentinel.js +58 -19
  197. package/package.json +10 -6
  198. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  199. package/scripts/benchmark-compact-content.cjs +368 -0
  200. package/scripts/check-contract-drift.cjs +4 -1
  201. package/scripts/check-env.cjs +36 -8
  202. package/scripts/check-glossary-refs.cjs +25 -21
  203. package/scripts/ci-next-health.cjs +271 -0
  204. package/scripts/ci-prepare-test-scope.cjs +7 -7
  205. package/scripts/ci-test-scope.cjs +126 -20
  206. package/scripts/ci-timeout-report.cjs +1 -1
  207. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  208. package/scripts/docs-guard-registry.cjs +7 -2
  209. package/scripts/gen-adr-index.cjs +8 -2
  210. package/scripts/gen-inventory-manifest.cjs +12 -0
  211. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  212. package/scripts/lib/drift-scan.cjs +1 -1
  213. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  214. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  215. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  216. package/scripts/lib/suite-detection.cjs +32 -0
  217. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  218. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
  219. package/scripts/lint-phase-id-drift.cjs +338 -13
  220. package/scripts/lint-response-language-coverage.cjs +9 -3
  221. package/scripts/lint-source-test-name-collision.cjs +1 -1
  222. package/scripts/lint-test-file-count.allowlist.json +1 -0
  223. package/scripts/lint-vendored-deps.cjs +128 -17
  224. package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
  225. package/scripts/prompt-injection-scan.sh +14 -0
  226. package/scripts/workflow-size.cjs +139 -0
  227. package/skills/gsd-cleanup/SKILL.md +1 -0
  228. package/skills/gsd-code-review/SKILL.md +2 -1
  229. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  230. package/skills/gsd-config/SKILL.md +1 -0
  231. package/skills/gsd-debug/SKILL.md +1 -0
  232. package/skills/gsd-graphify/SKILL.md +1 -0
  233. package/skills/gsd-health/SKILL.md +1 -0
  234. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  235. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  236. package/skills/gsd-new-milestone/SKILL.md +1 -0
  237. package/skills/gsd-new-project/SKILL.md +1 -0
  238. package/skills/gsd-next/SKILL.md +1 -0
  239. package/skills/gsd-pause-work/SKILL.md +1 -0
  240. package/skills/gsd-phase/SKILL.md +1 -0
  241. package/skills/gsd-pr-branch/SKILL.md +1 -0
  242. package/skills/gsd-resume-work/SKILL.md +1 -0
  243. package/skills/gsd-review-backlog/SKILL.md +1 -0
  244. package/skills/gsd-settings/SKILL.md +2 -1
  245. package/skills/gsd-stats/SKILL.md +1 -0
  246. package/skills/gsd-thread/SKILL.md +1 -0
  247. package/skills/gsd-workspace/SKILL.md +1 -0
  248. package/skills/gsd-workstreams/SKILL.md +1 -0
  249. package/vscode/package.json +1 -1
  250. package/gsd-core/templates/claude-md.md +0 -145
  251. package/gsd-core/templates/codebase/concerns.md +0 -310
  252. package/gsd-core/templates/codebase/conventions.md +0 -307
  253. package/gsd-core/templates/codebase/integrations.md +0 -280
  254. package/gsd-core/templates/codebase/structure.md +0 -285
  255. package/gsd-core/templates/codebase/testing.md +0 -480
  256. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  257. package/gsd-core/templates/discovery.md +0 -146
@@ -20,6 +20,34 @@
20
20
  * directly above). Sites that build their regex from `PHASE_NUMBER_TOKEN_SOURCE`
21
21
  * carry no literal grammar and pass automatically.
22
22
  *
23
+ * #4634 extends the same pattern with two more detectors:
24
+ *
25
+ * - name-validity-guard drift: `hasNameableContent(s)` in `src/roadmap-parser.cts`
26
+ * is the sole owner of the "does this string have nameable content" predicate
27
+ * (`/[\p{L}\p{N}]/u.test(s)`). Any other `src/**` file re-deriving that exact
28
+ * character class (regex-literal or `new RegExp` template form) instead of
29
+ * calling `hasNameableContent` is drift, sanctioned the same way as the token
30
+ * and bracket rules (`// phase-id-owner:` on the nearest preceding non-blank
31
+ * line), with a line-level escape for a line that already calls
32
+ * `hasNameableContent(`.
33
+ *
34
+ * - shell phase-number-arithmetic ban: `$((10#...))` base-10-forced arithmetic
35
+ * inside `gsd-core/workflows/**\/*.md` and `gsd-core/references/**\/*.md` breaks
36
+ * on decimal or multi-segment phase ids and is banned outright. This scan runs
37
+ * over markdown, not `.cts` source, so its sanction is an HTML comment on the
38
+ * nearest preceding non-blank line: `<!-- phase-id-owner: <reason> -->`.
39
+ *
40
+ * - branch-slug fallback drift: a `.replace('{slug}', ... || 'phase')` call
41
+ * silently substitutes the literal string `'phase'` when a phase's slug
42
+ * can't be derived, producing a non-identifying branch name like
43
+ * `gsd/phase-08-phase` (#4126, now fixed via the shared renderPhaseBranchName
44
+ * owner in phase-id.cts, consumed by both prior call sites). Sanctioned
45
+ * the same way as the token/bracket/name-validity rules (`// phase-id-owner:`
46
+ * on the nearest preceding non-blank line), with a line-level escape for a
47
+ * line that already calls `renderPhaseBranchName(`. Unlike the other
48
+ * `.cts`-scanning rules, this one has no per-file exemption — it is a banned
49
+ * anti-pattern everywhere, not a grammar with one legitimate owner site.
50
+ *
23
51
  * Detection is intentionally NARROW: only the contiguous canonical token
24
52
  * (`\d+[A-Z]?(?:\.\d+)*`, its `[A-Za-z]` and `[.-]` near-variants, in both
25
53
  * regex-literal `\d` and `new RegExp` template `\\d` escaping) is drift. Bare
@@ -82,6 +110,18 @@ const BRACKET_CODE_DRIFT_RE = /\[A-Z(?:a-z)?\]\[A-Z(?:a-z)?0-9_\]\*/;
82
110
  const BRACKET_OWNER_HINT =
83
111
  'BRACKET_PROJECT_CODE_SRC / BRACKET_ID_SRC / bracketMilestoneIntroSrcFor / BRACKET_MILESTONE_INTRO_CAPTURING_SRC';
84
112
 
113
+ /**
114
+ * Pure: true if the nearest preceding non-blank line to `lines[i]` is a
115
+ * dedicated sanction comment matching `ownerRe`. Shared by every detector in
116
+ * this file so the "how do you sanction a finding" walk has one owner instead
117
+ * of four independent copies that could silently diverge.
118
+ */
119
+ function isSanctionedByPrecedingComment(lines, i, ownerRe) {
120
+ let j = i - 1;
121
+ while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line
122
+ return j >= 0 && ownerRe.test(lines[j]);
123
+ }
124
+
85
125
  /**
86
126
  * Pure: find every literal re-derivation of the canonical phase-number token in
87
127
  * `text` that is NOT sanctioned. A site is sanctioned when the nearest preceding
@@ -100,9 +140,7 @@ function findPhaseIdRegexDrift(text) {
100
140
  const m = TOKEN_DRIFT_RE.exec(line);
101
141
  if (!m) continue;
102
142
  if (line.includes(CANON_REF)) continue;
103
- let j = i - 1;
104
- while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line
105
- if (j >= 0 && OWNER_RE.test(lines[j])) continue;
143
+ if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
106
144
  out.push({ line: i + 1, found: m[0] });
107
145
  }
108
146
  return out;
@@ -122,21 +160,249 @@ function findBracketGrammarDrift(text) {
122
160
  for (let i = 0; i < lines.length; i++) {
123
161
  const m = BRACKET_CODE_DRIFT_RE.exec(lines[i]);
124
162
  if (!m) continue;
125
- let j = i - 1;
126
- while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line
127
- if (j >= 0 && OWNER_RE.test(lines[j])) continue;
163
+ if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
164
+ out.push({ line: i + 1, found: m[0] });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ // #4634: the name-validity-guard grammar — `hasNameableContent(s)` in
170
+ // `src/roadmap-parser.cts` is `/[\p{L}\p{N}]/u.test(s)`. Tolerates both the
171
+ // regex-literal single-backslash form and the doubled-backslash template
172
+ // form (`new RegExp('[\\p{L}\\p{N}]'`), mirroring how TOKEN_DRIFT_RE tolerates
173
+ // both escapings.
174
+ const NAME_VALIDITY_DRIFT_RE = /\[\\{1,2}p\{L\}\\{1,2}p\{N\}\]/;
175
+ const NAME_VALIDITY_CANON_REF = 'hasNameableContent(';
176
+
177
+ /**
178
+ * Pure: find every literal re-derivation of the canonical name-validity
179
+ * character class in `text` that is NOT sanctioned. Same sanction mechanism
180
+ * as the token/bracket rules — a dedicated `// phase-id-owner:` comment on
181
+ * the nearest preceding non-blank line — plus a line-level escape for a line
182
+ * that already calls `hasNameableContent(`. Returns [{ line, found }].
183
+ */
184
+ function findNameValidityDrift(text) {
185
+ const out = [];
186
+ const lines = text.split('\n');
187
+ for (let i = 0; i < lines.length; i++) {
188
+ const line = lines[i];
189
+ const m = NAME_VALIDITY_DRIFT_RE.exec(line);
190
+ if (!m) continue;
191
+ if (line.includes(NAME_VALIDITY_CANON_REF)) continue;
192
+ if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
193
+ out.push({ line: i + 1, found: m[0] });
194
+ }
195
+ return out;
196
+ }
197
+
198
+ // #4634: the branch-slug fallback anti-pattern (#4126) — a
199
+ // `.replace('{slug}', ... || 'phase')` call silently falls back to the
200
+ // literal string `'phase'` when a phase's slug can't be derived, producing a
201
+ // non-identifying branch name like `gsd/phase-08-phase`. Now fixed at both
202
+ // prior call sites (commands.cts, init.cts) via the shared
203
+ // `renderPhaseBranchName` owner in phase-id.cts; this rule is the ratchet
204
+ // against a THIRD site reintroducing the inline fallback. Deliberately
205
+ // narrow: it requires the literal `'phase'` fallback on the same line as the
206
+ // `{slug}` template token, so it does NOT match the sibling milestone-branch
207
+ // fallback (`|| 'milestone'`), which is a different, correct-as-is case.
208
+ const BRANCH_SLUG_FALLBACK_DRIFT_RE = /\{slug\}'.*\|\|\s*'phase'/;
209
+
210
+ // The canonical fix is `renderPhaseBranchName(...)`. There is no "owner file"
211
+ // for this rule the way there is for the token/bracket/name-validity
212
+ // grammars above — it is a banned anti-pattern everywhere, so no per-file
213
+ // exemption exists.
214
+ const BRANCH_SLUG_FALLBACK_CANON_REF = 'renderPhaseBranchName(';
215
+
216
+ /**
217
+ * Pure: find every unsanctioned branch-slug `|| 'phase'` fallback in `text`.
218
+ * Same sanction mechanism as the token/bracket/name-validity rules — a
219
+ * dedicated `// phase-id-owner:` comment on the nearest preceding non-blank
220
+ * line — plus a line-level escape for a line that already calls
221
+ * `renderPhaseBranchName(`. Returns [{ line, found }].
222
+ */
223
+ function findBranchSlugFallbackDrift(text) {
224
+ const out = [];
225
+ const lines = text.split('\n');
226
+ for (let i = 0; i < lines.length; i++) {
227
+ const line = lines[i];
228
+ const m = BRANCH_SLUG_FALLBACK_DRIFT_RE.exec(line);
229
+ if (!m) continue;
230
+ if (line.includes(BRANCH_SLUG_FALLBACK_CANON_REF)) continue;
231
+ if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
232
+ out.push({ line: i + 1, found: m[0] });
233
+ }
234
+ return out;
235
+ }
236
+
237
+ // #4634: ban base-10-forced shell arithmetic (`$((10#...))`) on a variable
238
+ // that still carries a possibly-decimal/multi-segment phase id — this
239
+ // construct is exactly the pattern that breaks on a value like `08.5`. The
240
+ // capture group grabs the token immediately inside the parens (after an
241
+ // optional `$` and/or `{`, stripping a trailing `}`) so callers can inspect
242
+ // *which* variable is being coerced, not merely that the substring occurred.
243
+ //
244
+ // Refined post-#4619: the original blunt "ban `$((10#` outright" version
245
+ // over-fired on three false-positive classes once #4619's fix landed:
246
+ // 1. Prose mentioning the literal pattern in a full-line `#`-comment
247
+ // (filtered by the caller, not this regex — see below).
248
+ // 2. `$((10#$PHASE_INT))` / `$((10#$SPOT_PHASE_INT))` — arithmetic on the
249
+ // NOW-safe variable the #4619 fix produces via `PHASE_INT=${PHASE_NUMBER%%.*}`;
250
+ // a `%%.*`-stripped value can never contain a dot, so base-10 arithmetic
251
+ // on it can never hit the #4619 syntax-error class. Any name ending in
252
+ // `_INT` (case-insensitive) is that established "already reduced to a
253
+ // safe integer" convention.
254
+ // 3. `$((10#{plan_padded}))` / `$((10#${PLAN_ID}))` — plan ids are plain
255
+ // integers and were never in scope; this rule only polices variables
256
+ // that carry a *phase* id.
257
+ // So a match is only a violation when the captured name contains `phase`
258
+ // case-insensitively (it is phase-carrying) AND does not end in `_int`
259
+ // case-insensitively (it has not already been reduced to a safe integer).
260
+ const SHELL_PHASE_ARITH_DRIFT_RE = /\$\(\(\s*10#\$?\{?([A-Za-z0-9_]+)\}?/;
261
+
262
+ // A markdown comment can't easily carry a `//` line, so the sanction for the
263
+ // shell-arithmetic rule is an HTML comment on the nearest preceding non-blank
264
+ // line: `<!-- phase-id-owner: <reason> -->`.
265
+ const MD_OWNER_RE = /^\s*<!--.*phase-id-owner:/;
266
+
267
+ /**
268
+ * Pure: find every unsanctioned `$((10#...))` base-10-forced shell arithmetic
269
+ * site in `text` that still coerces an un-reduced phase-carrying variable.
270
+ * Skips full-line `#` comments outright (pure prose mentioning the pattern,
271
+ * not executable code), and skips any captured variable name that either
272
+ * doesn't contain `phase` (never in scope — e.g. plan ids) or already ends
273
+ * in `_int` (the #4619-fix convention for "safely stripped to an integer").
274
+ * Sanctioned by an HTML comment `<!-- phase-id-owner: ... -->` on the
275
+ * nearest preceding non-blank line. Returns [{ line, found }].
276
+ */
277
+ function findShellPhaseArithDrift(text) {
278
+ const out = [];
279
+ const lines = text.split('\n');
280
+ for (let i = 0; i < lines.length; i++) {
281
+ const line = lines[i];
282
+ if (/^\s*#/.test(line)) continue;
283
+ const m = SHELL_PHASE_ARITH_DRIFT_RE.exec(line);
284
+ if (!m) continue;
285
+ const name = m[1];
286
+ if (!/phase/i.test(name)) continue;
287
+ if (/_int$/i.test(name)) continue;
288
+ if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
289
+ out.push({ line: i + 1, found: m[0] });
290
+ }
291
+ return out;
292
+ }
293
+
294
+ // #4634: the markdown scan roots — shell embedded in workflow/reference docs.
295
+ const MD_SCAN_DIRS = [path.join('gsd-core', 'workflows'), path.join('gsd-core', 'references')];
296
+
297
+ // #4568 (epic #4634): the single-segment phase regex ban scans a THIRD root,
298
+ // `agents/**/*.md`, that the #4619 shell-arithmetic extension above never
299
+ // touched — the gsd-code-fixer agent prompts re-derive the phase-number
300
+ // grammar too. Reuses the same `walkMd` walker as the shell-arith scan.
301
+ const SINGLE_SEGMENT_SCAN_DIRS = [...MD_SCAN_DIRS, 'agents'];
302
+
303
+ /**
304
+ * Scan `gsd-core/workflows/**\/*.md` and `gsd-core/references/**\/*.md` for
305
+ * unsanctioned `$((10#...))` shell arithmetic. Returns [{ file, line, found }]
306
+ * with repo-relative paths.
307
+ */
308
+ function scanMarkdownShellArith(root) {
309
+ const violations = [];
310
+ for (const dir of MD_SCAN_DIRS) {
311
+ for (const file of walkMd(path.join(root, dir), [])) {
312
+ const rel = path.relative(root, file);
313
+ let text;
314
+ try {
315
+ text = fs.readFileSync(file, 'utf8');
316
+ } catch {
317
+ continue;
318
+ }
319
+ for (const d of findShellPhaseArithDrift(text)) {
320
+ violations.push({ file: rel, kind: 'shell-arith', ...d });
321
+ }
322
+ }
323
+ }
324
+ return violations;
325
+ }
326
+
327
+ // #4568 (epic #4634): ban the single-optional-dotted-segment phase regex
328
+ // shape `[0-9]+(\.[0-9]+)?` (and its `\d`/doubled-backslash near-variants)
329
+ // outright — this is exactly the grammar that hard-rejects or silently
330
+ // truncates a 3-or-more-segment phase id like `23.1.2`. The canonical
331
+ // grammar (`src/phase-id.cts`) uses the unbounded `(?:\.\d+)*` form; shell
332
+ // snippets embedded in markdown can't import that module, so textual parity
333
+ // (`*` in place of `?`) is the fix, and this rule is the ratchet against a
334
+ // future site re-deriving the bounded form. Deliberately narrow to the
335
+ // bounded ONE-optional-segment shape — the fixed `*`-form is not flagged.
336
+ const SINGLE_SEGMENT_PHASE_DRIFT_RE =
337
+ /(?:\\{1,2}d|\[0-9\])\+\(\\{1,2}\.(?:\\{1,2}d|\[0-9\])\+\)\?/;
338
+
339
+ // A single-segment shape like `[0-9]+(\.[0-9]+)?` is not inherently
340
+ // phase-specific (e.g. it could describe a version number), so the rule
341
+ // only fires on a line whose text plausibly carries a phase-number
342
+ // variable — a case-insensitive `phase` substring anywhere on the line,
343
+ // mirroring the phase-carrying filter `findShellPhaseArithDrift` already
344
+ // applies to its own variable-name capture.
345
+ const PHASE_CARRYING_LINE_RE = /phase/i;
346
+
347
+ /**
348
+ * Pure: find every unsanctioned single-optional-dotted-segment phase regex
349
+ * in `text`, restricted to lines that plausibly carry a phase-number
350
+ * variable. Sanctioned by an HTML comment `<!-- phase-id-owner: ... -->` on
351
+ * the nearest preceding non-blank line (same convention as the shell-arith
352
+ * rule). Returns [{ line, found }].
353
+ */
354
+ function findSingleSegmentPhaseRegexDrift(text) {
355
+ const out = [];
356
+ const lines = text.split('\n');
357
+ for (let i = 0; i < lines.length; i++) {
358
+ const line = lines[i];
359
+ const m = SINGLE_SEGMENT_PHASE_DRIFT_RE.exec(line);
360
+ if (!m) continue;
361
+ if (!PHASE_CARRYING_LINE_RE.test(line)) continue;
362
+ if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
128
363
  out.push({ line: i + 1, found: m[0] });
129
364
  }
130
365
  return out;
131
366
  }
132
367
 
368
+ /**
369
+ * Scan `gsd-core/workflows/**\/*.md`, `gsd-core/references/**\/*.md`, and
370
+ * `agents/**\/*.md` for unsanctioned single-optional-dotted-segment phase
371
+ * regexes. Returns [{ file, line, found }] with repo-relative paths.
372
+ */
373
+ function scanMarkdownSingleSegmentPhaseRegex(root) {
374
+ const violations = [];
375
+ for (const dir of SINGLE_SEGMENT_SCAN_DIRS) {
376
+ for (const file of walkMd(path.join(root, dir), [])) {
377
+ const rel = path.relative(root, file);
378
+ let text;
379
+ try {
380
+ text = fs.readFileSync(file, 'utf8');
381
+ } catch {
382
+ continue;
383
+ }
384
+ for (const d of findSingleSegmentPhaseRegexDrift(text)) {
385
+ violations.push({ file: rel, kind: 'single-segment-phase-regex', ...d });
386
+ }
387
+ }
388
+ }
389
+ return violations;
390
+ }
391
+
133
392
  // Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
134
393
  const SCAN_DIRS = ['src'];
135
394
  const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
136
395
  // The canonical owner defines the grammar; it is exempt by construction.
137
396
  const EXEMPT = new Set([path.join('src', 'phase-id.cts')]);
138
397
 
139
- function walk(dir, acc) {
398
+ // #4634: the name-validity-guard rule owns a DIFFERENT file (roadmap-parser.cts
399
+ // defines `hasNameableContent`), so it needs its own exemption set — the
400
+ // token/bracket rules above must NOT start exempting roadmap-parser.cts too,
401
+ // since it is not their owner.
402
+ const NAME_VALIDITY_EXEMPT = new Set([path.join('src', 'roadmap-parser.cts')]);
403
+
404
+ function walk(dir, acc, ext) {
405
+ const extSet = ext || SCAN_EXT;
140
406
  let entries;
141
407
  try {
142
408
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -147,14 +413,22 @@ function walk(dir, acc) {
147
413
  const full = path.join(dir, entry.name);
148
414
  if (entry.isDirectory()) {
149
415
  if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
150
- walk(full, acc);
151
- } else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
416
+ walk(full, acc, extSet);
417
+ } else if (entry.isFile() && extSet.has(path.extname(entry.name))) {
152
418
  acc.push(full);
153
419
  }
154
420
  }
155
421
  return acc;
156
422
  }
157
423
 
424
+ // #4634: markdown scan for the shell phase-arithmetic ban walks a disjoint set
425
+ // of roots/extensions from the src/**/*.cts scan above, so it gets its own thin
426
+ // wrapper over the same `walk` rather than a parallel tree-walker.
427
+ const MD_EXT = new Set(['.md']);
428
+ function walkMd(dir, acc) {
429
+ return walk(dir, acc, MD_EXT);
430
+ }
431
+
158
432
  // ─── #2761 M4: the heading-baseline selector census ────────────────────────
159
433
  //
160
434
  // `phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.<MODE>)` is the other half
@@ -239,23 +513,63 @@ function scanRepo(root) {
239
513
  for (const d of findBracketGrammarDrift(text)) {
240
514
  violations.push({ file: rel, kind: 'bracket', ...d });
241
515
  }
516
+ // #4634: name-validity-guard drift, exempting only its own owner file.
517
+ if (!NAME_VALIDITY_EXEMPT.has(rel)) {
518
+ for (const d of findNameValidityDrift(text)) {
519
+ violations.push({ file: rel, kind: 'name-validity', ...d });
520
+ }
521
+ }
522
+ // #4634: branch-slug fallback anti-pattern, exempt nowhere.
523
+ for (const d of findBranchSlugFallbackDrift(text)) {
524
+ violations.push({ file: rel, kind: 'branch-slug-fallback', ...d });
525
+ }
242
526
  }
243
527
  }
244
528
  return violations;
245
529
  }
246
530
 
531
+ /**
532
+ * Scan EVERYTHING this seam guards: the `src/**\/*.cts` token/bracket/
533
+ * name-validity invariants (`scanRepo`) plus the markdown shell
534
+ * phase-arithmetic ban (`scanMarkdownShellArith`). This is what the CLI runs;
535
+ * `scanRepo` alone stays narrowly scoped to its original src/** contract so
536
+ * a live, separately-tracked markdown defect (#4619) cannot make the
537
+ * pinned-clean `scanRepo` test spuriously fail.
538
+ */
539
+ function scanAll(root) {
540
+ return [
541
+ ...scanRepo(root),
542
+ ...scanMarkdownShellArith(root),
543
+ ...scanMarkdownSingleSegmentPhaseRegex(root),
544
+ ];
545
+ }
546
+
247
547
  function main() {
248
548
  const root = path.join(__dirname, '..');
249
- const violations = scanRepo(root);
549
+ const violations = scanAll(root);
250
550
  if (violations.length === 0) {
251
- process.stdout.write('ok phase-id-drift: no unsanctioned phase-token or bracket-grammar re-derivations outside phase-id.cts\n');
551
+ process.stdout.write(
552
+ 'ok phase-id-drift: no unsanctioned phase-token, bracket-grammar, name-validity, ' +
553
+ 'branch-slug-fallback, or shell phase-arithmetic re-derivations found\n',
554
+ );
252
555
  return;
253
556
  }
254
557
  process.stderr.write('phase-id-drift: literal re-derivation(s) of a canonical grammar found.\n');
255
558
  process.stderr.write(`Build the regex from phase-id.cjs \`${CANON_REF}\` (or phaseMarkdownRegexSource for a\n`);
256
559
  process.stderr.write(`known number) for the phase-number token, or from ${BRACKET_OWNER_HINT}\n`);
257
- process.stderr.write('for the bracket grammar — or sanction the site with a dedicated\n');
258
- process.stderr.write('`// phase-id-owner: <reason>` comment on the line directly above the regex:\n');
560
+ process.stderr.write('for the bracket grammar, or call `hasNameableContent(` (src/roadmap-parser.cts) for\n');
561
+ process.stderr.write('the name-validity predicate — or sanction the site with a dedicated\n');
562
+ process.stderr.write('`// phase-id-owner: <reason>` comment on the line directly above the regex.\n');
563
+ process.stderr.write('`$((10#...))` base-10-forced shell arithmetic is banned outright in\n');
564
+ process.stderr.write('gsd-core/workflows/**/*.md and gsd-core/references/**/*.md — sanction with\n');
565
+ process.stderr.write('`<!-- phase-id-owner: <reason> -->` on the line directly above.\n');
566
+ process.stderr.write('The single-optional-dotted-segment phase regex `[0-9]+(\\.[0-9]+)?` (or its \\d\n');
567
+ process.stderr.write('near-variant) is banned outright in gsd-core/workflows/**/*.md,\n');
568
+ process.stderr.write('gsd-core/references/**/*.md, and agents/**/*.md — widen it to `*` (unbounded\n');
569
+ process.stderr.write('segments) or sanction with `<!-- phase-id-owner: <reason> -->`.\n');
570
+ process.stderr.write('A `.replace(\'{slug}\', ... || \'phase\')` fallback is banned outright (#4126) —\n');
571
+ process.stderr.write('use `renderPhaseBranchName(` or sanction with\n');
572
+ process.stderr.write('`// phase-id-owner: <reason>` on the line directly above:\n');
259
573
  for (const d of violations) {
260
574
  process.stderr.write(` [${d.kind}] ${d.file}:${d.line} ${d.found}\n`);
261
575
  }
@@ -267,9 +581,20 @@ if (require.main === module) main();
267
581
  module.exports = {
268
582
  findPhaseIdRegexDrift,
269
583
  findBracketGrammarDrift,
584
+ findNameValidityDrift,
585
+ findBranchSlugFallbackDrift,
586
+ findShellPhaseArithDrift,
587
+ findSingleSegmentPhaseRegexDrift,
588
+ scanMarkdownShellArith,
589
+ scanMarkdownSingleSegmentPhaseRegex,
270
590
  scanRepo,
591
+ scanAll,
271
592
  countSelectorBaselines,
272
593
  scanSelectorBaselines,
273
594
  TOKEN_DRIFT_RE,
274
595
  BRACKET_CODE_DRIFT_RE,
596
+ NAME_VALIDITY_DRIFT_RE,
597
+ BRANCH_SLUG_FALLBACK_DRIFT_RE,
598
+ SHELL_PHASE_ARITH_DRIFT_RE,
599
+ SINGLE_SEGMENT_PHASE_DRIFT_RE,
275
600
  };
@@ -22,8 +22,10 @@
22
22
  * explanations" describes the gap rather than closing it. Coverage therefore
23
23
  * requires a narration-class token as well — see `NARRATION_CLASS_RE`.
24
24
  *
25
- * A workflow FRAGMENT (`<workflow>/<modes|steps|templates>/<name>.md`, the shape
26
- * the #1671 fragment epic extracts) additionally passes when its parent workflow
25
+ * A workflow FRAGMENT (`<workflow>/<modes|steps|templates|detail>/<name>.md`, the
26
+ * shape the #1671 fragment epic extracts — `detail/` is the fourth such
27
+ * subdirectory kind, added by the #4403/ADR-4139 spine+detail split) additionally
28
+ * passes when its parent workflow
27
29
  * names that exact fragment path and is itself covered — see
28
30
  * `inheritsParentCoverage`, which proves the inheritance per file instead of
29
31
  * granting it to a directory.
@@ -124,7 +126,11 @@ const EXACT_INLINE_DIRECTIVE_WORKFLOWS = new Set([
124
126
  // failure for a quiet assumption; as it stands, extracting a fragment-of-a-
125
127
  // fragment without pinning it turns the lint RED, which is the correct answer
126
128
  // and names the file to fix.
127
- const FRAGMENT_DIRS = new Set(['modes', 'steps', 'templates']);
129
+ // `detail` (ADR-4139 §6 Decision 6 / #4403) is the fourth fragment-directory kind,
130
+ // alongside modes/steps/templates from #1671: a spine's `detail/<part>.md` is
131
+ // reached the same way -- a `read and execute` stub in the top-level parent -- so
132
+ // it inherits coverage through the exact same mechanism, not a parallel one.
133
+ const FRAGMENT_DIRS = new Set(['modes', 'steps', 'templates', 'detail']);
128
134
  const DIRECTIVE_ACTION_RE = /\b(?:apply|present|render|respond|translate|use|write|must|should)\b/i;
129
135
  const USER_OUTPUT_RE = /\b(?:explanations?|language|narration|outputs?|prompts?|prose|questions?|templates?|user-facing)\b/i;
130
136
  // The defect #2529 reports is NARRATION, not the question/answer surface: a
@@ -114,7 +114,7 @@ function toPosix(p) {
114
114
  function isGeneratedOutput(relPath) {
115
115
  const posixRel = toPosix(relPath);
116
116
  return GENERATED_OUTPUT_PREFIXES.some(
117
- (prefix) => posixRel === prefix || posixRel.startsWith(`${prefix}/`)
117
+ (prefix) => posixRel === prefix || posixRel.startsWith(`${prefix}/`) // allow-handrolled-containment: scan-exclusion membership test against a fixed generated-output prefix list, not a filesystem root-confinement gate
118
118
  );
119
119
  }
120
120
 
@@ -123,6 +123,7 @@
123
123
  "state-prune.test.cjs",
124
124
  "state-rebuild-cli.test.cjs",
125
125
  "state-rebuild.test.cjs",
126
+ "state-todos-render.test.cjs",
126
127
  "state-write-path-drift-guard.test.cjs",
127
128
  "state.test.cjs"
128
129
  ],
@@ -161,8 +161,24 @@ function compareFiles(relA, relB) {
161
161
  * @param {string} spec
162
162
  * @returns {string}
163
163
  */
164
+ const PIN_OPERATOR_RE = /^(\^|~|>=|<=|>|<|=)?/;
165
+
164
166
  function stripRangeOperator(spec) {
165
- return String(spec || '').trim().replace(/^[\^~]|^>=|^<=|^>|^<|^=/, '').trim();
167
+ return String(spec || '').trim().replace(PIN_OPERATOR_RE, '').trim();
168
+ }
169
+
170
+ /**
171
+ * The leading range-operator token (if any) a package.json dependency spec
172
+ * was written with — the inverse half of stripRangeOperator, needed by
173
+ * `fixRow` to rebuild a pin (`<same operator>` + `<new version>`) that
174
+ * preserves the author's original range style instead of collapsing every
175
+ * pin to an exact version.
176
+ * @param {string} spec
177
+ * @returns {string} the operator (e.g. "^", "~", ">="), or "" for an exact pin
178
+ */
179
+ function pinOperatorPrefix(spec) {
180
+ const m = String(spec || '').trim().match(PIN_OPERATOR_RE);
181
+ return (m && m[1]) || '';
166
182
  }
167
183
 
168
184
  /**
@@ -211,12 +227,40 @@ function checkHandAuthoredTwin(row) {
211
227
  return findings;
212
228
  }
213
229
 
230
+ /**
231
+ * Read a row's pin state: the package.json devDependencies spec for
232
+ * `row.name` and, if `node_modules/<row.name>/package.json` exists, its
233
+ * installed version. Shared by checkRow (compares) and fixRow (rewrites) so
234
+ * the two can never silently diverge on how a pin is read.
235
+ * @param {VendoredPackage} row
236
+ * @param {string} [pkgRoot] Override for testing -- defaults to the real repo ROOT. Lets a
237
+ * test point fixRow's pin-rewrite at an isolated temp package.json instead of writing the
238
+ * real, shared one, which other concurrently-running node --test files read at module
239
+ * top-level (the same race class already fixed for the vendored .cjs copy).
240
+ * @returns {{pinnedSpec: string | undefined, installedVersion: string | undefined}}
241
+ */
242
+ function readPinState(row, pkgRoot = ROOT) {
243
+ const pkgPath = path.join(pkgRoot, 'package.json');
244
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
245
+ const pinnedSpec = pkg.devDependencies && pkg.devDependencies[row.name];
246
+ const installedPkgPath = path.join(pkgRoot, 'node_modules', row.name, 'package.json');
247
+ let installedVersion;
248
+ if (fs.existsSync(installedPkgPath)) {
249
+ installedVersion = JSON.parse(fs.readFileSync(installedPkgPath, 'utf8')).version;
250
+ }
251
+ return { pinnedSpec, installedVersion };
252
+ }
253
+
214
254
  /**
215
255
  * Run all applicable freshness checks for one vendored package row.
216
256
  * @param {VendoredPackage} row
257
+ * @param {string} [pkgRoot] Override for testing -- defaults to the real repo ROOT. Lets a
258
+ * test point fixRow's pin-rewrite at an isolated temp package.json instead of writing the
259
+ * real, shared one, which other concurrently-running node --test files read at module
260
+ * top-level (the same race class already fixed for the vendored .cjs copy).
217
261
  * @returns {string[]} findings (empty when the row is fresh)
218
262
  */
219
- function checkRow(row) {
263
+ function checkRow(row, pkgRoot = ROOT) {
220
264
  const findings = [];
221
265
 
222
266
  const cjsDrift = compareFiles(row.vendoredCjs, row.upstreamCjs);
@@ -235,31 +279,96 @@ function checkRow(row) {
235
279
  findings.push(...checkHandAuthoredTwin(row));
236
280
  }
237
281
 
238
- const pkgPath = path.join(ROOT, 'package.json');
239
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
240
- const pinnedSpec = pkg.devDependencies && pkg.devDependencies[row.name];
282
+ const { pinnedSpec, installedVersion } = readPinState(row, pkgRoot);
241
283
  if (!pinnedSpec) {
242
284
  findings.push(`package.json devDependencies.${row.name} is missing`);
285
+ } else if (installedVersion === undefined) {
286
+ findings.push(`node_modules/${row.name}/package.json does not exist (run npm install)`);
243
287
  } else {
244
- const installedPkgPath = path.join(ROOT, 'node_modules', row.name, 'package.json');
245
- if (!fs.existsSync(installedPkgPath)) {
246
- findings.push(`node_modules/${row.name}/package.json does not exist (run npm install)`);
247
- } else {
248
- const installed = JSON.parse(fs.readFileSync(installedPkgPath, 'utf8'));
249
- const pinned = stripRangeOperator(pinnedSpec);
250
- if (pinned !== installed.version) {
251
- findings.push(
252
- `package.json devDependencies.${row.name} ("${pinnedSpec}" -> "${pinned}") != `
253
- + `node_modules/${row.name}/package.json version ("${installed.version}")`,
254
- );
255
- }
288
+ const pinned = stripRangeOperator(pinnedSpec);
289
+ if (pinned !== installedVersion) {
290
+ findings.push(
291
+ `package.json devDependencies.${row.name} ("${pinnedSpec}" -> "${pinned}") != `
292
+ + `node_modules/${row.name}/package.json version ("${installedVersion}")`,
293
+ );
256
294
  }
257
295
  }
258
296
 
259
297
  return findings;
260
298
  }
261
299
 
300
+ /**
301
+ * Mechanically resolve a vendored package's byte/pin drift: copy the
302
+ * upstream .cjs (and, for `upstream-verbatim` rows, the .d.cts twins) over
303
+ * the vendored copy, and bump the package.json pin to the installed
304
+ * version, preserving the original range-operator prefix. Then re-runs
305
+ * `checkRow` and returns whatever findings remain.
306
+ *
307
+ * This NEVER hand-edits a `hand-authored` twin (e.g. js-yaml.d.cts) — that
308
+ * file encodes a human's deliberate judgment about which exports are safe
309
+ * to expose, and only a human can tell whether a declared-export-missing
310
+ * finding is mechanical drift or a real upstream API break. If one remains
311
+ * after this runs, that is by design: the caller must not treat it as
312
+ * fixed.
313
+ * @param {VendoredPackage} row
314
+ * @param {string} [pkgRoot] Override for testing -- defaults to the real repo ROOT. Lets a
315
+ * test point fixRow's pin-rewrite at an isolated temp package.json instead of writing the
316
+ * real, shared one, which other concurrently-running node --test files read at module
317
+ * top-level (the same race class already fixed for the vendored .cjs copy).
318
+ * @returns {string[]} findings remaining after the fix (empty when fully resolved)
319
+ */
320
+ function fixRow(row, pkgRoot = ROOT) {
321
+ fs.copyFileSync(resolvePath(row.upstreamCjs), resolvePath(row.vendoredCjs));
322
+
323
+ if (row.twinKind === 'upstream-verbatim' && row.upstreamDts) {
324
+ if (row.vendoredDts) fs.copyFileSync(resolvePath(row.upstreamDts), resolvePath(row.vendoredDts));
325
+ if (row.srcTwin) fs.copyFileSync(resolvePath(row.upstreamDts), resolvePath(row.srcTwin));
326
+ }
327
+
328
+ const { pinnedSpec, installedVersion } = readPinState(row, pkgRoot);
329
+ if (pinnedSpec && installedVersion !== undefined) {
330
+ const newPin = `${pinOperatorPrefix(pinnedSpec)}${installedVersion}`;
331
+ if (newPin !== pinnedSpec) {
332
+ const pkgPath = path.join(pkgRoot, 'package.json');
333
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
334
+ pkg.devDependencies[row.name] = newPin;
335
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
336
+ }
337
+ }
338
+
339
+ return checkRow(row, pkgRoot);
340
+ }
341
+
262
342
  function main() {
343
+ if (process.argv.includes('--fix')) {
344
+ /** @type {Record<string, string[]>} */
345
+ const remaining = {};
346
+ for (const row of VENDORED) {
347
+ const findings = fixRow(row);
348
+ if (findings.length > 0) remaining[row.name] = findings;
349
+ }
350
+
351
+ const names = VENDORED.map((row) => row.name).join(', ');
352
+
353
+ if (Object.keys(remaining).length === 0) {
354
+ process.stdout.write(
355
+ `ok lint-vendored-deps --fix: gsd-core/bin/lib/vendor/{${names}} refreshed and now match node_modules and their pinned versions\n`,
356
+ );
357
+ return 0;
358
+ }
359
+
360
+ const detail = Object.entries(remaining)
361
+ .map(([name, findings]) => ` ${name}:\n${findings.map((f) => ` ${f}`).join('\n')}`)
362
+ .join('\n');
363
+ throw new ExitError(
364
+ 1,
365
+ `lint-vendored-deps --fix: mechanical drift refreshed, but the following row(s)\n`
366
+ + 'still have findings that --fix cannot resolve automatically — these need a\n'
367
+ + 'human, not just a re-run of --fix:\n'
368
+ + detail,
369
+ );
370
+ }
371
+
263
372
  const findings = [];
264
373
  for (const row of VENDORED) {
265
374
  findings.push(...checkRow(row));
@@ -288,9 +397,11 @@ if (require.main === module) runMain(main);
288
397
  module.exports = {
289
398
  compareFiles,
290
399
  stripRangeOperator,
400
+ pinOperatorPrefix,
291
401
  VENDORED,
292
402
  buildRefreshCommand,
293
403
  checkRow,
404
+ fixRow,
294
405
  declaredValueExports,
295
406
  checkHandAuthoredTwin,
296
407
  resolvePath,