diff --git a/.github/scripts/tests/verify-no-work-records.test.py b/.github/scripts/tests/verify-no-work-records.test.py index 4de823c..1e5e19d 100644 --- a/.github/scripts/tests/verify-no-work-records.test.py +++ b/.github/scripts/tests/verify-no-work-records.test.py @@ -102,6 +102,18 @@ def test_documentation_directories_exempt_shape_but_not_session_rule(self): self.assertEqual(self.run_script(self.fixture(CLEAN + ["docs/Plan-roadmap.md"])).returncode, 0) self.assertEqual(self.run_script(self.fixture(CLEAN + ["decisions/HANDOVER.md"])).returncode, 1) + def test_vendored_trees_skip_name_rules_but_not_runtime_rules(self): + vendored = ["vendor/claude-seo/extensions/banana/references/prompt-engineering.md", + "tools/vendor/lib/HANDOVER-notes.md", "vendor/PLAN-upstream.md"] + result = self.run_script(self.fixture(CLEAN + vendored)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for name in ["references/prompt-x.md", "Vendor/lib/prompt-x.md", "vendor-lib/prompt-x.md", ".ai-work/vendor/notes.md"]: + with self.subTest(path=name): + # Without the vendored files, so Vendor/ cannot fold into vendor/ on a case-insensitive disk. + result = self.run_script(self.fixture(CLEAN + [name])) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("violations: 1", result.stdout) + def test_extra_allowed_inputs(self): files = CLEAN + ["LIFECYCLE.md", "product-docs/plan-coverage-audit.md", "product-docs/Plan-coverage.md"] self.assertEqual(self.run_script(self.fixture(files)).returncode, 1) @@ -225,7 +237,7 @@ def test_self_test_passes_and_breaks_on_demand(self): with tempfile.TemporaryDirectory() as cwd: passing = self.run_script(cwd, "--self-test") self.assertEqual(passing.returncode, 0, passing.stdout + passing.stderr) - self.assertIn("self-test: 5 planted, 5 red, control 0, broken-listing 2", passing.stdout) + self.assertIn("self-test: 7 planted, 7 red, control 0, broken-listing 2", passing.stdout) broken = self.run_script(cwd, "--self-test", VERIFY_SELF_TEST_BREAK="1") self.assertNotEqual(broken.returncode, 0) diff --git a/.github/scripts/verify-no-work-records.mjs b/.github/scripts/verify-no-work-records.mjs index c06f7dc..7ed813f 100644 --- a/.github/scripts/verify-no-work-records.mjs +++ b/.github/scripts/verify-no-work-records.mjs @@ -15,6 +15,9 @@ const rootNames = 'README LICENSE AGENTS CLAUDE GEMINI CODE_OF_CONDUCT CONTRIBUT const legacySession = /(^|\/)[^/]*HANDOVER[^/]*\.md$|(^|\/)PROMPT-[^/]+\.md$|(^|\/)[^/]*NEXT-SESSION[^/]*\.md$|(^|\/)SESSION-PROMPT[^/]*\.md$|(^|\/)[^/]*SESSION[-_]HANDOVER[^/]*\.md$/; const session = /(^|\/)(HANDOVER|PROMPT|SESSION)[-_][^/]+\.md$|(^|\/)NEXT-SESSION([-_][^/]+)?\.md$/i; const shape = /(^|\/)(PLAN|DESIGN|REPORT|STATUS)([-_][^/]*)?\.md$/i; +// Third-party trees vendored under a directory named exactly `vendor` keep their +// upstream file names, so the name-based rules skip them. +const vendored = /(^|\/)vendor\//; const splitInput = value => (value ?? '').split(',').map(part => part.trim()).filter(Boolean); export function checkPaths(files, environment = process.env) { @@ -32,7 +35,7 @@ export function checkPaths(files, environment = process.env) { const documentation = /(^|\/)templates\//i.test(path) || docs.some(prefix => path.toLowerCase().startsWith(prefix)); if (!path.includes('/') && /^[A-Z][A-Z0-9_.-]*[A-Z0-9]\.md$/.test(path) && !allowedRoot) { rule = 'root-document'; - } else if (!/^\.(claude|github|pi|agents|cratis)\//.test(path)) { + } else if (!/^\.(claude|github|pi|agents|cratis)\//.test(path) && !vendored.test(path)) { if (legacySession.test(path) || session.test(path)) { rule = 'session'; } else if (shape.test(path) && !allowedRoot && !documentation) { @@ -98,7 +101,9 @@ function selfTest() { ['pi-runtime', '.pi/fusion/x/prompt.md'], ['root-document', 'IMPLEMENTATION_STATUS.md'], ['session', 'decisions/hAnDoVeR-notes.md'], - ['work-record-shape', 'Notes/pLaN-something.md'] + ['work-record-shape', 'Notes/pLaN-something.md'], + ['session', 'references/prompt-x.md'], + ['session', 'Vendor/lib/prompt-x.md'] ]; let red = 0; for (const [index, [rule, path]] of plants.entries()) { @@ -106,7 +111,7 @@ function selfTest() { if (result.status === 1 && result.stdout.includes(`[${rule}] ${JSON.stringify(path)}`) && result.stdout.includes('violations: 1')) red++; else console.error(`self-test: planted defect not caught: ${path}`); } - const clean = ['README.md', 'DECISIONS.md', 'decisions/D-0001-example.md', 'Documentation/decisions/0003-kernel-boundary.md', 'Source/Reporting.md', 'templates/build-kit/lib/prompt.md', 'templates/session.md', 'templates/handover.md', '.pi/settings.json']; + const clean = ['README.md', 'DECISIONS.md', 'decisions/D-0001-example.md', 'Documentation/decisions/0003-kernel-boundary.md', 'Source/Reporting.md', 'templates/build-kit/lib/prompt.md', 'templates/session.md', 'templates/handover.md', '.pi/settings.json', 'vendor/claude-seo/references/prompt-engineering.md', 'tools/vendor/lib/HANDOVER-notes.md', 'vendor/PLAN-upstream.md']; if (process.env.VERIFY_SELF_TEST_BREAK === '1') clean.push('PLAN-break.md'); const control = run('control', clean); const counts = control.stdout.includes(`scanned: ${clean.length} tracked files, rules: ${ruleCount}, violations: 0`); diff --git a/.github/workflows/verify-no-work-records.yml b/.github/workflows/verify-no-work-records.yml index 0e446d3..ef17174 100644 --- a/.github/workflows/verify-no-work-records.yml +++ b/.github/workflows/verify-no-work-records.yml @@ -57,12 +57,12 @@ jobs: fi script="$RUNNER_TEMP/verify-no-work-records.mjs" if ! curl --fail --silent --show-error --location --connect-timeout 10 --max-time 60 \ - 'https://raw.githubusercontent.com/Cratis/Workflows/366135082754c74199b284163fb00b8e596cc424/.github/scripts/verify-no-work-records.mjs' \ + 'https://raw.githubusercontent.com/Cratis/Workflows/c310348e882067468f8b7b517a57799cdf9a3be9/.github/scripts/verify-no-work-records.mjs' \ --output "$script"; then echo 'could not run: downloading the pinned work-record guard failed' >&2 exit 2 fi - script_sha256='4b0628fa06fb573f8b16f4398ded1c394f2b39294d6000bb1a65ce2711387c34' + script_sha256='7f9314da92eb4764e9baad91d302d25001588836408aacc4ae5275b42cc49b38' if ! node --input-type=module --eval ' import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; diff --git a/.github/workflows/verify-pull-request-policy.yml b/.github/workflows/verify-pull-request-policy.yml index 4e03f10..2d2cfaa 100644 --- a/.github/workflows/verify-pull-request-policy.yml +++ b/.github/workflows/verify-pull-request-policy.yml @@ -863,12 +863,12 @@ jobs: fi script="$RUNNER_TEMP/verify-no-work-records.mjs" if ! curl --fail --silent --show-error --location --connect-timeout 10 --max-time 60 \ - 'https://raw.githubusercontent.com/Cratis/Workflows/366135082754c74199b284163fb00b8e596cc424/.github/scripts/verify-no-work-records.mjs' \ + 'https://raw.githubusercontent.com/Cratis/Workflows/c310348e882067468f8b7b517a57799cdf9a3be9/.github/scripts/verify-no-work-records.mjs' \ --output "$script"; then echo 'could not run: downloading the pinned work-record guard failed' >&2 exit 2 fi - script_sha256='4b0628fa06fb573f8b16f4398ded1c394f2b39294d6000bb1a65ce2711387c34' + script_sha256='7f9314da92eb4764e9baad91d302d25001588836408aacc4ae5275b42cc49b38' if ! node --input-type=module --eval ' import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; diff --git a/README.md b/README.md index 4ed3537..4275985 100644 --- a/README.md +++ b/README.md @@ -1008,7 +1008,7 @@ A reusable guard that fails when AI session work records are tracked. It applies | `session` | explicit session prefixes at any depth, case-insensitively (`Handover-*`, `PROMPT-*`, `NEXT-SESSION`, `Session-*`, with `-` or `_` separators); legacy embedded all-caps names such as `HANDOVER.md` are also caught; never exempted by a documentation directory. Bare `prompt.md`, `session.md` and `handover.md` remain ordinary product files | | `work-record-shape` | a `PLAN`, `DESIGN`, `REPORT` or `STATUS` file at any depth (`PLAN-foo.md`, `Plan-foo.md`, `Status.md`), outside the documentation directories and product `templates/` directories | -Decision records are documentation. `decisions/`, `Documentation/`, `docs/`, `Knowledge/`, `evidence/` and `governance/` at the repository root exempt the `work-record-shape` rule, case-insensitively. Product `templates/` directories at any depth also exempt the shape rule (for example Direct's prompt sections), not the session/runtime rules. Files under `.claude/`, `.github/`, `.pi/`, `.agents/` and `.cratis/` are skipped by the name rules. Work-record prefixes match every letter case, including `pLaN-notes.md` and `design-notes.md`, outside the documentation directories. +Decision records are documentation. `decisions/`, `Documentation/`, `docs/`, `Knowledge/`, `evidence/` and `governance/` at the repository root exempt the `work-record-shape` rule, case-insensitively. Product `templates/` directories at any depth also exempt the shape rule (for example Direct's prompt sections), not the session/runtime rules. Files under `.claude/`, `.github/`, `.pi/`, `.agents/` and `.cratis/` are skipped by the name rules. Third-party trees under a directory named exactly `vendor/`, at any depth, are skipped by the name rules too, so vendored files keep their upstream names (for example `vendor/claude-seo/references/prompt-engineering.md`); the `.ai-work/` and `.pi/` runtime rules still apply. Work-record prefixes match every letter case, including `pLaN-notes.md` and `design-notes.md`, outside the documentation directories. Inputs: