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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +48 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/config.json +9 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/config.json +20 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/expected.json +12 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r002.md +26 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/invalid-fork/docs/prd/graph/r003.md +26 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/valid/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/packaged/valid/docs/prd/graph/r002.md +26 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r001.body.md +39 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-dropped.body.md +35 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-incomplete.body.md +37 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-one-replaced.body.md +39 -0
- package/ci/fixtures/doc-lifecycle/prd-revisions/ten-reqs/r002-overlap.body.md +39 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/config.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/docs/adr/GRPH-001.md +8 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/docs/adr/GRPH-002.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-active/expected.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/config.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/docs/adr/GRPH-001.md +8 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/docs/adr/GRPH-002.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-active-mismatch/expected.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/config.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/docs/adr/GRPH-010.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/docs/adr/legacy/GRPH-007.md +8 -0
- package/ci/fixtures/doc-lifecycle/scenarios/active-to-legacy/expected.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/config.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/docs/adr/GRAPH-007.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/docs/adr/legacy/GRPH-007.md +8 -0
- package/ci/fixtures/doc-lifecycle/scenarios/alias-rename/expected.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/config.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/docs/adr/legacy/v1/GRPH-007.md +8 -0
- package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/docs/adr/legacy/v2/GRPH-007.md +8 -0
- package/ci/fixtures/doc-lifecycle/scenarios/duplicate-legacy/expected.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/config.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-003.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-004.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-005.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-006.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/docs/adr/GRPH-007.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/invalid-refs/expected.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/config.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/docs/adr/GRPH-020.md +8 -0
- package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/docs/adr/GRPH-021.md +9 -0
- package/ci/fixtures/doc-lifecycle/scenarios/pending-edge/expected.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/config.json +1 -0
- package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/docs/adr/GRPH-003.md +8 -0
- package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/docs/prd/graph.md +10 -0
- package/ci/fixtures/doc-lifecycle/scenarios/recorded-conflict/expected.json +1 -0
- package/ci/fixtures/doc-obligations/derive/acceptance-only/base/docs/prd/graph.md +22 -0
- package/ci/fixtures/doc-obligations/derive/acceptance-only/candidate/docs/prd/graph.md +22 -0
- package/ci/fixtures/doc-obligations/derive/alias-only/base/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/alias-only/candidate/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision/base/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision/candidate/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/base/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/candidate/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/assertion-no-revision-attested/inventory.json +7 -0
- package/ci/fixtures/doc-obligations/derive/attribution-impossible/base/ci/mechanism-open.test.js +3 -0
- package/ci/fixtures/doc-obligations/derive/attribution-impossible/candidate/ci/mechanism-open.test.js.fixture +2 -0
- package/ci/fixtures/doc-obligations/derive/clause-context-typo/base/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/clause-context-typo/candidate/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/code-only/base/src/lint.js +1 -0
- package/ci/fixtures/doc-obligations/derive/code-only/candidate/src/lint.js +1 -0
- package/ci/fixtures/doc-obligations/derive/comment-only-test/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/comment-only-test/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/computed-member-assert/base/ci/mechanism-c1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/computed-member-assert/candidate/ci/mechanism-c1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/computed-member-variable/base/ci/mechanism-c1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/computed-member-variable/candidate/ci/mechanism-c1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/config.json +11 -0
- package/ci/fixtures/doc-obligations/derive/decision-fence-backtick/base/docs/adr/GRPH-010.md +20 -0
- package/ci/fixtures/doc-obligations/derive/decision-fence-backtick/candidate/docs/adr/GRPH-010.md +20 -0
- package/ci/fixtures/doc-obligations/derive/decision-fence-tilde/base/docs/adr/GRPH-010.md +20 -0
- package/ci/fixtures/doc-obligations/derive/decision-fence-tilde/candidate/docs/adr/GRPH-010.md +20 -0
- package/ci/fixtures/doc-obligations/derive/deep-strict-equal/base/ci/mechanism-s1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/deep-strict-equal/candidate/ci/mechanism-s1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/deleted-adr/base/docs/adr/GRPH-008.md +14 -0
- package/ci/fixtures/doc-obligations/derive/deleted-prd/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/deleted-prd/base/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/deleted-prd/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/derived-rename/base/docs/index.md +3 -0
- package/ci/fixtures/doc-obligations/derive/derived-rename/base/src/model/a.js +1 -0
- package/ci/fixtures/doc-obligations/derive/derived-rename/candidate/docs/index.md +3 -0
- package/ci/fixtures/doc-obligations/derive/derived-rename/candidate/src/model/b.js +1 -0
- package/ci/fixtures/doc-obligations/derive/derived-rename/config.json +7 -0
- package/ci/fixtures/doc-obligations/derive/derived-two-outputs/base/docs/index.md +3 -0
- package/ci/fixtures/doc-obligations/derive/derived-two-outputs/base/docs/model-summary.md +3 -0
- package/ci/fixtures/doc-obligations/derive/derived-two-outputs/base/src/model.js +1 -0
- package/ci/fixtures/doc-obligations/derive/derived-two-outputs/candidate/docs/index.md +3 -0
- package/ci/fixtures/doc-obligations/derive/derived-two-outputs/candidate/docs/model-summary.md +3 -0
- package/ci/fixtures/doc-obligations/derive/derived-two-outputs/candidate/src/model.js +1 -0
- package/ci/fixtures/doc-obligations/derive/derived-two-outputs/config.json +10 -0
- package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/base/docs/prd/graph-one/r001.md +19 -0
- package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/base/docs/prd/graph-two/r001.md +19 -0
- package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/candidate/docs/prd/graph-one/r001.md +19 -0
- package/ci/fixtures/doc-obligations/derive/distinct-logical-prds/candidate/docs/prd/graph-two/r001.md +19 -0
- package/ci/fixtures/doc-obligations/derive/docs-only/base/docs/reference/guide.md +3 -0
- package/ci/fixtures/doc-obligations/derive/docs-only/candidate/docs/reference/guide.md +3 -0
- package/ci/fixtures/doc-obligations/derive/dup-req-id/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/dup-req-id/base/docs/prd/graph-a.md +20 -0
- package/ci/fixtures/doc-obligations/derive/dup-req-id/base/docs/prd/graph-b.md +20 -0
- package/ci/fixtures/doc-obligations/derive/dup-req-id/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/dup-req-id/candidate/docs/prd/graph-a.md +20 -0
- package/ci/fixtures/doc-obligations/derive/dup-req-id/candidate/docs/prd/graph-b.md +20 -0
- package/ci/fixtures/doc-obligations/derive/expect-computed-literal/base/ci/mechanism-e.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/expect-computed-literal/candidate/ci/mechanism-e.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/expect-computed-matcher/base/ci/mechanism-e.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/expect-computed-matcher/candidate/ci/mechanism-e.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/expect-not-to-equal/base/ci/mechanism-s1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/expect-not-to-equal/candidate/ci/mechanism-s1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/formatting-only-test/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/formatting-only-test/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/helper-delegated-test/base/docs/prd/graph.md +18 -0
- package/ci/fixtures/doc-obligations/derive/helper-delegated-test/candidate/ci/mechanism-helper.test.js +1 -0
- package/ci/fixtures/doc-obligations/derive/helper-delegated-test/candidate/docs/prd/graph.md +18 -0
- package/ci/fixtures/doc-obligations/derive/historical-only-req/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/historical-only-req/base/docs/prd/graph/r001.md +25 -0
- package/ci/fixtures/doc-obligations/derive/historical-only-req/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/historical-only-req/candidate/docs/prd/graph/r001.md +25 -0
- package/ci/fixtures/doc-obligations/derive/historical-only-req/candidate/docs/prd/graph/r002.md +23 -0
- package/ci/fixtures/doc-obligations/derive/in-place-current/base/ci/mechanism-inplace.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/in-place-current/base/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-obligations/derive/in-place-current/base/docs/prd/graph/r002.md +26 -0
- package/ci/fixtures/doc-obligations/derive/in-place-current/candidate/ci/mechanism-inplace.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/in-place-current/candidate/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-obligations/derive/in-place-current/candidate/docs/prd/graph/r002.md +28 -0
- package/ci/fixtures/doc-obligations/derive/in-place-historical/base/ci/mechanism-inplace.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/in-place-historical/base/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-obligations/derive/in-place-historical/base/docs/prd/graph/r002.md +26 -0
- package/ci/fixtures/doc-obligations/derive/in-place-historical/candidate/ci/mechanism-inplace.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/in-place-historical/candidate/docs/prd/graph/r001.md +22 -0
- package/ci/fixtures/doc-obligations/derive/in-place-historical/candidate/docs/prd/graph/r002.md +26 -0
- package/ci/fixtures/doc-obligations/derive/invalid-clause/base/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/invalid-clause/candidate/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/legacy-untagged-fallback/base/ci/mechanism-legacy.test.js +6 -0
- package/ci/fixtures/doc-obligations/derive/legacy-untagged-fallback/candidate/ci/mechanism-legacy.test.js +6 -0
- package/ci/fixtures/doc-obligations/derive/literal-whitespace-assert/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/literal-whitespace-assert/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/malformed-citation-attribution/base/ci/mechanism-bad.test.js +6 -0
- package/ci/fixtures/doc-obligations/derive/malformed-citation-attribution/candidate/ci/mechanism-bad.test.js.fixture +6 -0
- package/ci/fixtures/doc-obligations/derive/malformed-prd/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/malformed-prd/base/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/malformed-prd/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/malformed-prd/candidate/docs/prd/graph.md +11 -0
- package/ci/fixtures/doc-obligations/derive/missing-approval/base/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/missing-approval/candidate/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/mixed-attribution/base/ci/mechanism-mixed.test.js +6 -0
- package/ci/fixtures/doc-obligations/derive/mixed-attribution/base/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/mixed-attribution/candidate/ci/mechanism-mixed.test.js.fixture +5 -0
- package/ci/fixtures/doc-obligations/derive/mixed-attribution/candidate/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/mixed-docs-code/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/mixed-docs-code/base/docs/reference/guide.md +3 -0
- package/ci/fixtures/doc-obligations/derive/mixed-docs-code/base/src/lint.js +1 -0
- package/ci/fixtures/doc-obligations/derive/mixed-docs-code/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/mixed-docs-code/candidate/docs/reference/guide.md +3 -0
- package/ci/fixtures/doc-obligations/derive/mixed-docs-code/candidate/src/lint.js +1 -0
- package/ci/fixtures/doc-obligations/derive/multiline-comment-only/base/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/multiline-comment-only/candidate/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/multiline-expect/base/ci/mechanism-multi.test.js +6 -0
- package/ci/fixtures/doc-obligations/derive/multiline-expect/candidate/ci/mechanism-multi.test.js +6 -0
- package/ci/fixtures/doc-obligations/derive/multiline-formatting-only/base/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/multiline-formatting-only/candidate/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/multiline-numeric-prd/base/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/multiline-numeric-prd/base/docs/prd/graph.md +18 -0
- package/ci/fixtures/doc-obligations/derive/multiline-numeric-prd/candidate/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/multiline-numeric-prd/candidate/docs/prd/graph.md +18 -0
- package/ci/fixtures/doc-obligations/derive/multiline-strictequal/base/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/multiline-strictequal/candidate/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/new-prd/candidate/docs/prd/graph-new.md +20 -0
- package/ci/fixtures/doc-obligations/derive/new-reference/candidate/docs/reference/new-note.md +3 -0
- package/ci/fixtures/doc-obligations/derive/new-req-test/candidate/ci/mechanism-new.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/orphan-req-test/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/orphan-req-test/base/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/orphan-req-test/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/orphan-req-test/candidate/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/partial-revision/base/ci/mechanism-partial.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/partial-revision/base/docs/prd/graph/r001.md +24 -0
- package/ci/fixtures/doc-obligations/derive/partial-revision/candidate/ci/mechanism-partial.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/partial-revision/candidate/docs/prd/graph/r001.md +24 -0
- package/ci/fixtures/doc-obligations/derive/partial-revision/candidate/docs/prd/graph/r002.md +26 -0
- package/ci/fixtures/doc-obligations/derive/regex-literal-whitespace/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/regex-literal-whitespace/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/rename/base/docs/reference/old-guide.md +3 -0
- package/ci/fixtures/doc-obligations/derive/rename/candidate/docs/reference/guide.md +3 -0
- package/ci/fixtures/doc-obligations/derive/replacement-target/base/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/replacement-target/candidate/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/req-context-typo/base/docs/prd/graph.md +26 -0
- package/ci/fixtures/doc-obligations/derive/req-context-typo/candidate/docs/prd/graph.md +26 -0
- package/ci/fixtures/doc-obligations/derive/req-revision/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/req-revision/base/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-obligations/derive/req-revision/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/req-revision/candidate/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-obligations/derive/req-revision/candidate/docs/prd/graph/r002.md +28 -0
- package/ci/fixtures/doc-obligations/derive/req-revision-in-place/base/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-obligations/derive/req-revision-in-place/candidate/docs/prd/graph/r001.md +22 -0
- package/ci/fixtures/doc-obligations/derive/retained-revision-chain/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/retained-revision-chain/base/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-obligations/derive/retained-revision-chain/base/docs/prd/graph/r002.md +26 -0
- package/ci/fixtures/doc-obligations/derive/retained-revision-chain/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/retained-revision-chain/candidate/docs/prd/graph/r001.md +20 -0
- package/ci/fixtures/doc-obligations/derive/retained-revision-chain/candidate/docs/prd/graph/r002.md +26 -0
- package/ci/fixtures/doc-obligations/derive/string-literal-tag/base/ci/mechanism-str.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/string-literal-tag/candidate/ci/mechanism-str.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/tag-inside-test-call/base/ci/mechanism-inside.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/tag-inside-test-call/candidate/ci/mechanism-inside.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/tagged-test/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/tagged-test/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/tagged-test-prd/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/tagged-test-prd/base/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/tagged-test-prd/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/tagged-test-prd/candidate/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/template-literal-tag/base/ci/mechanism-tpl.test.js +4 -0
- package/ci/fixtures/doc-obligations/derive/template-literal-tag/base/docs/prd/graph.md +18 -0
- package/ci/fixtures/doc-obligations/derive/template-literal-tag/candidate/ci/mechanism-tpl.test.js +4 -0
- package/ci/fixtures/doc-obligations/derive/template-literal-tag/candidate/docs/prd/graph.md +18 -0
- package/ci/fixtures/doc-obligations/derive/template-literal-whitespace/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/template-literal-whitespace/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/title-only-rename/base/ci/mechanism-title.test.js +3 -0
- package/ci/fixtures/doc-obligations/derive/title-only-rename/candidate/ci/mechanism-title.test.js +3 -0
- package/ci/fixtures/doc-obligations/derive/title-only-unrelated-computed/base/ci/mechanism-u.test.js +4 -0
- package/ci/fixtures/doc-obligations/derive/title-only-unrelated-computed/candidate/ci/mechanism-u.test.js +4 -0
- package/ci/fixtures/doc-obligations/derive/top-level-no-file-tag/base/ci/mechanism-top.test.js +4 -0
- package/ci/fixtures/doc-obligations/derive/top-level-no-file-tag/candidate/ci/mechanism-top.test.js +4 -0
- package/ci/fixtures/doc-obligations/derive/trailing-comment-test/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/trailing-comment-test/base/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/trailing-comment-test/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/trailing-comment-test/candidate/docs/prd/graph.md +20 -0
- package/ci/fixtures/doc-obligations/derive/two-tests-one-file/base/ci/mechanism-two.test.js +6 -0
- package/ci/fixtures/doc-obligations/derive/two-tests-one-file/base/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/two-tests-one-file/candidate/ci/mechanism-two.test.js +6 -0
- package/ci/fixtures/doc-obligations/derive/two-tests-one-file/candidate/docs/prd/graph.md +24 -0
- package/ci/fixtures/doc-obligations/derive/typo-context/base/docs/adr/GRPH-007.md +14 -0
- package/ci/fixtures/doc-obligations/derive/typo-context/candidate/docs/adr/GRPH-007.md +14 -0
- package/ci/fixtures/doc-obligations/derive/typo-decision/base/docs/adr/GRPH-007.md +14 -0
- package/ci/fixtures/doc-obligations/derive/typo-decision/candidate/docs/adr/GRPH-007.md +14 -0
- package/ci/fixtures/doc-obligations/derive/unbalanced-statement/base/ci/mechanism-multi.test.js +5 -0
- package/ci/fixtures/doc-obligations/derive/unbalanced-statement/candidate/ci/mechanism-multi.test.js.fixture +4 -0
- package/ci/fixtures/doc-obligations/derive/unknown-adr/base/docs/adr/GRPH-009.md +14 -0
- package/ci/fixtures/doc-obligations/derive/unknown-adr/candidate/docs/adr/GRPH-009.md +12 -0
- package/ci/fixtures/doc-obligations/derive/unsupported-assertion-syntax/base/ci/mechanism-s1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/unsupported-assertion-syntax/candidate/ci/mechanism-s1.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/whitespace-only-test/base/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/derive/whitespace-only-test/candidate/ci/mechanism-lint.test.js +2 -0
- package/ci/fixtures/doc-obligations/evidence/base/docs/adr/GRPH-007.md +15 -0
- package/ci/fixtures/doc-obligations/evidence/base/docs/prd/PRD-GRPH-001.md +25 -0
- package/ci/fixtures/doc-obligations/evidence/candidate/docs/adr/GRPH-007.md +15 -0
- package/ci/fixtures/doc-obligations/evidence/candidate/docs/prd/PRD-GRPH-001.md +25 -0
- package/ci/mechanism-2aw-project-docs-config.test.js +759 -0
- package/ci/mechanism-2ax-doc-lifecycle.test.js +1061 -0
- package/ci/mechanism-2ay-prd-revisions.test.js +2891 -0
- package/ci/mechanism-325-3a-cross.test.js +795 -0
- package/ci/mechanism-325-3a-doc-evidence.test.js +1172 -0
- package/ci/mechanism-325-3a-doc-obligations.test.js +742 -0
- package/ci/mechanism-pr334-haiku.test.js +298 -0
- package/ci/mechanism-pr334-luna.test.js +192 -0
- package/ci/mechanism-pr334-opencode.test.js +313 -0
- package/ci/mechanism-pr334-r2-boundary.test.js +239 -0
- package/ci/mechanism-pr334-r2-resolution.test.js +243 -0
- package/ci/mechanism-pr334-r2-scope.test.js +127 -0
- package/ci/mechanism-pr334-r3-boundary.test.js +356 -0
- package/ci/mechanism-pr334-r3-citations.test.js +205 -0
- package/ci/mechanism-pr334-r3-headings.test.js +268 -0
- package/docs/document-lifecycle.md +262 -0
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/doc-evidence.js +471 -0
- package/harness-everything/scripts/doc-lifecycle.js +2401 -0
- package/harness-everything/scripts/doc-obligations.js +1571 -0
- package/harness-everything/scripts/project-docs-resolver.js +764 -0
- package/multi-agent-workspace/SKILL.md +1 -1
- package/multi-agent-workspace/scripts/project-docs-resolver.js +437 -30
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence.js +471 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +2401 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/doc-obligations.js +1571 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +764 -0
- package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
- package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +437 -30
- package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
- package/plugins/harness-everything/skills/to-spec/scripts/check-project-docs.js +168 -54
- package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +749 -96
- package/sbom.cdx.json +7 -7
- package/to-spec/SKILL.md +1 -1
- package/to-spec/scripts/check-project-docs.js +168 -54
- package/to-spec/scripts/project-docs-resolver.js +749 -96
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const helper = require('./test-helper');
|
|
10
|
+
|
|
11
|
+
const modulePath = path.join(__dirname, '..', 'harness-everything', 'scripts', 'doc-lifecycle.js');
|
|
12
|
+
let lifecycle;
|
|
13
|
+
try {
|
|
14
|
+
lifecycle = require(modulePath);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
helper.check('2ax. lifecycle module is available', false, error.message);
|
|
17
|
+
helper.finish();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-2ax-doc-lifecycle-'));
|
|
21
|
+
process.on('exit', () => fs.rmSync(tempRoot, { recursive: true, force: true }));
|
|
22
|
+
|
|
23
|
+
console.log('\n[2ax] document lifecycle lint, reference resolution, and index...');
|
|
24
|
+
|
|
25
|
+
const CONFIG = Object.freeze({
|
|
26
|
+
governed: [
|
|
27
|
+
{ path: 'docs/prd/**', role: 'prd' },
|
|
28
|
+
{ path: 'docs/adr/**', role: 'adr' },
|
|
29
|
+
{ path: 'docs/reference/**', role: 'reference' },
|
|
30
|
+
{ path: 'docs/ticket/**', role: 'ticket' },
|
|
31
|
+
{ path: 'docs/log/**', role: 'log' },
|
|
32
|
+
{ path: 'docs/report/**', role: 'report' }
|
|
33
|
+
],
|
|
34
|
+
domains: ['GRAPH', 'GRPH', 'IFCE'],
|
|
35
|
+
legacy: ['docs/adr/legacy/**']
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function checkCase(name, run) {
|
|
39
|
+
try {
|
|
40
|
+
run();
|
|
41
|
+
helper.check(name, true);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
helper.check(name, false, error && error.stack ? error.stack : String(error));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function workspace(name) {
|
|
48
|
+
const root = path.join(tempRoot, name);
|
|
49
|
+
fs.mkdirSync(root, { recursive: true });
|
|
50
|
+
return root;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeDoc(root, relativePath, frontmatter, body = '') {
|
|
54
|
+
const file = path.join(root, ...relativePath.split('/'));
|
|
55
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
56
|
+
const header = typeof frontmatter === 'string'
|
|
57
|
+
? frontmatter
|
|
58
|
+
: Object.entries(frontmatter || {}).map(([key, value]) => `${key}: ${yaml(value)}`).join('\n');
|
|
59
|
+
fs.writeFileSync(file, `---\n${header}\n---\n${body}`, 'utf8');
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sortedJson(value) {
|
|
64
|
+
if (Array.isArray(value)) return `[${value.map(sortedJson).join(',')}]`;
|
|
65
|
+
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${sortedJson(value[key])}`).join(',')}}`;
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeRevisionPrd(root, relativePath, id, body, extra = {}) {
|
|
70
|
+
const frontmatter = {
|
|
71
|
+
id, revision: 1, status: 'effective', drafted_by: 'PRD Draft Author',
|
|
72
|
+
effective_at: '2026-10-17T00:01:00Z', ...extra
|
|
73
|
+
};
|
|
74
|
+
const approval = {
|
|
75
|
+
by: 'Independent PRD Verifier', at: '2026-10-17T00:00:00Z',
|
|
76
|
+
ref: 'https://approvals.example.invalid/2ax', role: 'human', content_digest: ''
|
|
77
|
+
};
|
|
78
|
+
const projection = { ...frontmatter };
|
|
79
|
+
delete projection.status;
|
|
80
|
+
delete projection.effective_at;
|
|
81
|
+
approval.content_digest = `sha256:${crypto.createHash('sha256').update(`${sortedJson(projection)}\n${body}`, 'utf8').digest('hex')}`;
|
|
82
|
+
const values = { ...frontmatter, approval };
|
|
83
|
+
const lines = [];
|
|
84
|
+
for (const [key, value] of Object.entries(values)) {
|
|
85
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
86
|
+
lines.push(`${key}:`);
|
|
87
|
+
for (const [child, nested] of Object.entries(value)) lines.push(` ${child}: ${yaml(nested)}`);
|
|
88
|
+
} else lines.push(`${key}: ${yaml(value)}`);
|
|
89
|
+
}
|
|
90
|
+
return writeDoc(root, relativePath, lines.join('\n'), body);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function yaml(value) {
|
|
94
|
+
if (Array.isArray(value)) return `[${value.map(item => JSON.stringify(item)).join(', ')}]`;
|
|
95
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function collect(root, config = CONFIG) {
|
|
100
|
+
return lifecycle.collectDocs(root, config);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function lint(root, config = CONFIG, changedPaths) {
|
|
104
|
+
const docs = collect(root, config);
|
|
105
|
+
return lifecycle.lintDocs(docs, config, changedPaths === 'all' ? { all: true } : { changedPaths });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function codes(diagnostics) {
|
|
109
|
+
return diagnostics.map(item => item.code);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function byCode(diagnostics, code) {
|
|
113
|
+
return diagnostics.filter(item => item.code === code);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function roleConfig() {
|
|
117
|
+
return {
|
|
118
|
+
governed: [
|
|
119
|
+
{ path: 'docs/prd/**', role: 'prd' },
|
|
120
|
+
{ path: 'docs/adr/**', role: 'adr' },
|
|
121
|
+
{ path: 'docs/ticket/**', role: 'ticket' },
|
|
122
|
+
{ path: 'docs/reference/**', role: 'reference' },
|
|
123
|
+
{ path: 'docs/log/**', role: 'log' },
|
|
124
|
+
{ path: 'docs/report/**', role: 'report' }
|
|
125
|
+
],
|
|
126
|
+
domains: ['GRAPH', 'GRPH', 'IFCE'],
|
|
127
|
+
legacy: []
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function gitAt(root, args) {
|
|
132
|
+
const result = spawnSync('git', ['-C', root, ...args], {
|
|
133
|
+
encoding: 'utf8',
|
|
134
|
+
env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' }
|
|
135
|
+
});
|
|
136
|
+
if (result.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
|
|
137
|
+
return String(result.stdout || '').trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function gitBytesAt(root, revision, relativePath) {
|
|
141
|
+
const result = spawnSync('git', ['-C', root, 'show', `${revision}:${relativePath}`], {
|
|
142
|
+
encoding: 'buffer',
|
|
143
|
+
env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' }
|
|
144
|
+
});
|
|
145
|
+
if (result.status !== 0) throw new Error(`git show ${revision}:${relativePath} failed: ${String(result.stderr || result.stdout)}`);
|
|
146
|
+
return Buffer.from(result.stdout);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function legacyHistory(name, {
|
|
150
|
+
frontmatter = { id: 'GRAPH-900', status: 'accepted', aliases: [], supersedes: [] },
|
|
151
|
+
reference = false,
|
|
152
|
+
baseProjectConfig = null,
|
|
153
|
+
baseHostConfig = null
|
|
154
|
+
} = {}) {
|
|
155
|
+
const root = workspace(name);
|
|
156
|
+
gitAt(root, ['init', '--quiet']);
|
|
157
|
+
const configPath = path.join(root, 'lifecycle-config.json');
|
|
158
|
+
const projectConfig = baseProjectConfig || { governed: [], domains: CONFIG.domains, legacy: [] };
|
|
159
|
+
fs.writeFileSync(configPath, `${JSON.stringify(projectConfig, null, 2)}\n`, 'utf8');
|
|
160
|
+
const hostManifestPath = path.join(root, '.claude', 'harness-everything', 'manifest.json');
|
|
161
|
+
fs.mkdirSync(path.dirname(hostManifestPath), { recursive: true });
|
|
162
|
+
fs.writeFileSync(hostManifestPath, `${JSON.stringify({ projectDocs: baseHostConfig || {
|
|
163
|
+
governed: [], domains: CONFIG.domains, legacy: ['docs/adr/legacy/**']
|
|
164
|
+
} }, null, 2)}\n`, 'utf8');
|
|
165
|
+
const legacyPath = writeDoc(root, 'docs/adr/legacy/GRAPH-900.md', frontmatter, '# Archived decision\n\nOriginal editorial text.\n');
|
|
166
|
+
if (reference) {
|
|
167
|
+
writeDoc(root, 'docs/adr/current.md', {
|
|
168
|
+
id: 'GRAPH-901', status: 'accepted',
|
|
169
|
+
supersedes: ['legacy:GRAPH-900@docs/adr/legacy/GRAPH-900.md']
|
|
170
|
+
}, 'This decision refers to the archived decision.\n');
|
|
171
|
+
}
|
|
172
|
+
gitAt(root, ['add', '--all']);
|
|
173
|
+
gitAt(root, ['-c', 'user.name=2ax fixture', '-c', 'user.email=2ax@example.invalid', 'commit', '--quiet', '-m', 'trusted legacy base']);
|
|
174
|
+
const base = gitAt(root, ['rev-parse', 'HEAD']);
|
|
175
|
+
fs.writeFileSync(configPath, `${JSON.stringify(CONFIG, null, 2)}\n`, 'utf8');
|
|
176
|
+
return { root, base, configPath, legacyPath, legacyRelative: 'docs/adr/legacy/GRAPH-900.md' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function runLegacyLint(root, config, base, mode, changedPath = 'docs/adr/legacy/GRAPH-900.md') {
|
|
180
|
+
const configPath = path.join(root, 'lifecycle-config.json');
|
|
181
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
182
|
+
const args = [modulePath, 'lint', '--workspace', root, '--config', configPath];
|
|
183
|
+
if (base) args.push('--base', base);
|
|
184
|
+
if (mode === 'all') args.push('--all');
|
|
185
|
+
else args.push('--changed', changedPath);
|
|
186
|
+
const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
|
|
187
|
+
if (![0, 1].includes(result.status)) throw new Error(`doc-lifecycle exited ${result.status}: ${result.stderr || result.stdout}`);
|
|
188
|
+
return { status: result.status, analysis: JSON.parse(result.stdout) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function legacyModePair(root, config, base, changedPath = 'docs/adr/legacy/GRAPH-900.md') {
|
|
192
|
+
const changed = runLegacyLint(root, config, base, 'changed', changedPath);
|
|
193
|
+
const all = runLegacyLint(root, config, base, 'all', changedPath);
|
|
194
|
+
assert.strictEqual(changed.status, all.status, 'changed/all CLI exit codes must agree');
|
|
195
|
+
assert.deepStrictEqual(changed.analysis.diagnostics, all.analysis.diagnostics, 'changed/all diagnostics must agree');
|
|
196
|
+
assert.deepStrictEqual(changed.analysis.evidence, all.analysis.evidence, 'changed/all evidence must agree');
|
|
197
|
+
return changed.analysis;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function legacyAnalysisModePair(root, config, base, configSources, changedPath = 'docs/adr/legacy/GRAPH-900.md') {
|
|
201
|
+
const analyze = mode => lifecycle.analyzeDocs(collect(root, config), config, {
|
|
202
|
+
workspace: root, base, configSources,
|
|
203
|
+
...(mode === 'all' ? { all: true } : { changedPaths: [changedPath] })
|
|
204
|
+
});
|
|
205
|
+
const changed = analyze('changed');
|
|
206
|
+
const all = analyze('all');
|
|
207
|
+
assert.deepStrictEqual(changed.diagnostics, all.diagnostics, 'changed/all diagnostics must agree');
|
|
208
|
+
assert.deepStrictEqual(changed.evidence, all.evidence, 'changed/all evidence must agree');
|
|
209
|
+
return changed;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
checkCase('2ax. frontmatter parser supports scalars, inline arrays, and block lists', () => {
|
|
213
|
+
const parsed = lifecycle.parseFrontmatter('---\nid: GRAPH-007\naliases: [GRPH-007, IFCE-002]\nsupersedes:\n - GRAPH-006\n - legacy:GRPH-004\n---\n# Body\n');
|
|
214
|
+
assert.strictEqual(parsed.error, null);
|
|
215
|
+
assert.deepStrictEqual(parsed.data, {
|
|
216
|
+
id: 'GRAPH-007',
|
|
217
|
+
aliases: ['GRPH-007', 'IFCE-002'],
|
|
218
|
+
supersedes: ['GRAPH-006', 'legacy:GRPH-004']
|
|
219
|
+
});
|
|
220
|
+
assert.strictEqual(parsed.body, '# Body\n');
|
|
221
|
+
assert.ok(lifecycle.parseFrontmatter('---\nid: [broken\n---\n').error);
|
|
222
|
+
assert.deepStrictEqual(lifecycle.parseFrontmatter('# no frontmatter\n').data, {});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
checkCase('2ax. frontmatter supports trailing comments and unindented block lists', () => {
|
|
226
|
+
const parsed = lifecycle.parseFrontmatter('---\nid: GRAPH-008 # chosen id\naliases: [GRPH-008] # renamed\nsupersedes:\n- GRAPH-001\n- legacy:GRPH-002\nstatus: proposed\n---\n# Body\n');
|
|
227
|
+
assert.strictEqual(parsed.error, null);
|
|
228
|
+
assert.deepStrictEqual(parsed.data, {
|
|
229
|
+
id: 'GRAPH-008',
|
|
230
|
+
aliases: ['GRPH-008'],
|
|
231
|
+
supersedes: ['GRAPH-001', 'legacy:GRPH-002'],
|
|
232
|
+
status: 'proposed'
|
|
233
|
+
});
|
|
234
|
+
assert.strictEqual(lifecycle.parseFrontmatter('---\nid: "GRAPH-009 # x"\n---\n').data.id, 'GRAPH-009 # x');
|
|
235
|
+
assert.deepStrictEqual(lifecycle.parseFrontmatter('---\naliases: ["a # b", GRPH-002] # trailing\n---\n').data.aliases, ['a # b', 'GRPH-002']);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
checkCase('2ax. every role accepts its vocabulary and reports missing or invalid status', () => {
|
|
239
|
+
const config = roleConfig();
|
|
240
|
+
const valid = [
|
|
241
|
+
...['draft', 'approved', 'effective', 'rejected', 'withdrawn'].map((status, index) => [`docs/prd/${index}.md`, { id: `PRD-GRAPH-${String(index + 1).padStart(3, '0')}`, revision: 1, status }]),
|
|
242
|
+
...['proposed', 'accepted', 'rejected', 'superseded'].map((status, index) => [`docs/adr/${index}.md`, { id: `GRAPH-${String(index + 1).padStart(3, '0')}`, status }]),
|
|
243
|
+
...['open', 'blocked', 'done', 'withdrawn'].map((status, index) => [`docs/ticket/${index}.md`, { status }]),
|
|
244
|
+
...['active', 'deprecated'].map((status, index) => [`docs/reference/${index}.md`, { status }]),
|
|
245
|
+
...['final', 'archived'].map((status, index) => [`docs/log/${index}.md`, { status }]),
|
|
246
|
+
...['final', 'archived'].map((status, index) => [`docs/report/${index}.md`, { status }])
|
|
247
|
+
];
|
|
248
|
+
const root = workspace('role-vocabulary');
|
|
249
|
+
for (const [file, fields] of valid) writeDoc(root, file, fields);
|
|
250
|
+
const docs = collect(root, config);
|
|
251
|
+
const diagnostics = lifecycle.lintDocs(docs, config, { all: true });
|
|
252
|
+
assert.ok(!diagnostics.some(item => ['missing-status', 'invalid-status'].includes(item.code)), JSON.stringify(diagnostics));
|
|
253
|
+
const badRoot = workspace('role-status-errors');
|
|
254
|
+
writeDoc(badRoot, 'docs/prd/missing.md', { id: 'PRD-B' });
|
|
255
|
+
writeDoc(badRoot, 'docs/adr/invalid.md', { id: 'GRAPH-002', status: 'active' });
|
|
256
|
+
writeDoc(badRoot, 'docs/ticket/missing.md', { id: 'TICKET-002' });
|
|
257
|
+
const bad = lifecycle.lintDocs(collect(badRoot, config), config, { all: true });
|
|
258
|
+
assert.ok(codes(bad).includes('missing-status'));
|
|
259
|
+
assert.ok(codes(bad).includes('invalid-status'));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
checkCase('2ax. role mismatch and malformed frontmatter are diagnosed', () => {
|
|
263
|
+
const root = workspace('parse-role');
|
|
264
|
+
writeDoc(root, 'docs/adr/mismatch.md', { id: 'GRAPH-003', role: 'prd', status: 'accepted' });
|
|
265
|
+
writeDoc(root, 'docs/prd/broken.md', 'id: [unterminated\nstatus: active');
|
|
266
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
267
|
+
assert.ok(codes(diagnostics).includes('role-mismatch'));
|
|
268
|
+
assert.ok(codes(diagnostics).includes('malformed-frontmatter'));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
checkCase('2ax. ADR id is required and validates format and declared domain', () => {
|
|
272
|
+
const root = workspace('adr-id-rules');
|
|
273
|
+
writeDoc(root, 'docs/adr/missing.md', { status: 'proposed' });
|
|
274
|
+
writeDoc(root, 'docs/adr/bad-format.md', { id: 'GRAPH-02', status: 'proposed' });
|
|
275
|
+
writeDoc(root, 'docs/adr/unknown-domain.md', { id: 'UNKN-001', status: 'proposed' });
|
|
276
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
277
|
+
assert.ok(codes(diagnostics).includes('missing-id'));
|
|
278
|
+
assert.ok(codes(diagnostics).includes('invalid-id'));
|
|
279
|
+
assert.ok(codes(diagnostics).includes('unknown-domain'));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
checkCase('2ax. PRD identity and revision are mandatory and duplicate revision pairs are rejected', () => {
|
|
283
|
+
const root = workspace('duplicate-id');
|
|
284
|
+
writeDoc(root, 'docs/prd/no-id.md', { status: 'draft' });
|
|
285
|
+
writeDoc(root, 'docs/prd/one.md', { id: 'PRD-GRPH-001', revision: 1, status: 'draft' });
|
|
286
|
+
writeDoc(root, 'docs/prd/two.md', { id: 'PRD-GRPH-001', revision: 1, status: 'draft' });
|
|
287
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
288
|
+
assert.ok(byCode(diagnostics, 'invalid-prd-id').some(item => item.path === 'docs/prd/no-id.md'));
|
|
289
|
+
assert.ok(byCode(diagnostics, 'invalid-revision').some(item => item.path === 'docs/prd/no-id.md'));
|
|
290
|
+
const duplicates = byCode(diagnostics, 'duplicate-revision');
|
|
291
|
+
assert.deepStrictEqual(duplicates.map(item => item.path), ['docs/prd/one.md', 'docs/prd/two.md']);
|
|
292
|
+
assert.deepStrictEqual(duplicates.map(item => item.candidates), [['docs/prd/two.md'], ['docs/prd/one.md']]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
checkCase('2ax. REQ definitions, aliases, duplicate definitions, and missing REQ references', () => {
|
|
296
|
+
const root = workspace('requirements');
|
|
297
|
+
writeRevisionPrd(root, 'docs/prd/active.md', 'PRD-GRPH-012', [
|
|
298
|
+
'### REQ-GRAPH-012 (aliases: REQ-GRPH-012, REQ-GRPH-099): Render graph',
|
|
299
|
+
'',
|
|
300
|
+
'See REQ-GRPH-012 and REQ-IFCE-404.',
|
|
301
|
+
'Acceptance: graph output remains visible.',
|
|
302
|
+
'```md',
|
|
303
|
+
'REQ-IFCE-405 is only an example in code.',
|
|
304
|
+
'```',
|
|
305
|
+
''
|
|
306
|
+
].join('\n'));
|
|
307
|
+
writeRevisionPrd(root, 'docs/prd/duplicate.md', 'PRD-IFCE-012', '### REQ-GRAPH-012: duplicate\n\nAcceptance: duplicate clause.\n');
|
|
308
|
+
const docs = collect(root, CONFIG);
|
|
309
|
+
const found = lifecycle.lintDocs(docs, CONFIG, { all: true });
|
|
310
|
+
const duplicates = byCode(found, 'duplicate-req');
|
|
311
|
+
assert.deepStrictEqual(duplicates.map(item => item.path), ['docs/prd/active.md', 'docs/prd/duplicate.md']);
|
|
312
|
+
assert.deepStrictEqual(duplicates.map(item => item.candidates), [['docs/prd/duplicate.md'], ['docs/prd/active.md']]);
|
|
313
|
+
assert.ok(codes(found).includes('missing-req'));
|
|
314
|
+
assert.ok(!found.some(item => item.id === 'REQ-IFCE-405'));
|
|
315
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'REQ-GRPH-099').doc.path, 'docs/prd/active.md');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
checkCase('2ax. fence closers must match delimiter char and length with only trailing whitespace', () => {
|
|
319
|
+
const root = workspace('fence-closers');
|
|
320
|
+
writeRevisionPrd(root, 'docs/prd/fences.md', 'PRD-GRPH-121', [
|
|
321
|
+
'### REQ-GRAPH-121: real',
|
|
322
|
+
'Acceptance: preserve the rendered graph.',
|
|
323
|
+
'',
|
|
324
|
+
'```md',
|
|
325
|
+
'Plain code line mentioning REQ-GRAPH-140 inline.',
|
|
326
|
+
'```text trailing info is content',
|
|
327
|
+
'```',
|
|
328
|
+
'',
|
|
329
|
+
'Prose after the pseudo-closer sees REQ-GRAPH-141.',
|
|
330
|
+
'~~~md',
|
|
331
|
+
'Plain tilde code mentioning REQ-GRAPH-142.',
|
|
332
|
+
'~~~',
|
|
333
|
+
'',
|
|
334
|
+
'````md',
|
|
335
|
+
'Plain code mentioning REQ-GRAPH-143.',
|
|
336
|
+
'```',
|
|
337
|
+
'Plain outside text mentioning REQ-GRAPH-144.',
|
|
338
|
+
'````'
|
|
339
|
+
].join('\n'));
|
|
340
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
341
|
+
const missing = byCode(diagnostics, 'missing-req').map(item => item.id).sort();
|
|
342
|
+
assert.deepStrictEqual(missing, ['REQ-GRAPH-141'], `fence handling is wrong: ${JSON.stringify(missing)}`);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
checkCase('2ax. document alias collision with another canonical ID or alias is rejected', () => {
|
|
346
|
+
const root = workspace('alias-collisions');
|
|
347
|
+
writeDoc(root, 'docs/adr/one.md', { id: 'GRAPH-010', status: 'accepted', aliases: ['GRPH-010', 'IFCE-009'] });
|
|
348
|
+
writeDoc(root, 'docs/adr/two.md', { id: 'IFCE-010', status: 'proposed', aliases: ['GRPH-010', 'IFCE-009'] });
|
|
349
|
+
writeDoc(root, 'docs/adr/three.md', { id: 'GRPH-010', status: 'rejected' });
|
|
350
|
+
writeDoc(root, 'docs/adr/invalid-alias.md', { id: 'GRAPH-011', status: 'proposed', aliases: ['legacy:GRAPH-010', 7, 'not-an-id'] });
|
|
351
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
352
|
+
const canonicalCollision = byCode(diagnostics, 'alias-collision').filter(item => item.id === 'GRPH-010');
|
|
353
|
+
assert.deepStrictEqual(canonicalCollision.map(item => item.path), ['docs/adr/one.md', 'docs/adr/three.md', 'docs/adr/two.md']);
|
|
354
|
+
assert.deepStrictEqual(canonicalCollision.map(item => item.candidates), [
|
|
355
|
+
['docs/adr/three.md', 'docs/adr/two.md'],
|
|
356
|
+
['docs/adr/one.md', 'docs/adr/two.md'],
|
|
357
|
+
['docs/adr/one.md', 'docs/adr/three.md']
|
|
358
|
+
]);
|
|
359
|
+
const aliasPair = byCode(diagnostics, 'alias-collision').filter(item => item.id === 'IFCE-009');
|
|
360
|
+
assert.deepStrictEqual(aliasPair.map(item => item.path), ['docs/adr/one.md', 'docs/adr/two.md']);
|
|
361
|
+
assert.deepStrictEqual(aliasPair.map(item => item.candidates), [['docs/adr/two.md'], ['docs/adr/one.md']]);
|
|
362
|
+
assert.ok(codes(diagnostics).includes('invalid-alias'));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
checkCase('2ax. scalar or null aliases produce invalid-alias and the lint/resolution invariant holds', () => {
|
|
366
|
+
const root = workspace('scalar-aliases');
|
|
367
|
+
writeDoc(root, 'docs/adr/valid.md', { id: 'GRAPH-110', status: 'accepted', aliases: ['GRPH-110'] });
|
|
368
|
+
writeDoc(root, 'docs/adr/scalar.md', { id: 'GRAPH-111', status: 'accepted', aliases: 'GRPH-111' });
|
|
369
|
+
writeDoc(root, 'docs/adr/null.md', { id: 'GRAPH-112', status: 'accepted', aliases: null });
|
|
370
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
371
|
+
const flagged = byCode(diagnostics, 'invalid-alias').map(item => item.path).sort();
|
|
372
|
+
assert.deepStrictEqual(flagged, ['docs/adr/null.md', 'docs/adr/scalar.md'], JSON.stringify(diagnostics));
|
|
373
|
+
const docs = collect(root, CONFIG);
|
|
374
|
+
const clean = docs.filter(doc => !flagged.includes(doc.path));
|
|
375
|
+
for (const doc of clean) {
|
|
376
|
+
for (const alias of Array.isArray(doc.frontmatter.aliases) ? doc.frontmatter.aliases : []) {
|
|
377
|
+
const resolved = lifecycle.resolveRef(docs, alias);
|
|
378
|
+
assert.ok(resolved.doc && resolved.doc.path === doc.path, `invariant violated: alias ${alias} of ${doc.path} does not resolve to its declaring document`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
checkCase('2ax. historical domain rename through alias is identity only, not supersession', () => {
|
|
384
|
+
const root = workspace('domain-rename');
|
|
385
|
+
writeDoc(root, 'docs/adr/renamed.md', { id: 'GRAPH-007', status: 'accepted', aliases: ['GRPH-007'] });
|
|
386
|
+
const docs = collect(root, CONFIG);
|
|
387
|
+
const index = lifecycle.buildIndex(docs, CONFIG);
|
|
388
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'GRPH-007').doc.path, 'docs/adr/renamed.md');
|
|
389
|
+
assert.ok(!index.includes('superseded by'));
|
|
390
|
+
assert.ok(!index.includes('GRPH-007 →'));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
checkCase('2ax. legacy globs take precedence over governed overlap and stay schema-exempt', () => {
|
|
394
|
+
const root = workspace('legacy-overlap');
|
|
395
|
+
const bytes = '---\nid: GRAPH-020\nstatus: INVALID\nrole: report\n---\nold bytes\n';
|
|
396
|
+
const file = path.join(root, 'docs/adr/legacy/GRAPH-020.md');
|
|
397
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
398
|
+
fs.writeFileSync(file, bytes);
|
|
399
|
+
const docs = collect(root);
|
|
400
|
+
assert.strictEqual(docs.length, 1);
|
|
401
|
+
assert.strictEqual(docs[0].legacy, true);
|
|
402
|
+
assert.strictEqual(docs[0].role, 'adr');
|
|
403
|
+
assert.deepStrictEqual(lifecycle.lintDocs(docs, CONFIG, { all: true }), []);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
checkCase('2ax. trusted base legacy frontmatter stays frozen while body-only editorial fixes remain allowed', () => {
|
|
407
|
+
const unchanged = legacyHistory('legacy-frozen-unchanged');
|
|
408
|
+
const unchangedResult = legacyModePair(unchanged.root, CONFIG, unchanged.base);
|
|
409
|
+
assert.strictEqual(unchangedResult.evidence.immutability, 'observed');
|
|
410
|
+
assert.ok(!unchangedResult.diagnostics.some(item => item.code === 'legacy-frozen-modified'), JSON.stringify(unchangedResult.diagnostics));
|
|
411
|
+
|
|
412
|
+
const editorial = legacyHistory('legacy-frozen-editorial');
|
|
413
|
+
fs.writeFileSync(editorial.legacyPath,
|
|
414
|
+
'---\nid: GRAPH-900\nstatus: accepted\naliases: []\nsupersedes: []\n---\n# Archived decision\n\nCorrected editorial link: [guide](../guide.md).\n', 'utf8');
|
|
415
|
+
const editorialResult = legacyModePair(editorial.root, CONFIG, editorial.base);
|
|
416
|
+
assert.strictEqual(editorialResult.evidence.immutability, 'observed');
|
|
417
|
+
assert.ok(!editorialResult.diagnostics.some(item => item.code === 'legacy-frozen-modified'), JSON.stringify(editorialResult.diagnostics));
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
for (const [key, value] of [
|
|
421
|
+
['id', 'GRAPH-990'],
|
|
422
|
+
['status', 'rejected'],
|
|
423
|
+
['supersedes', ['GRAPH-899']],
|
|
424
|
+
['aliases', ['GRAPH-899']],
|
|
425
|
+
['historical_note', 'changed normative metadata']
|
|
426
|
+
]) {
|
|
427
|
+
checkCase(`2ax. trusted-base legacy ${key} change is rejected at the archive`, () => {
|
|
428
|
+
const fixture = legacyHistory(`legacy-frozen-${key}`);
|
|
429
|
+
const nextFrontmatter = {
|
|
430
|
+
id: 'GRAPH-900', status: 'accepted', aliases: [], supersedes: [], [key]: value
|
|
431
|
+
};
|
|
432
|
+
writeDoc(fixture.root, fixture.legacyRelative, nextFrontmatter, '# Archived decision\n\nOriginal editorial text.\n');
|
|
433
|
+
const result = legacyModePair(fixture.root, CONFIG, fixture.base);
|
|
434
|
+
const finding = result.diagnostics.find(item => item.code === 'legacy-frozen-modified');
|
|
435
|
+
assert.ok(finding, `${key} edit was accepted: ${JSON.stringify(result.diagnostics)}`);
|
|
436
|
+
assert.strictEqual(finding.severity, 'error');
|
|
437
|
+
assert.strictEqual(finding.path, fixture.legacyRelative);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
checkCase('2ax. trusted-base legacy frontmatter that no longer parses is rejected', () => {
|
|
442
|
+
const malformed = legacyHistory('legacy-frozen-malformed');
|
|
443
|
+
writeDoc(malformed.root, malformed.legacyRelative, 'id: [broken', '# Archived decision\n');
|
|
444
|
+
const malformedResult = legacyModePair(malformed.root, CONFIG, malformed.base);
|
|
445
|
+
assert.ok(malformedResult.diagnostics.some(item => item.code === 'legacy-frozen-modified' && item.severity === 'error'), JSON.stringify(malformedResult.diagnostics));
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
checkCase('2ax. legacy classification from any trusted config source is retained and current reclassification errors', () => {
|
|
449
|
+
const fixture = legacyHistory('legacy-frozen-reclassified');
|
|
450
|
+
const base = legacyModePair(fixture.root, CONFIG, fixture.base);
|
|
451
|
+
assert.ok(!base.diagnostics.some(item => item.code === 'legacy-frozen-modified'), JSON.stringify(base.diagnostics));
|
|
452
|
+
const reclassified = legacyModePair(fixture.root, { ...CONFIG, legacy: [] }, fixture.base);
|
|
453
|
+
const finding = reclassified.diagnostics.find(item => item.code === 'legacy-frozen-modified');
|
|
454
|
+
assert.ok(finding, JSON.stringify(reclassified.diagnostics));
|
|
455
|
+
assert.strictEqual(finding.severity, 'error');
|
|
456
|
+
assert.strictEqual(finding.path, fixture.legacyRelative);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
checkCase('2ax. legacy freeze preserves each source authority and offsets only its own governed overlap', () => {
|
|
460
|
+
const competing = legacyHistory('legacy-frozen-competing-source', {
|
|
461
|
+
baseProjectConfig: {
|
|
462
|
+
governed: [{ path: 'docs/adr/**', role: 'adr' }], domains: CONFIG.domains, legacy: []
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
writeDoc(competing.root, competing.legacyRelative,
|
|
466
|
+
{ id: 'GRAPH-999', status: 'accepted', aliases: [], supersedes: [] },
|
|
467
|
+
'# Archived decision\n\nChanged metadata under independent governance.\n');
|
|
468
|
+
const competingResult = legacyModePair(competing.root, CONFIG, competing.base);
|
|
469
|
+
assert.ok(!competingResult.diagnostics.some(item => item.code === 'legacy-frozen-modified'), JSON.stringify(competingResult.diagnostics));
|
|
470
|
+
|
|
471
|
+
const ownOverlap = legacyHistory('legacy-frozen-own-source-overlap', {
|
|
472
|
+
baseProjectConfig: {
|
|
473
|
+
governed: [{ path: 'docs/adr/**', role: 'adr' }], domains: CONFIG.domains,
|
|
474
|
+
legacy: ['docs/adr/legacy/**']
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
writeDoc(ownOverlap.root, ownOverlap.legacyRelative,
|
|
478
|
+
{ id: 'GRAPH-999', status: 'accepted', aliases: [], supersedes: [] },
|
|
479
|
+
'# Archived decision\n\nChanged metadata where the same source marks legacy.\n');
|
|
480
|
+
const ownOverlapResult = legacyModePair(ownOverlap.root, CONFIG, ownOverlap.base);
|
|
481
|
+
assert.ok(ownOverlapResult.diagnostics.some(item => item.code === 'legacy-frozen-modified' && item.severity === 'error'), JSON.stringify(ownOverlapResult.diagnostics));
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
checkCase('2ax. candidate legacy reclassification uses the same per-source authority rule', () => {
|
|
485
|
+
const fixture = legacyHistory('legacy-frozen-candidate-competing-source');
|
|
486
|
+
const candidateSources = [
|
|
487
|
+
{ governed: [{ path: 'docs/adr/**', role: 'adr' }], legacy: [] },
|
|
488
|
+
{ governed: [], legacy: ['docs/adr/legacy/**'] }
|
|
489
|
+
];
|
|
490
|
+
const result = legacyAnalysisModePair(fixture.root, CONFIG, fixture.base, candidateSources);
|
|
491
|
+
const finding = result.diagnostics.find(item => item.code === 'legacy-frozen-modified');
|
|
492
|
+
assert.ok(finding, JSON.stringify(result.diagnostics));
|
|
493
|
+
assert.match(finding.message, /reclassified outside legacy scope/);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
checkCase('2ax. offline legacy flags and base config sources match Git legacy-freeze diagnostics', () => {
|
|
497
|
+
const fixture = legacyHistory('legacy-frozen-offline-equivalence', {
|
|
498
|
+
frontmatter: 'id: GRAPH-900\nstatus: accepted\naliases: []\nsupersedes: []\ncustom:\n owner: alice\n enabled: true'
|
|
499
|
+
});
|
|
500
|
+
const trustedBytes = gitBytesAt(fixture.root, fixture.base, fixture.legacyRelative);
|
|
501
|
+
writeDoc(fixture.root, fixture.legacyRelative,
|
|
502
|
+
'id: GRAPH-900\nstatus: accepted\naliases: []\nsupersedes: []\ncustom:\n owner: bob\n enabled: true',
|
|
503
|
+
'# Archived decision\n\nOriginal editorial text.\n');
|
|
504
|
+
const docs = collect(fixture.root, CONFIG);
|
|
505
|
+
const hostSource = { governed: [], domains: CONFIG.domains, legacy: ['docs/adr/legacy/**'] };
|
|
506
|
+
const candidateSources = [CONFIG, hostSource];
|
|
507
|
+
const fromGit = lifecycle.analyzeDocs(docs, CONFIG, {
|
|
508
|
+
workspace: fixture.root, base: fixture.base, configPath: fixture.configPath,
|
|
509
|
+
configSources: candidateSources, all: true
|
|
510
|
+
});
|
|
511
|
+
const fromInventoryFlag = lifecycle.analyzeDocs(docs, CONFIG, {
|
|
512
|
+
workspace: fixture.root,
|
|
513
|
+
baseInventory: [{ path: fixture.legacyRelative, bytes: trustedBytes, id: 'GRAPH-900', status: 'accepted', legacy: true }],
|
|
514
|
+
configSources: candidateSources,
|
|
515
|
+
all: true
|
|
516
|
+
});
|
|
517
|
+
assert.deepStrictEqual(fromInventoryFlag.diagnostics, fromGit.diagnostics, 'preclassified offline legacy inventory matches the Git-base diagnostics');
|
|
518
|
+
assert.deepStrictEqual(fromInventoryFlag.evidence, fromGit.evidence, 'preclassified offline legacy evidence matches Git');
|
|
519
|
+
|
|
520
|
+
const fromInventorySources = lifecycle.analyzeDocs(docs, CONFIG, {
|
|
521
|
+
workspace: fixture.root,
|
|
522
|
+
baseInventory: [{ path: fixture.legacyRelative, bytes: trustedBytes, id: 'GRAPH-900', status: 'accepted' }],
|
|
523
|
+
baseConfigSources: [
|
|
524
|
+
{ governed: [], domains: CONFIG.domains, legacy: [] },
|
|
525
|
+
hostSource
|
|
526
|
+
],
|
|
527
|
+
configSources: candidateSources,
|
|
528
|
+
all: true
|
|
529
|
+
});
|
|
530
|
+
assert.deepStrictEqual(fromInventorySources.diagnostics, fromGit.diagnostics, 'per-source offline base config matches Git-base diagnostics');
|
|
531
|
+
assert.deepStrictEqual(fromInventorySources.evidence, fromGit.evidence, 'per-source offline base evidence matches Git');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
checkCase('2ax. offline inventory without legacy classification reports legacy immutability not observed', () => {
|
|
535
|
+
const fixture = legacyHistory('legacy-frozen-offline-unclassified', {
|
|
536
|
+
frontmatter: 'id: GRAPH-900\nstatus: accepted\naliases: []\nsupersedes: []\ncustom:\n owner: alice\n enabled: true'
|
|
537
|
+
});
|
|
538
|
+
const trustedBytes = gitBytesAt(fixture.root, fixture.base, fixture.legacyRelative);
|
|
539
|
+
writeDoc(fixture.root, fixture.legacyRelative,
|
|
540
|
+
'id: GRAPH-900\nstatus: accepted\naliases: []\nsupersedes: []\ncustom:\n owner: bob\n enabled: true',
|
|
541
|
+
'# Archived decision\n\nOriginal editorial text.\n');
|
|
542
|
+
const result = lifecycle.analyzeDocs(collect(fixture.root, CONFIG), CONFIG, {
|
|
543
|
+
workspace: fixture.root,
|
|
544
|
+
baseInventory: [{ path: fixture.legacyRelative, bytes: trustedBytes, id: 'GRAPH-900', status: 'accepted' }],
|
|
545
|
+
configSources: [CONFIG],
|
|
546
|
+
all: true
|
|
547
|
+
});
|
|
548
|
+
assert.strictEqual(result.evidence.immutability, 'observed', 'stored-byte revision immutability remains observed');
|
|
549
|
+
assert.strictEqual(result.evidence.legacyImmutability, 'not-observed', 'missing offline base legacy authority is visible');
|
|
550
|
+
assert.ok(!result.diagnostics.some(item => item.code === 'legacy-frozen-modified'), JSON.stringify(result.diagnostics));
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
checkCase('2ax. deleted legacy files rely on explicit path refs and are quiet when unreferenced', () => {
|
|
554
|
+
const referenced = legacyHistory('legacy-frozen-deleted-referenced', { reference: true });
|
|
555
|
+
fs.rmSync(referenced.legacyPath);
|
|
556
|
+
const result = legacyModePair(referenced.root, CONFIG, referenced.base, referenced.legacyRelative);
|
|
557
|
+
const refs = result.diagnostics.filter(item => item.code === 'unresolved-ref');
|
|
558
|
+
assert.ok(refs.some(item => item.severity === 'error' && item.path === 'docs/adr/current.md'), JSON.stringify(result.diagnostics));
|
|
559
|
+
assert.ok(!result.diagnostics.some(item => item.code === 'legacy-frozen-modified'), JSON.stringify(result.diagnostics));
|
|
560
|
+
const repeated = legacyModePair(referenced.root, CONFIG, referenced.base, referenced.legacyRelative);
|
|
561
|
+
assert.deepStrictEqual(repeated.diagnostics, result.diagnostics, 'deleted archive escalation is deterministic');
|
|
562
|
+
|
|
563
|
+
const unreferenced = legacyHistory('legacy-frozen-deleted-unreferenced');
|
|
564
|
+
fs.rmSync(unreferenced.legacyPath);
|
|
565
|
+
const quiet = legacyModePair(unreferenced.root, CONFIG, unreferenced.base, unreferenced.legacyRelative);
|
|
566
|
+
assert.strictEqual(quiet.evidence.immutability, 'observed');
|
|
567
|
+
assert.ok(!quiet.diagnostics.some(item => item.code === 'legacy-frozen-modified' || item.severity === 'error'), JSON.stringify(quiet.diagnostics));
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
checkCase('2ax. no trusted base reports not-observed without claiming legacy immutability', () => {
|
|
571
|
+
const root = workspace('legacy-frozen-no-base');
|
|
572
|
+
writeDoc(root, 'docs/adr/legacy/GRAPH-900.md', { id: 'GRAPH-999', status: 'rejected', aliases: [], supersedes: [] }, '# Edited archive\n');
|
|
573
|
+
const result = legacyModePair(root, CONFIG, null);
|
|
574
|
+
assert.strictEqual(result.evidence.immutability, 'not-observed');
|
|
575
|
+
assert.ok(!result.diagnostics.some(item => item.code === 'legacy-frozen-modified'), JSON.stringify(result.diagnostics));
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
checkCase('2ax. plain prose mention of a legacy-superseded ID still produces the legacy hint', () => {
|
|
579
|
+
const root = workspace('plain-ref-suppression');
|
|
580
|
+
writeDoc(root, 'docs/adr/new.md', { id: 'GRAPH-211', status: 'accepted', supersedes: ['legacy:GRAPH-210'] }, 'See GRAPH-210.');
|
|
581
|
+
writeDoc(root, 'docs/adr/legacy/GRAPH-210.md', { id: 'GRAPH-210', status: 'accepted' });
|
|
582
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
583
|
+
assert.ok(diagnostics.some(item => item.code === 'unresolved-ref' && /use legacy:GRAPH-210/.test(item.message)),
|
|
584
|
+
`plain ref was suppressed by the legacy edge: ${JSON.stringify(diagnostics)}`);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
checkCase('2ax. plain resolution uses active canonical IDs and aliases, never legacy-only IDs', () => {
|
|
588
|
+
const root = workspace('active-resolution');
|
|
589
|
+
writeDoc(root, 'docs/adr/active.md', { id: 'GRAPH-021', status: 'accepted', aliases: ['GRPH-021'] }, 'See GRAPH-022.');
|
|
590
|
+
writeDoc(root, 'docs/adr/legacy/GRAPH-022.md', { id: 'GRAPH-022', status: 'accepted' });
|
|
591
|
+
const docs = collect(root);
|
|
592
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'GRAPH-021').doc.path, 'docs/adr/active.md');
|
|
593
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'GRPH-021').doc.path, 'docs/adr/active.md');
|
|
594
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'GRAPH-022').error, 'unresolved-ref');
|
|
595
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
596
|
+
assert.ok(diagnostics.some(item => item.code === 'unresolved-ref' && /legacy:/i.test(item.message)));
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
checkCase('2ax. active alias may equal legacy ID while explicit legacy selector remains separate', () => {
|
|
600
|
+
const root = workspace('alias-equals-legacy');
|
|
601
|
+
writeDoc(root, 'docs/adr/active.md', { id: 'GRAPH-023', status: 'accepted', aliases: ['GRPH-023'] });
|
|
602
|
+
writeDoc(root, 'docs/adr/legacy/old.md', { id: 'GRPH-023', status: 'accepted' });
|
|
603
|
+
const docs = collect(root);
|
|
604
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'GRPH-023').doc.path, 'docs/adr/active.md');
|
|
605
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'legacy:GRPH-023').doc.path, 'docs/adr/legacy/old.md');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
checkCase('2ax. duplicate legacy IDs are ambiguous, path selectors are exact, and malformed selectors fail', () => {
|
|
609
|
+
const root = workspace('legacy-selectors');
|
|
610
|
+
writeDoc(root, 'docs/adr/legacy/v1/old.md', { id: 'GRAPH-024', status: 'accepted' });
|
|
611
|
+
writeDoc(root, 'docs/adr/legacy/v2/old.md', { id: 'GRAPH-024', status: 'accepted' });
|
|
612
|
+
const docs = collect(root);
|
|
613
|
+
const ambiguous = lifecycle.resolveRef(docs, 'legacy:GRAPH-024');
|
|
614
|
+
assert.strictEqual(ambiguous.error, 'ambiguous-legacy-ref');
|
|
615
|
+
assert.deepStrictEqual(ambiguous.candidates.map(item => item.path), ['docs/adr/legacy/v1/old.md', 'docs/adr/legacy/v2/old.md']);
|
|
616
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'legacy:GRAPH-024@docs/adr/legacy/v2/old.md').doc.path, 'docs/adr/legacy/v2/old.md');
|
|
617
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'legacy:GRAPH-024@docs/adr/legacy/old.md').error, 'unresolved-ref');
|
|
618
|
+
for (const ref of ['legacy:GRAPH-024@/docs/adr/legacy/v1/old.md', 'legacy:GRAPH-024@../outside.md', 'legacy:GRAPH-024@docs\\adr\\legacy\\v1\\old.md', 'legacy:GRAPH-024@C:/archive.md', 'legacy:GRAPH-024@docs/./adr/old.md']) {
|
|
619
|
+
assert.strictEqual(lifecycle.resolveRef(docs, ref).error, 'invalid-ref', ref);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
checkCase('2ax. legacy-only plain refs and wrong archive discriminators produce unresolved diagnostics', () => {
|
|
624
|
+
const root = workspace('legacy-unresolved');
|
|
625
|
+
writeDoc(root, 'docs/adr/new.md', { id: 'GRAPH-025', status: 'accepted', supersedes: ['GRAPH-026', 'legacy:GRAPH-026@wrong/path.md'] }, 'See GRAPH-026.');
|
|
626
|
+
writeDoc(root, 'docs/adr/legacy/v1/GRAPH-026.md', { status: 'accepted' });
|
|
627
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
628
|
+
assert.ok(diagnostics.some(item => item.code === 'unresolved-ref' && /legacy:/i.test(item.message)));
|
|
629
|
+
assert.ok(diagnostics.some(item => item.code === 'unresolved-ref' && /wrong\/path\.md/.test(item.message)));
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
checkCase('2ax. changed archive selector paths escalate declaring-doc unresolved refs to error', () => {
|
|
633
|
+
const root = workspace('selector-ratchet');
|
|
634
|
+
writeDoc(root, 'docs/adr/new.md', { id: 'GRAPH-201', status: 'accepted', supersedes: ['legacy:GRPH-200@docs/adr/legacy/old.md'] });
|
|
635
|
+
const docs = collect(root, CONFIG);
|
|
636
|
+
const unchanged = lifecycle.lintDocs(docs, CONFIG, { changedPaths: [] });
|
|
637
|
+
const plainRef = byCode(unchanged, 'unresolved-ref');
|
|
638
|
+
assert.ok(plainRef.length >= 1, JSON.stringify(unchanged));
|
|
639
|
+
assert.ok(plainRef.every(item => item.severity === 'warning'), 'no-baseline ID refs stay declaring-doc scoped');
|
|
640
|
+
const escalated = lifecycle.lintDocs(docs, CONFIG, { changedPaths: ['docs/adr/legacy/old.md'] });
|
|
641
|
+
assert.ok(byCode(escalated, 'unresolved-ref').some(item => item.severity === 'error'), JSON.stringify(escalated));
|
|
642
|
+
const configFile = path.join(root, 'config.json');
|
|
643
|
+
fs.writeFileSync(configFile, JSON.stringify(CONFIG), 'utf8');
|
|
644
|
+
const cli = spawnSync(process.execPath, [modulePath, 'lint', '--workspace', root, '--config', configFile, '--changed', 'docs/adr/legacy/old.md'], { encoding: 'utf8' });
|
|
645
|
+
assert.strictEqual(cli.status, 1, `CLI should exit 1: ${cli.stderr}`);
|
|
646
|
+
const output = JSON.parse(cli.stdout);
|
|
647
|
+
assert.ok(output.diagnostics.some(item => item.code === 'unresolved-ref' && item.severity === 'error'));
|
|
648
|
+
assert.strictEqual(output.evidence.immutability, 'not-observed');
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
checkCase('2ax. active-to-active supersession requires the target to be superseded', () => {
|
|
652
|
+
const root = workspace('supersede-status');
|
|
653
|
+
writeDoc(root, 'docs/adr/new.md', { id: 'GRAPH-031', status: 'accepted', supersedes: ['GRAPH-030'] });
|
|
654
|
+
writeDoc(root, 'docs/adr/old.md', { id: 'GRAPH-030', status: 'accepted' });
|
|
655
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
656
|
+
assert.ok(codes(diagnostics).includes('supersede-status-mismatch'));
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
checkCase('2ax. active-to-legacy supersession projects status without changing archive bytes', () => {
|
|
660
|
+
const root = workspace('legacy-projection');
|
|
661
|
+
writeDoc(root, 'docs/adr/new.md', { id: 'GRAPH-041', status: 'accepted', supersedes: ['legacy:GRAPH-040'] });
|
|
662
|
+
const legacyPath = path.join(root, 'docs/adr/legacy/GRAPH-040.md');
|
|
663
|
+
const oldBytes = '---\nid: GRAPH-040\nstatus: accepted\n---\nFrozen legacy text.\n';
|
|
664
|
+
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
|
665
|
+
fs.writeFileSync(legacyPath, oldBytes);
|
|
666
|
+
const docs = collect(root);
|
|
667
|
+
assert.deepStrictEqual(lifecycle.lintDocs(docs, CONFIG, { all: true }), []);
|
|
668
|
+
assert.strictEqual(fs.readFileSync(legacyPath, 'utf8'), oldBytes);
|
|
669
|
+
const index = lifecycle.buildIndex(docs, CONFIG);
|
|
670
|
+
assert.match(index, /stored status: accepted/i);
|
|
671
|
+
assert.match(index, /effective status: superseded/i);
|
|
672
|
+
assert.match(index, /GRAPH-041/);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
checkCase('2ax. pending supersession edges do not require target status or project status', () => {
|
|
676
|
+
const root = workspace('pending-edges');
|
|
677
|
+
writeDoc(root, 'docs/adr/draft.md', { id: 'GRAPH-051', status: 'proposed', supersedes: ['GRAPH-050'] });
|
|
678
|
+
writeDoc(root, 'docs/adr/target.md', { id: 'GRAPH-050', status: 'accepted' });
|
|
679
|
+
const docs = collect(root);
|
|
680
|
+
const diagnostics = lifecycle.lintDocs(docs, CONFIG, { all: true });
|
|
681
|
+
assert.ok(!codes(diagnostics).includes('supersede-status-mismatch'));
|
|
682
|
+
assert.match(lifecycle.buildIndex(docs, CONFIG), /pending/i);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
checkCase('2ax. self-supersession and multi-document cycles are errors', () => {
|
|
686
|
+
const root = workspace('supersede-cycles');
|
|
687
|
+
writeDoc(root, 'docs/adr/self.md', { id: 'GRAPH-061', status: 'accepted', supersedes: ['GRAPH-061'] });
|
|
688
|
+
writeDoc(root, 'docs/adr/one.md', { id: 'GRAPH-062', status: 'accepted', supersedes: ['GRAPH-063'] });
|
|
689
|
+
writeDoc(root, 'docs/adr/two.md', { id: 'GRAPH-063', status: 'accepted', supersedes: ['GRAPH-062'] });
|
|
690
|
+
const diagnostics = lint(root, CONFIG, 'all');
|
|
691
|
+
assert.ok(byCode(diagnostics, 'supersede-cycle').length >= 2);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
checkCase('2ax. cycle severity and candidates are scoped to each strongly connected component', () => {
|
|
695
|
+
const root = workspace('cycle-components');
|
|
696
|
+
writeDoc(root, 'docs/adr/a.md', { id: 'GRAPH-301', status: 'accepted', supersedes: ['GRAPH-302'] });
|
|
697
|
+
writeDoc(root, 'docs/adr/b.md', { id: 'GRAPH-302', status: 'accepted', supersedes: ['GRAPH-301'] });
|
|
698
|
+
writeDoc(root, 'docs/adr/c.md', { id: 'GRAPH-303', status: 'accepted', supersedes: ['GRAPH-304'] });
|
|
699
|
+
writeDoc(root, 'docs/adr/d.md', { id: 'GRAPH-304', status: 'accepted', supersedes: ['GRAPH-303'] });
|
|
700
|
+
const docs = collect(root, CONFIG);
|
|
701
|
+
const diagnostics = lifecycle.lintDocs(docs, CONFIG, { changedPaths: ['docs/adr/a.md'] });
|
|
702
|
+
const cycles = byCode(diagnostics, 'supersede-cycle');
|
|
703
|
+
const severityByPath = Object.fromEntries(cycles.map(item => [item.path, item.severity]));
|
|
704
|
+
assert.strictEqual(severityByPath['docs/adr/a.md'], 'error', JSON.stringify(cycles));
|
|
705
|
+
assert.strictEqual(severityByPath['docs/adr/b.md'], 'error', JSON.stringify(cycles));
|
|
706
|
+
assert.strictEqual(severityByPath['docs/adr/c.md'], 'warning', `component C must keep warning severity: ${JSON.stringify(cycles)}`);
|
|
707
|
+
assert.strictEqual(severityByPath['docs/adr/d.md'], 'warning', `component D must keep warning severity: ${JSON.stringify(cycles)}`);
|
|
708
|
+
const messages = new Set(cycles.map(item => item.message));
|
|
709
|
+
assert.ok([...messages].every(message => !/GRAPH-301[\s\S]*GRAPH-303/.test(message)), `messages must not mix components: ${JSON.stringify([...messages])}`);
|
|
710
|
+
for (const item of cycles) {
|
|
711
|
+
const expected = { 'docs/adr/a.md': ['docs/adr/b.md'], 'docs/adr/b.md': ['docs/adr/a.md'], 'docs/adr/c.md': ['docs/adr/d.md'], 'docs/adr/d.md': ['docs/adr/c.md'] }[item.path];
|
|
712
|
+
assert.deepStrictEqual(item.candidates, expected, `candidates for ${item.path}: ${JSON.stringify(item)}`);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
checkCase('2ax. recorded conflicts remain unresolved until an effective supersession is recorded', () => {
|
|
717
|
+
const root = workspace('recorded-conflict');
|
|
718
|
+
writeRevisionPrd(root, 'docs/prd/a.md', 'PRD-GRPH-071', '', { conflicts_with: ['GRAPH-071'] });
|
|
719
|
+
writeDoc(root, 'docs/adr/b.md', { id: 'GRAPH-071', status: 'accepted' });
|
|
720
|
+
let docs = collect(root);
|
|
721
|
+
assert.ok(codes(lifecycle.lintDocs(docs, CONFIG, { all: true })).includes('recorded-conflict'));
|
|
722
|
+
writeDoc(root, 'docs/adr/b.md', { id: 'GRAPH-071', status: 'superseded' });
|
|
723
|
+
writeRevisionPrd(root, 'docs/prd/a.md', 'PRD-GRPH-071', '', { conflicts_with: ['GRAPH-071'], supersedes: ['GRAPH-071'] });
|
|
724
|
+
docs = collect(root);
|
|
725
|
+
assert.ok(!codes(lifecycle.lintDocs(docs, CONFIG, { all: true })).includes('recorded-conflict'));
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
checkCase('2ax. pre-migration active PRDs cannot resolve refs, declare REQs, or project conflict retirement', () => {
|
|
729
|
+
const root = workspace('active-prd-not-authoritative');
|
|
730
|
+
writeDoc(root, 'docs/prd/living.md', {
|
|
731
|
+
id: 'PRD-GRPH-071', status: 'active', conflicts_with: ['GRAPH-071'], supersedes: ['GRAPH-071']
|
|
732
|
+
}, '### REQ-GRAPH-071: Mutable requirement\n\nAcceptance: old living text.\n');
|
|
733
|
+
writeDoc(root, 'docs/adr/target.md', { id: 'GRAPH-071', status: 'accepted' });
|
|
734
|
+
writeDoc(root, 'docs/report/consumer.md', { id: 'REPORT-071', status: 'final' }, 'See PRD-GRPH-071 and REQ-GRAPH-071.\n');
|
|
735
|
+
const docs = collect(root, CONFIG);
|
|
736
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'PRD-GRPH-071').error, 'unresolved-ref');
|
|
737
|
+
assert.strictEqual(lifecycle.resolveRef(docs, 'REQ-GRAPH-071').error, 'unresolved-ref');
|
|
738
|
+
const diagnostics = lifecycle.lintDocs(docs, CONFIG, { all: true });
|
|
739
|
+
assert.ok(codes(diagnostics).includes('recorded-conflict'), JSON.stringify(diagnostics));
|
|
740
|
+
assert.ok(byCode(diagnostics, 'missing-req').some(item => item.id === 'REQ-GRAPH-071'), JSON.stringify(diagnostics));
|
|
741
|
+
const index = lifecycle.buildIndex(docs, CONFIG, { all: true });
|
|
742
|
+
const targetRow = index.split('\n').find(line => line.includes('docs/adr/target.md')) || '';
|
|
743
|
+
assert.ok(!targetRow.includes('superseded by: `PRD-GRPH-071`'), `old living PRD cannot project retirement: ${targetRow}`);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
checkCase('2ax. ratchet suppresses unchanged schema errors and promotes them when changed', () => {
|
|
747
|
+
const root = workspace('ratchet');
|
|
748
|
+
writeDoc(root, 'docs/adr/old.md', { id: 'BAD', status: 'accepted' });
|
|
749
|
+
const unchanged = lifecycle.lintDocs(collect(root), CONFIG, { changedPaths: [] });
|
|
750
|
+
assert.ok(!unchanged.some(item => item.severity === 'error'));
|
|
751
|
+
const changed = lifecycle.lintDocs(collect(root), CONFIG, { changedPaths: ['docs/adr/old.md'] });
|
|
752
|
+
assert.ok(changed.some(item => item.code === 'invalid-id' && item.severity === 'error'));
|
|
753
|
+
assert.ok(!lifecycle.lintDocs(collect(root), CONFIG, {}).some(item => item.code === 'invalid-id'));
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
checkCase('2ax. cross-file integrity uses warnings until one involved path changes', () => {
|
|
757
|
+
const root = workspace('cross-file-ratchet');
|
|
758
|
+
writeDoc(root, 'docs/adr/one.md', { id: 'GRAPH-081', status: 'accepted' });
|
|
759
|
+
writeDoc(root, 'docs/adr/two.md', { id: 'GRAPH-081', status: 'accepted' });
|
|
760
|
+
const docs = collect(root);
|
|
761
|
+
const unchanged = lifecycle.lintDocs(docs, CONFIG, { changedPaths: [] });
|
|
762
|
+
assert.ok(byCode(unchanged, 'duplicate-id').every(item => item.severity === 'warning'));
|
|
763
|
+
const changed = lifecycle.lintDocs(docs, CONFIG, { changedPaths: ['docs/adr/two.md'] });
|
|
764
|
+
assert.ok(byCode(changed, 'duplicate-id').some(item => item.severity === 'error'));
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
checkCase('2ax. index is deterministic, sorted, and includes resolution-affecting warnings', () => {
|
|
768
|
+
const root = workspace('deterministic-index');
|
|
769
|
+
writeDoc(root, 'docs/adr/z.md', { id: 'IFCE-099', status: 'accepted', supersedes: ['missing:ID'] });
|
|
770
|
+
writeDoc(root, 'docs/adr/a.md', { id: 'GRAPH-099', status: 'accepted' });
|
|
771
|
+
const docs = collect(root);
|
|
772
|
+
const first = lifecycle.buildIndex(docs, CONFIG);
|
|
773
|
+
const second = lifecycle.buildIndex(docs, CONFIG);
|
|
774
|
+
assert.strictEqual(first, second);
|
|
775
|
+
assert.ok(first.indexOf('GRAPH-099') < first.indexOf('IFCE-099'));
|
|
776
|
+
assert.match(first, /unresolved-ref/);
|
|
777
|
+
const ordered = lifecycle.lintDocs(docs, CONFIG, { changedPaths: [] });
|
|
778
|
+
assert.deepStrictEqual(ordered, [...ordered].sort((a, b) =>
|
|
779
|
+
a.path.localeCompare(b.path) || a.code.localeCompare(b.code) || String(a.id || '').localeCompare(String(b.id || '')) || a.message.localeCompare(b.message)));
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
checkCase('2ax. index accepts ratchet modes or precomputed diagnostics and preserves warning labels', () => {
|
|
783
|
+
const root = workspace('index-mode');
|
|
784
|
+
writeDoc(root, 'docs/adr/one.md', { id: 'GRAPH-331', status: 'active' });
|
|
785
|
+
writeDoc(root, 'docs/adr/two.md', { id: 'GRAPH-332', status: 'accepted' });
|
|
786
|
+
writeDoc(root, 'docs/adr/three.md', { id: 'GRAPH-332', status: 'accepted' });
|
|
787
|
+
writeDoc(root, 'docs/prd/wrong-role.md', { id: 'PRD-GRPH-330', status: 'active' });
|
|
788
|
+
const docs = collect(root, CONFIG);
|
|
789
|
+
const indexRatchet = lifecycle.buildIndex(docs, CONFIG, { changedPaths: [] });
|
|
790
|
+
assert.ok(!/invalid-status/.test(indexRatchet), `unchanged schema error leaked into ratcheted index: ${indexRatchet}`);
|
|
791
|
+
assert.ok(!/\(error\)/.test(indexRatchet), `ratcheted index must not show errors: ${indexRatchet}`);
|
|
792
|
+
assert.match(indexRatchet, /`duplicate-id` \(warning\)/, `warnings must stay labelled warning: ${indexRatchet}`);
|
|
793
|
+
const ratchetDiags = lifecycle.lintDocs(docs, CONFIG, { changedPaths: [] });
|
|
794
|
+
const fromPrecomputed = lifecycle.buildIndex(docs, CONFIG, { diagnostics: ratchetDiags });
|
|
795
|
+
assert.strictEqual(fromPrecomputed, indexRatchet, 'precomputed diagnostics must reproduce the same index');
|
|
796
|
+
const changed = lifecycle.lintDocs(docs, CONFIG, { changedPaths: ['docs/adr/one.md'] });
|
|
797
|
+
assert.ok(byCode(changed, 'invalid-status').some(item => item.severity === 'error'), JSON.stringify(changed));
|
|
798
|
+
assert.match(lifecycle.buildIndex(docs, CONFIG, { all: true }), /\(error\)/);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
checkCase('2ax. CLI requires explicit ratchet mode and exits 0 clean / 1 for errors', () => {
|
|
802
|
+
const root = workspace('cli');
|
|
803
|
+
writeDoc(root, 'docs/adr/clean.md', { id: 'GRAPH-101', status: 'accepted' });
|
|
804
|
+
const configFile = path.join(root, 'config.json');
|
|
805
|
+
fs.writeFileSync(configFile, JSON.stringify(CONFIG), 'utf8');
|
|
806
|
+
const cli = modulePath;
|
|
807
|
+
const missingMode = spawnSync(process.execPath, [cli, 'lint', '--workspace', root, '--config', configFile], { encoding: 'utf8' });
|
|
808
|
+
assert.notStrictEqual(missingMode.status, 0);
|
|
809
|
+
const clean = spawnSync(process.execPath, [cli, 'lint', '--workspace', root, '--config', configFile, '--changed', 'docs/adr/clean.md'], { encoding: 'utf8' });
|
|
810
|
+
assert.strictEqual(clean.status, 0, clean.stderr);
|
|
811
|
+
assert.deepStrictEqual(JSON.parse(clean.stdout), { diagnostics: [], evidence: { immutability: 'not-observed' } });
|
|
812
|
+
writeDoc(root, 'docs/adr/bad.md', { id: 'GRAPH-2', status: 'accepted' });
|
|
813
|
+
const bad = spawnSync(process.execPath, [cli, 'lint', '--workspace', root, '--config', configFile, '--all'], { encoding: 'utf8' });
|
|
814
|
+
assert.strictEqual(bad.status, 1);
|
|
815
|
+
const output = JSON.parse(bad.stdout);
|
|
816
|
+
assert.ok(output.diagnostics.some(item => item.code === 'invalid-id'));
|
|
817
|
+
assert.strictEqual(output.evidence.immutability, 'not-observed');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
checkCase('2ax dogfood. prose resolver receives whole scoped PRD tokens through the CLI', () => {
|
|
821
|
+
const root = workspace('cli-scoped-prose-reference');
|
|
822
|
+
const firstBody = '### REQ-IFCE-001: First requirement\n\nAcceptance: retain the first requirement.\n';
|
|
823
|
+
const secondBody = '### REQ-IFCE-002: Second requirement\n\nAcceptance: retain the second requirement.\n';
|
|
824
|
+
writeRevisionPrd(root, 'docs/prd/interface/r001.md', 'PRD-IFCE-001', firstBody);
|
|
825
|
+
writeRevisionPrd(root, 'docs/prd/interface/r002.md', 'PRD-IFCE-002', secondBody);
|
|
826
|
+
const configFile = path.join(root, 'config.json');
|
|
827
|
+
fs.writeFileSync(configFile, JSON.stringify(CONFIG), 'utf8');
|
|
828
|
+
const reportPath = 'docs/report/interface.md';
|
|
829
|
+
const writeReport = body => writeDoc(root, reportPath, { id: 'GRPH-901', status: 'final' }, body);
|
|
830
|
+
const run = () => spawnSync(process.execPath, [modulePath, 'lint', '--workspace', root, '--config', configFile, '--all'], { cwd: root, encoding: 'utf8' });
|
|
831
|
+
|
|
832
|
+
writeReport('See PRD-IFCE-001#REQ-IFCE-002.\n');
|
|
833
|
+
const wrongUnversioned = run();
|
|
834
|
+
assert.strictEqual(wrongUnversioned.status, 1, `foreign unversioned scoped REQ must fail: ${wrongUnversioned.stdout} ${wrongUnversioned.stderr}`);
|
|
835
|
+
assert.ok(JSON.parse(wrongUnversioned.stdout).diagnostics.some(item => item.code === 'unresolved-ref' && item.id === 'PRD-IFCE-001#REQ-IFCE-002'));
|
|
836
|
+
|
|
837
|
+
writeReport('See PRD-IFCE-001#REQ-IFCE-001 and PRD-IFCE-001@r1#REQ-IFCE-001.\n');
|
|
838
|
+
const validOwnRequirements = run();
|
|
839
|
+
assert.strictEqual(validOwnRequirements.status, 0, `unversioned and @r1 same-PRD scopes remain valid: ${validOwnRequirements.stdout} ${validOwnRequirements.stderr}`);
|
|
840
|
+
assert.deepStrictEqual(JSON.parse(validOwnRequirements.stdout).diagnostics, []);
|
|
841
|
+
|
|
842
|
+
writeReport('See PRD-IFCE-001@r1#REQ-IFCE-002.\n');
|
|
843
|
+
const wrongVersioned = run();
|
|
844
|
+
assert.strictEqual(wrongVersioned.status, 1, `versioned foreign scope remains rejected: ${wrongVersioned.stdout} ${wrongVersioned.stderr}`);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
checkCase('2ax dogfood. lifecycle CLI validates explicit configs before collecting documents', () => {
|
|
848
|
+
const root = workspace('cli-config-validation');
|
|
849
|
+
writeDoc(root, 'docs/adr/decision.md', { status: 'accepted' }, 'A decision without an ID.\n');
|
|
850
|
+
const configFile = path.join(root, 'config.json');
|
|
851
|
+
const run = config => {
|
|
852
|
+
fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');
|
|
853
|
+
return spawnSync(process.execPath, [modulePath, 'lint', '--workspace', root, '--config', configFile, '--all'], { cwd: root, encoding: 'utf8' });
|
|
854
|
+
};
|
|
855
|
+
for (const [name, invalidConfig, code] of [
|
|
856
|
+
['malformed governed', { governed: 'docs/adr/**' }, 'invalid-governed'],
|
|
857
|
+
['unknown role', { governed: [{ path: 'docs/adr/**', role: 'adrr' }] }, 'invalid-governed-role']
|
|
858
|
+
]) {
|
|
859
|
+
const result = run(invalidConfig);
|
|
860
|
+
assert.notStrictEqual(result.status, 0, `${name} config must fail before collection: ${result.stdout} ${result.stderr}`);
|
|
861
|
+
assert.match(result.stderr, /invalid project-docs config/i);
|
|
862
|
+
assert.ok(result.stderr.includes(code), `${name} message identifies ${code}: ${result.stderr}`);
|
|
863
|
+
}
|
|
864
|
+
const valid = run({ governed: [{ path: 'docs/adr/**', role: 'adr' }] });
|
|
865
|
+
assert.strictEqual(valid.status, 1, `valid config reaches document schema checks: ${valid.stdout} ${valid.stderr}`);
|
|
866
|
+
assert.ok(JSON.parse(valid.stdout).diagnostics.some(item => item.code === 'missing-id'));
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
checkCase('2ax dogfood. CLI normalizes --changed spellings and rejects paths outside workspace', () => {
|
|
870
|
+
const root = workspace('cli-changed-path-normalization');
|
|
871
|
+
writeDoc(root, 'docs/adr/GRPH-001.md', { id: 'GRPH-001', status: 'accepted' });
|
|
872
|
+
writeDoc(root, 'docs/adr/GRPH-002.md', { id: 'GRPH-002', status: 'accepted', supersedes: ['GRPH-001'] });
|
|
873
|
+
const configFile = path.join(root, 'config.json');
|
|
874
|
+
fs.writeFileSync(configFile, JSON.stringify(CONFIG), 'utf8');
|
|
875
|
+
const run = changedPath => spawnSync(process.execPath, [modulePath, 'lint', '--workspace', root, '--config', configFile, '--changed', changedPath], { cwd: root, encoding: 'utf8' });
|
|
876
|
+
const expectedError = result => {
|
|
877
|
+
assert.strictEqual(result.status, 1, `changed invalid ADR must remain an error: ${result.stdout} ${result.stderr}`);
|
|
878
|
+
assert.ok(JSON.parse(result.stdout).diagnostics.some(item => item.code === 'supersede-status-mismatch' && item.severity === 'error'));
|
|
879
|
+
};
|
|
880
|
+
expectedError(run('docs/adr/GRPH-002.md'));
|
|
881
|
+
expectedError(run('./docs/adr/GRPH-002.md'));
|
|
882
|
+
expectedError(run(path.join(root, 'docs/adr/GRPH-002.md')));
|
|
883
|
+
expectedError(run('docs\\adr\\GRPH-002.md'));
|
|
884
|
+
const unrelated = run('docs/adr/unrelated.md');
|
|
885
|
+
assert.strictEqual(unrelated.status, 0, `unrelated changed path keeps cross-file warning severity: ${unrelated.stdout} ${unrelated.stderr}`);
|
|
886
|
+
assert.ok(JSON.parse(unrelated.stdout).diagnostics.some(item => item.code === 'supersede-status-mismatch' && item.severity === 'warning'));
|
|
887
|
+
const outside = run(path.resolve(root, '..', 'outside-workspace.md'));
|
|
888
|
+
assert.strictEqual(outside.status, 2, `outside path is a usage failure: ${outside.stdout} ${outside.stderr}`);
|
|
889
|
+
assert.match(outside.stderr, /outside .*workspace/i);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
checkCase('2ax dogfood. repeated --changed-path transports one lossless path per occurrence and keeps legacy --changed', () => {
|
|
893
|
+
const root = workspace('cli-changed-path-option');
|
|
894
|
+
writeDoc(root, 'docs/adr/GRPH-001,extra.md', { status: 'accepted' }, 'Comma filename with missing-id changed-only schema.\n');
|
|
895
|
+
writeDoc(root, 'docs/adr/GRPH-003,extra.md', { id: 'GRPH-003', status: 'accepted' });
|
|
896
|
+
writeDoc(root, 'docs/adr/GRPH-101.md', { id: 'GRPH-101', status: 'accepted' });
|
|
897
|
+
writeDoc(root, 'docs/adr/GRPH-102.md', { status: 'accepted' }, 'Legacy CSV control: invalid changed-only schema.\n');
|
|
898
|
+
const configFile = path.join(root, 'config.json');
|
|
899
|
+
fs.writeFileSync(configFile, JSON.stringify(CONFIG), 'utf8');
|
|
900
|
+
const cli = extraArgs => {
|
|
901
|
+
const result = spawnSync(process.execPath, [modulePath, 'lint', '--workspace', root, '--config', configFile, ...extraArgs], { cwd: root, encoding: 'utf8' });
|
|
902
|
+
assert.ifError(result.error);
|
|
903
|
+
return result;
|
|
904
|
+
};
|
|
905
|
+
const commaInvalid = cli(['--changed-path', 'docs/adr/GRPH-001,extra.md']);
|
|
906
|
+
assert.strictEqual(commaInvalid.status, 1, `--changed-path must run the changed-only schema check on the comma filename: ${commaInvalid.stdout} ${commaInvalid.stderr}`);
|
|
907
|
+
assert.ok(JSON.parse(commaInvalid.stdout).diagnostics.some(item => item.code === 'missing-id' && item.severity === 'error'), commaInvalid.stdout);
|
|
908
|
+
writeDoc(root, 'docs/adr/ GRPH-005.md', { status: 'accepted' }, 'Leading-space basename with missing-id changed-only schema.\n');
|
|
909
|
+
const spaceInvalid = cli(['--changed-path', 'docs/adr/ GRPH-005.md']);
|
|
910
|
+
assert.strictEqual(spaceInvalid.status, 1, `--changed-path must pass the leading-space basename through untrimmed: ${spaceInvalid.stdout} ${spaceInvalid.stderr}`);
|
|
911
|
+
assert.ok(JSON.parse(spaceInvalid.stdout).diagnostics.some(item => item.path === 'docs/adr/ GRPH-005.md' && item.code === 'missing-id' && item.severity === 'error'), spaceInvalid.stdout);
|
|
912
|
+
const commaValid = cli(['--changed-path', 'docs/adr/GRPH-003,extra.md']);
|
|
913
|
+
assert.strictEqual(commaValid.status, 0, `a valid comma-named ADR must pass via --changed-path: ${commaValid.stdout} ${commaValid.stderr}`);
|
|
914
|
+
const csvLegacy = cli(['--changed', 'docs/adr/GRPH-101.md,docs/adr/GRPH-102.md']);
|
|
915
|
+
assert.strictEqual(csvLegacy.status, 1, `legacy comma-separated --changed still checks every listed document: ${csvLegacy.stdout} ${csvLegacy.stderr}`);
|
|
916
|
+
assert.ok(JSON.parse(csvLegacy.stdout).diagnostics.some(item => item.path === 'docs/adr/GRPH-102.md' && item.code === 'missing-id'), 'legacy CSV audits both listed paths');
|
|
917
|
+
const combined = cli(['--changed', 'docs/adr/GRPH-101.md', '--changed-path', 'docs/adr/GRPH-001,extra.md']);
|
|
918
|
+
assert.strictEqual(combined.status, 1, `--changed and --changed-path may be combined: ${combined.stdout} ${combined.stderr}`);
|
|
919
|
+
assert.ok(JSON.parse(combined.stdout).diagnostics.some(item => item.path === 'docs/adr/GRPH-001,extra.md' && item.code === 'missing-id'), 'the combined run audits both the CSV entry and the --changed-path entry');
|
|
920
|
+
const repeated = cli(['--changed', 'docs/adr/GRPH-101.md', '--changed', 'docs/adr/GRPH-102.md']);
|
|
921
|
+
assert.strictEqual(repeated.status, 1, `repeated --changed occurrences accumulate instead of overwriting: ${repeated.stdout} ${repeated.stderr}`);
|
|
922
|
+
assert.ok(JSON.parse(repeated.stdout).diagnostics.some(item => item.path === 'docs/adr/GRPH-102.md' && item.code === 'missing-id'), 'both repeated CSV declarations are audited');
|
|
923
|
+
const outside = cli(['--changed-path', path.resolve(root, '..', 'outside-workspace.md')]);
|
|
924
|
+
assert.strictEqual(outside.status, 2, `--changed-path outside workspace is a usage failure: ${outside.stdout} ${outside.stderr}`);
|
|
925
|
+
assert.match(outside.stderr, /outside .*workspace/i);
|
|
926
|
+
const withAll = cli(['--all', '--changed-path', 'docs/adr/GRPH-003,extra.md']);
|
|
927
|
+
assert.strictEqual(withAll.status, 2, `--changed-path is mutually exclusive with --all: ${withAll.stdout} ${withAll.stderr}`);
|
|
928
|
+
assert.match(withAll.stderr, /choose either/);
|
|
929
|
+
const missingValue = cli(['--changed-path']);
|
|
930
|
+
assert.strictEqual(missingValue.status, 2, `--changed-path requires a value: ${missingValue.stdout} ${missingValue.stderr}`);
|
|
931
|
+
assert.match(missingValue.stderr, /--changed-path requires a value/);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
checkCase('2ax dogfood. --changed-path preserves outer filename whitespace end to end', () => {
|
|
935
|
+
const root = workspace('cli-changed-path-lossless');
|
|
936
|
+
writeDoc(root, ' GRPH-001,extra.md', { status: 'accepted' }, 'Leading-space root filename with missing-id changed-only schema.\n');
|
|
937
|
+
let trailingCreated = true;
|
|
938
|
+
try {
|
|
939
|
+
writeDoc(root, 'docs/adr/GRPH-002,extra.md ', { status: 'accepted' }, 'Trailing-space filename with missing-id changed-only schema.\n');
|
|
940
|
+
} catch (error) {
|
|
941
|
+
trailingCreated = false;
|
|
942
|
+
console.log(` SKIP trailing-space case: file creation with a trailing space failed on this host (${error.message.split('\n')[0]})`);
|
|
943
|
+
}
|
|
944
|
+
writeDoc(root, 'GRPH-004.md', { id: 'GRPH-004', status: 'accepted' });
|
|
945
|
+
const configFile = path.join(root, 'config.json');
|
|
946
|
+
fs.writeFileSync(configFile, JSON.stringify({ governed: [{ path: '*.md', role: 'adr' }, { path: 'docs/adr/**', role: 'adr' }], domains: ['GRPH'] }), 'utf8');
|
|
947
|
+
const cli = extraArgs => {
|
|
948
|
+
const result = spawnSync(process.execPath, [modulePath, 'lint', '--workspace', root, '--config', configFile, ...extraArgs], { cwd: root, encoding: 'utf8' });
|
|
949
|
+
assert.ifError(result.error);
|
|
950
|
+
return result;
|
|
951
|
+
};
|
|
952
|
+
const audit = (result, relativePath, label) => {
|
|
953
|
+
assert.strictEqual(result.status, 1, `${label} must audit the real changed document: ${result.stdout} ${result.stderr}`);
|
|
954
|
+
assert.ok(JSON.parse(result.stdout).diagnostics.some(item => item.path === relativePath && item.code === 'missing-id' && item.severity === 'error'), `${label} diagnostics: ${result.stdout}`);
|
|
955
|
+
};
|
|
956
|
+
audit(cli(['--changed-path', ' GRPH-001,extra.md']), ' GRPH-001,extra.md', 'the raw leading-space argument');
|
|
957
|
+
audit(cli(['--changed-path', './ GRPH-001,extra.md']), ' GRPH-001,extra.md', 'the dot-relative leading-space argument');
|
|
958
|
+
if (trailingCreated) audit(cli(['--changed-path', 'docs/adr/GRPH-002,extra.md ']), 'docs/adr/GRPH-002,extra.md ', 'the trailing-space argument');
|
|
959
|
+
const valid = cli(['--changed-path', 'GRPH-004.md']);
|
|
960
|
+
assert.strictEqual(valid.status, 0, `a valid governed document still passes via --changed-path: ${valid.stdout} ${valid.stderr}`);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
checkCase('2ax dogfood. relative --config resolves from --workspace even when CLI cwd differs', () => {
|
|
964
|
+
const root = workspace('cli-relative-config');
|
|
965
|
+
const configFile = path.join(root, '.agents/harness-everything/project-docs.json');
|
|
966
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true });
|
|
967
|
+
fs.writeFileSync(configFile, JSON.stringify(CONFIG), 'utf8');
|
|
968
|
+
writeDoc(root, 'docs/adr/clean.md', { id: 'GRPH-991', status: 'accepted' });
|
|
969
|
+
const result = spawnSync(process.execPath, [modulePath, 'lint', '--workspace', root, '--config', '.agents/harness-everything/project-docs.json', '--all'], { cwd: tempRoot, encoding: 'utf8' });
|
|
970
|
+
assert.strictEqual(result.status, 0, `workspace-relative config should load outside the workspace cwd: ${result.stdout} ${result.stderr}`);
|
|
971
|
+
assert.deepStrictEqual(JSON.parse(result.stdout).diagnostics, []);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
const EXPECTED_SCENARIOS = Object.freeze([
|
|
975
|
+
'active-to-active',
|
|
976
|
+
'active-to-active-mismatch',
|
|
977
|
+
'active-to-legacy',
|
|
978
|
+
'alias-rename',
|
|
979
|
+
'duplicate-legacy',
|
|
980
|
+
'invalid-refs',
|
|
981
|
+
'pending-edge',
|
|
982
|
+
'recorded-conflict'
|
|
983
|
+
]);
|
|
984
|
+
|
|
985
|
+
function runScenarioSet(scenariosRoot) {
|
|
986
|
+
const problems = [];
|
|
987
|
+
if (!fs.existsSync(scenariosRoot)) return [`scenario tree is missing: ${scenariosRoot}`];
|
|
988
|
+
const scenarios = fs.readdirSync(scenariosRoot, { withFileTypes: true })
|
|
989
|
+
.filter(entry => entry.isDirectory()).map(entry => entry.name).sort();
|
|
990
|
+
for (const expectedName of EXPECTED_SCENARIOS) {
|
|
991
|
+
if (!scenarios.includes(expectedName)) problems.push(`missing scenario: ${expectedName}`);
|
|
992
|
+
}
|
|
993
|
+
for (const name of scenarios) {
|
|
994
|
+
if (!EXPECTED_SCENARIOS.includes(name)) { problems.push(`unexpected scenario: ${name}`); continue; }
|
|
995
|
+
const dir = path.join(scenariosRoot, name);
|
|
996
|
+
if (!fs.existsSync(path.join(dir, 'config.json'))) { problems.push(`${name}: missing config.json`); continue; }
|
|
997
|
+
if (!fs.existsSync(path.join(dir, 'expected.json'))) { problems.push(`${name}: missing expected.json`); continue; }
|
|
998
|
+
try {
|
|
999
|
+
const configFile = path.join(dir, 'config.json');
|
|
1000
|
+
const expectedFile = path.join(dir, 'expected.json');
|
|
1001
|
+
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
1002
|
+
const expected = JSON.parse(fs.readFileSync(expectedFile, 'utf8'));
|
|
1003
|
+
const root = path.join(tempRoot, `scenario-${path.basename(scenariosRoot)}-${name}`);
|
|
1004
|
+
fs.mkdirSync(root, { recursive: true });
|
|
1005
|
+
const copyTree = (from, into) => {
|
|
1006
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
1007
|
+
if (entry.name === 'config.json' || entry.name === 'expected.json') continue;
|
|
1008
|
+
const source = path.join(from, entry.name);
|
|
1009
|
+
const target = path.join(into, entry.name);
|
|
1010
|
+
if (entry.isDirectory()) { fs.mkdirSync(target, { recursive: true }); copyTree(source, target); }
|
|
1011
|
+
else fs.copyFileSync(source, target);
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
copyTree(dir, root);
|
|
1015
|
+
const docs = collect(root, config);
|
|
1016
|
+
const options = expected.all ? { all: true } : { changedPaths: expected.changed || [] };
|
|
1017
|
+
const diagnostics = lifecycle.lintDocs(docs, config, options);
|
|
1018
|
+
const expectedDiagnostics = expected.diagnostics || [];
|
|
1019
|
+
const simplified = diagnostics.map((item, index) => Object.fromEntries(
|
|
1020
|
+
Object.keys(expectedDiagnostics[index] || {}).map(key => [key, key === 'path' ? item.path : item[key]])
|
|
1021
|
+
));
|
|
1022
|
+
assert.deepStrictEqual(simplified, expectedDiagnostics, `${name}: diagnostics`);
|
|
1023
|
+
for (const [ref, want] of Object.entries(expected.resolve || {})) {
|
|
1024
|
+
const result = lifecycle.resolveRef(docs, ref);
|
|
1025
|
+
assert.strictEqual(result.doc ? result.doc.path : result.error, want, `${name}: resolve ${ref}`);
|
|
1026
|
+
}
|
|
1027
|
+
const index = lifecycle.buildIndex(docs, config);
|
|
1028
|
+
for (const fragment of expected.indexContains || []) assert.ok(index.includes(fragment), `${name}: index should contain ${fragment}`);
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
problems.push(`${name}: ${error.message}`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return problems;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
checkCase('2ax. scenario fixtures enforce the full inventory and required files', () => {
|
|
1037
|
+
const realRoot = path.join(__dirname, 'fixtures', 'doc-lifecycle', 'scenarios');
|
|
1038
|
+
assert.deepStrictEqual(runScenarioSet(realRoot), [], `real scenario tree must run clean: ${JSON.stringify(runScenarioSet(realRoot))}`);
|
|
1039
|
+
const names = fs.readdirSync(realRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name).sort();
|
|
1040
|
+
assert.deepStrictEqual(names, [...EXPECTED_SCENARIOS], 'scenario inventory must match the expected set exactly');
|
|
1041
|
+
assert.ok(runScenarioSet(path.join(tempRoot, 'no-such-tree')).length >= 1, 'a missing scenario tree must be reported');
|
|
1042
|
+
const copied = path.join(tempRoot, 'copied-scenarios');
|
|
1043
|
+
fs.mkdirSync(copied, { recursive: true });
|
|
1044
|
+
const copyFullTree = (from, into) => {
|
|
1045
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
1046
|
+
const source = path.join(from, entry.name);
|
|
1047
|
+
const target = path.join(into, entry.name);
|
|
1048
|
+
if (entry.isDirectory()) { fs.mkdirSync(target, { recursive: true }); copyFullTree(source, target); }
|
|
1049
|
+
else fs.copyFileSync(source, target);
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
copyFullTree(realRoot, copied);
|
|
1053
|
+
fs.rmSync(path.join(copied, 'active-to-legacy', 'expected.json'));
|
|
1054
|
+
assert.ok(runScenarioSet(copied).includes('active-to-legacy: missing expected.json'), JSON.stringify(runScenarioSet(copied)));
|
|
1055
|
+
const unexpected = path.join(tempRoot, 'unexpected-scenarios');
|
|
1056
|
+
copyFullTree(realRoot, unexpected);
|
|
1057
|
+
fs.mkdirSync(path.join(unexpected, 'incomplete'), { recursive: true });
|
|
1058
|
+
assert.ok(runScenarioSet(unexpected).includes('unexpected scenario: incomplete'), JSON.stringify(runScenarioSet(unexpected)));
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
helper.finish();
|