harness-everything 0.33.0 → 0.35.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 (297) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +48 -0
  4. package/ci/fixtures/doc-lifecycle/prd-revisions/config.json +9 -0
  5. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/config.json +20 -0
  6. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/expected.json +12 -0
  7. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r001.md +20 -0
  8. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r002.md +26 -0
  9. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r003.md +26 -0
  10. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/valid/docs/prd/graph/r001.md +20 -0
  11. package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/valid/docs/prd/graph/r002.md +26 -0
  12. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r001.body.md +39 -0
  13. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-dropped.body.md +35 -0
  14. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-incomplete.body.md +37 -0
  15. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-one-replaced.body.md +39 -0
  16. package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-overlap.body.md +39 -0
  17. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/config.json +1 -0
  18. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/docs/adr/GRPH-001.md +8 -0
  19. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/docs/adr/GRPH-002.md +9 -0
  20. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/expected.json +1 -0
  21. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/config.json +1 -0
  22. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/docs/adr/GRPH-001.md +8 -0
  23. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/docs/adr/GRPH-002.md +9 -0
  24. package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/expected.json +1 -0
  25. package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/config.json +1 -0
  26. package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/docs/adr/GRPH-010.md +9 -0
  27. package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/docs/adr/legacy/GRPH-007.md +8 -0
  28. package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/expected.json +1 -0
  29. package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/config.json +1 -0
  30. package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/docs/adr/GRAPH-007.md +9 -0
  31. package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/docs/adr/legacy/GRPH-007.md +8 -0
  32. package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/expected.json +1 -0
  33. package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/config.json +1 -0
  34. package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/docs/adr/legacy/v1/GRPH-007.md +8 -0
  35. package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/docs/adr/legacy/v2/GRPH-007.md +8 -0
  36. package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/expected.json +1 -0
  37. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/config.json +1 -0
  38. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-003.md +9 -0
  39. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-004.md +9 -0
  40. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-005.md +9 -0
  41. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-006.md +9 -0
  42. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-007.md +9 -0
  43. package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/expected.json +1 -0
  44. package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/config.json +1 -0
  45. package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/docs/adr/GRPH-020.md +8 -0
  46. package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/docs/adr/GRPH-021.md +9 -0
  47. package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/expected.json +1 -0
  48. package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/config.json +1 -0
  49. package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/docs/adr/GRPH-003.md +8 -0
  50. package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/docs/prd/graph.md +10 -0
  51. package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/expected.json +1 -0
  52. package/ci/fixtures/doc-obligations/derive/acceptance-only/base/docs/prd/graph.md +22 -0
  53. package/ci/fixtures/doc-obligations/derive/acceptance-only/candidate/docs/prd/graph.md +22 -0
  54. package/ci/fixtures/doc-obligations/derive/alias-only/base/docs/prd/graph.md +24 -0
  55. package/ci/fixtures/doc-obligations/derive/alias-only/candidate/docs/prd/graph.md +24 -0
  56. package/ci/fixtures/doc-obligations/derive/assertion-no-revision/base/ci/mechanism-lint.test.js +2 -0
  57. package/ci/fixtures/doc-obligations/derive/assertion-no-revision/base/docs/prd/graph.md +20 -0
  58. package/ci/fixtures/doc-obligations/derive/assertion-no-revision/candidate/ci/mechanism-lint.test.js +2 -0
  59. package/ci/fixtures/doc-obligations/derive/assertion-no-revision/candidate/docs/prd/graph.md +20 -0
  60. package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/base/ci/mechanism-lint.test.js +2 -0
  61. package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/base/docs/prd/graph.md +20 -0
  62. package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/candidate/ci/mechanism-lint.test.js +2 -0
  63. package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/candidate/docs/prd/graph.md +20 -0
  64. package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/inventory.json +7 -0
  65. package/ci/fixtures/doc-obligations/derive/attribution-impossible/base/ci/mechanism-open.test.js +3 -0
  66. package/ci/fixtures/doc-obligations/derive/attribution-impossible/candidate/ci/mechanism-open.test.js.fixture +2 -0
  67. package/ci/fixtures/doc-obligations/derive/clause-context-typo/base/docs/prd/graph.md +24 -0
  68. package/ci/fixtures/doc-obligations/derive/clause-context-typo/candidate/docs/prd/graph.md +24 -0
  69. package/ci/fixtures/doc-obligations/derive/code-only/base/src/lint.js +1 -0
  70. package/ci/fixtures/doc-obligations/derive/code-only/candidate/src/lint.js +1 -0
  71. package/ci/fixtures/doc-obligations/derive/comment-only-test/base/ci/mechanism-lint.test.js +2 -0
  72. package/ci/fixtures/doc-obligations/derive/comment-only-test/candidate/ci/mechanism-lint.test.js +2 -0
  73. package/ci/fixtures/doc-obligations/derive/computed-member-assert/base/ci/mechanism-c1.test.js +2 -0
  74. package/ci/fixtures/doc-obligations/derive/computed-member-assert/candidate/ci/mechanism-c1.test.js +2 -0
  75. package/ci/fixtures/doc-obligations/derive/computed-member-variable/base/ci/mechanism-c1.test.js +2 -0
  76. package/ci/fixtures/doc-obligations/derive/computed-member-variable/candidate/ci/mechanism-c1.test.js +2 -0
  77. package/ci/fixtures/doc-obligations/derive/config.json +11 -0
  78. package/ci/fixtures/doc-obligations/derive/decision-fence-backtick/base/docs/adr/GRPH-010.md +20 -0
  79. package/ci/fixtures/doc-obligations/derive/decision-fence-backtick/candidate/docs/adr/GRPH-010.md +20 -0
  80. package/ci/fixtures/doc-obligations/derive/decision-fence-tilde/base/docs/adr/GRPH-010.md +20 -0
  81. package/ci/fixtures/doc-obligations/derive/decision-fence-tilde/candidate/docs/adr/GRPH-010.md +20 -0
  82. package/ci/fixtures/doc-obligations/derive/deep-strict-equal/base/ci/mechanism-s1.test.js +2 -0
  83. package/ci/fixtures/doc-obligations/derive/deep-strict-equal/candidate/ci/mechanism-s1.test.js +2 -0
  84. package/ci/fixtures/doc-obligations/derive/deleted-adr/base/docs/adr/GRPH-008.md +14 -0
  85. package/ci/fixtures/doc-obligations/derive/deleted-prd/base/ci/mechanism-lint.test.js +2 -0
  86. package/ci/fixtures/doc-obligations/derive/deleted-prd/base/docs/prd/graph.md +20 -0
  87. package/ci/fixtures/doc-obligations/derive/deleted-prd/candidate/ci/mechanism-lint.test.js +2 -0
  88. package/ci/fixtures/doc-obligations/derive/derived-rename/base/docs/index.md +3 -0
  89. package/ci/fixtures/doc-obligations/derive/derived-rename/base/src/model/a.js +1 -0
  90. package/ci/fixtures/doc-obligations/derive/derived-rename/candidate/docs/index.md +3 -0
  91. package/ci/fixtures/doc-obligations/derive/derived-rename/candidate/src/model/b.js +1 -0
  92. package/ci/fixtures/doc-obligations/derive/derived-rename/config.json +7 -0
  93. package/ci/fixtures/doc-obligations/derive/derived-two-outputs/base/docs/index.md +3 -0
  94. package/ci/fixtures/doc-obligations/derive/derived-two-outputs/base/docs/model-summary.md +3 -0
  95. package/ci/fixtures/doc-obligations/derive/derived-two-outputs/base/src/model.js +1 -0
  96. package/ci/fixtures/doc-obligations/derive/derived-two-outputs/candidate/docs/index.md +3 -0
  97. package/ci/fixtures/doc-obligations/derive/derived-two-outputs/candidate/docs/model-summary.md +3 -0
  98. package/ci/fixtures/doc-obligations/derive/derived-two-outputs/candidate/src/model.js +1 -0
  99. package/ci/fixtures/doc-obligations/derive/derived-two-outputs/config.json +10 -0
  100. package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/base/ci/mechanism-lint.test.js +2 -0
  101. package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/base/docs/prd/graph-one/r001.md +19 -0
  102. package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/base/docs/prd/graph-two/r001.md +19 -0
  103. package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/candidate/ci/mechanism-lint.test.js +2 -0
  104. package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/candidate/docs/prd/graph-one/r001.md +19 -0
  105. package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/candidate/docs/prd/graph-two/r001.md +19 -0
  106. package/ci/fixtures/doc-obligations/derive/docs-only/base/docs/reference/guide.md +3 -0
  107. package/ci/fixtures/doc-obligations/derive/docs-only/candidate/docs/reference/guide.md +3 -0
  108. package/ci/fixtures/doc-obligations/derive/dup-req-id/base/ci/mechanism-lint.test.js +2 -0
  109. package/ci/fixtures/doc-obligations/derive/dup-req-id/base/docs/prd/graph-a.md +20 -0
  110. package/ci/fixtures/doc-obligations/derive/dup-req-id/base/docs/prd/graph-b.md +20 -0
  111. package/ci/fixtures/doc-obligations/derive/dup-req-id/candidate/ci/mechanism-lint.test.js +2 -0
  112. package/ci/fixtures/doc-obligations/derive/dup-req-id/candidate/docs/prd/graph-a.md +20 -0
  113. package/ci/fixtures/doc-obligations/derive/dup-req-id/candidate/docs/prd/graph-b.md +20 -0
  114. package/ci/fixtures/doc-obligations/derive/expect-computed-literal/base/ci/mechanism-e.test.js +2 -0
  115. package/ci/fixtures/doc-obligations/derive/expect-computed-literal/candidate/ci/mechanism-e.test.js +2 -0
  116. package/ci/fixtures/doc-obligations/derive/expect-computed-matcher/base/ci/mechanism-e.test.js +2 -0
  117. package/ci/fixtures/doc-obligations/derive/expect-computed-matcher/candidate/ci/mechanism-e.test.js +2 -0
  118. package/ci/fixtures/doc-obligations/derive/expect-not-to-equal/base/ci/mechanism-s1.test.js +2 -0
  119. package/ci/fixtures/doc-obligations/derive/expect-not-to-equal/candidate/ci/mechanism-s1.test.js +2 -0
  120. package/ci/fixtures/doc-obligations/derive/formatting-only-test/base/ci/mechanism-lint.test.js +2 -0
  121. package/ci/fixtures/doc-obligations/derive/formatting-only-test/candidate/ci/mechanism-lint.test.js +2 -0
  122. package/ci/fixtures/doc-obligations/derive/helper-delegated-test/base/docs/prd/graph.md +18 -0
  123. package/ci/fixtures/doc-obligations/derive/helper-delegated-test/candidate/ci/mechanism-helper.test.js +1 -0
  124. package/ci/fixtures/doc-obligations/derive/helper-delegated-test/candidate/docs/prd/graph.md +18 -0
  125. package/ci/fixtures/doc-obligations/derive/historical-only-req/base/ci/mechanism-lint.test.js +2 -0
  126. package/ci/fixtures/doc-obligations/derive/historical-only-req/base/docs/prd/graph/r001.md +25 -0
  127. package/ci/fixtures/doc-obligations/derive/historical-only-req/candidate/ci/mechanism-lint.test.js +2 -0
  128. package/ci/fixtures/doc-obligations/derive/historical-only-req/candidate/docs/prd/graph/r001.md +25 -0
  129. package/ci/fixtures/doc-obligations/derive/historical-only-req/candidate/docs/prd/graph/r002.md +23 -0
  130. package/ci/fixtures/doc-obligations/derive/in-place-current/base/ci/mechanism-inplace.test.js +2 -0
  131. package/ci/fixtures/doc-obligations/derive/in-place-current/base/docs/prd/graph/r001.md +20 -0
  132. package/ci/fixtures/doc-obligations/derive/in-place-current/base/docs/prd/graph/r002.md +26 -0
  133. package/ci/fixtures/doc-obligations/derive/in-place-current/candidate/ci/mechanism-inplace.test.js +2 -0
  134. package/ci/fixtures/doc-obligations/derive/in-place-current/candidate/docs/prd/graph/r001.md +20 -0
  135. package/ci/fixtures/doc-obligations/derive/in-place-current/candidate/docs/prd/graph/r002.md +28 -0
  136. package/ci/fixtures/doc-obligations/derive/in-place-historical/base/ci/mechanism-inplace.test.js +2 -0
  137. package/ci/fixtures/doc-obligations/derive/in-place-historical/base/docs/prd/graph/r001.md +20 -0
  138. package/ci/fixtures/doc-obligations/derive/in-place-historical/base/docs/prd/graph/r002.md +26 -0
  139. package/ci/fixtures/doc-obligations/derive/in-place-historical/candidate/ci/mechanism-inplace.test.js +2 -0
  140. package/ci/fixtures/doc-obligations/derive/in-place-historical/candidate/docs/prd/graph/r001.md +22 -0
  141. package/ci/fixtures/doc-obligations/derive/in-place-historical/candidate/docs/prd/graph/r002.md +26 -0
  142. package/ci/fixtures/doc-obligations/derive/invalid-clause/base/docs/prd/graph.md +24 -0
  143. package/ci/fixtures/doc-obligations/derive/invalid-clause/candidate/docs/prd/graph.md +24 -0
  144. package/ci/fixtures/doc-obligations/derive/legacy-untagged-fallback/base/ci/mechanism-legacy.test.js +6 -0
  145. package/ci/fixtures/doc-obligations/derive/legacy-untagged-fallback/candidate/ci/mechanism-legacy.test.js +6 -0
  146. package/ci/fixtures/doc-obligations/derive/literal-whitespace-assert/base/ci/mechanism-lint.test.js +2 -0
  147. package/ci/fixtures/doc-obligations/derive/literal-whitespace-assert/candidate/ci/mechanism-lint.test.js +2 -0
  148. package/ci/fixtures/doc-obligations/derive/malformed-citation-attribution/base/ci/mechanism-bad.test.js +6 -0
  149. package/ci/fixtures/doc-obligations/derive/malformed-citation-attribution/candidate/ci/mechanism-bad.test.js.fixture +6 -0
  150. package/ci/fixtures/doc-obligations/derive/malformed-prd/base/ci/mechanism-lint.test.js +2 -0
  151. package/ci/fixtures/doc-obligations/derive/malformed-prd/base/docs/prd/graph.md +20 -0
  152. package/ci/fixtures/doc-obligations/derive/malformed-prd/candidate/ci/mechanism-lint.test.js +2 -0
  153. package/ci/fixtures/doc-obligations/derive/malformed-prd/candidate/docs/prd/graph.md +11 -0
  154. package/ci/fixtures/doc-obligations/derive/missing-approval/base/docs/prd/graph.md +20 -0
  155. package/ci/fixtures/doc-obligations/derive/missing-approval/candidate/docs/prd/graph.md +20 -0
  156. package/ci/fixtures/doc-obligations/derive/mixed-attribution/base/ci/mechanism-mixed.test.js +6 -0
  157. package/ci/fixtures/doc-obligations/derive/mixed-attribution/base/docs/prd/graph.md +24 -0
  158. package/ci/fixtures/doc-obligations/derive/mixed-attribution/candidate/ci/mechanism-mixed.test.js.fixture +5 -0
  159. package/ci/fixtures/doc-obligations/derive/mixed-attribution/candidate/docs/prd/graph.md +24 -0
  160. package/ci/fixtures/doc-obligations/derive/mixed-docs-code/base/ci/mechanism-lint.test.js +2 -0
  161. package/ci/fixtures/doc-obligations/derive/mixed-docs-code/base/docs/reference/guide.md +3 -0
  162. package/ci/fixtures/doc-obligations/derive/mixed-docs-code/base/src/lint.js +1 -0
  163. package/ci/fixtures/doc-obligations/derive/mixed-docs-code/candidate/ci/mechanism-lint.test.js +2 -0
  164. package/ci/fixtures/doc-obligations/derive/mixed-docs-code/candidate/docs/reference/guide.md +3 -0
  165. package/ci/fixtures/doc-obligations/derive/mixed-docs-code/candidate/src/lint.js +1 -0
  166. package/ci/fixtures/doc-obligations/derive/multiline-comment-only/base/ci/mechanism-multi.test.js +5 -0
  167. package/ci/fixtures/doc-obligations/derive/multiline-comment-only/candidate/ci/mechanism-multi.test.js +5 -0
  168. package/ci/fixtures/doc-obligations/derive/multiline-expect/base/ci/mechanism-multi.test.js +6 -0
  169. package/ci/fixtures/doc-obligations/derive/multiline-expect/candidate/ci/mechanism-multi.test.js +6 -0
  170. package/ci/fixtures/doc-obligations/derive/multiline-formatting-only/base/ci/mechanism-multi.test.js +5 -0
  171. package/ci/fixtures/doc-obligations/derive/multiline-formatting-only/candidate/ci/mechanism-multi.test.js +5 -0
  172. package/ci/fixtures/doc-obligations/derive/multiline-numeric-prd/base/ci/mechanism-multi.test.js +5 -0
  173. package/ci/fixtures/doc-obligations/derive/multiline-numeric-prd/base/docs/prd/graph.md +18 -0
  174. package/ci/fixtures/doc-obligations/derive/multiline-numeric-prd/candidate/ci/mechanism-multi.test.js +5 -0
  175. package/ci/fixtures/doc-obligations/derive/multiline-numeric-prd/candidate/docs/prd/graph.md +18 -0
  176. package/ci/fixtures/doc-obligations/derive/multiline-strictequal/base/ci/mechanism-multi.test.js +5 -0
  177. package/ci/fixtures/doc-obligations/derive/multiline-strictequal/candidate/ci/mechanism-multi.test.js +5 -0
  178. package/ci/fixtures/doc-obligations/derive/new-prd/candidate/docs/prd/graph-new.md +20 -0
  179. package/ci/fixtures/doc-obligations/derive/new-reference/candidate/docs/reference/new-note.md +3 -0
  180. package/ci/fixtures/doc-obligations/derive/new-req-test/candidate/ci/mechanism-new.test.js +2 -0
  181. package/ci/fixtures/doc-obligations/derive/orphan-req-test/base/ci/mechanism-lint.test.js +2 -0
  182. package/ci/fixtures/doc-obligations/derive/orphan-req-test/base/docs/prd/graph.md +20 -0
  183. package/ci/fixtures/doc-obligations/derive/orphan-req-test/candidate/ci/mechanism-lint.test.js +2 -0
  184. package/ci/fixtures/doc-obligations/derive/orphan-req-test/candidate/docs/prd/graph.md +20 -0
  185. package/ci/fixtures/doc-obligations/derive/partial-revision/base/ci/mechanism-partial.test.js +5 -0
  186. package/ci/fixtures/doc-obligations/derive/partial-revision/base/docs/prd/graph/r001.md +24 -0
  187. package/ci/fixtures/doc-obligations/derive/partial-revision/candidate/ci/mechanism-partial.test.js +5 -0
  188. package/ci/fixtures/doc-obligations/derive/partial-revision/candidate/docs/prd/graph/r001.md +24 -0
  189. package/ci/fixtures/doc-obligations/derive/partial-revision/candidate/docs/prd/graph/r002.md +26 -0
  190. package/ci/fixtures/doc-obligations/derive/regex-literal-whitespace/base/ci/mechanism-lint.test.js +2 -0
  191. package/ci/fixtures/doc-obligations/derive/regex-literal-whitespace/candidate/ci/mechanism-lint.test.js +2 -0
  192. package/ci/fixtures/doc-obligations/derive/rename/base/docs/reference/old-guide.md +3 -0
  193. package/ci/fixtures/doc-obligations/derive/rename/candidate/docs/reference/guide.md +3 -0
  194. package/ci/fixtures/doc-obligations/derive/replacement-target/base/docs/prd/graph.md +24 -0
  195. package/ci/fixtures/doc-obligations/derive/replacement-target/candidate/docs/prd/graph.md +24 -0
  196. package/ci/fixtures/doc-obligations/derive/req-context-typo/base/docs/prd/graph.md +26 -0
  197. package/ci/fixtures/doc-obligations/derive/req-context-typo/candidate/docs/prd/graph.md +26 -0
  198. package/ci/fixtures/doc-obligations/derive/req-revision/base/ci/mechanism-lint.test.js +2 -0
  199. package/ci/fixtures/doc-obligations/derive/req-revision/base/docs/prd/graph/r001.md +20 -0
  200. package/ci/fixtures/doc-obligations/derive/req-revision/candidate/ci/mechanism-lint.test.js +2 -0
  201. package/ci/fixtures/doc-obligations/derive/req-revision/candidate/docs/prd/graph/r001.md +20 -0
  202. package/ci/fixtures/doc-obligations/derive/req-revision/candidate/docs/prd/graph/r002.md +28 -0
  203. package/ci/fixtures/doc-obligations/derive/req-revision-in-place/base/docs/prd/graph/r001.md +20 -0
  204. package/ci/fixtures/doc-obligations/derive/req-revision-in-place/candidate/docs/prd/graph/r001.md +22 -0
  205. package/ci/fixtures/doc-obligations/derive/retained-revision-chain/base/ci/mechanism-lint.test.js +2 -0
  206. package/ci/fixtures/doc-obligations/derive/retained-revision-chain/base/docs/prd/graph/r001.md +20 -0
  207. package/ci/fixtures/doc-obligations/derive/retained-revision-chain/base/docs/prd/graph/r002.md +26 -0
  208. package/ci/fixtures/doc-obligations/derive/retained-revision-chain/candidate/ci/mechanism-lint.test.js +2 -0
  209. package/ci/fixtures/doc-obligations/derive/retained-revision-chain/candidate/docs/prd/graph/r001.md +20 -0
  210. package/ci/fixtures/doc-obligations/derive/retained-revision-chain/candidate/docs/prd/graph/r002.md +26 -0
  211. package/ci/fixtures/doc-obligations/derive/string-literal-tag/base/ci/mechanism-str.test.js +2 -0
  212. package/ci/fixtures/doc-obligations/derive/string-literal-tag/candidate/ci/mechanism-str.test.js +2 -0
  213. package/ci/fixtures/doc-obligations/derive/tag-inside-test-call/base/ci/mechanism-inside.test.js +5 -0
  214. package/ci/fixtures/doc-obligations/derive/tag-inside-test-call/candidate/ci/mechanism-inside.test.js +5 -0
  215. package/ci/fixtures/doc-obligations/derive/tagged-test/base/ci/mechanism-lint.test.js +2 -0
  216. package/ci/fixtures/doc-obligations/derive/tagged-test/candidate/ci/mechanism-lint.test.js +2 -0
  217. package/ci/fixtures/doc-obligations/derive/tagged-test-prd/base/ci/mechanism-lint.test.js +2 -0
  218. package/ci/fixtures/doc-obligations/derive/tagged-test-prd/base/docs/prd/graph.md +20 -0
  219. package/ci/fixtures/doc-obligations/derive/tagged-test-prd/candidate/ci/mechanism-lint.test.js +2 -0
  220. package/ci/fixtures/doc-obligations/derive/tagged-test-prd/candidate/docs/prd/graph.md +20 -0
  221. package/ci/fixtures/doc-obligations/derive/template-literal-tag/base/ci/mechanism-tpl.test.js +4 -0
  222. package/ci/fixtures/doc-obligations/derive/template-literal-tag/base/docs/prd/graph.md +18 -0
  223. package/ci/fixtures/doc-obligations/derive/template-literal-tag/candidate/ci/mechanism-tpl.test.js +4 -0
  224. package/ci/fixtures/doc-obligations/derive/template-literal-tag/candidate/docs/prd/graph.md +18 -0
  225. package/ci/fixtures/doc-obligations/derive/template-literal-whitespace/base/ci/mechanism-lint.test.js +2 -0
  226. package/ci/fixtures/doc-obligations/derive/template-literal-whitespace/candidate/ci/mechanism-lint.test.js +2 -0
  227. package/ci/fixtures/doc-obligations/derive/title-only-rename/base/ci/mechanism-title.test.js +3 -0
  228. package/ci/fixtures/doc-obligations/derive/title-only-rename/candidate/ci/mechanism-title.test.js +3 -0
  229. package/ci/fixtures/doc-obligations/derive/title-only-unrelated-computed/base/ci/mechanism-u.test.js +4 -0
  230. package/ci/fixtures/doc-obligations/derive/title-only-unrelated-computed/candidate/ci/mechanism-u.test.js +4 -0
  231. package/ci/fixtures/doc-obligations/derive/top-level-no-file-tag/base/ci/mechanism-top.test.js +4 -0
  232. package/ci/fixtures/doc-obligations/derive/top-level-no-file-tag/candidate/ci/mechanism-top.test.js +4 -0
  233. package/ci/fixtures/doc-obligations/derive/trailing-comment-test/base/ci/mechanism-lint.test.js +2 -0
  234. package/ci/fixtures/doc-obligations/derive/trailing-comment-test/base/docs/prd/graph.md +20 -0
  235. package/ci/fixtures/doc-obligations/derive/trailing-comment-test/candidate/ci/mechanism-lint.test.js +2 -0
  236. package/ci/fixtures/doc-obligations/derive/trailing-comment-test/candidate/docs/prd/graph.md +20 -0
  237. package/ci/fixtures/doc-obligations/derive/two-tests-one-file/base/ci/mechanism-two.test.js +6 -0
  238. package/ci/fixtures/doc-obligations/derive/two-tests-one-file/base/docs/prd/graph.md +24 -0
  239. package/ci/fixtures/doc-obligations/derive/two-tests-one-file/candidate/ci/mechanism-two.test.js +6 -0
  240. package/ci/fixtures/doc-obligations/derive/two-tests-one-file/candidate/docs/prd/graph.md +24 -0
  241. package/ci/fixtures/doc-obligations/derive/typo-context/base/docs/adr/GRPH-007.md +14 -0
  242. package/ci/fixtures/doc-obligations/derive/typo-context/candidate/docs/adr/GRPH-007.md +14 -0
  243. package/ci/fixtures/doc-obligations/derive/typo-decision/base/docs/adr/GRPH-007.md +14 -0
  244. package/ci/fixtures/doc-obligations/derive/typo-decision/candidate/docs/adr/GRPH-007.md +14 -0
  245. package/ci/fixtures/doc-obligations/derive/unbalanced-statement/base/ci/mechanism-multi.test.js +5 -0
  246. package/ci/fixtures/doc-obligations/derive/unbalanced-statement/candidate/ci/mechanism-multi.test.js.fixture +4 -0
  247. package/ci/fixtures/doc-obligations/derive/unknown-adr/base/docs/adr/GRPH-009.md +14 -0
  248. package/ci/fixtures/doc-obligations/derive/unknown-adr/candidate/docs/adr/GRPH-009.md +12 -0
  249. package/ci/fixtures/doc-obligations/derive/unsupported-assertion-syntax/base/ci/mechanism-s1.test.js +2 -0
  250. package/ci/fixtures/doc-obligations/derive/unsupported-assertion-syntax/candidate/ci/mechanism-s1.test.js +2 -0
  251. package/ci/fixtures/doc-obligations/derive/whitespace-only-test/base/ci/mechanism-lint.test.js +2 -0
  252. package/ci/fixtures/doc-obligations/derive/whitespace-only-test/candidate/ci/mechanism-lint.test.js +2 -0
  253. package/ci/fixtures/doc-obligations/evidence/base/docs/adr/GRPH-007.md +15 -0
  254. package/ci/fixtures/doc-obligations/evidence/base/docs/prd/PRD-GRPH-001.md +25 -0
  255. package/ci/fixtures/doc-obligations/evidence/candidate/docs/adr/GRPH-007.md +15 -0
  256. package/ci/fixtures/doc-obligations/evidence/candidate/docs/prd/PRD-GRPH-001.md +25 -0
  257. package/ci/mechanism-2aw-project-docs-config.test.js +759 -0
  258. package/ci/mechanism-2ax-doc-lifecycle.test.js +1061 -0
  259. package/ci/mechanism-2ay-prd-revisions.test.js +2891 -0
  260. package/ci/mechanism-325-3a-cross.test.js +795 -0
  261. package/ci/mechanism-325-3a-doc-evidence.test.js +1172 -0
  262. package/ci/mechanism-325-3a-doc-obligations.test.js +742 -0
  263. package/ci/mechanism-pr334-haiku.test.js +298 -0
  264. package/ci/mechanism-pr334-luna.test.js +192 -0
  265. package/ci/mechanism-pr334-opencode.test.js +313 -0
  266. package/ci/mechanism-pr334-r2-boundary.test.js +239 -0
  267. package/ci/mechanism-pr334-r2-resolution.test.js +243 -0
  268. package/ci/mechanism-pr334-r2-scope.test.js +127 -0
  269. package/ci/mechanism-pr334-r3-boundary.test.js +356 -0
  270. package/ci/mechanism-pr334-r3-citations.test.js +205 -0
  271. package/ci/mechanism-pr334-r3-headings.test.js +268 -0
  272. package/docs/document-lifecycle.md +262 -0
  273. package/harness-everything/SKILL.md +1 -1
  274. package/harness-everything/scripts/doc-evidence.js +471 -0
  275. package/harness-everything/scripts/doc-lifecycle.js +2401 -0
  276. package/harness-everything/scripts/doc-obligations.js +1571 -0
  277. package/harness-everything/scripts/project-docs-resolver.js +764 -0
  278. package/multi-agent-workspace/SKILL.md +1 -1
  279. package/multi-agent-workspace/scripts/project-docs-resolver.js +437 -30
  280. package/opencode-plugin/plugin.json +1 -1
  281. package/package.json +1 -1
  282. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  283. package/plugins/harness-everything/plugin.json +1 -1
  284. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  285. package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence.js +471 -0
  286. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +2401 -0
  287. package/plugins/harness-everything/skills/harness-everything/scripts/doc-obligations.js +1571 -0
  288. package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +764 -0
  289. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  290. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +437 -30
  291. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  292. package/plugins/harness-everything/skills/to-spec/scripts/check-project-docs.js +168 -54
  293. package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +749 -96
  294. package/sbom.cdx.json +7 -7
  295. package/to-spec/SKILL.md +1 -1
  296. package/to-spec/scripts/check-project-docs.js +168 -54
  297. package/to-spec/scripts/project-docs-resolver.js +749 -96
@@ -0,0 +1,2401 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Pure project-document lifecycle helpers. This module intentionally does not
5
+ // load a host resolver or execute configured derived-document generators.
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { execFileSync } = require('child_process');
10
+ const { isDeepStrictEqual } = require('node:util');
11
+
12
+ const ROLE_STATUSES = Object.freeze({
13
+ prd: new Set(['draft', 'approved', 'effective', 'rejected', 'withdrawn']),
14
+ adr: new Set(['proposed', 'accepted', 'rejected', 'superseded']),
15
+ ticket: new Set(['open', 'blocked', 'done', 'withdrawn']),
16
+ reference: new Set(['active', 'deprecated']),
17
+ log: new Set(['final', 'archived']),
18
+ report: new Set(['final', 'archived'])
19
+ });
20
+ const ADR_ID = /^([A-Z][A-Z0-9]{1,9})-(\d{3,})$/;
21
+ const REQ_ID = /^REQ-([A-Z][A-Z0-9]{1,9})-(\d{3,})$/;
22
+ const ANY_ID = /^(?:REQ-)?[A-Z][A-Z0-9]{1,9}-\d{3,}$/;
23
+ const PRD_ID = /^PRD-[A-Z][A-Z0-9]{1,9}-\d{3,}$/;
24
+ // One blocker taxonomy serves two projections: retirement eligibility blocks
25
+ // on these findings regardless of ratchet severity, while index current-state
26
+ // presentation blocks only when the finding is an error.
27
+ const AUTHORITY_BLOCKER_DIAGNOSTICS = new Set([
28
+ 'invalid-prd-id', 'invalid-revision', 'invalid-status', 'invalid-alias',
29
+ 'duplicate-revision', 'missing-parent', 'multiple-baselines', 'revision-cycle',
30
+ 'revision-fork', 'revision-digest-mismatch', 'req-dropped', 'incomplete-snapshot',
31
+ 'invalid-req-clause', 'duplicate-req', 'replacement-alias-overlap', 'replacement-cycle',
32
+ 'approval-missing', 'approval-not-independent', 'approval-content-mismatch',
33
+ 'invalid-transition', 'stale-draft-base', 'migration-record-missing', 'migration-record-mismatch',
34
+ 'immutable-revision-modified', 'immutable-revision-deleted', 'legacy-frozen-modified', 'duplicate-id', 'alias-collision',
35
+ 'unknown-domain', 'invalid-id', 'role-mismatch'
36
+ ]);
37
+ const RETIREMENT_PREFIX_BLOCKERS = AUTHORITY_BLOCKER_DIAGNOSTICS;
38
+ const RESOLUTION_DIAGNOSTICS = new Set([
39
+ ...AUTHORITY_BLOCKER_DIAGNOSTICS,
40
+ 'malformed-frontmatter', 'missing-id', 'missing-req', 'unresolved-ref',
41
+ 'ambiguous-legacy-ref', 'invalid-ref', 'missing-status',
42
+ 'supersede-status-mismatch', 'supersede-cycle', 'recorded-conflict',
43
+ 'prd-migration-required'
44
+ ]);
45
+
46
+ function stripTrailingComment(value) {
47
+ const source = String(value);
48
+ let quote = null;
49
+ let escaped = false;
50
+ let depth = 0;
51
+ for (let index = 0; index < source.length; index++) {
52
+ const char = source[index];
53
+ if (escaped) { escaped = false; continue; }
54
+ if (quote === '"' && char === '\\') { escaped = true; continue; }
55
+ if (quote) {
56
+ if (char === quote) quote = null;
57
+ continue;
58
+ }
59
+ if (char === '"' || char === "'") { quote = char; continue; }
60
+ if (char === '[' || char === '{') depth++;
61
+ else if (char === ']' || char === '}') depth--;
62
+ else if (char === '#' && depth === 0 && (index === 0 || /[ \t]/.test(source[index - 1]))) {
63
+ return source.slice(0, index).replace(/[ \t]+$/, '');
64
+ }
65
+ }
66
+ return source;
67
+ }
68
+
69
+ function parseScalar(source) {
70
+ const value = stripTrailingComment(source).trim();
71
+ if (!value) return '';
72
+ if (value[0] === '[') {
73
+ if (value[value.length - 1] !== ']') throw new Error('unterminated inline list');
74
+ const inner = value.slice(1, -1).trim();
75
+ if (!inner) return [];
76
+ const items = [];
77
+ let token = '';
78
+ let quote = null;
79
+ let escaped = false;
80
+ for (const char of inner) {
81
+ if (escaped) { token += char; escaped = false; continue; }
82
+ if (quote === '"' && char === '\\') { token += char; escaped = true; continue; }
83
+ if (quote) {
84
+ token += char;
85
+ if (char === quote) quote = null;
86
+ continue;
87
+ }
88
+ if (char === '"' || char === "'") { quote = char; token += char; continue; }
89
+ if (char === ',') { items.push(parseScalar(token)); token = ''; continue; }
90
+ token += char;
91
+ }
92
+ if (quote) throw new Error('unterminated quoted list item');
93
+ if (token.trim()) items.push(parseScalar(token));
94
+ else throw new Error('empty inline list item');
95
+ if (items.some(Array.isArray)) throw new Error('nested lists are not supported');
96
+ return items;
97
+ }
98
+ if (value[0] === '{' || value[value.length - 1] === '}') throw new Error('flow mappings are not supported');
99
+ if ((value[0] === '"' && value[value.length - 1] !== '"') || (value[0] === "'" && value[value.length - 1] !== "'")) {
100
+ throw new Error('unterminated quoted scalar');
101
+ }
102
+ if (value[0] === '"') {
103
+ try { return JSON.parse(value); } catch { throw new Error('invalid double-quoted scalar'); }
104
+ }
105
+ if (value[0] === "'") return value.slice(1, -1).replace(/''/g, "'");
106
+ const plain = value.replace(/\s+#.*$/, '').trim();
107
+ if (plain === 'null' || plain === '~') return null;
108
+ if (plain === 'true') return true;
109
+ if (plain === 'false') return false;
110
+ if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(plain)) return Number(plain);
111
+ return plain;
112
+ }
113
+
114
+ function parseFrontmatter(text) {
115
+ const source = String(text);
116
+ const firstEnd = source.indexOf('\n');
117
+ const firstLine = (firstEnd < 0 ? source : source.slice(0, firstEnd)).replace(/\r$/, '');
118
+ if (firstLine !== '---') return { data: {}, body: source, error: null };
119
+ if (firstEnd < 0) return { data: {}, body: '', error: 'frontmatter opening delimiter is not closed' };
120
+
121
+ const data = {};
122
+ let position = firstEnd + 1;
123
+ let closeEnd = -1;
124
+ let bodyStart = -1;
125
+ const lines = [];
126
+ while (position <= source.length) {
127
+ const newline = source.indexOf('\n', position);
128
+ const end = newline < 0 ? source.length : newline;
129
+ const raw = source.slice(position, end).replace(/\r$/, '');
130
+ if (/^---[ \t]*$/.test(raw) || /^\.\.\.[ \t]*$/.test(raw)) {
131
+ closeEnd = end;
132
+ bodyStart = newline < 0 ? source.length : newline + 1;
133
+ break;
134
+ }
135
+ lines.push(raw);
136
+ if (newline < 0) break;
137
+ position = newline + 1;
138
+ }
139
+ if (closeEnd < 0) return { data: {}, body: source, error: 'frontmatter closing delimiter is missing' };
140
+
141
+ try {
142
+ for (let index = 0; index < lines.length; index++) {
143
+ const line = lines[index];
144
+ if (!line.trim() || /^\s*#/.test(line)) continue;
145
+ if (/\t/.test(line)) throw new Error('tabs are not supported in frontmatter');
146
+ const match = line.match(/^([A-Za-z0-9_-]+):(?:[ \t]*(.*))?$/);
147
+ if (!match) throw new Error(`unsupported frontmatter line: ${line}`);
148
+ const key = match[1];
149
+ if (Object.prototype.hasOwnProperty.call(data, key)) throw new Error(`duplicate key: ${key}`);
150
+ const rest = match[2] || '';
151
+ if (rest.trim()) {
152
+ data[key] = parseScalar(rest);
153
+ continue;
154
+ }
155
+
156
+ let cursor = index + 1;
157
+ while (cursor < lines.length && !lines[cursor].trim()) cursor++;
158
+ if (cursor < lines.length && /^[ \t]*-[ \t]/.test(lines[cursor])) {
159
+ const list = [];
160
+ for (; cursor < lines.length; cursor++) {
161
+ const itemLine = lines[cursor];
162
+ if (!itemLine.trim()) continue;
163
+ const item = itemLine.match(/^[ \t]*-[ \t]+(.*)$/);
164
+ if (!item) break;
165
+ if (item[1].trim()) list.push(parseScalar(item[1]));
166
+ else throw new Error(`empty block-list item for ${key}`);
167
+ }
168
+ data[key] = list;
169
+ index = cursor - 1;
170
+ } else if (cursor < lines.length && /^ [A-Za-z0-9_-]+:(?:[ \t].*)?$/.test(lines[cursor])) {
171
+ const mapping = {};
172
+ for (; cursor < lines.length; cursor++) {
173
+ const itemLine = lines[cursor];
174
+ if (!itemLine.trim()) continue;
175
+ const item = itemLine.match(/^ ([A-Za-z0-9_-]+):(?:[ \t]*(.*))?$/);
176
+ if (!item) break;
177
+ if (Object.prototype.hasOwnProperty.call(mapping, item[1])) throw new Error(`duplicate nested key: ${item[1]}`);
178
+ mapping[item[1]] = parseScalar(item[2] || '');
179
+ }
180
+ data[key] = mapping;
181
+ index = cursor - 1;
182
+ } else {
183
+ data[key] = '';
184
+ }
185
+ }
186
+ return { data, body: source.slice(bodyStart), error: null };
187
+ } catch (error) {
188
+ return { data: {}, body: source.slice(bodyStart), error: error.message };
189
+ }
190
+ }
191
+
192
+ function compareText(left, right) {
193
+ const a = String(left);
194
+ const b = String(right);
195
+ return a < b ? -1 : a > b ? 1 : 0;
196
+ }
197
+
198
+ function globToRegExp(glob) {
199
+ const source = String(glob).replace(/\\/g, '/');
200
+ let pattern = '^';
201
+ for (let index = 0; index < source.length; index++) {
202
+ const char = source[index];
203
+ if (char === '*' && source[index + 1] === '*') {
204
+ if (source[index + 2] === '/') {
205
+ pattern += '(?:.*/)?';
206
+ index += 2;
207
+ } else {
208
+ pattern += '.*';
209
+ index++;
210
+ }
211
+ } else if (char === '*') {
212
+ pattern += '[^/]*';
213
+ } else {
214
+ pattern += char.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
215
+ }
216
+ }
217
+ return new RegExp(`${pattern}$`);
218
+ }
219
+
220
+ function matchesAny(relativePath, globs) {
221
+ return (Array.isArray(globs) ? globs : []).some(glob => typeof glob === 'string' && globToRegExp(glob).test(relativePath));
222
+ }
223
+
224
+ function globSegmentToRegExp(segment) {
225
+ return new RegExp(`^${String(segment).split('*').map(part => part.replace(/[|\\{}()[\]^$+?.]/g, '\\$&')).join('.*')}$`);
226
+ }
227
+
228
+ function globCanMatchDescendant(relativeDirectory, glob) {
229
+ const normalizedDirectory = String(relativeDirectory || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
230
+ const directorySegments = normalizedDirectory ? normalizedDirectory.split('/') : [];
231
+ const patternSegments = String(glob || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
232
+ if (!patternSegments.length) return false;
233
+ const visited = new Set();
234
+ function visit(patternIndex, directoryIndex) {
235
+ const key = `${patternIndex}:${directoryIndex}`;
236
+ if (visited.has(key)) return false;
237
+ visited.add(key);
238
+ if (directoryIndex === directorySegments.length) return patternIndex < patternSegments.length;
239
+ if (patternIndex >= patternSegments.length) return false;
240
+ const segment = patternSegments[patternIndex];
241
+ if (segment === '**') return visit(patternIndex + 1, directoryIndex) || visit(patternIndex, directoryIndex + 1);
242
+ // A non-component ** can span slashes in globToRegExp. Keep such patterns
243
+ // conservative during pruning instead of risking a false-negative walk.
244
+ if (segment.includes('**')) return true;
245
+ return globSegmentToRegExp(segment).test(directorySegments[directoryIndex])
246
+ && visit(patternIndex + 1, directoryIndex + 1);
247
+ }
248
+ return visit(0, 0);
249
+ }
250
+
251
+ function ignoredTraversalPath(relativePath) {
252
+ return String(relativePath || '').split('/').some(segment => segment === '.git' || segment === 'node_modules');
253
+ }
254
+
255
+ function exactPathCanMatchDescendant(relativeDirectory, paths) {
256
+ const directory = String(relativeDirectory || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
257
+ const prefix = directory ? `${directory}/` : '';
258
+ return [...(paths || [])].some(item => typeof item === 'string' && item !== directory && item.startsWith(prefix));
259
+ }
260
+
261
+ function directoryCanContainMatch(relativeDirectory, globs, exactPaths = []) {
262
+ if (ignoredTraversalPath(relativeDirectory)) return false;
263
+ if ((Array.isArray(exactPaths) ? exactPaths : []).some(item => typeof item === 'string' && item === relativeDirectory)) return false;
264
+ return (Array.isArray(globs) ? globs : []).some(glob => typeof glob === 'string' && globCanMatchDescendant(relativeDirectory, glob))
265
+ || exactPathCanMatchDescendant(relativeDirectory, exactPaths);
266
+ }
267
+
268
+ function collectDocs(root, config) {
269
+ const workspace = path.resolve(root);
270
+ const governed = Array.isArray(config && config.governed) ? config.governed : [];
271
+ const legacyGlobs = Array.isArray(config && config.legacy) ? config.legacy : [];
272
+ const globs = [
273
+ ...governed.map(entry => entry && entry.path),
274
+ ...legacyGlobs
275
+ ].filter(value => typeof value === 'string');
276
+ const files = [];
277
+ function walk(directory, relativeDirectory) {
278
+ let entries;
279
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
280
+ entries.sort((a, b) => compareText(a.name, b.name));
281
+ for (const entry of entries) {
282
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
283
+ const absolute = path.join(directory, entry.name);
284
+ const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
285
+ if (entry.isDirectory()) {
286
+ if (directoryCanContainMatch(relativePath, globs)) walk(absolute, relativePath);
287
+ } else if (entry.isFile() && matchesAny(relativePath, globs)) files.push(absolute);
288
+ }
289
+ }
290
+ walk(workspace, '');
291
+
292
+ const selected = [];
293
+ for (const absolute of files) {
294
+ const relativePath = path.relative(workspace, absolute).split(path.sep).join('/');
295
+ const roleEntry = governed.find(entry => entry && typeof entry.path === 'string' && matchesAny(relativePath, [entry.path]));
296
+ const legacy = matchesAny(relativePath, legacyGlobs);
297
+ if (!roleEntry && !legacy) continue;
298
+ let bytes;
299
+ try { bytes = fs.readFileSync(absolute); } catch { continue; }
300
+ selected.push({ relativePath, role: roleEntry ? roleEntry.role : null, legacy, bytes });
301
+ }
302
+ const rules = readWorkingTreeRules(workspace, selected.filter(item => item.bytes.includes(0x0d)).map(item => item.relativePath));
303
+ const docs = [];
304
+ for (const item of selected) {
305
+ const bytes = workingStoredBytes(workspace, item.relativePath, item.bytes, rules);
306
+ const parsed = parseFrontmatter(bytes.toString('utf8'));
307
+ const doc = {
308
+ path: item.relativePath,
309
+ role: item.role,
310
+ legacy: item.legacy,
311
+ frontmatter: parsed.data,
312
+ body: parsed.body,
313
+ error: parsed.error
314
+ };
315
+ Object.defineProperties(doc, {
316
+ workspace: { value: workspace, enumerable: false, writable: true },
317
+ storedBytes: { value: bytes, enumerable: false, writable: true }
318
+ });
319
+ docs.push(doc);
320
+ }
321
+ docs.sort((a, b) => compareText(a.path, b.path));
322
+ return docs;
323
+ }
324
+
325
+ function idFromLegacy(doc) {
326
+ const frontmatter = doc.frontmatter || {};
327
+ if (typeof frontmatter.id === 'string' && frontmatter.id) return frontmatter.id;
328
+ const stem = path.posix.basename(doc.path, path.posix.extname(doc.path));
329
+ return ANY_ID.test(stem) ? stem : null;
330
+ }
331
+
332
+ function validLegacySelectorPath(value) {
333
+ if (!value || value.includes('\\') || value.startsWith('/') || /^[A-Za-z]:/.test(value)) return false;
334
+ const segments = value.split('/');
335
+ return !segments.some(segment => segment === '.' || segment === '..');
336
+ }
337
+
338
+ function sha256(bytes) {
339
+ return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
340
+ }
341
+
342
+ function sortedJson(value) {
343
+ if (Array.isArray(value)) return `[${value.map(sortedJson).join(',')}]`;
344
+ if (value && typeof value === 'object') {
345
+ return `{${Object.keys(value).sort(compareText).map(key => `${JSON.stringify(key)}:${sortedJson(value[key])}`).join(',')}}`;
346
+ }
347
+ return JSON.stringify(value);
348
+ }
349
+
350
+ function isoTimestamp(value) {
351
+ if (typeof value !== 'string') return null;
352
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/);
353
+ if (!match) return null;
354
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, zone] = match;
355
+ const year = Number(yearText);
356
+ const month = Number(monthText);
357
+ const day = Number(dayText);
358
+ const hour = Number(hourText);
359
+ const minute = Number(minuteText);
360
+ const second = Number(secondText);
361
+ if (month < 1 || month > 12 || day < 1 || day > new Date(Date.UTC(year, month, 0)).getUTCDate()) return null;
362
+ if (hour > 23 || minute > 59 || second > 59) return null;
363
+ if (zone !== 'Z' && Number(zone.slice(1, 3)) > 23) return null;
364
+ const milliseconds = Date.parse(value);
365
+ return Number.isFinite(milliseconds) ? milliseconds : null;
366
+ }
367
+
368
+ function docBytes(doc) {
369
+ if (doc && Buffer.isBuffer(doc.storedBytes)) return doc.storedBytes;
370
+ if (doc && doc.storedBytes instanceof Uint8Array) return Buffer.from(doc.storedBytes);
371
+ return Buffer.from(String(doc && doc.body || ''), 'utf8');
372
+ }
373
+
374
+ function artifactDigest(doc) {
375
+ return sha256(docBytes(doc));
376
+ }
377
+
378
+ function approvalDigest(doc) {
379
+ const data = { ...(doc.frontmatter || {}) };
380
+ delete data.status;
381
+ delete data.effective_at;
382
+ delete data.approval;
383
+ const bodyBytes = Buffer.from(String(doc.body || ''), 'utf8');
384
+ return sha256(Buffer.concat([Buffer.from(`${sortedJson(data)}\n`, 'utf8'), bodyBytes]));
385
+ }
386
+
387
+ function isPrdCompatibility(doc) {
388
+ if (!doc || doc.legacy || doc.role !== 'prd' || !doc.frontmatter) return false;
389
+ const data = doc.frontmatter;
390
+ return !Object.prototype.hasOwnProperty.call(data, 'revision')
391
+ && !Object.prototype.hasOwnProperty.call(data, 'previous_revision')
392
+ && ['active', 'superseded'].includes(data.status);
393
+ }
394
+
395
+ function isRevisionPrd(doc) {
396
+ return Boolean(doc && !doc.legacy && doc.role === 'prd' && doc.frontmatter && !isPrdCompatibility(doc));
397
+ }
398
+
399
+ function eligibleRetirementSource(source, validatedEffectivePrefix) {
400
+ return validatedEffectivePrefix instanceof Set && validatedEffectivePrefix.has(source);
401
+ }
402
+
403
+ function supersessionEligibility(source, target, ref, validatedEffectivePrefix, graph = null) {
404
+ const sameLogicalPrd = graph && isRevisionPrd(source) && isRevisionPrd(target)
405
+ && graph.logicalIdByDoc.get(source) === graph.logicalIdByDoc.get(target);
406
+ const retirement = isRevisionPrd(source) && isRevisionPrd(target)
407
+ && !sameLogicalPrd
408
+ && /@r[1-9]\d*$/.test(ref);
409
+ const effective = retirement
410
+ ? eligibleRetirementSource(source, validatedEffectivePrefix)
411
+ : (source.role === 'adr' && source.frontmatter.status === 'accepted')
412
+ || (source.role === 'prd' && isRevisionPrd(source) && eligibleRetirementSource(source, validatedEffectivePrefix));
413
+ return { retirement, effective };
414
+ }
415
+
416
+ function validatedEffectivePrefix(docs, graph, config = null, validationDiagnostics = [], { includeGraphDiagnostics = true } = {}) {
417
+ const entries = Array.isArray(docs) ? docs : [];
418
+ const diagnostics = [...(includeGraphDiagnostics && graph && Array.isArray(graph.diagnostics) ? graph.diagnostics : []),
419
+ ...(Array.isArray(validationDiagnostics) ? validationDiagnostics : [])];
420
+ const blockedPaths = new Set(diagnostics
421
+ .filter(item => item && typeof item.path === 'string'
422
+ && RETIREMENT_PREFIX_BLOCKERS.has(item.code))
423
+ .map(item => item.path));
424
+ const domains = new Set(Array.isArray(config && config.domains) ? config.domains : []);
425
+ const validEffectiveRevision = doc => {
426
+ if (!isRevisionPrd(doc) || doc.error || blockedPaths.has(doc.path)) return false;
427
+ const data = doc.frontmatter || {};
428
+ const idMatch = typeof data.id === 'string' ? data.id.match(/^PRD-([A-Z][A-Z0-9]{1,9})-\d{3,}$/) : null;
429
+ if (!idMatch || !Number.isInteger(data.revision) || data.revision <= 0 || data.status !== 'effective') return false;
430
+ if (!domains.has(idMatch[1])) return false;
431
+ if (data.role !== undefined && data.role !== 'prd') return false;
432
+ if (typeof data.drafted_by !== 'string' || !data.drafted_by.trim()) return false;
433
+ const approval = data.approval;
434
+ if (!approval || typeof approval !== 'object'
435
+ || ['by', 'at', 'ref', 'content_digest', 'role'].some(key => typeof approval[key] !== 'string' || !approval[key].trim())
436
+ || !['human', 'cold-verifier'].includes(approval.role)
437
+ || approval.by === data.drafted_by
438
+ || approval.content_digest !== approvalDigest(doc)) return false;
439
+ const approvalTime = isoTimestamp(approval.at);
440
+ const effectiveTime = isoTimestamp(data.effective_at);
441
+ if (approvalTime === null || effectiveTime === null || effectiveTime < approvalTime) return false;
442
+ return true;
443
+ };
444
+
445
+ const eligible = new Set();
446
+ for (const [logicalId, group] of (graph && graph.groups || new Map())) {
447
+ const logicalNames = graph.namesByLogicalId.get(logicalId) || new Set();
448
+ if ([...logicalNames].some(name => (graph.logicalIdsByName.get(name) || new Set()).size !== 1)) continue;
449
+ const baselines = group.filter(doc => doc.frontmatter.previous_revision === undefined
450
+ || doc.frontmatter.previous_revision === null || doc.frontmatter.previous_revision === '');
451
+ if (baselines.length !== 1) continue;
452
+ const baseline = baselines[0];
453
+ if (!validEffectiveRevision(baseline)) continue;
454
+ const byRevision = new Map();
455
+ for (const doc of group) {
456
+ const revision = doc.frontmatter.revision;
457
+ if (!byRevision.has(revision)) byRevision.set(revision, []);
458
+ byRevision.get(revision).push(doc);
459
+ }
460
+ eligible.add(baseline);
461
+ let cursor = baseline;
462
+ const visited = new Set([baseline]);
463
+ while (cursor) {
464
+ const children = group.filter(doc => doc.frontmatter.status === 'effective'
465
+ && doc.frontmatter.previous_revision === cursor.frontmatter.revision);
466
+ if (!children.length || children.length !== 1) break;
467
+ const child = children[0];
468
+ if (visited.has(child)
469
+ || (byRevision.get(child.frontmatter.revision) || []).length !== 1
470
+ || graph.logicalIdByDoc.get(child) !== logicalId
471
+ || child.frontmatter.revision <= cursor.frontmatter.revision
472
+ || child.frontmatter.previous_digest !== artifactDigest(cursor)
473
+ || !validEffectiveRevision(child)) break;
474
+ eligible.add(child);
475
+ visited.add(child);
476
+ cursor = child;
477
+ }
478
+ }
479
+ return eligible;
480
+ }
481
+
482
+ function parseRequirementBlocks(doc) {
483
+ const lines = withoutFencedCode(doc.body || '').split(/\r?\n/);
484
+ const headings = [];
485
+ for (let index = 0; index < lines.length; index++) {
486
+ const match = lines[index].match(/^#{1,6}[ \t]+(REQ-[A-Z][A-Z0-9]{1,9}-\d{3,})(.*)$/);
487
+ if (match) headings.push({ index, id: match[1], tail: match[2] });
488
+ }
489
+ return headings.map((heading, position) => {
490
+ const end = position + 1 < headings.length ? headings[position + 1].index : lines.length;
491
+ const block = lines.slice(heading.index + 1, end).join('\n');
492
+ const item = { id: heading.id, aliases: [], replaces: [], doc, title: '', block, validClause: true };
493
+ let tail = heading.tail;
494
+ if (/^[ \t]*\(/.test(tail)) {
495
+ const open = tail.indexOf('(');
496
+ const close = tail.indexOf(')', open + 1);
497
+ if (close < 0) item.validClause = false;
498
+ else {
499
+ const clauseText = tail.slice(open + 1, close);
500
+ tail = tail.slice(close + 1);
501
+ const seen = new Set();
502
+ for (const clause of clauseText.split(';')) {
503
+ const match = clause.trim().match(/^([a-z][a-z_-]*):[ \t]*(.*)$/);
504
+ if (!match || !['replaces', 'aliases'].includes(match[1]) || seen.has(match[1]) || !match[2].trim()) {
505
+ item.validClause = false;
506
+ continue;
507
+ }
508
+ seen.add(match[1]);
509
+ const values = match[2].split(',').map(value => value.trim());
510
+ if (values.some(value => !REQ_ID.test(value))) item.validClause = false;
511
+ if (match[1] === 'replaces') item.replaces = values;
512
+ else item.aliases = values;
513
+ }
514
+ }
515
+ }
516
+ item.title = tail.replace(/^[: \t]+/, '').trim();
517
+ const acceptance = block.match(/(?:^|\n)[ \t]*Acceptance:[ \t]*([^\n]*(?:\n(?!#{1,6}[ \t]+)[^\n]*)*)/i);
518
+ item.acceptance = acceptance ? acceptance[1].trim() : '';
519
+ return item;
520
+ });
521
+ }
522
+
523
+ function allRevisionRequirements(docs) {
524
+ return (docs || []).filter(isRevisionPrd).flatMap(parseRequirementBlocks);
525
+ }
526
+
527
+ function revisionGraph(docs) {
528
+ const entries = (Array.isArray(docs) ? docs : []).filter(isRevisionPrd).slice().sort((a, b) => compareText(a.path, b.path));
529
+ const groups = new Map();
530
+ const diagnostics = [];
531
+ const artifactDigests = new Map();
532
+ const digestFor = doc => {
533
+ if (!artifactDigests.has(doc)) artifactDigests.set(doc, artifactDigest(doc));
534
+ return artifactDigests.get(doc);
535
+ };
536
+ const add = (code, doc, message, id, candidates) => diagnostics.push(makeDiagnostic(code, 'error', doc, message, id, candidates));
537
+ const validEntries = [];
538
+ for (const doc of entries) {
539
+ const data = doc.frontmatter || {};
540
+ if (typeof data.id !== 'string' || !PRD_ID.test(data.id)) add('invalid-prd-id', doc, 'PRD id must match PRD-<DOMAIN>-NNN', data.id);
541
+ if (!Number.isInteger(data.revision) || data.revision <= 0) {
542
+ add('invalid-revision', doc, 'revision must be a positive integer without leading zeros', data.id);
543
+ }
544
+ if (!['draft', 'approved', 'effective', 'rejected', 'withdrawn'].includes(data.status)) add('invalid-status', doc, `status ${String(data.status)} is not valid for revisioned PRDs`, data.id);
545
+ if (data.aliases !== undefined && data.aliases !== '' && !Array.isArray(data.aliases)) {
546
+ add('invalid-alias', doc, 'document aliases must be a list', data.id);
547
+ }
548
+ for (const alias of Array.isArray(data.aliases) ? data.aliases : []) {
549
+ if (typeof alias !== 'string' || !PRD_ID.test(alias) || alias === data.id) {
550
+ add('invalid-alias', doc, `invalid PRD alias ${String(alias)}`, typeof alias === 'string' ? alias : data.id);
551
+ }
552
+ }
553
+ if (typeof data.id === 'string' && PRD_ID.test(data.id) && Number.isInteger(data.revision) && data.revision > 0) validEntries.push(doc);
554
+ }
555
+
556
+ // A PRD can change its canonical spelling between revisions. Union only
557
+ // identities linked by an adjacent previous_revision edge; a free-standing
558
+ // alias that collides with another logical PRD remains ambiguous and is
559
+ // reported by the active namespace checks below.
560
+ const identityParent = new Map(validEntries.map(doc => [doc, doc]));
561
+ const findIdentity = doc => {
562
+ const parent = identityParent.get(doc);
563
+ if (parent !== doc) identityParent.set(doc, findIdentity(parent));
564
+ return identityParent.get(doc);
565
+ };
566
+ const joinIdentity = (left, right) => {
567
+ const a = findIdentity(left);
568
+ const b = findIdentity(right);
569
+ if (a !== b) identityParent.set(b, a);
570
+ };
571
+ const docsByCanonicalId = new Map();
572
+ for (const doc of validEntries) {
573
+ const id = doc.frontmatter.id;
574
+ if (!docsByCanonicalId.has(id)) docsByCanonicalId.set(id, []);
575
+ docsByCanonicalId.get(id).push(doc);
576
+ }
577
+ for (const sameId of docsByCanonicalId.values()) for (let index = 1; index < sameId.length; index++) joinIdentity(sameId[0], sameId[index]);
578
+ for (const doc of validEntries) {
579
+ const data = doc.frontmatter;
580
+ if (!Number.isInteger(data.previous_revision)) continue;
581
+ for (const alias of Array.isArray(data.aliases) ? data.aliases : []) {
582
+ for (const candidate of docsByCanonicalId.get(alias) || []) {
583
+ if (candidate.frontmatter.revision === data.previous_revision) joinIdentity(doc, candidate);
584
+ }
585
+ }
586
+ }
587
+ const components = new Map();
588
+ for (const doc of validEntries) {
589
+ const root = findIdentity(doc);
590
+ if (!components.has(root)) components.set(root, []);
591
+ components.get(root).push(doc);
592
+ }
593
+ const logicalIdByDoc = new Map();
594
+ for (const component of components.values()) {
595
+ component.sort((a, b) => a.frontmatter.revision - b.frontmatter.revision || compareText(a.path, b.path));
596
+ const baselines = component.filter(doc => doc.frontmatter.previous_revision === undefined || doc.frontmatter.previous_revision === null || doc.frontmatter.previous_revision === '');
597
+ const logicalId = (baselines[0] || component[0]).frontmatter.id;
598
+ groups.set(logicalId, component);
599
+ for (const doc of component) logicalIdByDoc.set(doc, logicalId);
600
+ }
601
+
602
+ const currentById = new Map();
603
+ const structurallyValid = new Map();
604
+ const parentByDoc = new Map();
605
+ for (const [id, group] of groups) {
606
+ group.sort((a, b) => a.frontmatter.revision - b.frontmatter.revision || compareText(a.path, b.path));
607
+ const byRevision = new Map();
608
+ for (const doc of group) {
609
+ const revision = doc.frontmatter.revision;
610
+ if (!byRevision.has(revision)) byRevision.set(revision, []);
611
+ byRevision.get(revision).push(doc);
612
+ }
613
+ let valid = true;
614
+ for (const [revision, members] of byRevision) if (members.length > 1) {
615
+ valid = false;
616
+ const paths = members.map(item => item.path).sort(compareText);
617
+ for (const doc of members) add('duplicate-revision', doc, `${id}@r${revision} is declared more than once`, id, paths.filter(item => item !== doc.path));
618
+ }
619
+ const baselines = group.filter(doc => doc.frontmatter.previous_revision === undefined || doc.frontmatter.previous_revision === null || doc.frontmatter.previous_revision === '');
620
+ if (baselines.length !== 1) {
621
+ valid = false;
622
+ const involved = baselines.length ? baselines : group;
623
+ for (const doc of involved) add('multiple-baselines', doc, `${id} must have exactly one baseline revision`, id, involved.filter(item => item.path !== doc.path).map(item => item.path).sort(compareText));
624
+ }
625
+ const parentEdges = new Map();
626
+ const effectiveChildren = new Map();
627
+ for (const doc of group) {
628
+ const data = doc.frontmatter;
629
+ if (data.previous_revision === undefined || data.previous_revision === null || data.previous_revision === '') continue;
630
+ const parentRevision = data.previous_revision;
631
+ const parent = Number.isInteger(parentRevision) ? byRevision.get(parentRevision) : null;
632
+ if (!parent || parent.length !== 1) {
633
+ valid = false;
634
+ add('missing-parent', doc, `parent revision ${String(parentRevision)} of ${id} does not exist uniquely`, id);
635
+ continue;
636
+ }
637
+ if (parentRevision >= data.revision) {
638
+ valid = false;
639
+ add('missing-parent', doc, 'parent revision must be smaller than its child', id, [parent[0].path]);
640
+ }
641
+ parentEdges.set(doc, parent[0]);
642
+ parentByDoc.set(doc, parent[0]);
643
+ if (data.previous_digest !== digestFor(parent[0])) {
644
+ add('revision-digest-mismatch', doc, `previous_digest does not match ${id}@r${parentRevision}`, id, [parent[0].path]);
645
+ }
646
+ if (data.status === 'effective') {
647
+ if (!effectiveChildren.has(parent[0])) effectiveChildren.set(parent[0], []);
648
+ effectiveChildren.get(parent[0]).push(doc);
649
+ }
650
+ }
651
+ const visiting = new Set();
652
+ const visited = new Set();
653
+ let cycleFound = false;
654
+ const visit = doc => {
655
+ if (visiting.has(doc)) { cycleFound = true; return; }
656
+ if (visited.has(doc)) return;
657
+ visiting.add(doc);
658
+ const parent = parentEdges.get(doc);
659
+ if (parent) visit(parent);
660
+ visiting.delete(doc);
661
+ visited.add(doc);
662
+ };
663
+ for (const doc of group) visit(doc);
664
+ if (cycleFound) {
665
+ valid = false;
666
+ for (const doc of group) add('revision-cycle', doc, `${id} contains a revision parent cycle`, id);
667
+ }
668
+ for (const [parent, children] of effectiveChildren) if (children.length > 1) {
669
+ valid = false;
670
+ const involved = [parent, ...children].sort((a, b) => compareText(a.path, b.path));
671
+ for (const doc of children) add('revision-fork', doc, `effective revisions fork from ${parent.path}`, id, involved.filter(item => item.path !== doc.path).map(item => item.path));
672
+ }
673
+ const baseline = baselines.length === 1 ? baselines[0] : null;
674
+ let current = null;
675
+ if (valid && baseline) {
676
+ if (baseline.frontmatter.status === 'effective') current = baseline;
677
+ const effectiveSet = new Set(group.filter(doc => doc.frontmatter.status === 'effective'));
678
+ let cursor = baseline;
679
+ const reached = new Set();
680
+ if (current) reached.add(cursor);
681
+ while (current) {
682
+ const children = (effectiveChildren.get(cursor) || []).filter(child => effectiveSet.has(child));
683
+ if (children.length > 1) { current = null; valid = false; break; }
684
+ if (!children.length) break;
685
+ cursor = children[0];
686
+ reached.add(cursor);
687
+ current = cursor;
688
+ }
689
+ if ([...effectiveSet].some(doc => !reached.has(doc))) {
690
+ valid = false;
691
+ current = null;
692
+ const disconnected = [...effectiveSet].filter(doc => !reached.has(doc));
693
+ for (const doc of disconnected) add('revision-fork', doc, 'effective revision is disconnected from the baseline effective chain', id);
694
+ }
695
+ }
696
+ structurallyValid.set(id, valid);
697
+ if (valid && current) currentById.set(id, current);
698
+
699
+ const reqsByDoc = new Map(group.map(doc => [doc, parseRequirementBlocks(doc)]));
700
+ const replacementTargetsByDoc = new Map();
701
+ const lineageFor = doc => {
702
+ const lineage = [];
703
+ const seen = new Set();
704
+ let cursor = doc;
705
+ while (cursor && !seen.has(cursor)) {
706
+ seen.add(cursor);
707
+ lineage.unshift(cursor);
708
+ cursor = parentEdges.get(cursor);
709
+ }
710
+ return lineage;
711
+ };
712
+ for (const [doc, reqs] of reqsByDoc) {
713
+ const seen = new Set();
714
+ for (const req of reqs) {
715
+ if (!REQ_ID.test(req.id)) add('invalid-req-clause', doc, `invalid requirement ID ${req.id}`, req.id);
716
+ if (seen.has(req.id)) add('duplicate-req', doc, `requirement ${req.id} is repeated in this revision`, req.id);
717
+ seen.add(req.id);
718
+ if (!req.validClause) add('invalid-req-clause', doc, `requirement ${req.id} has an invalid clause`, req.id);
719
+ if (!req.acceptance) add('incomplete-snapshot', doc, `requirement ${req.id} has no non-empty Acceptance content`, req.id);
720
+ if (req.replaces.some(oldId => req.aliases.includes(oldId))) add('replacement-alias-overlap', doc, `requirement ${req.id} aliases an ID that it replaces`, req.id);
721
+ }
722
+
723
+ const ancestors = lineageFor(doc).slice(0, -1);
724
+ const historicalNames = new Map();
725
+ for (const ancestor of ancestors) for (const priorReq of reqsByDoc.get(ancestor) || []) {
726
+ for (const name of [priorReq.id, ...priorReq.aliases]) {
727
+ if (!historicalNames.has(name)) historicalNames.set(name, new Set());
728
+ historicalNames.get(name).add(priorReq.id);
729
+ }
730
+ }
731
+ const targetsByRequirement = new Map();
732
+ for (const req of reqs) {
733
+ const targets = [];
734
+ for (const name of req.replaces) {
735
+ const candidates = historicalNames.get(name);
736
+ if (!candidates || !candidates.size) {
737
+ add('invalid-req-clause', doc, `replacement target ${name} is absent from predecessor history`, name);
738
+ } else if (candidates.size > 1) {
739
+ add('invalid-req-clause', doc, `replacement target ${name} is ambiguous in predecessor history`, name, [...candidates].sort(compareText));
740
+ } else {
741
+ targets.push([...candidates][0]);
742
+ }
743
+ }
744
+ const overlapsReplacement = targets.some(target => req.aliases.some(alias => alias === target || (historicalNames.get(alias) || new Set()).has(target)));
745
+ if (overlapsReplacement && !req.replaces.some(name => req.aliases.includes(name))) {
746
+ add('replacement-alias-overlap', doc, `requirement ${req.id} aliases an ID that it replaces`, req.id);
747
+ }
748
+ targetsByRequirement.set(req, targets);
749
+ }
750
+ replacementTargetsByDoc.set(doc, targetsByRequirement);
751
+ }
752
+
753
+ // A removed or replaced identity is a tombstone along each parent chain.
754
+ // Reusing it with different title/acceptance content would recycle meaning.
755
+ for (const doc of group) {
756
+ const lineage = lineageFor(doc);
757
+ const tombstones = new Map();
758
+ for (let index = 0; index < lineage.length; index++) {
759
+ const currentDoc = lineage[index];
760
+ const currentReqs = reqsByDoc.get(currentDoc) || [];
761
+ const currentById = new Map(currentReqs.map(req => [req.id, req]));
762
+ const previousDoc = index > 0 ? lineage[index - 1] : null;
763
+ const previousReqs = previousDoc ? reqsByDoc.get(previousDoc) || [] : [];
764
+ const previousIds = new Set(previousReqs.map(req => req.id));
765
+ for (const req of currentReqs) {
766
+ const retired = tombstones.get(req.id);
767
+ if (retired && !previousIds.has(req.id)
768
+ && (req.title !== retired.title || req.acceptance !== retired.acceptance)) {
769
+ add('invalid-req-clause', currentDoc, `tombstoned requirement ${req.id} cannot be reused with a new meaning`, req.id, [retired.doc.path]);
770
+ }
771
+ }
772
+ if (!previousDoc) continue;
773
+ const removed = new Set(asList(currentDoc.frontmatter.removed_reqs));
774
+ const replaced = new Set([...(replacementTargetsByDoc.get(currentDoc) || new Map()).values()].flat());
775
+ for (const priorReq of previousReqs) {
776
+ if (!currentById.has(priorReq.id) && (removed.has(priorReq.id) || replaced.has(priorReq.id)) && !tombstones.has(priorReq.id)) {
777
+ tombstones.set(priorReq.id, { ...priorReq, doc: previousDoc });
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ for (const doc of group) {
784
+ const data = doc.frontmatter;
785
+ const parent = parentEdges.get(doc);
786
+ if (parent) {
787
+ const parentReqs = reqsByDoc.get(parent) || [];
788
+ const childReqs = reqsByDoc.get(doc) || [];
789
+ const childIds = new Set(childReqs.map(req => req.id));
790
+ const replaced = new Set([...(replacementTargetsByDoc.get(doc) || new Map()).values()].flat());
791
+ const removed = new Set(asList(data.removed_reqs));
792
+ for (const req of parentReqs) if (!childIds.has(req.id) && !replaced.has(req.id) && !removed.has(req.id)) {
793
+ add('req-dropped', doc, `parent requirement ${req.id} is missing without removed_reqs or a replaces edge`, req.id, [parent.path]);
794
+ }
795
+ }
796
+ if (['approved', 'effective'].includes(data.status)) {
797
+ if (typeof data.drafted_by !== 'string' || !data.drafted_by.trim()) {
798
+ add('approval-missing', doc, 'approved/effective revisions must include non-empty drafted_by', id);
799
+ }
800
+ const approval = data.approval;
801
+ const required = ['by', 'at', 'ref', 'content_digest', 'role'];
802
+ if (!approval || typeof approval !== 'object' || required.some(key => typeof approval[key] !== 'string' || !approval[key].trim())) {
803
+ add('approval-missing', doc, `approval must include ${required.join(', ')}`, id);
804
+ } else {
805
+ if (!['human', 'cold-verifier'].includes(approval.role)) add('approval-missing', doc, 'approval.role must be human or cold-verifier', id);
806
+ if (approval.by === data.drafted_by) add('approval-not-independent', doc, 'approval.by must differ from drafted_by', id);
807
+ if (approval.content_digest !== approvalDigest(doc)) add('approval-content-mismatch', doc, 'approval digest does not match the body and non-activation frontmatter', id);
808
+ if (isoTimestamp(approval.at) === null) add('invalid-transition', doc, 'approval.at must be an ISO 8601 timestamp with timezone', id);
809
+ if (!/^https?:\/\//i.test(approval.ref)) {
810
+ const workspace = doc.workspace;
811
+ const target = typeof workspace === 'string' && validLegacySelectorPath(approval.ref) ? path.join(workspace, ...approval.ref.split('/')) : null;
812
+ if (!target || !fs.existsSync(target)) add('approval-missing', doc, `approval evidence ref ${approval.ref} cannot be resolved`, id);
813
+ }
814
+ }
815
+ if (data.status === 'effective' && (typeof data.effective_at !== 'string' || !data.effective_at.trim())) {
816
+ add('invalid-transition', doc, 'effective revisions must record effective_at', id);
817
+ }
818
+ if (data.status === 'effective' && typeof data.effective_at === 'string' && data.effective_at.trim()) {
819
+ const approvalTime = approval && isoTimestamp(approval.at);
820
+ const effectiveTime = isoTimestamp(data.effective_at);
821
+ if (effectiveTime === null) add('invalid-transition', doc, 'effective_at must be an ISO 8601 timestamp with timezone', id);
822
+ else if (approvalTime !== null && effectiveTime < approvalTime) add('invalid-transition', doc, 'effective_at must not precede approval.at', id);
823
+ }
824
+ }
825
+ }
826
+
827
+ const replacementEdges = group.flatMap(doc => (reqsByDoc.get(doc) || []).flatMap(req => (replacementTargetsByDoc.get(doc).get(req) || []).map(from => ({ from, to: req.id, doc }))));
828
+ const adjacency = new Map();
829
+ for (const edge of replacementEdges) {
830
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
831
+ adjacency.get(edge.from).push(edge.to);
832
+ }
833
+ const reqVisiting = new Set();
834
+ const reqVisited = new Set();
835
+ let replacementCycle = false;
836
+ const reqVisit = idValue => {
837
+ if (reqVisiting.has(idValue)) { replacementCycle = true; return; }
838
+ if (reqVisited.has(idValue)) return;
839
+ reqVisiting.add(idValue);
840
+ for (const next of adjacency.get(idValue) || []) reqVisit(next);
841
+ reqVisiting.delete(idValue);
842
+ reqVisited.add(idValue);
843
+ };
844
+ for (const reqId of adjacency.keys()) reqVisit(reqId);
845
+ if (replacementCycle) for (const doc of group) add('replacement-cycle', doc, `${id} contains a REQ replacement cycle`, id);
846
+ const authoritativePaths = new Set(group.filter(doc => doc.frontmatter.status === 'effective').map(doc => doc.path));
847
+ const currentValidationCodes = new Set([
848
+ 'invalid-prd-id', 'invalid-revision', 'invalid-status', 'duplicate-revision',
849
+ 'invalid-alias',
850
+ 'missing-parent', 'multiple-baselines', 'revision-cycle', 'revision-fork',
851
+ 'revision-digest-mismatch', 'req-dropped', 'incomplete-snapshot',
852
+ 'invalid-req-clause', 'replacement-alias-overlap', 'replacement-cycle',
853
+ 'approval-missing', 'approval-not-independent', 'approval-content-mismatch', 'invalid-transition'
854
+ ]);
855
+ if (diagnostics.some(item => authoritativePaths.has(item.path) && currentValidationCodes.has(item.code))) {
856
+ structurallyValid.set(id, false);
857
+ currentById.delete(id);
858
+ }
859
+ }
860
+
861
+ const namesByLogicalId = new Map();
862
+ const logicalIdsByName = new Map();
863
+ for (const [logicalId, group] of groups) {
864
+ const names = new Set();
865
+ for (const doc of group) {
866
+ names.add(doc.frontmatter.id);
867
+ for (const alias of Array.isArray(doc.frontmatter.aliases) ? doc.frontmatter.aliases : []) if (typeof alias === 'string' && PRD_ID.test(alias)) names.add(alias);
868
+ }
869
+ namesByLogicalId.set(logicalId, names);
870
+ for (const name of names) {
871
+ if (!logicalIdsByName.has(name)) logicalIdsByName.set(name, new Set());
872
+ logicalIdsByName.get(name).add(logicalId);
873
+ }
874
+ }
875
+ const namesAtRevision = new Map();
876
+ for (const group of groups.values()) for (const doc of group) {
877
+ const names = new Set();
878
+ const seen = new Set();
879
+ let cursor = doc;
880
+ while (cursor && !seen.has(cursor)) {
881
+ seen.add(cursor);
882
+ names.add(cursor.frontmatter.id);
883
+ for (const alias of Array.isArray(cursor.frontmatter.aliases) ? cursor.frontmatter.aliases : []) if (typeof alias === 'string' && PRD_ID.test(alias)) names.add(alias);
884
+ cursor = parentByDoc.get(cursor);
885
+ }
886
+ namesAtRevision.set(doc, names);
887
+ }
888
+ return { groups, currentById, structurallyValid, logicalIdByDoc, logicalIdsByName, namesByLogicalId, namesAtRevision, parentByDoc, artifactDigests, diagnostics: sortDiagnostics(diagnostics) };
889
+ }
890
+
891
+ function reqDeclarations(docs, graph = null) {
892
+ const entries = Array.isArray(docs) ? docs : [];
893
+ const revisionState = graph || revisionGraph(entries);
894
+ const declarations = [];
895
+ for (const doc of revisionState.currentById.values()) declarations.push(...parseRequirementBlocks(doc));
896
+ return declarations;
897
+ }
898
+
899
+ function activeDeclarations(docs, graph = null) {
900
+ const result = [];
901
+ const revisionState = graph || revisionGraph(docs);
902
+ const currentDocs = new Set(revisionState.currentById.values());
903
+ for (const doc of docs || []) {
904
+ if (doc.legacy || !doc.frontmatter
905
+ || (doc.role === 'prd' && (!isRevisionPrd(doc) || !currentDocs.has(doc)))) continue;
906
+ const id = doc.frontmatter.id;
907
+ if (typeof id === 'string' && id) result.push({ id, aliases: Array.isArray(doc.frontmatter.aliases) ? doc.frontmatter.aliases : [], doc, kind: 'document' });
908
+ }
909
+ for (const req of reqDeclarations(docs || [], revisionState)) result.push({ ...req, kind: 'requirement' });
910
+ return result;
911
+ }
912
+
913
+ function revisionResult(doc, requirement, digest = null) {
914
+ const result = {
915
+ doc,
916
+ revision: doc.frontmatter.revision,
917
+ status: doc.frontmatter.status,
918
+ digest: digest || artifactDigest(doc),
919
+ provenance: { id: doc.frontmatter.id, revision: doc.frontmatter.revision, path: doc.path, parent: doc.frontmatter.previous_revision }
920
+ };
921
+ if (requirement) result.requirement = { id: requirement.id };
922
+ return result;
923
+ }
924
+
925
+ function resolveRef(docs, ref, context = {}) {
926
+ if (typeof ref !== 'string' || !ref.trim()) return { error: 'unresolved-ref', candidates: [] };
927
+ const value = ref.trim();
928
+ const allDocs = Array.isArray(docs) ? docs : [];
929
+ const graph = context.graph || revisionGraph(allDocs);
930
+ if (value.startsWith('legacy:')) {
931
+ const selector = value.slice('legacy:'.length);
932
+ const at = selector.indexOf('@');
933
+ const id = at < 0 ? selector : selector.slice(0, at);
934
+ const archivePath = at < 0 ? null : selector.slice(at + 1);
935
+ if (!ANY_ID.test(id) || (archivePath !== null && !validLegacySelectorPath(archivePath))) return { error: 'invalid-ref', candidates: [] };
936
+ let candidates = allDocs.filter(doc => doc.legacy && idFromLegacy(doc) === id);
937
+ if (archivePath !== null) candidates = candidates.filter(doc => doc.path === archivePath);
938
+ candidates.sort((a, b) => compareText(a.path, b.path));
939
+ if (candidates.length === 1) return { doc: candidates[0] };
940
+ if (candidates.length > 1) return { error: 'ambiguous-legacy-ref', candidates };
941
+ return { error: 'unresolved-ref', candidates: [] };
942
+ }
943
+
944
+ const prdMatch = value.match(/^(PRD-[A-Z][A-Z0-9]{1,9}-\d{3,})(?:@r([1-9]\d*))?(?:#(REQ-[A-Z][A-Z0-9]{1,9}-\d{3,}))?$/);
945
+ if (value.startsWith('PRD-') || value.includes('@r') || value.includes('#')) {
946
+ if (!prdMatch || !PRD_ID.test(prdMatch[1])) return { error: 'invalid-ref', candidates: [] };
947
+ const logicalIds = graph.logicalIdsByName.get(prdMatch[1]) || new Set();
948
+ if (logicalIds.size > 1) {
949
+ const candidates = [...logicalIds].flatMap(id => graph.groups.get(id) || []).sort((a, b) => compareText(a.path, b.path));
950
+ return { doc: null, error: 'unresolved-ref', candidates, message: `PRD identity ${prdMatch[1]} is ambiguous across logical PRDs` };
951
+ }
952
+ const logicalId = [...logicalIds][0];
953
+ const group = logicalId ? graph.groups.get(logicalId) || [] : [];
954
+ if (prdMatch[2] || prdMatch[3] || group.length) {
955
+ let target;
956
+ if (prdMatch[2]) {
957
+ const exact = group.filter(doc => doc.frontmatter.revision === Number(prdMatch[2]));
958
+ if (exact.length > 1) return { doc: null, error: 'unresolved-ref', candidates: exact.sort((a, b) => compareText(a.path, b.path)) };
959
+ target = exact[0];
960
+ }
961
+ else target = logicalId ? graph.currentById.get(logicalId) : undefined;
962
+ if (!target) return { doc: null, error: 'unresolved-ref', candidates: group.slice().sort((a, b) => compareText(a.path, b.path)) };
963
+ if (!graph.namesAtRevision.get(target)?.has(prdMatch[1])) {
964
+ return { doc: null, error: 'unresolved-ref', candidates: [target], message: `PRD identity ${prdMatch[1]} was not declared by ${target.frontmatter.id}@r${target.frontmatter.revision}` };
965
+ }
966
+ const digest = graph.artifactDigests.get(target);
967
+ if (!prdMatch[3]) return revisionResult(target, null, digest);
968
+ const requested = prdMatch[3];
969
+ const requirement = parseRequirementBlocks(target).find(item => item.id === requested || item.aliases.includes(requested));
970
+ return requirement ? revisionResult(target, requirement, digest) : { error: 'unresolved-ref', candidates: [target], message: `requirement ${requested} is not declared by ${prdMatch[1]}@r${target.frontmatter.revision}` };
971
+ }
972
+ }
973
+
974
+ const matches = [];
975
+ for (const declaration of activeDeclarations(allDocs, graph)) if (declaration.id === value || declaration.aliases.includes(value)) matches.push(declaration);
976
+ const uniqueMatches = [...new Map(matches.map(item => [`${item.doc.path}\0${item.id}`, item])).values()]
977
+ .sort((a, b) => compareText(a.doc.path, b.doc.path));
978
+ if (uniqueMatches.length === 1) {
979
+ const match = uniqueMatches[0];
980
+ if (match.kind === 'requirement') return { doc: match.doc, requirement: { id: match.id } };
981
+ return { doc: match.doc };
982
+ }
983
+ if (uniqueMatches.length > 1) return { error: 'unresolved-ref', candidates: [...new Map(uniqueMatches.map(item => [item.doc.path, item.doc])).values()] };
984
+ const legacyMatches = allDocs.filter(doc => doc.legacy && idFromLegacy(doc) === value).sort((a, b) => compareText(a.path, b.path));
985
+ if (!legacyMatches.length && /^REQ-/.test(value)) {
986
+ const historical = allRevisionRequirements(allDocs).filter(item => item.id === value || item.aliases.includes(value));
987
+ const replacement = allRevisionRequirements(allDocs).flatMap(item => item.replaces.includes(value) ? [{ item }] : []);
988
+ if (historical.length || replacement.length) return { error: 'unresolved-ref', candidates: historical.map(item => item.doc), message: replacement.length ? `historical requirement ${value} was replaced by ${replacement.map(item => item.item.id).sort(compareText).join(', ')}` : `requirement ${value} is historical and not current` };
989
+ }
990
+ return { error: 'unresolved-ref', candidates: legacyMatches, ...(legacyMatches.length ? { message: `legacy-only ID; use legacy:${value}` } : {}) };
991
+ }
992
+
993
+ function withoutFencedCode(body) {
994
+ let fenced = null;
995
+ return String(body).split(/\r?\n/).filter(line => {
996
+ const marker = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
997
+ if (marker) {
998
+ const token = marker[1];
999
+ const rest = marker[2];
1000
+ if (!fenced) fenced = { char: token[0], length: token.length };
1001
+ else if (token[0] === fenced.char && token.length >= fenced.length && /^\s*$/.test(rest)) fenced = null;
1002
+ return false;
1003
+ }
1004
+ return !fenced;
1005
+ }).join('\n');
1006
+ }
1007
+
1008
+ function asList(value) {
1009
+ if (value === undefined || value === null || value === '') return [];
1010
+ return Array.isArray(value) ? value : [value];
1011
+ }
1012
+
1013
+ function makeDiagnostic(code, severity, doc, message, id, candidates) {
1014
+ const diagnostic = { code, severity, path: doc && doc.path ? doc.path : '', message };
1015
+ if (id !== undefined && id !== null) diagnostic.id = id;
1016
+ if (candidates && candidates.length) diagnostic.candidates = candidates;
1017
+ return diagnostic;
1018
+ }
1019
+
1020
+ function sortDiagnostics(diagnostics) {
1021
+ return diagnostics.sort((a, b) => compareText(a.path, b.path)
1022
+ || compareText(a.code, b.code)
1023
+ || compareText(a.id || '', b.id || '')
1024
+ || compareText(a.message, b.message));
1025
+ }
1026
+
1027
+ function lintDocs(docs, config, options = {}) {
1028
+ const entries = Array.isArray(docs) ? docs : [];
1029
+ const governed = entries.filter(doc => !doc.legacy);
1030
+ const changedSet = new Set((Array.isArray(options.changedPaths) ? options.changedPaths : []).map(String));
1031
+ const full = options.all === true;
1032
+ const diagnostics = [];
1033
+ const changed = doc => full || changedSet.has(doc.path);
1034
+ const crossSeverity = involved => full || involved.some(doc => doc && changedSet.has(doc.path)) ? 'error' : 'warning';
1035
+ const domains = new Set(Array.isArray(config && config.domains) ? config.domains : []);
1036
+
1037
+ const graph = options._graph || revisionGraph(entries);
1038
+ const validatedPrefix = options._retirementEligibleSources instanceof Set
1039
+ ? options._retirementEligibleSources
1040
+ : validatedEffectivePrefix(entries, graph, config, options._retirementValidationDiagnostics);
1041
+ for (const item of graph.diagnostics) {
1042
+ const source = entries.find(doc => doc.path === item.path);
1043
+ const candidates = (item.candidates || []).map(candidate => entries.find(doc => doc.path === candidate) || { path: candidate });
1044
+ diagnostics.push({ ...item, severity: crossSeverity([...(source ? [source] : []), ...candidates]) });
1045
+ }
1046
+
1047
+ for (const doc of governed) {
1048
+ if (isPrdCompatibility(doc)) {
1049
+ const severity = changedSet.has(doc.path) ? 'error' : 'warning';
1050
+ const evidence = changedSet.has(doc.path)
1051
+ ? 'changed or newly governed'
1052
+ : options._trustedBaseObserved === true ? 'unchanged in the trusted base' : 'not shown changed, but no trusted base proves it is unchanged';
1053
+ diagnostics.push(makeDiagnostic('prd-migration-required', severity, doc,
1054
+ `pre-migration PRD status ${doc.frontmatter.status} is compatibility-only (${evidence}); adopt the D6.9 revision contract`, doc.frontmatter.id));
1055
+ continue;
1056
+ }
1057
+ if (!changed(doc)) continue;
1058
+ if (doc.error) {
1059
+ diagnostics.push(makeDiagnostic('malformed-frontmatter', 'error', doc, `frontmatter cannot be parsed: ${doc.error}`));
1060
+ continue;
1061
+ }
1062
+ const data = doc.frontmatter || {};
1063
+ if (data.role !== undefined && data.role !== doc.role) {
1064
+ diagnostics.push(makeDiagnostic('role-mismatch', 'error', doc, `frontmatter role ${String(data.role)} does not match path role ${String(doc.role)}`));
1065
+ }
1066
+ if (doc.role === 'prd' && isRevisionPrd(doc) && typeof data.id === 'string') {
1067
+ const match = data.id.match(/^PRD-([A-Z][A-Z0-9]{1,9})-\d{3,}$/);
1068
+ if (match && !domains.has(match[1])) diagnostics.push(makeDiagnostic('unknown-domain', 'error', doc, `PRD ID domain ${match[1]} is not declared`, data.id));
1069
+ }
1070
+ const statusSet = isRevisionPrd(doc) ? null : ROLE_STATUSES[doc.role];
1071
+ if (statusSet) {
1072
+ if (data.status === undefined || data.status === '') diagnostics.push(makeDiagnostic('missing-status', 'error', doc, `status is required for ${doc.role} documents`, data.id));
1073
+ else if (typeof data.status !== 'string' || !statusSet.has(data.status)) diagnostics.push(makeDiagnostic('invalid-status', 'error', doc, `status ${String(data.status)} is not valid for ${doc.role}`, data.id));
1074
+ }
1075
+ if (doc.role === 'adr') {
1076
+ if (data.id === undefined || data.id === '') diagnostics.push(makeDiagnostic('missing-id', 'error', doc, 'ADR id is required'));
1077
+ else if (typeof data.id !== 'string' || !ADR_ID.test(data.id)) diagnostics.push(makeDiagnostic('invalid-id', 'error', doc, 'ADR id must match <DOMAIN>-NNN', data.id));
1078
+ else if (!domains.has(data.id.match(ADR_ID)[1])) diagnostics.push(makeDiagnostic('unknown-domain', 'error', doc, `ADR ID domain ${data.id.match(ADR_ID)[1]} is not declared`, data.id));
1079
+ } else if (data.id !== undefined && data.id !== '' && typeof data.id !== 'string') {
1080
+ diagnostics.push(makeDiagnostic('invalid-id', 'error', doc, 'document id must be a string', data.id));
1081
+ }
1082
+ if (data.aliases !== undefined && data.aliases !== '') {
1083
+ if (!Array.isArray(data.aliases)) {
1084
+ diagnostics.push(makeDiagnostic('invalid-alias', 'error', doc, `document aliases must be a list, received scalar ${JSON.stringify(data.aliases)}`));
1085
+ } else {
1086
+ for (const alias of data.aliases) {
1087
+ const aliasPattern = isRevisionPrd(doc) ? PRD_ID : ANY_ID;
1088
+ if (typeof alias !== 'string' || alias.startsWith('legacy:') || !aliasPattern.test(alias) || alias === data.id) {
1089
+ diagnostics.push(makeDiagnostic('invalid-alias', 'error', doc, `invalid document alias ${String(alias)}`, typeof alias === 'string' ? alias : undefined));
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ // Canonical document IDs occupy one active namespace; legacy IDs do not.
1097
+ const idGroups = new Map();
1098
+ for (const doc of governed) {
1099
+ if (isPrdCompatibility(doc)) continue;
1100
+ const id = doc.frontmatter && doc.frontmatter.id;
1101
+ if (typeof id !== 'string' || !id || doc.error) continue;
1102
+ if (!idGroups.has(id)) idGroups.set(id, []);
1103
+ idGroups.get(id).push(doc);
1104
+ }
1105
+ for (const [id, group] of idGroups) {
1106
+ if (group.length < 2) continue;
1107
+ if (group.every(isRevisionPrd) && group.every(doc => doc.frontmatter.id === id)) continue;
1108
+ const severity = crossSeverity(group);
1109
+ const paths = [...new Set(group.map(item => item.path))].sort(compareText);
1110
+ for (const doc of group.slice().sort((a, b) => compareText(a.path, b.path))) {
1111
+ const candidates = paths.filter(candidate => candidate !== doc.path);
1112
+ diagnostics.push(makeDiagnostic('duplicate-id', severity, doc, `canonical ID ${id} is also used by ${candidates.join(', ')}`, id, candidates));
1113
+ }
1114
+ }
1115
+
1116
+ const reqs = reqDeclarations(entries, graph);
1117
+ const reqGroups = new Map();
1118
+ for (const declaration of reqs) {
1119
+ if (!reqGroups.has(declaration.id)) reqGroups.set(declaration.id, []);
1120
+ reqGroups.get(declaration.id).push(declaration);
1121
+ const domain = declaration.id.match(REQ_ID)[1];
1122
+ if (!domains.has(domain) && changed(declaration.doc)) diagnostics.push(makeDiagnostic('unknown-domain', 'error', declaration.doc, `REQ ID domain ${domain} is not declared`, declaration.id));
1123
+ for (const alias of declaration.aliases) {
1124
+ if ((typeof alias !== 'string' || alias.startsWith('legacy:') || !ANY_ID.test(alias) || alias === declaration.id) && changed(declaration.doc)) {
1125
+ diagnostics.push(makeDiagnostic('invalid-alias', 'error', declaration.doc, `invalid REQ alias ${String(alias)}`, typeof alias === 'string' ? alias : undefined));
1126
+ }
1127
+ }
1128
+ }
1129
+ for (const [id, group] of reqGroups) {
1130
+ if (group.length < 2) continue;
1131
+ const severity = crossSeverity(group.map(item => item.doc));
1132
+ const paths = [...new Set(group.map(item => item.doc.path))].sort(compareText);
1133
+ for (const docPath of paths) {
1134
+ const doc = group.find(item => item.doc.path === docPath).doc;
1135
+ const candidates = paths.filter(candidate => candidate !== docPath);
1136
+ diagnostics.push(makeDiagnostic('duplicate-req', severity, doc, `requirement ${id} is also defined by ${candidates.join(', ') || 'another definition in this document'}`, id, candidates));
1137
+ }
1138
+ }
1139
+
1140
+ // Alias uniqueness includes document IDs and REQ IDs/aliases, but excludes archives.
1141
+ const claims = new Map();
1142
+ function addClaim(name, doc, kind, canonical) {
1143
+ if (typeof name !== 'string' || !name) return;
1144
+ if (!claims.has(name)) claims.set(name, []);
1145
+ claims.get(name).push({ name, doc, kind, canonical });
1146
+ }
1147
+ const effectiveRevisionDocs = new Set(graph.currentById.values());
1148
+ for (const doc of governed) {
1149
+ if (isPrdCompatibility(doc)) continue;
1150
+ if (isRevisionPrd(doc) && !effectiveRevisionDocs.has(doc)) continue;
1151
+ if (!doc.frontmatter || doc.error) continue;
1152
+ addClaim(doc.frontmatter.id, doc, 'document', true);
1153
+ if (isRevisionPrd(doc)) {
1154
+ const logicalId = graph.logicalIdByDoc.get(doc);
1155
+ for (const name of graph.namesByLogicalId.get(logicalId) || []) {
1156
+ if (name !== doc.frontmatter.id) addClaim(name, doc, 'document', false);
1157
+ }
1158
+ } else {
1159
+ for (const alias of asList(doc.frontmatter.aliases)) if (typeof alias === 'string') addClaim(alias, doc, 'document', false);
1160
+ }
1161
+ }
1162
+ for (const declaration of reqs) {
1163
+ addClaim(declaration.id, declaration.doc, 'requirement', true);
1164
+ for (const alias of declaration.aliases) addClaim(alias, declaration.doc, 'requirement', false);
1165
+ }
1166
+ for (const [name, group] of claims) {
1167
+ const aliasClaims = group.filter(item => !item.canonical);
1168
+ if (!aliasClaims.length || group.length < 2) continue;
1169
+ const involved = group.map(item => item.doc);
1170
+ const severity = crossSeverity(involved);
1171
+ const paths = [...new Set(involved.map(doc => doc.path))].sort(compareText);
1172
+ for (const docPath of paths) {
1173
+ const doc = involved.find(item => item.path === docPath);
1174
+ const candidates = paths.filter(candidate => candidate !== docPath);
1175
+ diagnostics.push(makeDiagnostic('alias-collision', severity, doc, `ID ${name} collides with another canonical ID or alias`, name, candidates));
1176
+ }
1177
+ }
1178
+
1179
+ const allReqIds = new Set(reqs.flatMap(item => [item.id, ...item.aliases]));
1180
+ const edges = [];
1181
+ const unresolvedSeen = new Set();
1182
+ function unresolved(code, source, ref, result, message) {
1183
+ const key = `${source.path}\0${String(ref)}`;
1184
+ if (unresolvedSeen.has(key)) return;
1185
+ unresolvedSeen.add(key);
1186
+ const candidates = (result.candidates || []).map(doc => doc.path).sort(compareText);
1187
+ const detail = message || result.message || `reference ${String(ref)} cannot be resolved`;
1188
+ const involved = [source];
1189
+ let basePaths = [];
1190
+ if (typeof ref === 'string' && ref.startsWith('legacy:')) {
1191
+ const selector = ref.slice('legacy:'.length);
1192
+ const at = selector.indexOf('@');
1193
+ const id = at < 0 ? selector : selector.slice(0, at);
1194
+ if (at >= 0) {
1195
+ const archivePath = selector.slice(at + 1);
1196
+ if (validLegacySelectorPath(archivePath)) involved.push({ path: archivePath });
1197
+ }
1198
+ basePaths = options._baseIdPaths ? options._baseIdPaths.get(id) || [] : [];
1199
+ } else if (options._baseIdPaths) {
1200
+ const identity = String(ref || '').match(/^(PRD-[A-Z][A-Z0-9]{1,9}-\d{3,}|REQ-[A-Z][A-Z0-9]{1,9}-\d{3,}|[A-Z][A-Z0-9]{1,9}-\d{3,})(?:@r[1-9]\d*)?(?:#.*)?$/);
1201
+ basePaths = identity ? options._baseIdPaths.get(identity[1]) || [] : [];
1202
+ }
1203
+ for (const targetPath of basePaths) if (!entries.some(doc => doc.path === targetPath)) involved.push({ path: targetPath });
1204
+ const allCandidates = [...new Set([...candidates, ...basePaths.filter(targetPath => !entries.some(doc => doc.path === targetPath))])].sort(compareText);
1205
+ diagnostics.push(makeDiagnostic(code || result.error || 'unresolved-ref', crossSeverity(involved), source, detail, ref, allCandidates));
1206
+ }
1207
+
1208
+ for (const doc of governed) {
1209
+ if (doc.error || !doc.frontmatter) continue;
1210
+ const supersedes = asList(doc.frontmatter.supersedes);
1211
+ for (const ref of supersedes) {
1212
+ if (typeof ref !== 'string') { unresolved('unresolved-ref', doc, ref, { candidates: [] }, 'supersedes entries must be string references'); continue; }
1213
+ const result = resolveRef(entries, ref, { graph });
1214
+ if (!result.doc) { unresolved(result.error, doc, ref, result); continue; }
1215
+ const target = result.doc;
1216
+ const eligibility = supersessionEligibility(doc, target, ref, validatedPrefix, graph);
1217
+ const edge = { source: doc, target, ref, ...eligibility };
1218
+ edges.push(edge);
1219
+ if (target.path === doc.path) {
1220
+ diagnostics.push(makeDiagnostic('supersede-cycle', crossSeverity([doc]), doc, `${doc.path} supersedes itself`, doc.frontmatter.id));
1221
+ } else if (edge.effective && !edge.retirement && !target.legacy && (!target.frontmatter || target.frontmatter.status !== 'superseded')) {
1222
+ diagnostics.push(makeDiagnostic('supersede-status-mismatch', crossSeverity([doc, target]), doc, `${target.path} must have status superseded`, target.frontmatter && target.frontmatter.id));
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ // Supersession cycles are diagnosed per strongly connected component: each
1228
+ // component is one cycle, so its members' changed paths alone decide severity
1229
+ // and only sibling component members are listed as candidates.
1230
+ const adjacency = new Map();
1231
+ for (const edge of edges) {
1232
+ if (!adjacency.has(edge.source.path)) adjacency.set(edge.source.path, []);
1233
+ if (!edge.target.legacy) adjacency.get(edge.source.path).push(edge.target.path);
1234
+ }
1235
+ const index = new Map();
1236
+ const lowlink = new Map();
1237
+ const stackNodes = [];
1238
+ const onStack = new Set();
1239
+ let counter = 0;
1240
+ const components = [];
1241
+ function strongconnect(node) {
1242
+ index.set(node, counter);
1243
+ lowlink.set(node, counter);
1244
+ counter++;
1245
+ stackNodes.push(node);
1246
+ onStack.add(node);
1247
+ for (const next of adjacency.get(node) || []) {
1248
+ if (!index.has(next)) {
1249
+ strongconnect(next);
1250
+ lowlink.set(node, Math.min(lowlink.get(node), lowlink.get(next)));
1251
+ } else if (onStack.has(next)) {
1252
+ lowlink.set(node, Math.min(lowlink.get(node), index.get(next)));
1253
+ }
1254
+ }
1255
+ if (lowlink.get(node) === index.get(node)) {
1256
+ const component = [];
1257
+ for (;;) {
1258
+ const member = stackNodes.pop();
1259
+ onStack.delete(member);
1260
+ component.push(member);
1261
+ if (member === node) break;
1262
+ }
1263
+ components.push(component);
1264
+ }
1265
+ }
1266
+ for (const node of adjacency.keys()) if (!index.has(node)) strongconnect(node);
1267
+ const docByPath = new Map(entries.map(doc => [doc.path, doc]));
1268
+ for (const component of components) {
1269
+ if (component.length < 2) continue;
1270
+ const members = component.slice().sort(compareText);
1271
+ const involved = members.map(member => docByPath.get(member)).filter(Boolean);
1272
+ const severity = crossSeverity(involved);
1273
+ for (const memberPath of members) {
1274
+ const doc = docByPath.get(memberPath);
1275
+ if (!doc || edges.some(edge => edge.source.path === memberPath && edge.target.path === memberPath)) continue;
1276
+ diagnostics.push(makeDiagnostic('supersede-cycle', severity, doc, `supersession cycle includes ${members.join(', ')}`, doc.frontmatter && doc.frontmatter.id, members.filter(item => item !== memberPath)));
1277
+ }
1278
+ }
1279
+
1280
+ // Scan prose for references, excluding fenced examples. REQ references get a
1281
+ // dedicated missing-req diagnostic when no active PRD defines that ID.
1282
+ const referencePattern = /(?:^|[^A-Za-z0-9_-])((?:legacy:)?(?:PRD-[A-Z][A-Z0-9]{1,9}-\d{3,}|REQ-[A-Z][A-Z0-9]{1,9}-\d{3,}|[A-Z][A-Z0-9]{1,9}-\d{3,})(?:[@#][^\s`<>()\[\]{}]+)?)/g;
1283
+ for (const doc of governed) {
1284
+ if (doc.error) continue;
1285
+ const body = withoutFencedCode(doc.body || '');
1286
+ const seen = new Set();
1287
+ for (const line of body.split(/\r?\n/)) {
1288
+ if (/^#{1,6}[ \t]+REQ-[A-Z][A-Z0-9]{1,9}-\d{3,}/.test(line)) continue;
1289
+ for (const match of line.matchAll(referencePattern)) {
1290
+ const ref = match[1].replace(/[.,;:!?]+$/, '');
1291
+ if (seen.has(ref)) continue;
1292
+ seen.add(ref);
1293
+ if (ref.startsWith('REQ-') && !allReqIds.has(ref)) {
1294
+ unresolved('missing-req', doc, ref, { error: 'missing-req', candidates: [] }, `requirement ${ref} is referenced but is not defined in an active PRD`);
1295
+ continue;
1296
+ }
1297
+ const result = resolveRef(entries, ref, { graph });
1298
+ // A duplicate canonical ID or alias already has a structural diagnostic;
1299
+ // avoid adding a second unresolved-reference finding for prose that names
1300
+ // that conflicted identity.
1301
+ if (result.error === 'unresolved-ref' && result.candidates && result.candidates.length > 1 && result.candidates.some(candidate => !candidate.legacy)) continue;
1302
+ if (!result.doc) unresolved(result.error, doc, ref, result);
1303
+ }
1304
+ }
1305
+ }
1306
+
1307
+ // Conflicts are owner-recorded; only an eligible supersession between the
1308
+ // pair resolves them. The declaration is kept on the document that records it.
1309
+ const effectivePairs = new Set(edges.filter(edge => edge.effective).map(edge => `${edge.source.path}\0${edge.target.path}`));
1310
+ for (const doc of governed) {
1311
+ if (doc.error || !doc.frontmatter) continue;
1312
+ for (const ref of asList(doc.frontmatter.conflicts_with)) {
1313
+ const result = resolveRef(entries, ref, { graph });
1314
+ if (!result.doc) { unresolved(result.error, doc, ref, result); continue; }
1315
+ const target = result.doc;
1316
+ if (target.path === doc.path || effectivePairs.has(`${doc.path}\0${target.path}`) || effectivePairs.has(`${target.path}\0${doc.path}`)) continue;
1317
+ diagnostics.push(makeDiagnostic('recorded-conflict', crossSeverity([doc, target]), doc, `recorded conflict between ${doc.frontmatter.id || doc.path} and ${target.frontmatter && target.frontmatter.id || target.path}`, ref, [target.path]));
1318
+ }
1319
+ }
1320
+
1321
+ return sortDiagnostics(diagnostics);
1322
+ }
1323
+
1324
+ function workingStoredBytes(root, relativePath, bytes, rules = null) {
1325
+ const source = Buffer.from(bytes);
1326
+ if (!root || !relativePath || !source.includes(0x0d)) return source;
1327
+ const currentRules = rules || readWorkingTreeRules(root, [relativePath]);
1328
+ const attrs = currentRules.attributes.get(relativePath) || {};
1329
+ const textValue = attrs.text || 'unspecified';
1330
+ const eolValue = attrs.eol || 'unspecified';
1331
+ if (textValue === 'unset') return source;
1332
+ const forcedText = textValue === 'set' || textValue === 'auto' || eolValue === 'lf' || eolValue === 'crlf';
1333
+ const autocrlfText = textValue === 'unspecified' && ['true', 'input'].includes(currentRules.autocrlf);
1334
+ if (!forcedText && !autocrlfText) return source;
1335
+ const automaticText = textValue === 'auto' || autocrlfText;
1336
+ if (automaticText && source.includes(0)) return source;
1337
+ return Buffer.from(source.toString('utf8').replace(/\r\n/g, '\n'), 'utf8');
1338
+ }
1339
+
1340
+ function readWorkingTreeRules(root, paths) {
1341
+ const attributes = new Map();
1342
+ const uniquePaths = [...new Set((paths || []).filter(item => typeof item === 'string' && item))].sort(compareText);
1343
+ let autocrlf = '';
1344
+ if (!root || !uniquePaths.length) return { attributes, autocrlf };
1345
+ const batches = [];
1346
+ let batch = [];
1347
+ let byteCount = 0;
1348
+ for (const relativePath of uniquePaths) {
1349
+ const length = Buffer.byteLength(relativePath, 'utf8') + 1;
1350
+ if (batch.length && (batch.length >= 2048 || byteCount + length > 1024 * 1024)) {
1351
+ batches.push(batch);
1352
+ batch = [];
1353
+ byteCount = 0;
1354
+ }
1355
+ batch.push(relativePath);
1356
+ byteCount += length;
1357
+ }
1358
+ if (batch.length) batches.push(batch);
1359
+ for (const group of batches) {
1360
+ try {
1361
+ const input = Buffer.from(`${group.join('\0')}\0`, 'utf8');
1362
+ const output = execFileSync('git', ['-C', root, 'check-attr', '-z', '--stdin', 'text', 'eol'], { encoding: 'buffer', input, maxBuffer: 8 * 1024 * 1024 });
1363
+ const fields = output.toString('utf8').split('\0');
1364
+ for (let index = 0; index + 2 < fields.length; index += 3) {
1365
+ const [relativePath, attribute, value] = fields.slice(index, index + 3);
1366
+ if (!relativePath || !attribute) continue;
1367
+ if (!attributes.has(relativePath)) attributes.set(relativePath, {});
1368
+ attributes.get(relativePath)[attribute] = value;
1369
+ }
1370
+ } catch { /* outside a Git worktree: retain caller-provided bytes */ }
1371
+ }
1372
+ try {
1373
+ autocrlf = execFileSync('git', ['-C', root, 'config', '--get', 'core.autocrlf'], { encoding: 'utf8', maxBuffer: 4096 }).trim();
1374
+ } catch { /* unset */ }
1375
+ return { attributes, autocrlf };
1376
+ }
1377
+
1378
+ function gitOutput(root, args, encoding = 'buffer') {
1379
+ return execFileSync('git', ['-C', root, ...args], { encoding, maxBuffer: 64 * 1024 * 1024 });
1380
+ }
1381
+
1382
+ function parseInventoryDoc(relativePath, bytes) {
1383
+ const parsed = parseFrontmatter(Buffer.from(bytes).toString('utf8'));
1384
+ if (parsed.error || !parsed.data || typeof parsed.data.id !== 'string') return null;
1385
+ return {
1386
+ id: parsed.data.id,
1387
+ revision: parsed.data.revision,
1388
+ path: relativePath,
1389
+ status: parsed.data.status,
1390
+ bytes: Buffer.from(bytes),
1391
+ frontmatter: parsed.data,
1392
+ body: parsed.body
1393
+ };
1394
+ }
1395
+
1396
+ function scanWorkspaceFiles(root, scope = null) {
1397
+ if (!root) return [];
1398
+ const workspace = path.resolve(root);
1399
+ const scoped = scope !== null;
1400
+ const globs = Array.isArray(scope && scope.globs) ? scope.globs.filter(value => typeof value === 'string') : [];
1401
+ const exactPaths = Array.isArray(scope && scope.paths) ? scope.paths.filter(value => typeof value === 'string') : [];
1402
+ const files = [];
1403
+ function walk(directory, relativeDirectory) {
1404
+ let entries;
1405
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
1406
+ entries.sort((a, b) => compareText(a.name, b.name));
1407
+ for (const entry of entries) {
1408
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
1409
+ const absolute = path.join(directory, entry.name);
1410
+ const relative = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
1411
+ if (entry.isDirectory()) {
1412
+ if (!scoped || directoryCanContainMatch(relative, globs, exactPaths)) walk(absolute, relative);
1413
+ continue;
1414
+ }
1415
+ if (!entry.isFile()) continue;
1416
+ if (scoped && !matchesAny(relative, globs) && !exactPaths.includes(relative)) continue;
1417
+ let bytes;
1418
+ try { bytes = fs.readFileSync(absolute); } catch { continue; }
1419
+ files.push({ relative, bytes });
1420
+ }
1421
+ }
1422
+ walk(workspace, '');
1423
+ const rules = readWorkingTreeRules(workspace, files.filter(item => item.bytes.includes(0x0d)).map(item => item.relative));
1424
+ return files.map(item => ({ path: item.relative, bytes: workingStoredBytes(workspace, item.relative, item.bytes, rules) }));
1425
+ }
1426
+
1427
+ function scanWorkspaceInventory(root) {
1428
+ return scanWorkspaceFiles(root).map(item => parseInventoryDoc(item.path, item.bytes)).filter(Boolean);
1429
+ }
1430
+
1431
+ function provenancePaths(docs) {
1432
+ const paths = new Set();
1433
+ for (const item of docs || []) {
1434
+ let frontmatter = item && item.frontmatter;
1435
+ if ((!frontmatter || typeof frontmatter !== 'object') && item && typeof item.path === 'string') {
1436
+ const bytes = item.bytes || item.storedBytes;
1437
+ if (bytes) frontmatter = parseInventoryDoc(item.path, bytes)?.frontmatter;
1438
+ }
1439
+ if (!frontmatter || typeof frontmatter !== 'object') continue;
1440
+ const candidates = [frontmatter.migration_record, frontmatter.approval && frontmatter.approval.ref];
1441
+ for (const candidate of candidates) {
1442
+ if (typeof candidate === 'string' && validLegacySelectorPath(candidate) && !/^https?:\/\//i.test(candidate)) paths.add(candidate);
1443
+ }
1444
+ }
1445
+ return [...paths];
1446
+ }
1447
+
1448
+ function readGitBlobBatches(root, records) {
1449
+ const result = new Map();
1450
+ const maxBatchBytes = 16 * 1024 * 1024;
1451
+ const maxBatchObjects = 128;
1452
+ const maxBlobBytes = 32 * 1024 * 1024;
1453
+ const batches = [];
1454
+ let batch = [];
1455
+ let batchBytes = 0;
1456
+ for (const record of records) {
1457
+ if (!Number.isInteger(record.size) || record.size < 0 || record.size > maxBlobBytes) throw new Error(`base blob ${record.oid} exceeds the bounded read limit`);
1458
+ const estimated = record.size + 128;
1459
+ if (batch.length && (batch.length >= maxBatchObjects || batchBytes + estimated > maxBatchBytes)) {
1460
+ batches.push(batch);
1461
+ batch = [];
1462
+ batchBytes = 0;
1463
+ }
1464
+ batch.push(record);
1465
+ batchBytes += estimated;
1466
+ }
1467
+ if (batch.length) batches.push(batch);
1468
+ for (const group of batches) {
1469
+ const input = Buffer.from(`${group.map(item => item.oid).join('\n')}\n`, 'ascii');
1470
+ const maxOutput = Math.min(40 * 1024 * 1024, group.reduce((total, item) => total + item.size + 128, 0));
1471
+ const output = execFileSync('git', ['-C', root, 'cat-file', '--batch'], { encoding: 'buffer', input, maxBuffer: Math.max(1024 * 1024, maxOutput) });
1472
+ let offset = 0;
1473
+ for (const expected of group) {
1474
+ const newline = output.indexOf(0x0a, offset);
1475
+ if (newline < 0) throw new Error(`malformed cat-file header for ${expected.oid}`);
1476
+ const header = output.subarray(offset, newline).toString('ascii').split(' ');
1477
+ if (header.length !== 3 || header[0] !== expected.oid || header[1] !== 'blob' || Number(header[2]) !== expected.size) {
1478
+ throw new Error(`unexpected cat-file response for ${expected.oid}`);
1479
+ }
1480
+ const start = newline + 1;
1481
+ const end = start + expected.size;
1482
+ if (end >= output.length || output[end] !== 0x0a) throw new Error(`truncated cat-file body for ${expected.oid}`);
1483
+ result.set(expected.oid, Buffer.from(output.subarray(start, end)));
1484
+ offset = end + 1;
1485
+ }
1486
+ if (offset !== output.length) throw new Error('cat-file batch returned trailing bytes');
1487
+ }
1488
+ return result;
1489
+ }
1490
+
1491
+ function configPathsForWorkspace(root, explicitConfigPath = null) {
1492
+ const configPaths = new Set([
1493
+ '.agents/harness-everything/project-docs.json',
1494
+ 'project-docs.json',
1495
+ 'lifecycle-config.json',
1496
+ ...['.claude', '.cursor', '.github', '.codex', '.continue'].map(home => `${home}/harness-everything/manifest.json`)
1497
+ ]);
1498
+ if (typeof explicitConfigPath === 'string' && explicitConfigPath) {
1499
+ const absoluteConfigPath = path.isAbsolute(explicitConfigPath)
1500
+ ? path.resolve(explicitConfigPath)
1501
+ : path.resolve(root, explicitConfigPath);
1502
+ const relativeConfigPath = path.relative(path.resolve(root), absoluteConfigPath).split(path.sep).join('/');
1503
+ if (relativeConfigPath && relativeConfigPath !== '..' && !relativeConfigPath.startsWith('../') && !path.isAbsolute(relativeConfigPath)) {
1504
+ configPaths.add(relativeConfigPath);
1505
+ }
1506
+ }
1507
+ return configPaths;
1508
+ }
1509
+
1510
+ function readWorkspaceConfigSources(root, explicitConfigPath, fallbackConfig) {
1511
+ if (!root) return [fallbackConfig];
1512
+ const sources = [];
1513
+ const resolvedExplicitPath = typeof explicitConfigPath === 'string' && explicitConfigPath
1514
+ ? path.resolve(explicitConfigPath)
1515
+ : null;
1516
+ let explicitSourceLoaded = false;
1517
+ for (const relativePath of configPathsForWorkspace(root, explicitConfigPath)) {
1518
+ const absolutePath = path.join(root, ...relativePath.split('/'));
1519
+ if (!fs.existsSync(absolutePath)) continue;
1520
+ try {
1521
+ const parsed = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
1522
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
1523
+ const source = parsed.projectDocs && typeof parsed.projectDocs === 'object' && !Array.isArray(parsed.projectDocs)
1524
+ ? parsed.projectDocs
1525
+ : parsed;
1526
+ sources.push(source);
1527
+ if (resolvedExplicitPath && path.resolve(absolutePath) === resolvedExplicitPath) explicitSourceLoaded = true;
1528
+ } catch { /* a malformed candidate source cannot grant or cancel authority */ }
1529
+ }
1530
+ if (!explicitSourceLoaded) sources.unshift(fallbackConfig);
1531
+ return sources.length ? sources : [fallbackConfig];
1532
+ }
1533
+
1534
+ function readGitBaseInventory(root, baseRef, config, explicitConfigPath = null) {
1535
+ if (!root || typeof baseRef !== 'string' || !baseRef.trim()) return { available: false, inventory: [], files: [] };
1536
+ try {
1537
+ const commit = gitOutput(root, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${baseRef}^{commit}`], 'utf8').trim();
1538
+ const entries = gitOutput(root, ['ls-tree', '-r', '-l', '-z', '--full-tree', commit], 'buffer').toString('utf8').split('\0').filter(Boolean);
1539
+ const tree = [];
1540
+ for (const entry of entries) {
1541
+ const tab = entry.indexOf('\t');
1542
+ if (tab < 0) continue;
1543
+ const header = entry.slice(0, tab).match(/^(\d+) (blob|commit) ([0-9a-f]+)\s+(\d+)$/);
1544
+ if (!header) continue;
1545
+ const [, mode, type, oid, sizeText] = header;
1546
+ const relative = entry.slice(tab + 1);
1547
+ if (type !== 'blob' || !/^[0-9a-f]{40,64}$/.test(oid)) continue;
1548
+ const size = Number(sizeText);
1549
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error(`invalid base blob size for ${relative}`);
1550
+ tree.push({ mode, oid, path: relative, size });
1551
+ }
1552
+ const configPaths = configPathsForWorkspace(root, explicitConfigPath);
1553
+ const configRecords = tree.filter(item => configPaths.has(item.path));
1554
+ const currentGoverned = Array.isArray(config && config.governed) ? config.governed.map(item => item && item.path).filter(item => typeof item === 'string') : [];
1555
+ const currentLegacy = Array.isArray(config && config.legacy) ? config.legacy : [];
1556
+ // Conventional PRD paths remain conservative artifact discovery only.
1557
+ // Frozen authority is selected later from the trusted base configuration.
1558
+ const isRelevantDocument = item => matchesAny(item.path, currentGoverned)
1559
+ || matchesAny(item.path, currentLegacy)
1560
+ || item.path.startsWith('docs/prd/') && /\.md$/i.test(item.path);
1561
+ const initialDocuments = tree.filter(isRelevantDocument);
1562
+ const initial = [...new Map([...configRecords, ...initialDocuments].map(item => [item.path, item])).values()];
1563
+ const initialBlobs = readGitBlobBatches(root, initial);
1564
+ const baseConfigs = [];
1565
+ for (const item of configRecords) {
1566
+ const bytes = initialBlobs.get(item.oid);
1567
+ if (!bytes) continue;
1568
+ try {
1569
+ const parsed = JSON.parse(bytes.toString('utf8'));
1570
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1571
+ const projectDocs = parsed.projectDocs && typeof parsed.projectDocs === 'object' && !Array.isArray(parsed.projectDocs)
1572
+ ? parsed.projectDocs
1573
+ : parsed;
1574
+ baseConfigs.push(projectDocs);
1575
+ }
1576
+ } catch { /* malformed historical config cannot add scan globs */ }
1577
+ }
1578
+ const baseGoverned = baseConfigs.flatMap(item => Array.isArray(item.governed) ? item.governed.map(entry => entry && entry.path).filter(value => typeof value === 'string') : []);
1579
+ const baseLegacy = baseConfigs.flatMap(item => Array.isArray(item.legacy) ? item.legacy : []);
1580
+ const selectedDocuments = tree.filter(item => isRelevantDocument(item)
1581
+ || matchesAny(item.path, baseGoverned)
1582
+ || matchesAny(item.path, baseLegacy));
1583
+ const selectedByPath = new Map(selectedDocuments.map(item => [item.path, item]));
1584
+ const extraDocuments = [...selectedByPath.values()].filter(item => !initialBlobs.has(item.oid));
1585
+ const extraBlobs = readGitBlobBatches(root, extraDocuments);
1586
+ const blobByOid = new Map([...initialBlobs, ...extraBlobs]);
1587
+ const inventory = [];
1588
+ const files = [];
1589
+ for (const item of selectedByPath.values()) {
1590
+ const bytes = blobByOid.get(item.oid);
1591
+ if (!bytes) continue;
1592
+ const relative = item.path;
1593
+ files.push({ path: relative, bytes: Buffer.from(bytes) });
1594
+ const record = parseInventoryDoc(relative, bytes);
1595
+ if (record) inventory.push(record);
1596
+ }
1597
+ const baseConfig = baseConfigs.length ? {
1598
+ governed: baseConfigs.flatMap(item => Array.isArray(item.governed) ? item.governed : []),
1599
+ legacy: baseConfigs.flatMap(item => Array.isArray(item.legacy) ? item.legacy : [])
1600
+ } : null;
1601
+ return { available: true, commit, inventory, files, config: baseConfig, configSources: baseConfigs };
1602
+ } catch {
1603
+ return { available: false, inventory: [], files: [], configSources: [] };
1604
+ }
1605
+ }
1606
+
1607
+ function baseIdPaths(inventory, config, baseConfigSources = null) {
1608
+ const result = new Map();
1609
+ const add = (id, relative) => {
1610
+ if (typeof id !== 'string' || !id || typeof relative !== 'string') return;
1611
+ if (!result.has(id)) result.set(id, []);
1612
+ if (!result.get(id).includes(relative)) result.get(id).push(relative);
1613
+ };
1614
+ for (const item of inventory || []) {
1615
+ if (!item || typeof item.id !== 'string' || typeof item.path !== 'string') continue;
1616
+ add(item.id, item.path);
1617
+ for (const alias of asList(item.frontmatter && item.frontmatter.aliases)) add(alias, item.path);
1618
+ const isPrdArtifact = (Array.isArray(baseConfigSources)
1619
+ ? governedPrdPath(item.path, baseConfigSources)
1620
+ : governedPrdPath(item.path, [config]))
1621
+ || item.frontmatter && item.frontmatter.role === 'prd'
1622
+ || item.frontmatter && Object.prototype.hasOwnProperty.call(item.frontmatter, 'revision')
1623
+ || item.path.startsWith('docs/prd/') && !item.path.includes('/legacy/');
1624
+ if (isPrdArtifact && item.frontmatter && item.body !== undefined) {
1625
+ const doc = { ...item, role: 'prd', legacy: false };
1626
+ for (const requirement of parseRequirementBlocks(doc)) {
1627
+ add(requirement.id, item.path);
1628
+ for (const alias of requirement.aliases) add(alias, item.path);
1629
+ }
1630
+ }
1631
+ }
1632
+ for (const paths of result.values()) paths.sort(compareText);
1633
+ return result;
1634
+ }
1635
+
1636
+ function positivelyGovernedPath(relativePath, configSources, role = null) {
1637
+ const sources = Array.isArray(configSources) ? configSources : [configSources];
1638
+ return sources.some(config => {
1639
+ const governedGlobs = (Array.isArray(config && config.governed) ? config.governed : [])
1640
+ .filter(item => item && typeof item.path === 'string' && (!role || item.role === role))
1641
+ .map(item => item.path);
1642
+ return matchesAny(relativePath, governedGlobs)
1643
+ && !matchesAny(relativePath, Array.isArray(config && config.legacy) ? config.legacy : []);
1644
+ });
1645
+ }
1646
+
1647
+ function governedPrdPath(relativePath, configSources) {
1648
+ return positivelyGovernedPath(relativePath, configSources, 'prd');
1649
+ }
1650
+
1651
+ function isLegacyInConfigSources(relativePath, configSources) {
1652
+ const sources = Array.isArray(configSources) ? configSources : configSources ? [configSources] : [];
1653
+ return sources.some(config => matchesAny(relativePath, Array.isArray(config && config.legacy) ? config.legacy : []))
1654
+ && !positivelyGovernedPath(relativePath, sources);
1655
+ }
1656
+
1657
+ function immutableBasePrdInventory(inventory, baseConfig, explicitInventory = false) {
1658
+ const entries = Array.isArray(inventory) ? inventory : [];
1659
+ if (explicitInventory) return entries;
1660
+ return entries.filter(item => item && typeof item.path === 'string' && governedPrdPath(item.path, baseConfig));
1661
+ }
1662
+
1663
+ function immutabilityDiagnostics(baseInventory, candidateInventory) {
1664
+ const diagnostics = [];
1665
+ const candidateByKey = new Map();
1666
+ for (const item of candidateInventory || []) {
1667
+ if (!Number.isInteger(item.revision) || typeof item.id !== 'string') continue;
1668
+ const key = `${item.id}\0${item.revision}`;
1669
+ if (!candidateByKey.has(key)) candidateByKey.set(key, []);
1670
+ candidateByKey.get(key).push(item);
1671
+ }
1672
+ for (const base of baseInventory || []) {
1673
+ if (base.status !== 'effective' || !Number.isInteger(base.revision) || typeof base.id !== 'string') continue;
1674
+ const matches = candidateByKey.get(`${base.id}\0${base.revision}`) || [];
1675
+ if (!matches.length) {
1676
+ diagnostics.push({ code: 'immutable-revision-deleted', severity: 'error', path: base.path, id: base.id, message: `effective revision ${base.id}@r${base.revision} was deleted or moved outside the candidate inventory` });
1677
+ continue;
1678
+ }
1679
+ const exact = matches.find(item => item.path === base.path && Buffer.from(item.bytes).equals(Buffer.from(base.bytes)));
1680
+ if (!exact) {
1681
+ const candidate = matches.slice().sort((a, b) => compareText(a.path, b.path))[0];
1682
+ diagnostics.push({ code: 'immutable-revision-modified', severity: 'error', path: candidate.path, id: base.id, message: `effective revision ${base.id}@r${base.revision} must retain its path and stored bytes`, candidates: [base.path] });
1683
+ }
1684
+ }
1685
+ return diagnostics;
1686
+ }
1687
+
1688
+ function frontmatterEnvelope(source) {
1689
+ const text = String(source);
1690
+ const lines = text.split('\n');
1691
+ if ((lines[0] || '').replace(/\r$/, '') !== '---') return '';
1692
+ for (let index = 1; index < lines.length; index++) {
1693
+ const line = lines[index].replace(/\r$/, '');
1694
+ if (/^---[ \t]*$/.test(line) || /^\.\.\.[ \t]*$/.test(line)) return lines.slice(0, index + 1).join('\n');
1695
+ }
1696
+ return text;
1697
+ }
1698
+
1699
+ function legacyHistoryDiagnostics(baseFiles, candidateFiles, candidateConfigSources, baseConfigSources = null) {
1700
+ const candidateByPath = new Map((candidateFiles || []).filter(item => item && typeof item.path === 'string')
1701
+ .map(item => [item.path, Buffer.from(item.bytes || [])]));
1702
+ const legacyBaseFiles = (baseFiles || []).filter(item => item && typeof item.path === 'string'
1703
+ && (Array.isArray(baseConfigSources)
1704
+ ? isLegacyInConfigSources(item.path, baseConfigSources)
1705
+ : item.legacy === true));
1706
+ const diagnostics = [];
1707
+ for (const base of legacyBaseFiles) {
1708
+ const candidateBytes = candidateByPath.get(base.path);
1709
+ // Deleted and moved archives are handled by existing explicit legacy ref escalation.
1710
+ if (!candidateBytes) continue;
1711
+ const baseBytes = Buffer.from(base.bytes || []);
1712
+ const baseText = baseBytes.toString('utf8');
1713
+ const candidateText = candidateBytes.toString('utf8');
1714
+ const baseFrontmatter = parseFrontmatter(baseText);
1715
+ const candidateFrontmatter = parseFrontmatter(candidateText);
1716
+ const problems = [];
1717
+ if (!isLegacyInConfigSources(base.path, candidateConfigSources)) problems.push('archive is reclassified outside legacy scope');
1718
+ if (baseFrontmatter.error) {
1719
+ if (!candidateFrontmatter.error || frontmatterEnvelope(baseText) !== frontmatterEnvelope(candidateText)) {
1720
+ problems.push('frontmatter differs from the unparseable trusted-base frontmatter');
1721
+ }
1722
+ } else if (candidateFrontmatter.error) {
1723
+ problems.push(`frontmatter no longer parses: ${candidateFrontmatter.error}`);
1724
+ } else if (!isDeepStrictEqual(baseFrontmatter.data, candidateFrontmatter.data)) {
1725
+ problems.push('parsed frontmatter differs from the trusted base');
1726
+ }
1727
+ if (!problems.length) continue;
1728
+ const id = baseFrontmatter.data && baseFrontmatter.data.id
1729
+ || candidateFrontmatter.data && candidateFrontmatter.data.id;
1730
+ diagnostics.push({
1731
+ code: 'legacy-frozen-modified', severity: 'error', path: base.path,
1732
+ ...(typeof id === 'string' ? { id } : {}),
1733
+ message: `legacy archive is frozen against normative frontmatter changes: ${problems.join('; ')}`
1734
+ });
1735
+ }
1736
+ return diagnostics;
1737
+ }
1738
+
1739
+ function migrationDiagnostics(docs, root, baseInventory, baseFiles = null, baseConfigSources = null) {
1740
+ const diagnostics = [];
1741
+ const baseByPath = new Map((baseFiles || baseInventory || []).filter(item => item && typeof item.path === 'string')
1742
+ .map(item => [item.path, { ...item, bytes: Buffer.from(item.bytes || []) }]));
1743
+ const livingSources = [...baseByPath.values()].filter(item => {
1744
+ const parsed = parseFrontmatter(item.bytes.toString('utf8'));
1745
+ return !parsed.error
1746
+ && (Array.isArray(baseConfigSources)
1747
+ ? governedPrdPath(item.path, baseConfigSources)
1748
+ : roleAtPath(item.path, null, true) === 'prd')
1749
+ && !Object.prototype.hasOwnProperty.call(parsed.data || {}, 'revision')
1750
+ && !Object.prototype.hasOwnProperty.call(parsed.data || {}, 'previous_revision')
1751
+ && ['active', 'superseded'].includes(parsed.data && parsed.data.status);
1752
+ });
1753
+ const candidates = (docs || []).filter(doc => isRevisionPrd(doc)
1754
+ && doc.frontmatter.revision === 1
1755
+ && (doc.frontmatter.previous_revision === undefined || doc.frontmatter.previous_revision === null));
1756
+ for (const doc of candidates) {
1757
+ const samePathSource = livingSources.find(item => item.path === doc.path);
1758
+ const sameIdentitySources = livingSources.filter(item => {
1759
+ const parsed = parseFrontmatter(item.bytes.toString('utf8'));
1760
+ return parsed.data && parsed.data.id === doc.frontmatter.id;
1761
+ });
1762
+ const priorWasLivingPrd = Boolean(samePathSource || sameIdentitySources.length);
1763
+ const priorSource = samePathSource || (sameIdentitySources.length === 1 ? sameIdentitySources[0] : null);
1764
+ const hasMigrationMetadata = doc.frontmatter.migrated_from_digest !== undefined
1765
+ || doc.frontmatter.migration_record !== undefined;
1766
+ if (!priorWasLivingPrd && !hasMigrationMetadata) continue;
1767
+ if (priorWasLivingPrd && (!priorSource || doc.frontmatter.migrated_from_digest === undefined || doc.frontmatter.migration_record === undefined)) {
1768
+ diagnostics.push(makeDiagnostic('migration-record-missing', 'error', doc,
1769
+ 'existing living PRD adoption requires an unambiguous source, migrated_from_digest, and a migration_record', doc.frontmatter.id));
1770
+ continue;
1771
+ }
1772
+ const recordPath = doc.frontmatter.migration_record;
1773
+ if (typeof recordPath !== 'string' || !validLegacySelectorPath(recordPath)) {
1774
+ diagnostics.push(makeDiagnostic('migration-record-missing', 'error', doc, 'baseline migration_record must name an in-repository JSON record', doc.frontmatter.id));
1775
+ continue;
1776
+ }
1777
+ if (!root) {
1778
+ diagnostics.push(makeDiagnostic('migration-record-missing', 'error', doc, `migration record ${recordPath} cannot be checked without a workspace`, doc.frontmatter.id));
1779
+ continue;
1780
+ }
1781
+ let record;
1782
+ try { record = JSON.parse(fs.readFileSync(path.join(root, ...recordPath.split('/')), 'utf8')); } catch {
1783
+ diagnostics.push(makeDiagnostic('migration-record-missing', 'error', doc, `migration record ${recordPath} cannot be read`, doc.frontmatter.id));
1784
+ continue;
1785
+ }
1786
+ const source = typeof record.source === 'string' ? baseByPath.get(record.source) : null;
1787
+ if (!source || !Buffer.isBuffer(source.bytes)
1788
+ || (priorSource && record.source !== priorSource.path)
1789
+ || (!priorSource && record.source !== doc.path && !livingSources.some(item => item.path === record.source))) {
1790
+ diagnostics.push(makeDiagnostic('migration-record-mismatch', 'error', doc, `migration source ${String(record.source)} is absent from the trusted base`, doc.frontmatter.id));
1791
+ continue;
1792
+ }
1793
+ const priorBytes = Buffer.from(source.bytes);
1794
+ const priorDigest = sha256(priorBytes);
1795
+ let recordMatches = record.record === 'prd-baseline-migration'
1796
+ && record.id === doc.frontmatter.id
1797
+ && record.revision === 1
1798
+ && record.source === doc.path
1799
+ && record.migrated_from_digest === doc.frontmatter.migrated_from_digest
1800
+ && record.migrated_from_digest === priorDigest
1801
+ && record.approved_by === (doc.frontmatter.approval && doc.frontmatter.approval.by);
1802
+ const priorFrontmatter = parseFrontmatter(priorBytes.toString('utf8'));
1803
+ const currentBody = Buffer.from(doc.body || '', 'utf8');
1804
+ const priorBody = Buffer.from(priorFrontmatter.body || '', 'utf8');
1805
+ if (!priorFrontmatter || priorFrontmatter.error || !priorBody.equals(currentBody)) recordMatches = false;
1806
+ if (!recordMatches) diagnostics.push(makeDiagnostic('migration-record-mismatch', 'error', doc, `migration record ${recordPath} does not bind the metadata-only baseline adoption`, doc.frontmatter.id));
1807
+ }
1808
+ return diagnostics;
1809
+ }
1810
+
1811
+ function transitionDiagnostics(docs, baseInventory, root, candidateGraph = null) {
1812
+ const diagnostics = [];
1813
+ const baseDocs = (baseInventory || []).filter(item => item && item.frontmatter && item.frontmatter.revision)
1814
+ .map(item => ({ path: item.path, role: 'prd', legacy: false, frontmatter: item.frontmatter, body: item.body || '', storedBytes: item.bytes }));
1815
+ const baseGraph = revisionGraph(baseDocs);
1816
+ const liveGraph = candidateGraph || revisionGraph(docs);
1817
+ for (const base of baseDocs.filter(doc => doc.frontmatter.status === 'approved')) {
1818
+ const logicalId = baseGraph.logicalIdByDoc.get(base);
1819
+ const names = baseGraph.namesByLogicalId.get(logicalId) || new Set([base.frontmatter.id]);
1820
+ const candidateLogicalIds = new Set([...names].flatMap(name => [...(liveGraph.logicalIdsByName.get(name) || [])]));
1821
+ const present = [...candidateLogicalIds].some(id => (liveGraph.groups.get(id) || [])
1822
+ .some(doc => doc.frontmatter.revision === base.frontmatter.revision));
1823
+ if (!present) {
1824
+ diagnostics.push(makeDiagnostic('invalid-transition', 'error', base,
1825
+ 'approved revisions may not be deleted or moved outside the candidate inventory; return to draft or activate explicitly', base.frontmatter.id));
1826
+ }
1827
+ }
1828
+ for (const doc of (docs || []).filter(isRevisionPrd)) {
1829
+ const data = doc.frontmatter;
1830
+ const logicalId = liveGraph.logicalIdByDoc.get(doc);
1831
+ const names = liveGraph.namesByLogicalId.get(logicalId) || new Set([data.id]);
1832
+ const baseLogicalIds = new Set([...names].flatMap(name => [...(baseGraph.logicalIdsByName.get(name) || [])]));
1833
+ const baseCandidates = [...baseLogicalIds].flatMap(id => (baseGraph.groups.get(id) || []).filter(item => item.frontmatter.revision === data.revision));
1834
+ const base = baseCandidates.length === 1 ? baseCandidates[0] : null;
1835
+ if (!base) continue;
1836
+ const candidateBytes = docBytes(doc);
1837
+ if (base.frontmatter.status === 'approved' && data.status === 'approved') {
1838
+ if (!candidateBytes.equals(Buffer.from(base.storedBytes)) || doc.path !== base.path) {
1839
+ diagnostics.push(makeDiagnostic('invalid-transition', 'error', doc, 'approved revisions are frozen until returned to draft or activated', data.id));
1840
+ }
1841
+ continue;
1842
+ }
1843
+ if (base.frontmatter.status === 'approved' && data.status === 'draft') continue;
1844
+ if (base.frontmatter.status === 'approved' && !['approved', 'draft', 'effective'].includes(data.status)) {
1845
+ diagnostics.push(makeDiagnostic('invalid-transition', 'error', doc, 'approved revisions may only remain approved, return to draft, or activate', data.id));
1846
+ continue;
1847
+ }
1848
+ if (data.status !== 'effective') continue;
1849
+ if (base.frontmatter.status !== 'approved' && base.frontmatter.status !== 'effective') {
1850
+ diagnostics.push(makeDiagnostic('invalid-transition', 'error', doc, `effective revision must activate from approved, found base status ${String(base.frontmatter.status)}`, data.id));
1851
+ continue;
1852
+ }
1853
+ if (base.frontmatter.status === 'effective') continue;
1854
+ const priorProjection = { ...base.frontmatter };
1855
+ const nextProjection = { ...data };
1856
+ for (const key of ['status', 'effective_at']) { delete priorProjection[key]; delete nextProjection[key]; }
1857
+ const candidateBody = Buffer.from(doc.body || '', 'utf8');
1858
+ if (doc.path !== base.path
1859
+ || sortedJson(priorProjection) !== sortedJson(nextProjection)
1860
+ || !Buffer.from(base.body || '', 'utf8').equals(candidateBody)) {
1861
+ diagnostics.push(makeDiagnostic('invalid-transition', 'error', doc, 'approved-to-effective activation may change only status and effective_at', data.id));
1862
+ }
1863
+ const baseLogicalId = baseGraph.logicalIdByDoc.get(base);
1864
+ const priorCurrent = baseGraph.currentById.get(baseLogicalId);
1865
+ if (priorCurrent && data.previous_revision !== priorCurrent.frontmatter.revision) {
1866
+ diagnostics.push(makeDiagnostic('stale-draft-base', 'error', doc, `activation parent ${String(data.previous_revision)} is not the base current effective revision r${priorCurrent.frontmatter.revision}`, data.id));
1867
+ }
1868
+ }
1869
+ return diagnostics;
1870
+ }
1871
+
1872
+ function roleAtPath(relativePath, config, inferDefaultPrd = false) {
1873
+ const entry = (Array.isArray(config && config.governed) ? config.governed : [])
1874
+ .find(item => item && typeof item.path === 'string' && matchesAny(relativePath, [item.path]));
1875
+ if (entry && typeof entry.role === 'string') return entry.role;
1876
+ if (inferDefaultPrd && relativePath.startsWith('docs/prd/') && !relativePath.includes('/legacy/')) return 'prd';
1877
+ return null;
1878
+ }
1879
+
1880
+ function isLegacyPath(relativePath, config) {
1881
+ return matchesAny(relativePath, Array.isArray(config && config.legacy) ? config.legacy : []);
1882
+ }
1883
+
1884
+ function roleInConfigSources(relativePath, configSources) {
1885
+ for (const source of Array.isArray(configSources) ? configSources : configSources ? [configSources] : []) {
1886
+ const entry = (Array.isArray(source && source.governed) ? source.governed : [])
1887
+ .find(item => item && typeof item.path === 'string' && matchesAny(relativePath, [item.path]));
1888
+ if (!entry || matchesAny(relativePath, Array.isArray(source.legacy) ? source.legacy : [])) continue;
1889
+ if (typeof entry.role === 'string') return entry.role;
1890
+ }
1891
+ return null;
1892
+ }
1893
+
1894
+ function objectiveChangedPaths(baseFiles, candidateFiles, config, baseConfig, baseConfigSources = null, candidateConfigSources = null) {
1895
+ const baseByPath = new Map((baseFiles || []).filter(item => item && typeof item.path === 'string')
1896
+ .map(item => [item.path, Buffer.from(item.bytes || [])]));
1897
+ const candidateByPath = new Map((candidateFiles || []).filter(item => item && typeof item.path === 'string')
1898
+ .map(item => [item.path, Buffer.from(item.bytes || [])]));
1899
+ const currentPatterns = [
1900
+ ...(Array.isArray(config && config.governed) ? config.governed.map(item => item && item.path) : []),
1901
+ ...(Array.isArray(config && config.legacy) ? config.legacy : [])
1902
+ ].filter(value => typeof value === 'string');
1903
+ const currentSources = Array.isArray(candidateConfigSources) ? candidateConfigSources : [config];
1904
+ const basePatterns = [
1905
+ ...(Array.isArray(baseConfig && baseConfig.governed) ? baseConfig.governed.map(item => item && item.path) : []),
1906
+ ...(Array.isArray(baseConfig && baseConfig.legacy) ? baseConfig.legacy : [])
1907
+ ].filter(value => typeof value === 'string');
1908
+ if (Array.isArray(baseConfigSources)) {
1909
+ basePatterns.push(...baseConfigSources.flatMap(source => [
1910
+ ...(Array.isArray(source && source.governed) ? source.governed.map(item => item && item.path) : []),
1911
+ ...(Array.isArray(source && source.legacy) ? source.legacy : [])
1912
+ ]).filter(value => typeof value === 'string'));
1913
+ }
1914
+ const paths = new Set([...baseByPath.keys(), ...candidateByPath.keys()]);
1915
+ const changed = new Set();
1916
+ for (const relativePath of paths) {
1917
+ const currentRole = roleInConfigSources(relativePath, currentSources);
1918
+ const baseRole = Array.isArray(baseConfigSources)
1919
+ ? roleInConfigSources(relativePath, baseConfigSources)
1920
+ : roleAtPath(relativePath, baseConfig, !baseConfig);
1921
+ const currentLegacy = isLegacyInConfigSources(relativePath, currentSources);
1922
+ const baseLegacy = Array.isArray(baseConfigSources)
1923
+ ? isLegacyInConfigSources(relativePath, baseConfigSources)
1924
+ : isLegacyPath(relativePath, baseConfig);
1925
+ const currentInScope = Boolean(currentRole || currentLegacy);
1926
+ const baseInScope = Boolean(baseRole || baseLegacy);
1927
+ if (!currentInScope && !baseInScope && !matchesAny(relativePath, [...currentPatterns, ...basePatterns])) continue;
1928
+ const prior = baseByPath.get(relativePath);
1929
+ const current = candidateByPath.get(relativePath);
1930
+ if (!prior || !current || !prior.equals(current)) {
1931
+ changed.add(relativePath);
1932
+ continue;
1933
+ }
1934
+ const basePrd = Array.isArray(baseConfigSources)
1935
+ ? governedPrdPath(relativePath, baseConfigSources)
1936
+ : baseRole === 'prd' && !baseLegacy;
1937
+ const currentPrd = governedPrdPath(relativePath, currentSources);
1938
+ if (currentPrd && !basePrd) changed.add(relativePath);
1939
+ }
1940
+ return changed;
1941
+ }
1942
+
1943
+ function analyzeDocs(docs, config, options = {}) {
1944
+ const entries = Array.isArray(docs) ? docs : [];
1945
+ const candidateConfigSources = Array.isArray(options.configSources) ? options.configSources : [config];
1946
+ const graph = options._graph || revisionGraph(entries);
1947
+ const root = options.workspace || entries.find(doc => typeof doc.workspace === 'string')?.workspace || null;
1948
+ const explicitBaseInventory = Array.isArray(options.baseInventory);
1949
+ const suppliedBaseConfigSources = Array.isArray(options.baseConfigSources) ? options.baseConfigSources : null;
1950
+ let legacyClassificationObserved = true;
1951
+ let inventory = null;
1952
+ let baseState = null;
1953
+ let baseFiles = null;
1954
+ let disposition = 'not-observed';
1955
+ if (explicitBaseInventory) {
1956
+ const inventoryRows = options.baseInventory.filter(item => item && typeof item.path === 'string');
1957
+ legacyClassificationObserved = suppliedBaseConfigSources !== null
1958
+ || inventoryRows.length > 0 && inventoryRows.every(item => typeof item.legacy === 'boolean');
1959
+ baseFiles = options.baseInventory.filter(item => item && typeof item.path === 'string')
1960
+ .map(item => ({
1961
+ path: item.path,
1962
+ bytes: Buffer.from(item.bytes || []),
1963
+ ...(typeof item.legacy === 'boolean' ? { legacy: item.legacy } : {})
1964
+ }));
1965
+ inventory = options.baseInventory.map(item => {
1966
+ if (!item || typeof item.path !== 'string') return null;
1967
+ return parseInventoryDoc(item.path, Buffer.from(item.bytes || []));
1968
+ }).filter(Boolean);
1969
+ disposition = 'observed';
1970
+ } else if (options.base !== undefined) {
1971
+ baseState = readGitBaseInventory(root, options.base, config, options.configPath);
1972
+ if (baseState.available) { inventory = baseState.inventory; baseFiles = baseState.files; disposition = 'observed'; }
1973
+ else disposition = 'unavailable';
1974
+ }
1975
+ const resolvedBaseConfigSources = suppliedBaseConfigSources !== null
1976
+ ? suppliedBaseConfigSources
1977
+ : baseState && Array.isArray(baseState.configSources) && baseState.configSources.length
1978
+ ? baseState.configSources
1979
+ : null;
1980
+ const resolvedBaseConfig = baseState && baseState.config
1981
+ || (Array.isArray(resolvedBaseConfigSources) ? {
1982
+ governed: resolvedBaseConfigSources.flatMap(source => Array.isArray(source && source.governed) ? source.governed : []),
1983
+ legacy: resolvedBaseConfigSources.flatMap(source => Array.isArray(source && source.legacy) ? source.legacy : [])
1984
+ } : null);
1985
+ const normalizedInventory = inventory || [];
1986
+ const candidatePatterns = [
1987
+ ...(Array.isArray(config && config.governed) ? config.governed.map(item => item && item.path) : []),
1988
+ ...(Array.isArray(config && config.legacy) ? config.legacy : []),
1989
+ ...candidateConfigSources.flatMap(source => [
1990
+ ...(Array.isArray(source && source.governed) ? source.governed.map(item => item && item.path) : []),
1991
+ ...(Array.isArray(source && source.legacy) ? source.legacy : [])
1992
+ ]),
1993
+ ...(Array.isArray(resolvedBaseConfig && resolvedBaseConfig.governed) ? resolvedBaseConfig.governed.map(item => item && item.path) : []),
1994
+ ...(Array.isArray(resolvedBaseConfig && resolvedBaseConfig.legacy) ? resolvedBaseConfig.legacy : []),
1995
+ ...(Array.isArray(resolvedBaseConfigSources) ? resolvedBaseConfigSources.flatMap(source => [
1996
+ ...(Array.isArray(source && source.governed) ? source.governed.map(item => item && item.path) : []),
1997
+ ...(Array.isArray(source && source.legacy) ? source.legacy : [])
1998
+ ]) : []),
1999
+ // Discover conventional artifacts, but do not grant them base authority.
2000
+ 'docs/prd/**'
2001
+ ].filter(value => typeof value === 'string');
2002
+ const candidatePaths = [...new Set([
2003
+ ...entries.map(doc => doc.path),
2004
+ ...(baseFiles || []).map(item => item.path),
2005
+ ...provenancePaths(entries),
2006
+ ...provenancePaths(normalizedInventory)
2007
+ ].filter(value => typeof value === 'string'))];
2008
+ const candidateFiles = root && disposition === 'observed'
2009
+ ? scanWorkspaceFiles(root, { globs: candidatePatterns, paths: candidatePaths })
2010
+ : entries.map(doc => ({ path: doc.path, bytes: docBytes(doc) }));
2011
+ const changedPaths = new Set((Array.isArray(options.changedPaths) ? options.changedPaths : []).map(String));
2012
+ if (disposition === 'observed') {
2013
+ for (const relativePath of objectiveChangedPaths(baseFiles, candidateFiles, config, resolvedBaseConfig, resolvedBaseConfigSources, candidateConfigSources)) changedPaths.add(relativePath);
2014
+ }
2015
+ const frozenBaseInventory = immutableBasePrdInventory(
2016
+ normalizedInventory,
2017
+ resolvedBaseConfigSources,
2018
+ explicitBaseInventory
2019
+ );
2020
+ const additionalDiagnostics = [];
2021
+ if (disposition === 'observed') {
2022
+ const baseLegacyConfigSources = resolvedBaseConfigSources;
2023
+ additionalDiagnostics.push(...legacyHistoryDiagnostics(baseFiles, candidateFiles, candidateConfigSources, baseLegacyConfigSources));
2024
+ const candidateInventory = root
2025
+ ? candidateFiles.map(item => parseInventoryDoc(item.path, item.bytes)).filter(Boolean)
2026
+ : entries.map(doc => ({
2027
+ id: doc.frontmatter && doc.frontmatter.id,
2028
+ revision: doc.frontmatter && doc.frontmatter.revision,
2029
+ path: doc.path,
2030
+ status: doc.frontmatter && doc.frontmatter.status,
2031
+ bytes: docBytes(doc),
2032
+ frontmatter: doc.frontmatter,
2033
+ body: doc.body
2034
+ }));
2035
+ additionalDiagnostics.push(...immutabilityDiagnostics(frozenBaseInventory, candidateInventory));
2036
+
2037
+ const transitionDocs = new Map(entries.map(doc => [doc.path, doc]));
2038
+ const explicitBaselinePaths = new Set(explicitBaseInventory ? normalizedInventory.map(item => item.path) : []);
2039
+ for (const file of candidateFiles) {
2040
+ const basePrd = explicitBaselinePaths.has(file.path) || governedPrdPath(file.path, resolvedBaseConfigSources);
2041
+ const currentPrd = roleAtPath(file.path, config) === 'prd' && !isLegacyPath(file.path, config);
2042
+ if (!basePrd && !currentPrd) continue;
2043
+ if (transitionDocs.has(file.path)) continue;
2044
+ const parsed = parseFrontmatter(Buffer.from(file.bytes).toString('utf8'));
2045
+ if (parsed.error || !parsed.data || typeof parsed.data.id !== 'string') continue;
2046
+ transitionDocs.set(file.path, {
2047
+ path: file.path,
2048
+ role: 'prd',
2049
+ legacy: false,
2050
+ frontmatter: parsed.data,
2051
+ body: parsed.body,
2052
+ error: parsed.error,
2053
+ workspace: root,
2054
+ storedBytes: Buffer.from(file.bytes)
2055
+ });
2056
+ }
2057
+ const transitionCandidates = [...transitionDocs.values()];
2058
+ additionalDiagnostics.push(...transitionDiagnostics(transitionCandidates, frozenBaseInventory, root));
2059
+ additionalDiagnostics.push(...migrationDiagnostics(entries, root, normalizedInventory, baseFiles, resolvedBaseConfigSources));
2060
+ }
2061
+ const preliminaryRetirementPrefix = options._retirementEligibleSources instanceof Set
2062
+ ? options._retirementEligibleSources
2063
+ : validatedEffectivePrefix(entries, graph, config, additionalDiagnostics);
2064
+ const preliminaryOptions = {
2065
+ ...options,
2066
+ changedPaths: [...changedPaths],
2067
+ _trustedBaseObserved: disposition === 'observed',
2068
+ _graph: graph,
2069
+ _retirementEligibleSources: preliminaryRetirementPrefix,
2070
+ _retirementValidationDiagnostics: additionalDiagnostics,
2071
+ _baseIdPaths: disposition === 'observed' ? baseIdPaths(normalizedInventory, config, resolvedBaseConfigSources) : undefined
2072
+ };
2073
+ // Namespace and REQ collisions are computed by lintDocs and do not depend on
2074
+ // retirement eligibility. Collect the complete authority findings once with
2075
+ // the preliminary prefix, then rebuild the prefix before emitting supersession
2076
+ // and conflict-resolution diagnostics.
2077
+ const preliminaryDiagnostics = lintDocs(entries, config, preliminaryOptions);
2078
+ const retirementEligibleSources = validatedEffectivePrefix(entries, graph, config, [
2079
+ ...additionalDiagnostics,
2080
+ ...preliminaryDiagnostics.filter(item => AUTHORITY_BLOCKER_DIAGNOSTICS.has(item.code))
2081
+ ]);
2082
+ const effectiveOptions = {
2083
+ ...preliminaryOptions,
2084
+ _retirementEligibleSources: retirementEligibleSources,
2085
+ _retirementValidationDiagnostics: [
2086
+ ...additionalDiagnostics,
2087
+ ...preliminaryDiagnostics.filter(item => AUTHORITY_BLOCKER_DIAGNOSTICS.has(item.code))
2088
+ ]
2089
+ };
2090
+ const diagnostics = lintDocs(entries, config, effectiveOptions);
2091
+ diagnostics.push(...additionalDiagnostics);
2092
+ const evidence = { immutability: disposition };
2093
+ if (explicitBaseInventory && !legacyClassificationObserved) evidence.legacyImmutability = 'not-observed';
2094
+ const result = { diagnostics: sortDiagnostics(diagnostics), evidence };
2095
+ Object.defineProperty(result, '_graph', { value: graph, enumerable: false });
2096
+ Object.defineProperty(result, '_retirementEligibleSources', { value: retirementEligibleSources, enumerable: false });
2097
+ return result;
2098
+ }
2099
+
2100
+ function diffRevisions(docs, refA, refB) {
2101
+ const graph = revisionGraph(docs);
2102
+ const from = resolveRef(docs, refA, { graph });
2103
+ const to = resolveRef(docs, refB, { graph });
2104
+ if (!from.doc || !to.doc) return { error: 'unresolved-revision', from: from.error || null, to: to.error || null };
2105
+ const before = parseRequirementBlocks(from.doc);
2106
+ const after = parseRequirementBlocks(to.doc);
2107
+ const beforeById = new Map(before.map(item => [item.id, item]));
2108
+ const afterById = new Map(after.map(item => [item.id, item]));
2109
+ const beforeNames = new Map();
2110
+ for (const item of before) for (const name of [item.id, ...item.aliases]) {
2111
+ if (!beforeNames.has(name)) beforeNames.set(name, new Set());
2112
+ beforeNames.get(name).add(item.id);
2113
+ }
2114
+ const replaced = [];
2115
+ for (const item of after) for (const replacedName of item.replaces) {
2116
+ const targets = beforeNames.get(replacedName);
2117
+ if (targets && targets.size === 1) replaced.push({ from: [...targets][0], to: item.id });
2118
+ }
2119
+ replaced.sort((a, b) => compareText(a.from, b.from) || compareText(a.to, b.to));
2120
+ const replacementSources = new Set(replaced.map(item => item.from));
2121
+ const kept = [];
2122
+ const changed = [];
2123
+ for (const [id, prior] of beforeById) {
2124
+ const next = afterById.get(id);
2125
+ if (!next) continue;
2126
+ if (prior.title === next.title && prior.block.trim() === next.block.trim()) kept.push(id);
2127
+ else changed.push({ id, reviewRequired: true });
2128
+ }
2129
+ const removed = [...beforeById.keys()].filter(id => !afterById.has(id) && !replacementSources.has(id)).sort(compareText);
2130
+ const added = [...afterById.keys()].filter(id => !beforeById.has(id) && !replaced.some(edge => edge.to === id)).sort(compareText);
2131
+ return {
2132
+ from: { id: from.doc.frontmatter.id, revision: from.doc.frontmatter.revision, path: from.doc.path, digest: from.digest },
2133
+ to: { id: to.doc.frontmatter.id, revision: to.doc.frontmatter.revision, path: to.doc.path, digest: to.digest },
2134
+ requirements: {
2135
+ kept: kept.sort(compareText),
2136
+ added,
2137
+ changed: changed.sort((a, b) => compareText(a.id, b.id)),
2138
+ removed,
2139
+ replaced
2140
+ }
2141
+ };
2142
+ }
2143
+
2144
+ function documentId(doc) {
2145
+ return doc.legacy ? idFromLegacy(doc) : doc.frontmatter && typeof doc.frontmatter.id === 'string' ? doc.frontmatter.id : null;
2146
+ }
2147
+
2148
+ function documentPin(doc) {
2149
+ if (isRevisionPrd(doc)) return `${doc.frontmatter.id}@r${doc.frontmatter.revision}`;
2150
+ return documentId(doc) || doc.path;
2151
+ }
2152
+
2153
+ function effectiveRevisionHistory(group, parentByDoc) {
2154
+ const effective = group.filter(doc => doc.frontmatter.status === 'effective');
2155
+ if (!effective.length) return 'none';
2156
+ const effectiveSet = new Set(effective);
2157
+ const children = new Map();
2158
+ for (const doc of effective) {
2159
+ const parent = parentByDoc.get(doc);
2160
+ if (!effectiveSet.has(parent)) continue;
2161
+ if (!children.has(parent)) children.set(parent, []);
2162
+ children.get(parent).push(doc);
2163
+ }
2164
+ for (const values of children.values()) values.sort((a, b) => a.frontmatter.revision - b.frontmatter.revision || compareText(a.path, b.path));
2165
+ const roots = effective.filter(doc => doc.frontmatter.previous_revision === undefined
2166
+ || doc.frontmatter.previous_revision === null || doc.frontmatter.previous_revision === '');
2167
+ const paths = [];
2168
+ const visited = new Set();
2169
+ const walk = (doc, chain, visiting) => {
2170
+ if (visiting.has(doc)) return;
2171
+ visiting.add(doc);
2172
+ visited.add(doc);
2173
+ const next = children.get(doc) || [];
2174
+ if (!next.length) paths.push([...chain, documentPin(doc)]);
2175
+ else for (const child of next) walk(child, [...chain, documentPin(doc)], visiting);
2176
+ visiting.delete(doc);
2177
+ };
2178
+ for (const root of roots) walk(root, [], new Set());
2179
+ for (const doc of effective) if (!visited.has(doc)) paths.push([`unlinked: ${documentPin(doc)}`]);
2180
+ return paths.map(chain => chain.join(' -> ')).join('; ') || 'none';
2181
+ }
2182
+
2183
+ function buildIndex(docs, config, options = {}) {
2184
+ const entries = Array.isArray(docs) ? docs : [];
2185
+ const mode = options.all === true
2186
+ ? { all: true }
2187
+ : { changedPaths: Array.isArray(options.changedPaths) ? options.changedPaths : [] };
2188
+ const analysis = Array.isArray(options.diagnostics)
2189
+ ? null
2190
+ : analyzeDocs(entries, config, { ...options, ...mode });
2191
+ const graph = options._graph || analysis && analysis._graph || revisionGraph(entries);
2192
+ const validatedPrefix = options._retirementEligibleSources instanceof Set
2193
+ ? options._retirementEligibleSources
2194
+ : analysis
2195
+ ? analysis._retirementEligibleSources
2196
+ : validatedEffectivePrefix(entries, graph, config, options._retirementValidationDiagnostics || options.diagnostics);
2197
+ let diagnostics = Array.isArray(options.diagnostics) ? options.diagnostics : analysis.diagnostics;
2198
+ const indexAuthorityDiagnostics = [
2199
+ ...(Array.isArray(options._retirementValidationDiagnostics) ? options._retirementValidationDiagnostics : []),
2200
+ ...diagnostics.filter(item => item && item.severity === 'error')
2201
+ ].filter(item => item && item.severity === 'error');
2202
+ const indexAuthorityPrefix = validatedEffectivePrefix(entries, graph, config, indexAuthorityDiagnostics, {
2203
+ includeGraphDiagnostics: false
2204
+ });
2205
+ diagnostics = diagnostics.filter(item => RESOLUTION_DIAGNOSTICS.has(item.code));
2206
+ const edges = [];
2207
+ for (const doc of entries) {
2208
+ if (doc.legacy || doc.error || !doc.frontmatter) continue;
2209
+ for (const ref of asList(doc.frontmatter.supersedes)) {
2210
+ if (typeof ref !== 'string') continue;
2211
+ const result = resolveRef(entries, ref, { graph });
2212
+ if (result.doc) {
2213
+ const eligibility = supersessionEligibility(doc, result.doc, ref, validatedPrefix, graph);
2214
+ edges.push({ source: doc, target: result.doc, ref, ...eligibility });
2215
+ }
2216
+ }
2217
+ }
2218
+ const incoming = new Map();
2219
+ for (const edge of edges.filter(item => item.effective)) {
2220
+ if (!incoming.has(edge.target.path)) incoming.set(edge.target.path, []);
2221
+ incoming.get(edge.target.path).push(edge.source);
2222
+ }
2223
+ const sortDocs = (left, right) => compareText(documentId(left) || '', documentId(right) || '') || compareText(left.path, right.path);
2224
+ const active = entries.filter(doc => !doc.legacy).slice().sort(sortDocs);
2225
+ const legacy = entries.filter(doc => doc.legacy).slice().sort(sortDocs);
2226
+ const lines = ['# Document lifecycle index', '', '## Active documents', ''];
2227
+ if (!active.length) lines.push('_None._', '');
2228
+ for (const doc of active) {
2229
+ const data = doc.frontmatter || {};
2230
+ const id = documentId(doc) || '(no id)';
2231
+ const details = [`role: ${doc.role || 'unassigned'}`];
2232
+ if (isRevisionPrd(doc)) {
2233
+ const logicalId = graph.logicalIdByDoc.get(doc);
2234
+ const current = graph.currentById.get(logicalId);
2235
+ const valid = graph.structurallyValid.get(logicalId) !== false;
2236
+ const currentAuthorityInvalid = current === doc && !indexAuthorityPrefix.has(current);
2237
+ const derivedState = !valid || currentAuthorityInvalid ? 'invalid' : current === doc ? 'current' : current ? (data.status === 'effective' ? 'historical' : 'pending') : 'pre-activation';
2238
+ details.push(`stored status: ${data.status || '(missing)'}`, `derived state: ${derivedState}`);
2239
+ } else {
2240
+ details.push(`status: ${data.status || '(missing)'}`);
2241
+ if (isPrdCompatibility(doc)) details.push('pre-D6.9 compatibility: migration required; not current authority');
2242
+ }
2243
+ const aliases = Array.isArray(data.aliases) ? data.aliases.filter(item => typeof item === 'string').slice().sort(compareText) : [];
2244
+ if (aliases.length) details.push(`aliases: ${aliases.map(item => `\`${item}\``).join(', ')}`);
2245
+ const successors = (incoming.get(doc.path) || []).slice().sort(sortDocs);
2246
+ if (successors.length) {
2247
+ const provenance = successors.map(source => {
2248
+ const edge = edges.find(item => item.effective && item.source.path === source.path && item.target.path === doc.path);
2249
+ return edge && edge.retirement ? `\`${documentPin(source)}\` -> \`${documentPin(doc)}\`` : `\`${documentId(source) || source.path}\``;
2250
+ });
2251
+ details.push(`superseded by: ${provenance.join(', ')}`);
2252
+ const retirementEdges = successors.map(source => edges.find(item => item.effective && item.retirement && item.source.path === source.path && item.target.path === doc.path)).filter(Boolean);
2253
+ if (retirementEdges.length) details.push(`retirement provenance: ${retirementEdges.map(edge => `\`${documentPin(edge.source)}\` -> \`${documentPin(edge.target)}\``).sort(compareText).join(', ')}`);
2254
+ }
2255
+ const outgoing = edges.filter(edge => edge.source.path === doc.path);
2256
+ const pending = outgoing.filter(edge => !edge.effective);
2257
+ if (pending.length) details.push(`pending supersedes: ${pending.map(edge => `\`${edge.ref}\``).sort(compareText).join(', ')}`);
2258
+ const retirements = outgoing.filter(edge => edge.retirement);
2259
+ if (retirements.length) details.push(`retires: ${retirements.map(edge => `\`${edge.ref}\``).sort(compareText).join(', ')}`);
2260
+ lines.push(`- \`${id}\` — \`${doc.path}\` (${details.join('; ')})`);
2261
+ }
2262
+ lines.push('', '## PRD revision chains', '');
2263
+ if (!graph.groups.size) lines.push('_None._', '');
2264
+ for (const [logicalId, group] of [...graph.groups.entries()].sort((a, b) => compareText(a[0], b[0]))) {
2265
+ const revisions = group.slice().sort((a, b) => a.frontmatter.revision - b.frontmatter.revision || compareText(a.path, b.path));
2266
+ const current = graph.currentById.get(logicalId);
2267
+ const valid = graph.structurallyValid.get(logicalId) !== false;
2268
+ const currentAuthorityInvalid = Boolean(current && !indexAuthorityPrefix.has(current));
2269
+ const state = !valid || currentAuthorityInvalid ? 'invalid' : current ? 'valid' : 'pre-activation';
2270
+ const currentText = current && !currentAuthorityInvalid
2271
+ ? `\`${documentPin(current)}\` (\`${current.path}\`; digest: \`${graph.artifactDigests.get(current) || artifactDigest(current)}\`)`
2272
+ : 'none';
2273
+ const history = effectiveRevisionHistory(revisions, graph.parentByDoc).replace(/(PRD-[A-Z][A-Z0-9]{1,9}-\d{3,}@r[1-9]\d*)/g, '`$1`');
2274
+ const pending = revisions.filter(doc => ['draft', 'approved'].includes(doc.frontmatter.status));
2275
+ const pendingText = pending.length
2276
+ ? pending.map(doc => `\`${documentPin(doc)}\` (${doc.frontmatter.status})`).join(', ')
2277
+ : 'none';
2278
+ lines.push(`- \`${logicalId}\` — state: ${state}; current: ${currentText}; history: ${history}; pending: ${pendingText}`);
2279
+ }
2280
+ lines.push('', '## Legacy documents', '');
2281
+ if (!legacy.length) lines.push('_None._', '');
2282
+ for (const doc of legacy) {
2283
+ const id = documentId(doc) || '(no id)';
2284
+ const stored = doc.frontmatter && doc.frontmatter.status || '(missing)';
2285
+ const successors = (incoming.get(doc.path) || []).slice().sort(sortDocs);
2286
+ const projected = successors.length
2287
+ ? `superseded (projected) by ${successors.map(item => `\`${documentId(item) || item.path}\``).join(', ')}`
2288
+ : `same as stored status ${stored}`;
2289
+ lines.push(`- \`${id}\` — \`${doc.path}\` (stored status: ${stored}; effective status: ${projected}${successors.length ? `; source edge: ${successors.map(item => `\`${item.path}\``).join(', ')}` : ''})`);
2290
+ }
2291
+ lines.push('', '## Unresolved', '');
2292
+ if (!diagnostics.length) lines.push('_None._', '');
2293
+ for (const item of diagnostics) lines.push(`- \`${item.code}\` (${item.severity}) — \`${item.path}\`: ${item.message}`);
2294
+ return `${lines.join('\n').replace(/\n+$/, '')}\n`;
2295
+ }
2296
+
2297
+ function parseCli(argv) {
2298
+ const [command, ...rest] = argv;
2299
+ const options = { command, changedProvided: false, changedPaths: [], all: false };
2300
+ for (let index = 0; index < rest.length; index++) {
2301
+ const arg = rest[index];
2302
+ if (arg === '--all') options.all = true;
2303
+ else if (arg === '--changed-path') {
2304
+ if (index + 1 >= rest.length) throw new Error(`${arg} requires a value`);
2305
+ const value = rest[++index];
2306
+ if (!value.trim()) throw new Error(`${arg} requires a non-empty path`);
2307
+ options.changedProvided = true;
2308
+ options.changedPaths.push(value);
2309
+ }
2310
+ else if (arg === '--workspace' || arg === '--config' || arg === '--changed' || arg === '--base') {
2311
+ if (index + 1 >= rest.length) throw new Error(`${arg} requires a value`);
2312
+ const value = rest[++index];
2313
+ if (arg === '--workspace') options.workspace = value;
2314
+ if (arg === '--config') options.configFile = value;
2315
+ if (arg === '--changed') { options.changedProvided = true; options.changedPaths = options.changedPaths.concat(value.split(',').map(item => item.trim()).filter(Boolean)); }
2316
+ if (arg === '--base') options.base = value;
2317
+ } else throw new Error(`unknown argument: ${arg}`);
2318
+ }
2319
+ if (!['lint', 'index'].includes(command)) throw new Error('usage: doc-lifecycle.js <lint|index> --workspace <dir> --config <json-file, relative to --workspace> [--changed a,b | --changed-path <path> (repeatable, cumulative) | --all] [--base <git-rev>]');
2320
+ if (!options.workspace || !options.configFile) throw new Error('--workspace and --config are required');
2321
+ if (!options.changedProvided && !options.all) throw new Error('lint and index require --changed, --changed-path, or --all');
2322
+ if (options.all && options.changedProvided) throw new Error('choose either --changed/--changed-path or --all');
2323
+ return options;
2324
+ }
2325
+
2326
+ function normalizeChangedPath(workspacePath, value) {
2327
+ const input = String(value).replace(/\\/g, '/');
2328
+ if (!input) throw new Error('--changed entries must name files within --workspace');
2329
+ if (path.sep !== '\\' && /^[A-Za-z]:\//.test(input)) {
2330
+ throw new Error(`--changed path is outside --workspace: ${value}`);
2331
+ }
2332
+ const absolute = path.isAbsolute(input) ? path.resolve(input) : path.resolve(workspacePath, input);
2333
+ const relative = path.relative(workspacePath, absolute);
2334
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2335
+ throw new Error(`--changed path is outside --workspace or does not name a file: ${value}`);
2336
+ }
2337
+ return relative.split(path.sep).join('/');
2338
+ }
2339
+
2340
+ function validateExplicitConfig(config, configPath) {
2341
+ let validateDocsConfig;
2342
+ try {
2343
+ ({ validateDocsConfig } = require('./project-docs-resolver'));
2344
+ } catch (error) {
2345
+ throw new Error(`cannot load shared project-docs validator for ${configPath}: ${error.message}`);
2346
+ }
2347
+ if (typeof validateDocsConfig !== 'function') {
2348
+ throw new Error(`shared project-docs validator for ${configPath} does not export validateDocsConfig`);
2349
+ }
2350
+ const result = validateDocsConfig(config);
2351
+ if (!result.valid) {
2352
+ const details = result.diagnostics.map(item => `${item.code}: ${item.message}`).join('; ');
2353
+ throw new Error(`invalid project-docs config at ${configPath}: ${details}`);
2354
+ }
2355
+ }
2356
+
2357
+ function main(argv = process.argv.slice(2)) {
2358
+ let options;
2359
+ try { options = parseCli(argv); } catch (error) { process.stderr.write(`${error.message}\n`); return 2; }
2360
+ const workspacePath = path.resolve(options.workspace);
2361
+ if (options.changedProvided) {
2362
+ try { options.changedPaths = options.changedPaths.map(item => normalizeChangedPath(workspacePath, item)); } catch (error) {
2363
+ process.stderr.write(`${error.message}\n`);
2364
+ return 2;
2365
+ }
2366
+ }
2367
+ const configPath = path.isAbsolute(options.configFile)
2368
+ ? path.resolve(options.configFile)
2369
+ : path.resolve(workspacePath, options.configFile);
2370
+ let config;
2371
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (error) {
2372
+ process.stderr.write(`cannot read config: ${error.message}\n`);
2373
+ return 2;
2374
+ }
2375
+ try { validateExplicitConfig(config, configPath); } catch (error) {
2376
+ process.stderr.write(`${error.message}\n`);
2377
+ return 2;
2378
+ }
2379
+ const docs = collectDocs(workspacePath, config);
2380
+ const mode = {
2381
+ ...(options.all ? { all: true } : { changedPaths: options.changedPaths }),
2382
+ workspace: workspacePath, base: options.base, configPath,
2383
+ configSources: readWorkspaceConfigSources(workspacePath, configPath, config)
2384
+ };
2385
+ const analysis = analyzeDocs(docs, config, mode);
2386
+ if (options.command === 'index') {
2387
+ process.stdout.write(buildIndex(docs, config, {
2388
+ ...mode,
2389
+ diagnostics: analysis.diagnostics,
2390
+ _graph: analysis._graph,
2391
+ _retirementEligibleSources: analysis._retirementEligibleSources
2392
+ }));
2393
+ return 0;
2394
+ }
2395
+ process.stdout.write(`${JSON.stringify(analysis, null, 2)}\n`);
2396
+ return analysis.diagnostics.some(item => item.severity === 'error') ? 1 : 0;
2397
+ }
2398
+
2399
+ module.exports = { parseFrontmatter, parseRequirementBlocks, collectDocs, analyzeDocs, resolveRef, lintDocs, buildIndex, diffRevisions };
2400
+
2401
+ if (require.main === module) process.exitCode = main();