diff --git a/.cursor/rules/skeleton.mdc b/.cursor/rules/skeleton.mdc index 7e68ef3..9ae02cc 100644 --- a/.cursor/rules/skeleton.mdc +++ b/.cursor/rules/skeleton.mdc @@ -7,6 +7,6 @@ alwaysApply: true Follow [AGENTS.md](AGENTS.md) for cold-start and validation lanes. - Docs / config (non-policy) → `bun run validate:changed -- ` or `bun run audit:self` -- Plugin-wired policy YAML → `bun run validate:changed -- ` (local → `audit docs` **and** `audit skills`; `audit self` alone is not enough) -- Skill body → `bun run audit:skills` (path-scoped validate exits non-zero) -- TypeScript → `bun test` + `bun run typecheck` + `bun run build` +- Plugin-wired policy YAML → `bun run validate:changed -- ` (runs full docs and owned-skill prose) +- Skill body → `bun run validate:changed -- ` (runs `audit skills`) or `bun run audit:skills` +- TypeScript → `bun test` + `bun run typecheck` + `bun run build`. `validate:changed` fails uncovered coverage-candidate paths diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 48deb71..5eb5182 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: hooks: - id: skeleton-validate-staged name: skeleton validate changed (staged) - entry: bun run validate:changed -- --staged + entry: bun src/cli.ts validate changed --staged language: system pass_filenames: false - id: skeleton-test diff --git a/.skeleton/customize/code-review.md b/.skeleton/customize/code-review.md index ee469ba..f7146ca 100644 --- a/.skeleton/customize/code-review.md +++ b/.skeleton/customize/code-review.md @@ -2,9 +2,9 @@ - + - + Injected on skill read. Prefer this overlay over portable thinned sections when both apply. Portable ledger / exit-gate rules still apply and must not be weakened. @@ -14,11 +14,11 @@ Match [AGENTS.md](../../AGENTS.md) validation split: | Change type | Run before claiming validate / merge-ready | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| TypeScript under `src/` | `bun test` (or scoped path) + `bun run typecheck` + `bun run build`; validate also audits documents whose `review-deps` match changed paths | -| Docs / config (non-policy) | `bun run validate:changed -- ` or `bun run audit:self` | -| Plugin-wired policy YAML under `.skeleton/` | `bun run validate:changed -- ` (local → `audit docs` **and** `audit skills`; `audit self` alone is not enough — excluded skill trees stay uncovered) | -| Owned skill body (`SKILL.md` trees) | `bun run audit:skills` — path-scoped validate exits non-zero and redirects here (`audit self` does not cover excluded skill trees) | -| Foreign / lockfile-synced skill body | skipped — lint in the owning skills/toolbox repo | +| TypeScript under `src/` | `bun test` + `typecheck` + `build`. `validate:changed` fails uncovered coverage-candidate paths and audits owning papers | +| Docs / config (non-policy) | `bun run validate:changed -- ` or `bun run audit:self` | +| Plugin-wired policy YAML under `.skeleton/` | `bun run validate:changed -- ` (runs full docs and owned-skill prose) | +| Owned skill body (`SKILL.md` trees) | `bun run validate:changed -- ` (runs `audit skills`) or `bun run audit:skills` | +| Foreign / lockfile-synced skill body | skipped — lint in the owning skills/toolbox repo | `validate:changed` classifies code separately and leaves its correctness to native gates. It also discovers documents whose `review-deps` path or glob matched a changed file. A hash review-proof failure blocks until the document is re-read and explicitly attested. Code-only green is never code coverage. @@ -43,7 +43,7 @@ Close themes only after variant coverage for applicable rows | ---------------- | ------------------------------------------------------------------------------------------- | | Input mixes | code with/without impacted docs, skill-only, policy-only, docs+policy, docs+skills, mixed inputs | | Modes | local / pre-commit (no `--base`) vs CI `--base` | -| Fail posture | fail-closed redirects, fail-open “green means coverage” lies, orphan `.skeleton` YAML | +| Fail posture | uncovered-changed-path, stage-required, orphan `.skeleton` YAML, fail-open coverage lies | | Policy | plugin-wired vs unwired YAML; `config.yaml` not treated as policy | | Equivalence tips | `audit docs` / `audit skills` / `audit self` only when they truly cover the same corpus | | Review mode | date compatibility vs hash proof; changed doc bytes vs changed target bytes | @@ -57,7 +57,7 @@ Hotspots: `src/validate/changed.ts`, `AGENTS.md`, `docs/developer/validation.md` | Trees | configured scan roots, `.agents`, `.claude`, other excluded skill dirs | | Suites | path-scoped audit, bare `audit skills`, `audit self`, skills+prose-policy | | Exclude behavior | `scan.exclude` vs deliberate include of excluded skill trees for skill-body prose | -| Policy prove | local redirect vs `--base` full docs + path-scoped skills prove | +| Policy prove | local and `--base` full docs + path-scoped skills prove | Hotspots: `src/audit/core/collect.ts`, `src/audit/core/context.ts`, `src/audit/core/skill-roots.ts`, `src/audit/run.ts`. diff --git a/.skeleton/review-lock.json b/.skeleton/review-lock.json index 8b0858a..f14e5fb 100644 --- a/.skeleton/review-lock.json +++ b/.skeleton/review-lock.json @@ -2,120 +2,182 @@ "version": 2, "documents": { ".skeleton/customize/code-review.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:f9df3e875c53499eda112a7c70d2fb76c0cbbb0dfec250dc55e6d1201b04c33f", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:61c3b86c898279893352dbdeafc6f9ac591730197d4614cb4d4cfc5b1ce0587b", "reviewDependencies": { - "AGENTS.md": "sha256:f3059ccf7ba993212a97be4e628e3e4c7688739036e7b0151b2005f8dcb4393b", - "docs/developer/validation.md": "sha256:6e843854039b7a62700ccccabea9284cc00f19ce70deb3115bf77f3acc42f94a", - "src/validate/changed.ts": "sha256:c2210201db3962ca99e603a5ece323d5ffbd8a8145ee577bfa191831f977be5a" + "AGENTS.md": "sha256:db9e4e6756b85cd5882afda5e51ee676586af1dc621d2d4f111f93bc5820c0ba", + "docs/developer/validation.md": "sha256:278fed2d2b57985acbf555a532f4616ceda4c85c5e3b4f38f0433788642c1006", + "src/validate/changed.ts": "sha256:231ac839e2411a1826598a470a29a69f14387af3c6e0c115b73224b98500dd9a", + "src/validate/git-diff.ts": "sha256:b2c18f555d54d1bda79f3f246561640bb8ee3f5098980bbd8e16dfec283c2df4", + "src/validate/staged.ts": "sha256:50e512692be282c68f9568195c733e69d3b7c29eb9f1bec5f4bc12445e20aeb8" } }, "AGENTS.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:f3059ccf7ba993212a97be4e628e3e4c7688739036e7b0151b2005f8dcb4393b", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:db9e4e6756b85cd5882afda5e51ee676586af1dc621d2d4f111f93bc5820c0ba", "reviewDependencies": { - "package.json": "sha256:33c803d5fc5280994f12cf4b3e69c7706378d111dbd3a026b092f742b6d99c04", + "package.json": "sha256:0e72b7a7756de63dc8fa2994246129ae116a80188d23283e1b860f0812d58d5f", "src/cli.ts": "sha256:1fc7a773f0295fd209a4d3baed536c24dc7bdf3d3c079118dad6f94a2de6026a" } }, "README.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:265b48daf1265ce64be229be53c5545c43dc882585cb2e96f6858fc0410d182d", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:0dad5ac464bf617c00127ecafa1dbda459561f512030ec46ef22b7a48c5cf5b9", "reviewDependencies": { - "package.json": "sha256:33c803d5fc5280994f12cf4b3e69c7706378d111dbd3a026b092f742b6d99c04", + "package.json": "sha256:0e72b7a7756de63dc8fa2994246129ae116a80188d23283e1b860f0812d58d5f", "src/cli.ts": "sha256:1fc7a773f0295fd209a4d3baed536c24dc7bdf3d3c079118dad6f94a2de6026a" } }, "docs/authoring.md": { - "reviewedAt": "2026-08-24", - "documentHash": "sha256:f15d6b8abf6075cc5456eeeb3762fc6fe166547afb81b9c875b7f1a45090a7d2", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:66128f982236c9cd5c93aebcbd2604f115c032c826fc0ee452808ac00f82fbdf", "reviewDependencies": { - "src/catalog.ts": "sha256:8606f54aff1a5f15c971da6eda44a665535d880cec6a9e97d21a365a8325c25d" + "src/catalog.ts": "sha256:ff6b1d4f1b56be7ac24e2276b57f70b39039cf7f2646f4406350161034a89b04" } }, "docs/developer/audit.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:8c6ad9594c25e5b7dd4aa9c57b53bf0cf154107d0b78d473973abd10396dfbd1", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:d4cc4c04d85e1edf70c91925e1e6581892a6ebd1bf8188a14d1d694c445d6829", "reviewDependencies": { - "src/audit/run.ts": "sha256:e024e3bf02d4017609ee6d46883ecaa362d0bd9201c20bceac3f7fed5ab00c7a", - "src/cli.ts": "sha256:1fc7a773f0295fd209a4d3baed536c24dc7bdf3d3c079118dad6f94a2de6026a" + "src/audit/config/load.ts": "sha256:38b0f063408beb411872195f3f1e6183795d40b97380f5f5dec9169660556b1f", + "src/audit/config/types.ts": "sha256:5c23c9da56d56b818238c5669de30a1ca5cd2d02d2acd6e196810f6ae0cab1b0", + "src/audit/core/collect.ts": "sha256:853e7d16e005498e4040be12ef7f541c6b08a72ed104194dc941b58b9abdc8bf", + "src/audit/core/context.ts": "sha256:c63e7a3eb7709a862e56c58044ed080446d19a1c2660a86bc1106e5723f90fc4", + "src/audit/core/draft.ts": "sha256:68d5d980d34a439100960ce3b199f9f28c585ebdac7aae57a5da1f3f23002abb", + "src/audit/core/fix.ts": "sha256:f5a2d134a70cfee721cb130bf38539000f66e053ab5a19967015718a22498287", + "src/audit/core/git-meta.ts": "sha256:dc4391f3b699c7cb031eba1e8430256ba8369b6523f97429d314c1792098797e", + "src/audit/core/markdown.ts": "sha256:fad91c2ed2fb18ef09faf1a2bad8b2e631dba30e1201bd57d9f8b508cf8183df", + "src/audit/core/repo-files.ts": "sha256:9a008fa832ba85f6a9075347706f0c12756e6b12124d9a4df23ff60055c3bfc8", + "src/audit/core/report.ts": "sha256:e6afa845a67ae3c48c3db3776c7ad599c55d665810f689fd6c021feaa4773ec3", + "src/audit/core/review-coverage.ts": "sha256:c0d1c619588b99aa17ef676e2e20d61fc5ef44d99b7f292d8c56b03880c4df26", + "src/audit/core/review-deps.ts": "sha256:ebf37de0fe8a88a6ea3e75dec94d5e975e3ed55be3c55fff1130a41b297655ed", + "src/audit/core/review-proof.ts": "sha256:cebc6edb3eb784335367df29125a9b5e6c2844836123655521a2b7afca47fe7b", + "src/audit/core/shared.ts": "sha256:5d8ec137c0b2eb1bbe6041b09dba6c23fd825f946f241fb4616b790765825b11", + "src/audit/core/skill-provenance.ts": "sha256:050bd06fc57c166bd699e1f19bfdf874bf03d74a671becc016cadd2a78054100", + "src/audit/core/skill-roots.ts": "sha256:83db181b071daf03749854c2976e5ce777120c799dd23151012181d8d799ebc0", + "src/audit/core/skills-lock.ts": "sha256:b4892c84af5f1399a989c298d5d02dd3b195f2c8bd01681bd28d1e0e0e4c9d87", + "src/audit/core/ssot-collect.ts": "sha256:b4be82f62a5c9f911b91d26719c0180b3aa11625fa1e670a67c205e4419488f4", + "src/audit/core/ssot-fit.ts": "sha256:9b78c8ea894e4156d1c52183de17e004e8ddb2664920a61148d5620937cb0c8f", + "src/audit/core/ssot.ts": "sha256:832489a87617e415e939305aba195907e709009b41b1efecb781c51cf16b75c9", + "src/audit/fix/anchors.ts": "sha256:d932ab57c24ae61eb8ffe0e4bd91b6e98ed0de8e930713963c407ce6cf30c20f", + "src/audit/fix/doc-meta.ts": "sha256:e5a9097605b5dd03b823fe3d16d3e7ddb178c95379cd05548c3a40f986de94ad", + "src/audit/fix/match-anchor.ts": "sha256:d81ac6ae776b02402bedadc199425cbb3c9c593a7cda4271503479a61b153f4c", + "src/audit/fix/ssot.ts": "sha256:69199c2e71eaabde0a0ce9d6d2209530f834e5323a6dbbd58edfe162f60fff6d", + "src/audit/policies/load.ts": "sha256:62a2cc8936bfce21045ad11e38b507314fe66702bb28d7edcfa93f70e7628f0e", + "src/audit/policies/types.ts": "sha256:c1ee2f2b4fb7e4245b6f47031882d3dd122f31f48b06a09ea76e5b16e387ee73", + "src/audit/rules/banned.ts": "sha256:0e201da5dc638fac994d47fd66223c93d6d839bdd4b4fc1a1478270b7cd8a71d", + "src/audit/rules/doc-meta.ts": "sha256:680e6fd05b98a795c298c03ce9023eb72a2fcbeefce02edb203b618e662c3b43", + "src/audit/rules/index.ts": "sha256:bae2d28301d2a623724ac8adb1f14a47609a51570d1cce9a59c54b923d41b20f", + "src/audit/rules/links.ts": "sha256:87ee59195c6c7132e4af3d25c8a1414f3fd8fc9ac4aec0464b9a483e3fb2d535", + "src/audit/rules/near-duplicate.ts": "sha256:6d8bb94ca1b2717dd25811fc32a46781313a540ff376346d243c1fecb698502c", + "src/audit/rules/prose-policy.ts": "sha256:8ba2479c01bc7aae5e486d1fd6fbbd32c0a175ccc700fb208d0a01c091d1b1f3", + "src/audit/rules/review-coverage.ts": "sha256:21269d2d93598a44eeaace881b4a76ea799dcfccd9978f74cfec7892463e1aaa", + "src/audit/rules/review-deps.ts": "sha256:3b1ca8fe121a76832566e48b2bb4ae185046916f3f81f7e2c9f23da762ca6d3f", + "src/audit/rules/review-proof.ts": "sha256:8e73eabc66c31865e278e23afa252921c843604c1f3ee19bbf631ea67b57fb31", + "src/audit/rules/scan-gaps.ts": "sha256:e71306e83e2a3ac69f33df0b538a8821fd82e8929089d689b5dcf9888cecb4aa", + "src/audit/rules/scan-roots.ts": "sha256:69d27d9f6d9230b04f4b9e43c09c4921a1319bd0564ce0923ec4e592af750725", + "src/audit/rules/skill-index.ts": "sha256:a0301d3d27734ae92b20029308f2ce3a04da533a8f9c9294d38a9a892887d1a9", + "src/audit/rules/ssot-summary.ts": "sha256:853abc308ba1fbc2003069c66c41f5dcef7fb94f833a0ec50e715bd46d9031e5", + "src/audit/rules/ssot.ts": "sha256:006ea9f05da7c3887fa7cddb82b54f931552689c78f4dd123614115984952007", + "src/audit/run.ts": "sha256:01ee82a04363f23ad4d22b44d8773e7637fc08aecdfc54eb16d3feb8b177ead7", + "src/cli.ts": "sha256:1fc7a773f0295fd209a4d3baed536c24dc7bdf3d3c079118dad6f94a2de6026a", + "src/result-types.ts": "sha256:cfebe0bb52d5143ebee0e587d89bb1aac6cf8f035c92e4b5c9312cab607ea0a5" } }, "docs/developer/config.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:0f00d810dbd8de7d9db64d8df0139865dfe6404d6b6c74d0019e53ff1b81562d", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:1e0797f91bb7a97a688f71016da93e9f5edfea1317299bde0157f22390b020ad", "reviewDependencies": { "src/audit/config/load.ts": "sha256:38b0f063408beb411872195f3f1e6183795d40b97380f5f5dec9169660556b1f", - "src/audit/config/types.ts": "sha256:2c76827153f7af8fb513e90b3439e947bc479ba9d0e26a038c1ff549ea7c0d1f" + "src/audit/config/types.ts": "sha256:5c23c9da56d56b818238c5669de30a1ca5cd2d02d2acd6e196810f6ae0cab1b0" } }, "docs/developer/customize.md": { - "reviewedAt": "2026-08-24", - "documentHash": "sha256:2ca93b842bc49feddaad115b145df9aabc5809ea72af75ef0af0f66b6c00ceb3", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:2691ca04c5f1b94516c42643fbe1b6b3c9c8cb6476d7dc9241aa02d656a0b5b4", "reviewDependencies": { "src/customize/resolve.ts": "sha256:0bf0940f2b719ba07d80b282d1c005e1b50e34d78b73682d030b6e1a1d624bc4", + "src/hooks/customize-on-skill-read.ts": "sha256:7d6917be04c866b3a36b51d891a5e904fb571afcdaaef516e545bc364dd613c0", "src/hooks/run.ts": "sha256:5bcd20491434abb6534c308bce0abc91cf9e6f5fb13640025ac9d868c038e1ef" } }, "docs/developer/doc-system.md": { - "reviewedAt": "2026-08-24", - "documentHash": "sha256:7bf994578b83d207071730fbe21821ae6c818ed0f4d32f6a8f5984928e723d07", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:764293bfaf6c080dc5ab87d17efef24734e33f74acbf341e09ecec26f752de7a", "reviewDependencies": { "src/audit/core/ssot-fit.ts": "sha256:9b78c8ea894e4156d1c52183de17e004e8ddb2664920a61148d5620937cb0c8f", "src/audit/rules/doc-meta.ts": "sha256:680e6fd05b98a795c298c03ce9023eb72a2fcbeefce02edb203b618e662c3b43", - "src/catalog.ts": "sha256:8606f54aff1a5f15c971da6eda44a665535d880cec6a9e97d21a365a8325c25d" + "src/catalog.ts": "sha256:ff6b1d4f1b56be7ac24e2276b57f70b39039cf7f2646f4406350161034a89b04" } }, "docs/developer/getting-started.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:dd7a267b460f2c8ac79e9cc986a25e64c941b8b4fb8f51d0d35668459aa64cf4", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:92d444d464a6e3ea850a75ac4286d7bac30a6a0d876e45f2998860841283d933", "reviewDependencies": { "src/cli.ts": "sha256:1fc7a773f0295fd209a4d3baed536c24dc7bdf3d3c079118dad6f94a2de6026a", - "src/init/init.ts": "sha256:80a2bb7d7b6d6e016456dcb3568d161426fd02ebae838200f8cbbcfd48a100b3" + "src/init/init.ts": "sha256:a70cdf8a1a96a8438634cc69ce994eb84de763d4eb2f3feef947a1821c586109", + "src/init/merge-hooks.ts": "sha256:069088c4355dd52b7f225b5c88b2ae246c34fc3f206c9028c9d85f20a62c0362", + "src/init/merge-precommit.ts": "sha256:2fe8f93481571e7f56c0d380601488b36f8c9d12b2821f8dc8c324785daec2bf", + "src/init/package-paths.ts": "sha256:0476e3c9067d9128da789d2c9741a00e91cb1c1e55cc014f9d38ecf6c7a527cf", + "src/init/parse-args.ts": "sha256:fa3fe7d67fdc04d95157af1cdc46d0ee9dda3f55a3d4c65a5ec95693117d1f2f", + "src/init/resolve-hook-command.ts": "sha256:dcad15b2281ad210d7d35230b8424af4b1fb7dbccee593e369d388c590ee71e1", + "src/init/skills-args.ts": "sha256:347a7e793d697a66adcdaf5f3183421191ee738949b5163284f44da7b30193ea" } }, "docs/developer/install.md": { - "reviewedAt": "2026-08-24", - "documentHash": "sha256:df0f3d50120594144727dd992bc26a2393282f84550f2dfde8f760521ec79095", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:bb97167a3b784a7bf3da708ea046e0e090082cdd6a37bd2b6a4f83efd96564a8", "reviewDependencies": { - "src/init/init.ts": "sha256:80a2bb7d7b6d6e016456dcb3568d161426fd02ebae838200f8cbbcfd48a100b3" + "src/init/init.ts": "sha256:a70cdf8a1a96a8438634cc69ce994eb84de763d4eb2f3feef947a1821c586109", + "src/init/merge-hooks.ts": "sha256:069088c4355dd52b7f225b5c88b2ae246c34fc3f206c9028c9d85f20a62c0362", + "src/init/merge-precommit.ts": "sha256:2fe8f93481571e7f56c0d380601488b36f8c9d12b2821f8dc8c324785daec2bf", + "src/init/package-paths.ts": "sha256:0476e3c9067d9128da789d2c9741a00e91cb1c1e55cc014f9d38ecf6c7a527cf", + "src/init/parse-args.ts": "sha256:fa3fe7d67fdc04d95157af1cdc46d0ee9dda3f55a3d4c65a5ec95693117d1f2f", + "src/init/resolve-hook-command.ts": "sha256:dcad15b2281ad210d7d35230b8424af4b1fb7dbccee593e369d388c590ee71e1", + "src/init/skills-args.ts": "sha256:347a7e793d697a66adcdaf5f3183421191ee738949b5163284f44da7b30193ea" } }, "docs/developer/plugins.md": { - "reviewedAt": "2026-08-24", - "documentHash": "sha256:10a45d19b6ff5deee331fcc9b6f8d6aa3644e514d732e97283ba0a7079f00173", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:50a36b24df04a90e556984b507780ac56dad5cf82e365d04696f570b8be9a47b", "reviewDependencies": { + "src/plugin-types.ts": "sha256:96333179f49ed002358897dfa0d9daf5680b664d7b3dce6e998a2cfae185a0dc", "src/plugins/build.ts": "sha256:31897250e86e9e0e99e3da7ede5f2adbf67143db40b1b697c9705398b80d151d", - "src/plugins/load.ts": "sha256:4d4af512ccbac69ec8835d562bc6d7ae36732327f763280d03257445e7ea4294" + "src/plugins/load.ts": "sha256:4d4af512ccbac69ec8835d562bc6d7ae36732327f763280d03257445e7ea4294", + "src/plugins/paths.ts": "sha256:8cb62c162d6b9278b1ced4b7f95b5e07af98fce9d9cee30d5cbd2627094a2371" } }, "docs/developer/troubleshooting.md": { - "reviewedAt": "2026-08-24", - "documentHash": "sha256:4b3fb14ec0c51eef30abda032e2e92a4a6909731254741b93bd21229973143dc", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:69281df6dde433453c509710d141a7b61250993939a72f7cfc4e18e26486c193", "reviewDependencies": { - "src/audit/run.ts": "sha256:e024e3bf02d4017609ee6d46883ecaa362d0bd9201c20bceac3f7fed5ab00c7a", - "src/validate/changed.ts": "sha256:c2210201db3962ca99e603a5ece323d5ffbd8a8145ee577bfa191831f977be5a" + "src/audit/run.ts": "sha256:01ee82a04363f23ad4d22b44d8773e7637fc08aecdfc54eb16d3feb8b177ead7", + "src/validate/changed.ts": "sha256:231ac839e2411a1826598a470a29a69f14387af3c6e0c115b73224b98500dd9a", + "src/validate/git-diff.ts": "sha256:b2c18f555d54d1bda79f3f246561640bb8ee3f5098980bbd8e16dfec283c2df4", + "src/validate/staged.ts": "sha256:50e512692be282c68f9568195c733e69d3b7c29eb9f1bec5f4bc12445e20aeb8" } }, "docs/developer/validation.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:6e843854039b7a62700ccccabea9284cc00f19ce70deb3115bf77f3acc42f94a", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:278fed2d2b57985acbf555a532f4616ceda4c85c5e3b4f38f0433788642c1006", "reviewDependencies": { - "src/validate/changed.ts": "sha256:c2210201db3962ca99e603a5ece323d5ffbd8a8145ee577bfa191831f977be5a" + "src/validate/changed.ts": "sha256:231ac839e2411a1826598a470a29a69f14387af3c6e0c115b73224b98500dd9a", + "src/validate/git-diff.ts": "sha256:b2c18f555d54d1bda79f3f246561640bb8ee3f5098980bbd8e16dfec283c2df4", + "src/validate/staged.ts": "sha256:50e512692be282c68f9568195c733e69d3b7c29eb9f1bec5f4bc12445e20aeb8" } }, "docs/tiers.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:9e05cfff722ed269ebbdae62a5c8a99e0ce3639a1356e4ebc62946c22956961f", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:9cc412efac094e0e4194749866025ef315b9f19684314092058fc8827608d683", "reviewDependencies": { - "docs/developer/getting-started.md": "sha256:dd7a267b460f2c8ac79e9cc986a25e64c941b8b4fb8f51d0d35668459aa64cf4", - "docs/developer/install.md": "sha256:df0f3d50120594144727dd992bc26a2393282f84550f2dfde8f760521ec79095", - "docs/developer/validation.md": "sha256:6e843854039b7a62700ccccabea9284cc00f19ce70deb3115bf77f3acc42f94a" + "docs/developer/getting-started.md": "sha256:92d444d464a6e3ea850a75ac4286d7bac30a6a0d876e45f2998860841283d933", + "docs/developer/install.md": "sha256:bb97167a3b784a7bf3da708ea046e0e090082cdd6a37bd2b6a4f83efd96564a8", + "docs/developer/validation.md": "sha256:278fed2d2b57985acbf555a532f4616ceda4c85c5e3b4f38f0433788642c1006" } }, "skeleton/SKILL.md": { - "reviewedAt": "2026-09-02", - "documentHash": "sha256:0b96a183fd2e9b2179ac5b145cded595de778735fd65d7306a746f3c5358c8d5", + "reviewedAt": "2026-09-13", + "documentHash": "sha256:016542ac69a5dc051c0e434a75f5accf842dbe87cbae93da227493fabb45fa3a", "reviewDependencies": { "src/cli.ts": "sha256:1fc7a773f0295fd209a4d3baed536c24dc7bdf3d3c079118dad6f94a2de6026a" } diff --git a/AGENTS.md b/AGENTS.md index 9f974da..88e7cb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ - + @@ -10,7 +10,7 @@ SSOT audit CLI (`@csark0812/skeleton`). Not an app — no long-lived server. Day ## Doc routing (before long reads) -1. If `.skeleton/catalog.md` is missing, run `bun src/cli.ts catalog` (or `skeleton catalog`). +1. Local `audit` / `validate` writes `.skeleton/catalog.md` (skipped when `CI=true`). 2. Skim the catalog summaries. 3. For a hit, read only the source-of-truth line / first ~20 lines of that file. 4. Open the full doc only if it is truly relevant. @@ -41,15 +41,15 @@ bun test ./tests/smoke.test.ts ## Validation split -| Change type | Run | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Docs / config (non-policy) | `bun run validate:changed -- ` or `bun run audit:self` | -| Plugin-wired policy YAML under `.skeleton/` | `bun run validate:changed -- ` (local → `audit docs` **and** `audit skills`; `audit self` alone is not enough — excluded skill trees stay uncovered) | -| Owned skill body (`SKILL.md` trees) | `bun run audit:skills` (path-scoped validate exits non-zero for owned skill paths — alone or mixed with docs — and redirects here; `audit self` does not cover excluded skill trees) | -| Foreign / lockfile-synced skill body | skipped — lint in the owning skills/toolbox repo (`skills-lock.json` / `skillOwnership`) | -| TypeScript under `src/` | `bun test` (or scoped path) + `bun run typecheck` + `bun run build`; `validate:changed` also discovers and audits docs that target the changed code | +| Change type | Run | +| ------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Docs / config (non-policy) | `bun run validate:changed -- ` or `bun run audit:self` | +| Plugin-wired policy YAML under `.skeleton/` | `bun run validate:changed -- ` (runs full docs and owned-skill prose) | +| Owned skill body (`SKILL.md` trees) | `bun run validate:changed -- ` (runs `audit skills`) or `bun run audit:skills` | +| Foreign / lockfile-synced skill body | skipped — lint in the owning skills/toolbox repo (`skills-lock.json` / `skillOwnership`) | +| TypeScript under `src/` | `bun test` + `bun run typecheck` + `bun run build`. `validate:changed` fails uncovered paths | -`validate:changed` classifies code paths but leaves their correctness to `bun test` + `typecheck` + `build`. It scans `review-deps` markers and adds every document linked to any changed dependency to the docs audit. With hash review proof, changed dependency bytes invalidate the recorded review. Without hash mode, the linked document must co-change with a current explicit review attestation. Code-only changes with no linked docs exit non-zero locally and print the native gates. Under CI `--base`, code-only changes still run global rules; keep the TS lane in CI separately. Owned skill paths (alone or mixed with docs) exit non-zero without `--base` and point at `audit skills`; foreign lockfile skills are skipped. Plugin-wired policy YAML (matched by a plugin `policies` glob) schema-checks; local fails closed to `audit docs` **and** `audit skills` (`audit self` covers docs + `.skeleton` but not excluded skill trees), while `--base` runs full docs prose plus path-scoped skills prove over **owned** skill-tree markdown. Other `.skeleton/**` YAML (not `config.yaml`) fails if not wired to a plugin. Missing explicit paths also exit non-zero. +`validate:changed` classifies code paths and leaves correctness to `bun test` + `typecheck` + `build`. It scans `review-deps` and audits every linked document. A coverage-candidate path with no owning paper fails with `uncovered-changed-path` on local and `--base` runs. Mixed commits do not hide that. Hash review proof invalidates a paper when dependency bytes change. Date mode requires the paper in the change set with today's review date. `--staged` reads index bytes and fails `stage-required` when an impacted paper or the hash lockfile differs from HEAD and is not staged. Owned skill paths run the skills suite. Wired policy YAML runs full docs plus owned-skill prose. Foreign lockfile skills are skipped. Other `.skeleton/**` YAML (not `config.yaml`) fails if not wired to a plugin. Missing explicit paths also exit non-zero. Never bump `last-reviewed` as a mechanical cleanup. After a complete re-read, attest only explicit paths: @@ -57,7 +57,7 @@ Never bump `last-reviewed` as a mechanical cleanup. After a complete re-read, at bun src/cli.ts audit docs --paths=docs/a.md --fix=doc-meta --confirm-reviewed ``` -Optional local hooks: install [pre-commit](https://pre-commit.com/) (`brew install pre-commit` or `pipx install pre-commit`), then `pre-commit install`. Customize IDE hooks from `skeleton init` are optional — not required for audit. +Pre-commit: `.pre-commit-config.yaml` runs `bun src/cli.ts validate changed --staged`. Install [pre-commit](https://pre-commit.com/) once per machine, then `pre-commit install`. Customize IDE hooks from `skeleton init` are optional. Behavioral A/B dogfood (live Cursor, not part of `bun run check`): [agent-suites/README.md](agent-suites/README.md) · [refs/llm-harness.md](refs/llm-harness.md). diff --git a/README.md b/README.md index e3cc9be..d332090 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ - + @@ -157,15 +157,15 @@ skeleton customize resolve | Path | Action | | ----------------------------------------------------------------------------------- | --------------------------------------------- | | Docs in scan perimeter | path-scoped audit | -| Owned skill bodies (`SKILL.md` trees) | exit 1 → run `audit skills` | +| Owned skill bodies (`SKILL.md` trees) | run `audit skills` | | Foreign / lockfile-synced skill bodies | skip → lint in the owning skills/toolbox repo | | `.sh`, `.bash`, `.zsh` | shellcheck or `bash -n` | | Other `.json` | JSONC-tolerant syntax check | | Any repository file | native gates where applicable + audit documents whose `review-deps` path or glob matched | -Pre-commit: `skeleton validate changed --staged` (path-scoped, fast). +Pre-commit: `skeleton validate changed --staged` (index bytes, coverage, owning papers). -CI: `skeleton validate changed --base origin/main` (global rules first, then changed files). +CI: `skeleton validate changed --base origin/main` (global rules first, then changed files, same coverage fail). ## Ecosystem @@ -200,8 +200,8 @@ bun run check `bun run check` = lint + test + typecheck + build + `audit:self`. -`validate:changed` does not replace code tests. It classifies code separately and also audits every scanned document whose `review-deps` declaration matches a changed file. Owned skill-body edits need `audit skills`. Code-only changes with no linked document still exit non-zero locally and point to native gates. +`validate:changed` does not replace code tests. It classifies code separately and also audits every scanned document whose `review-deps` declaration matches a changed file. A coverage-candidate path with no owning paper fails on local and CI runs. Owned skill-body edits run the skills suite. `--staged` reads git index bytes. For code: `bun test`, `bun run typecheck`, `bun run build`. -Optional: `brew install pre-commit` (or `pipx install pre-commit`), then `pre-commit install` to wire `.pre-commit-config.yaml`. +`skeleton init` writes `.pre-commit-config.yaml`. Install [pre-commit](https://pre-commit.com/) once (`brew install pre-commit` or `pipx install pre-commit`), then `pre-commit install`. diff --git a/docs/authoring.md b/docs/authoring.md index bec3c06..2a6eddb 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -2,11 +2,11 @@ - + -Every canonical doc in a skeleton-enabled repo carries a `source-of-truth` marker (comment or visible line). Opt in that way — no hand-maintained registry. Refresh the agent index with `skeleton catalog` (`runCatalogCli` / `checkCatalog` / `writeCatalog`). +Every canonical doc in a skeleton-enabled repo carries a `source-of-truth` marker (comment or visible line). Opt in that way — no hand-maintained registry. Local audit and `validate changed` write the agent index (`refreshLocalCatalog` / `writeCatalog`). `skeleton catalog` still refreshes it on demand. Full day-one flow: [getting started](developer/getting-started.md). Banner / catalog / doc-meta detail: [doc system](developer/doc-system.md). diff --git a/docs/developer/audit.md b/docs/developer/audit.md index a47733d..93488d5 100644 --- a/docs/developer/audit.md +++ b/docs/developer/audit.md @@ -2,21 +2,21 @@ - + - + When to run which command: [validation](validation.md). Common failures: [troubleshooting](troubleshooting.md). Config keys: [config](config.md). ## Suites ```bash -skeleton audit docs # links, doc-meta, review-proof, review-deps, ssot, near-duplicate, ssot-summary, prose-policy +skeleton audit docs # links, doc-meta, review-proof, review-deps, review-coverage, ssot, near-duplicate, ssot-summary, prose-policy skeleton audit skills # skill-index, multi-root detection, prose-policy (owned skill trees under scan.exclude too; foreign lock skills skipped) skeleton audit self # config + all rules (scan corpus; excluded owned skill trees → use audit skills) ``` -`review-deps` is an opt-in dependency graph from documents to exact repo-relative paths or globs. `validate changed` uses it for any changed file type; hash review proof invalidates the document when a resolved dependency byte or set changes. +`review-deps` is an opt-in dependency graph from documents to exact repo-relative paths or globs. `validate changed` uses it for any changed file type; hash review proof invalidates the document when a resolved dependency byte or set changes. Local `audit docs` and `audit self` write `.skeleton/catalog.md` on each run. The write is skipped when `CI=true`. CLI dispatch in `src/cli.ts` covers `audit`, `build-plugin`, `catalog`, `customize`, `hook`, `init`, `register` (removed — errors with migration text), and `validate`. The audit runner exports `runAudit`, `parseAuditArgs`, and `AuditCliOptions` for suites `docs`, `skills`, and `self`. @@ -38,13 +38,13 @@ When `--paths` is set (including `validate changed`), global rules are skipped u | Rule | Global | | ------------------------------------------------------------------------------ | ------ | | links, doc-meta, review-proof, review-deps, prose-policy (`alwaysRun` — all marked docs) | no* | -| ssot, near-duplicate, ssot-summary, coverage-gaps, scan-roots, skill-index, banned (`deny.paths`) | yes | +| ssot, near-duplicate, ssot-summary, coverage-gaps, review-coverage, scan-roots, skill-index, banned (`deny.paths`) | yes | \* `review-deps` is not `global`, but still runs under `--paths` and scans the full perimeter for markers so dependency drift is not skipped when other files change. ## Config -Consumer config is thin: `scan.include`, `scan.exclude`, optional `deny.paths`, optional `scan.nonPublicSkills` (taxonomy exemptions), `daysUntilStale`, optional `docsLint`, optional `reviewProof`, optional `plugins`, optional `draftPathPrefixes`, optional `skillOwnership`. Full reference: [config](config.md). Schema: `schemas/config.schema.json`. +Consumer config is thin: `scan.include`, `scan.exclude`, optional `deny.paths`, optional `scan.nonPublicSkills` (taxonomy exemptions), `daysUntilStale`, optional `docsLint`, optional `reviewProof`, optional `reviewCoverage`, optional `plugins`, optional `draftPathPrefixes`, optional `skillOwnership`. Full reference: [config](config.md). Schema: `schemas/config.schema.json`. ## Machine-readable results diff --git a/docs/developer/config.md b/docs/developer/config.md index 289c495..066bcec 100644 --- a/docs/developer/config.md +++ b/docs/developer/config.md @@ -2,13 +2,13 @@ - + - + Machine schema: [`schemas/config.schema.json`](../../schemas/config.schema.json) (validates the loaded object). Init template: `templates/skeleton-init/skeleton.toml`. Day-one walkthrough: [getting started](getting-started.md). -Loader: `loadConfig` / `loadConfigDetailed` / `findRepoRoot` / `mergedExcludes` in `src/audit/config/load.ts`. Typed shape: `SkeletonConfig` (`ScanConfig`, `DocsLintConfig`, `DenyConfig`, `SkillOwnershipConfig`, `ReviewProofConfig`, `CustomizeConfig`). +Loader: `loadConfig` / `loadConfigDetailed` / `findRepoRoot` / `mergedExcludes` in `src/audit/config/load.ts`. Typed shape: `SkeletonConfig` (`ScanConfig`, `DocsLintConfig`, `DenyConfig`, `SkillOwnershipConfig`, `ReviewProofConfig`, `ReviewCoverageConfig`, `CustomizeConfig`). Preferred path: **`skeleton.toml` at the repo root**. Legacy `.skeleton/config.yaml` still loads when no TOML is present. If both exist, TOML wins and the CLI warns that YAML is ignored. @@ -33,6 +33,7 @@ Top-level required keys: `scan` and `daysUntilStale`. Inside `scan`, required: ` | `customize.alwaysInclude` | Basenames under `.skeleton/customize/` appended on every skill inject — [customize](customize.md) | | `skillOwnership` | Provenance-aware skill body linting (see below) | | `reviewProof` | Hash-backed evidence for exact reviewed document and `review-deps` bytes (see below) | +| `reviewCoverage` | Globs that must appear in at least one scanned paper's `review-deps` (see below) | | `docsLint` | Near-duplicate / SSOT-summary thresholds and ignore pairs (see below) | Deleted skills need no denylist: links to missing `…/SKILL.md` fail under the links / skill-index rules. @@ -69,10 +70,22 @@ mode = "hash" # lockfile = ".skeleton/review-lock.json" ``` -Hash mode makes `last-reviewed` verifiable. Explicit attestation stores SHA-256 digests for the complete document and every resolved `review-deps` dependency. Any byte or resolved-set change invalidates the review until a human re-reads and attests the document again. Commit the lockfile. +Hash mode makes `last-reviewed` verifiable. Explicit attestation stores SHA-256 digests for the complete document and every resolved `review-deps` dependency. Any byte or resolved-set change invalidates the review until a human re-reads and attests the document again. Commit the lockfile. Init enables this section by default. Without this section, Skeleton uses compatibility date mode. Changed review dependencies still pull linked documents into `validate changed`; the document must co-change with a current explicit review date. +## `reviewCoverage` + +```toml +[reviewCoverage] +include = ["src/**/*.ts", "package.json"] +exclude = ["src/**/__tests__/**", "src/**/*.test.ts"] +``` + +Files that match `include` (minus `exclude` and built-in test or fixture excludes) must appear in at least one scanned paper's `review-deps`. The global `review-coverage` rule checks the whole set. `validate changed` also fails `uncovered-changed-path` when a changed candidate has no owner. + +Omit the section to use built-in code defaults (`**/*.{ts,tsx,js,jsx,mjs,cjs,py}`, `package.json`, `project.json`). Built-in excludes drop tests, fixtures, templates, and `.skeleton/plugins/**`. Production `.mjs` stays in the coverage set. Set `include = []` to disable the gate. + ## `skillOwnership` When a consumer repo syncs skills from a toolbox (via `skills-lock.json`), Skeleton skips foreign skill **bodies** so linting stays with the owning repo. That skip covers skill-body lint (`audit skills`), path-scoped validate routing, and **doc-meta** for paths under foreign skill trees — including SSOT-bearing `references/**` files. Consumer customizations under `.skeleton/customize/` remain audited here. @@ -101,12 +114,12 @@ See [audit](audit.md#skill-ownership-consumer-vs-toolbox) and [validation](valid | Concern | Keys / behavior | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Path-scoped `validate changed` | Files in `scan.include` (minus exclude) get docs audit; owned skill trees route to skills; foreign lock skills skip (body lint + doc-meta); code extensions skip | -| Global rules (`--base` / full audit) | `deny.paths` (rule `banned`), SSOT dual/malformed, near-dupe, ssot-summary, coverage outside include, scan-roots, skill-index | +| Path-scoped `validate changed` | Files in `scan.include` (minus exclude) get docs audit; owned skill trees run the skills suite; foreign lock skills skip; coverage-candidate code without an owning paper fails | +| Global rules (`--base` / full audit) | `deny.paths` (rule `banned`), SSOT dual/malformed, near-dupe, ssot-summary, coverage outside include, review-coverage, scan-roots, skill-index | | Prose policies | Idle until `plugins` contribute policy YAML | | Customize inject | `customize.alwaysInclude` (optional hooks / `customize resolve`); customize paths are always in the audit corpus | | Skill body ownership | `skillOwnership` + `skills-lock.json` (foreign bodies skipped) | -| Catalog | Local audit warns if `.skeleton/catalog.md` missing/stale; skipped when `CI=true` | +| Catalog | Local audit and `validate changed` write `.skeleton/catalog.md` on each run; skipped when `CI=true` | ## Example: toolbox / docs-only diff --git a/docs/developer/customize.md b/docs/developer/customize.md index 145e1ad..91d49b4 100644 --- a/docs/developer/customize.md +++ b/docs/developer/customize.md @@ -2,9 +2,9 @@ - + - + Resolve overlays with `resolveCustomize` / `resolveCustomizeFromRoot` (`CUSTOMIZE_PREFIX`). Hook entry: `runCustomizeHook`. diff --git a/docs/developer/doc-system.md b/docs/developer/doc-system.md index b1001cb..d0e0b45 100644 --- a/docs/developer/doc-system.md +++ b/docs/developer/doc-system.md @@ -2,13 +2,13 @@ - + Day-one walkthrough: [getting started](getting-started.md). Short authoring summary: [authoring](../authoring.md). -Catalog CLI: `runCatalogCli`, `checkCatalog`, `writeCatalog`, `buildCatalogContent`, `catalogAuditWarnings`. Summary fit: `evaluateSsotFit` / `ssotEvidenceOverlap` / `buildEvidenceText`. Doc-meta rule: `runDocMetaRule` (`docMetaRule`). +Catalog CLI: `runCatalogCli`, `checkCatalog`, `writeCatalog`, `buildCatalogContent`, `refreshLocalCatalog`. Summary fit: `evaluateSsotFit` / `ssotEvidenceOverlap` / `buildEvidenceText`. Doc-meta rule: `runDocMetaRule` (`docMetaRule`). ## Source of truth (opt-in) @@ -30,7 +30,7 @@ Files without an SSOT marker are fine — they are simply not listed in the agen ## Catalog -`.skeleton/catalog.md` is **generated and gitignored**. Agents should run `skeleton catalog` if it is missing, then skim summaries before opening full papers. +`.skeleton/catalog.md` is **generated and gitignored**. Local `audit docs`, `audit self`, and `validate changed` write it on each run. The write is skipped when `CI=true`. ```bash skeleton catalog @@ -38,7 +38,7 @@ skeleton catalog --check # warn if missing/outdated (local) skeleton catalog --check --strict # fail if missing/outdated ``` -Local `audit docs` warns when the catalog is missing/stale; the check is skipped when `CI=true`. +Use `skeleton catalog` when you want a refresh without an audit. ## SSOT summary fit (`ssot-summary`) diff --git a/docs/developer/getting-started.md b/docs/developer/getting-started.md index 6e164e3..13ddd9c 100644 --- a/docs/developer/getting-started.md +++ b/docs/developer/getting-started.md @@ -2,9 +2,9 @@ - + - + Add Skeleton to a repo in six steps. Flag details: [install](install.md). Every config key: [config](config.md). @@ -17,7 +17,7 @@ npm install -D @csark0812/skeleton npx skeleton init --skills ``` -Init writes `skeleton.toml`, ensures `.skeleton/customize/`, may merge **optional** IDE customize hooks, and adds `validate:changed` / `validate:ci` scripts to `package.json`. Hooks are not required for audit. +Init writes `skeleton.toml`, ensures `.skeleton/customize/`, writes `.pre-commit-config.yaml`, may merge **optional** IDE customize hooks, and adds `validate:changed` / `validate:ci` scripts to `package.json`. Hash review proof is on by default. IDE customize hooks are not required for audit. ## 2. Set the scan perimeter @@ -69,14 +69,14 @@ see [config](config.md#skillownership). Plugin-enabled example and more keys: [config](config.md). -For the strongest review gate, enable hash-backed evidence: +Init enables hash-backed review evidence: ```toml [reviewProof] mode = "hash" ``` -Commit `.skeleton/review-lock.json` after the first explicit review. +Commit `.skeleton/review-lock.json` after the first explicit review. Optional `[reviewCoverage]` sets which code paths must have an owning paper. Omit it to use the built-in code defaults. Set `include = []` to disable that gate. ## 3. Write a canonical doc @@ -94,11 +94,13 @@ Keep request and response shapes consistent across services. ## 4. Refresh the agent catalog +Local `npx skeleton audit docs` and `npx skeleton validate changed` write `.skeleton/catalog.md`. You can also run: + ```bash npx skeleton catalog ``` -Writes gitignored `.skeleton/catalog.md` from SSOT-bearing files. Agents skim this before opening full papers. +The file is gitignored. Agents skim it before they open full papers. ## 5. Verify @@ -118,13 +120,15 @@ npx skeleton audit docs --paths=docs/example.md --fix=doc-meta --confirm-reviewe Changing the date alone is not a review. Re-read-cadence warnings remain advisory unless `--strict`. Failures → [troubleshooting](troubleshooting.md). -## 6. Optional pre-commit +## 6. Install the git hook + +Init writes `.pre-commit-config.yaml` with `skeleton validate changed --staged`. Install [pre-commit](https://pre-commit.com/) once per machine, then: ```bash pre-commit install ``` -Hook configs typically run `skeleton validate changed --staged`. Details: [install](install.md). +Details: [install](install.md). ## Day-one checklist @@ -134,7 +138,8 @@ Hook configs typically run `skeleton validate changed --staged`. Details: [insta - [ ] Write a canonical doc with source-of-truth (+ doc-meta as needed) - [ ] `npx skeleton catalog` - [ ] `npx skeleton audit docs` -- [ ] (Optional) `pre-commit install` / IDE customize hooks +- [ ] `pre-commit install` +- [ ] (Optional) IDE customize hooks ## Next diff --git a/docs/developer/install.md b/docs/developer/install.md index 407da2b..39a1da6 100644 --- a/docs/developer/install.md +++ b/docs/developer/install.md @@ -2,9 +2,9 @@ - + - + Install path runs `runInit` (`InitOptions` / `InitResult`); `--skills` builds `skillsAddArgs` for the skills CLI. @@ -17,22 +17,20 @@ npx skeleton init --skills `--skills` runs `npx skills add csark0812/skeleton …` with sensible defaults (`--skill skeleton`, `-a cursor claude-code codex`, `-y`). Pass any [skills add flags](https://github.com/vercel-labs/skills) after `--skills` — e.g. `-g` / `--global`, `--all`, `-a codex`, `--copy`, `--list`. -Init writes `skeleton.toml` / `.skeleton/`, may merge **optional** IDE customize hooks, and adds `validate:changed` / `validate:ci` scripts. +Init writes `skeleton.toml` / `.skeleton/`, writes `.pre-commit-config.yaml`, may merge **optional** IDE customize hooks, and adds `validate:changed` / `validate:ci` scripts. ## Config Open `skeleton.toml` and set `scan.include` / `scan.exclude` / optional `deny.paths` for your layout. See [config](config.md). -## Pre-commit (optional) +## Pre-commit -Install [pre-commit](https://pre-commit.com/) once per machine (`brew install pre-commit` or `pipx install pre-commit`), then in the consumer repo: +Init writes a portable `node …/dist/cli.js validate changed --staged` hook. Install [pre-commit](https://pre-commit.com/) once per machine (`brew install pre-commit` or `pipx install pre-commit`), then in the consumer repo: ```bash pre-commit install ``` -Hook config typically runs `skeleton validate changed --staged`. - ## Verify ```bash diff --git a/docs/developer/plugins.md b/docs/developer/plugins.md index 7adc262..a520e7b 100644 --- a/docs/developer/plugins.md +++ b/docs/developer/plugins.md @@ -2,9 +2,9 @@ - + - + Skeleton plugins extend audit with consumer-specific rules and prose-policy YAML. Core stays thin; product policies live in plugins (e.g. PostPrint later). diff --git a/docs/developer/troubleshooting.md b/docs/developer/troubleshooting.md index fcf5fef..ed74c93 100644 --- a/docs/developer/troubleshooting.md +++ b/docs/developer/troubleshooting.md @@ -2,59 +2,54 @@ - + - + Failures usually come from `runValidateChanged` / `codeValidationHint` or `runAudit` / `parseAuditArgs`. Decision table and routing: [validation](validation.md). Day-one setup: [getting started](getting-started.md). -## `validate changed: all paths were skipped` +## `uncovered-changed-path` -**Cause:** Every input was code/config (`.ts`, `.py`, `package.json`, etc.). Skeleton does not validate app code. +**Cause:** A coverage-candidate file changed and no scanned paper lists it in `review-deps`. -**Local / pre-commit (no `--base`):** exits non-zero. Run your repo’s code gates, for example: +**Fix:** Add a `review-deps` path or glob on the owning paper. Then re-read that paper and attest it. -```bash -bun test -bun run typecheck -bun run build -``` +The same error fires on local, `--staged`, and `--base` runs. A mixed docs commit does not hide it. + +## `stage-required` -(or the equivalent npm/Nx scripts). Pass docs/skill paths if you intended SSOT validation. +**Cause:** `--staged` saw an impacted paper or hash lockfile that differs from HEAD and is not in the index. -**CI (`--base`):** does not fail closed on all-skipped code — global rules still run. Keep `bun test` / typecheck / build as a separate CI lane. +**Fix:** Stage the attested document. In hash mode, stage `.skeleton/review-lock.json` too. -## Owned skill-only paths exit non-zero +## `validate changed: all paths were skipped` -**Cause:** Changes under an owned skill tree (`SKILL.md` or skill-tree markdown) are not covered by path-scoped docs audit. +**Cause:** Every input was outside the docs, skills, policy, and coverage-candidate set. -**Fix:** +**Fix:** Pass docs or skill paths if you intended SSOT validation. Run your repo code gates for application tests. + +## Owned skill-only paths + +**Cause:** Changes under an owned skill tree need the skills suite. + +`validate changed` runs that suite. You can also run: ```bash skeleton audit skills ``` -Under CI, `skeleton validate changed --base origin/main` still applies global skill rules. - Foreign / lockfile-synced skill bodies are skipped with a log message because their owning skills or toolbox repo is responsible for linting them. Ownership comes from `skills-lock.json` and optional `skillOwnership` overrides; see [config](config.md#skillownership). -## Plugin policy YAML redirects - -**Cause:** You changed YAML matched by a plugin `policies` glob. Local validate schema-checks then fails closed so prose coverage is not assumed. - -**Fix (local / pre-commit):** +## Plugin policy YAML -```bash -skeleton audit docs -skeleton audit skills -``` +**Cause:** You changed YAML matched by a plugin `policies` glob. -`audit self` alone is not enough if skill trees are under `scan.exclude`. CI `--base` / `validate:ci` proves docs + skills without the redirect. +`validate changed` schema-checks the file, then runs full docs and owned-skill prose. `audit self` alone does not cover excluded skill trees. ## Orphan `.skeleton/**/*.yaml` @@ -116,7 +111,7 @@ skeleton audit docs --paths=docs/example.md --fix=doc-meta --confirm-reviewed skeleton audit docs --paths=docs/example.md --fix=doc-meta --confirm-reviewed --dry-run ``` -If the diagnostic code is `review-document-changed` or `review-dependency-changed`, hash proof found exact byte drift. Review the whole document against every current `review-deps` dependency, then run the command above. Do not hand-edit the lockfile. +If the diagnostic code is `review-document-changed` or `review-dependency-changed`, hash proof found exact byte drift. Plain-text output prints one `file: error:` diagnostic per failed document and a `changed:` line for the files that triggered it. Review the whole document against every current `review-deps` dependency, then run the command above. Do not hand-edit the lockfile. **Re-read cadence** — message mentions `exceeds re-read cadence` / `daysUntilStale`. diff --git a/docs/developer/validation.md b/docs/developer/validation.md index 143ebf6..e7921b7 100644 --- a/docs/developer/validation.md +++ b/docs/developer/validation.md @@ -2,23 +2,23 @@ - + - + -Router for changed paths: `runValidateChanged` / `evaluateValidateChanged` (`ValidateChangedOptions`). Code paths get a `codeValidationHint` for native gates; any changed path can drive `review-deps` document-impact discovery. Package-manager detection may mention `bun` / `npm` / `pnpm` / `yarn`. +Router for changed paths: `runValidateChanged` / `evaluateValidateChanged` (`ValidateChangedOptions`). Code paths get a `codeValidationHint` for native gates. Any changed path can drive `review-deps` document-impact discovery. A coverage candidate with no owning paper fails with `uncovered-changed-path` on local and `--base` runs. Package-manager detection may mention `bun` / `npm` / `pnpm` / `yarn`. ## When you changed X, run Y -| You changed | Run | -| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Docs, catalog, non-policy `.skeleton/` / `skeleton.toml` | `skeleton validate changed ` or `skeleton audit self` | -| Owned skill body (`SKILL.md` trees authored in this repo) | `skeleton audit skills` (path-scoped validate exits non-zero and redirects here) | -| Foreign / lockfile-synced skill body | skipped — lint in the owning skills/toolbox repo | -| Plugin-wired policy YAML under `.skeleton/` | Local: `skeleton audit docs` **and** `skeleton audit skills`. CI: `validate:ci` / `--base` proves both | -| TypeScript / app code | Repo-native gates plus Skeleton audits for documents linked by `review-deps` | -| `package.json` / `project.json` | Repo-native gates; no docs dependency routing | -| Missing paths or code paths with no impacted documents | Pass real paths, or use `--staged` / `--base`; run the printed native gates | +| You changed | Run | +| --------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Docs, catalog, non-policy `.skeleton/` / `skeleton.toml` | `skeleton validate changed ` or `skeleton audit self` | +| Owned skill body (`SKILL.md` trees authored in this repo) | `skeleton validate changed` (runs the skills suite) or `skeleton audit skills` | +| Foreign / lockfile-synced skill body | skipped — lint in the owning skills/toolbox repo | +| Plugin-wired policy YAML under `.skeleton/` | `skeleton validate changed` (runs full docs and owned-skill prose) | +| TypeScript / app code | Repo-native gates plus owning-paper review. Unowned coverage candidates fail | +| `package.json` / `project.json` | Same coverage and `review-deps` discovery as other claimed paths | +| Missing paths | Pass real paths, or use `--staged` / `--base` | Common failures: [troubleshooting](troubleshooting.md). Suites and rule scoping: [audit](audit.md). @@ -34,15 +34,15 @@ skeleton validate changed --base origin/main # CI merge-base diff | Path | Action | | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Docs in scan perimeter | `audit docs` (path-scoped) | -| Owned skill trees (`SKILL.md` perimeter) | without `--base`, exits non-zero → run `audit skills` (including when mixed with docs); path-scoped skills include `prose-policy` when plugins supply policies under CI `--base` | -| Foreign skill trees (`skills-lock.json` github / non-local provenance) | skip with a log line — body lint belongs upstream | -| Plugin-wired policy YAML under `.skeleton/` | Schema check; local → exit non-zero (run `audit docs` **and** `audit skills`); `--base` → full docs + path-scoped skills over **owned** skill-tree markdown | -| Other `.skeleton/**` YAML (not `config.yaml`, not plugin-wired) | exits non-zero — not referenced by any plugin `policies` glob | -| `.sh`, `.bash`, `.zsh` | shellcheck or `bash -n` | -| Other `.json` | JSONC-tolerant syntax check | -| Any changed repository file | discover and audit scanned docs whose `review-deps` path or glob matches | -| `package.json`, `project.json` | skip to native code gates | +| Docs in scan perimeter | `audit docs` (path-scoped) | +| Owned skill trees (`SKILL.md` perimeter) | Local: full `audit skills`. CI `--base`: global skill rules plus path-scoped skills | +| Foreign skill trees (`skills-lock.json` github / non-local provenance) | skip with a log line — body lint belongs upstream | +| Plugin-wired policy YAML under `.skeleton/` | Schema check, then full docs plus path-scoped skills over **owned** skill-tree markdown | +| Other `.skeleton/**` YAML (not `config.yaml`, not plugin-wired) | exits non-zero — not referenced by any plugin `policies` glob | +| `.sh`, `.bash`, `.zsh` | shellcheck or `bash -n` | +| Other `.json` | JSONC-tolerant syntax check | +| Any changed repository file | discover and audit scanned docs whose `review-deps` path or glob matches | +| Coverage-candidate code or `package.json` / `project.json` | fail `uncovered-changed-path` when no scanned paper claims the path | ### Code paths and impacted documents @@ -50,7 +50,8 @@ Skeleton does not claim to validate application code. It classifies code paths, - Hash mode: changed dependency bytes make the linked document's `review-proof` entry invalid until explicit re-review and attestation. - Date mode: a linked document must be included in the changed set and carry today's explicit review date. -- No linked document: a local code-only invocation exits non-zero and prints native gates. Under `--base`, global Skeleton rules still run; the separate code job remains required. +- No linked document: a coverage-candidate path fails with `uncovered-changed-path` on local and `--base` runs. Mixed commits do not hide this. +- `--staged`: read document, lockfile, and dependency bytes from the git index. If an impacted paper or the hash lockfile differs from HEAD and is not staged, fail `stage-required`. In this repo: @@ -60,13 +61,13 @@ bun run typecheck bun run build ``` -Mixed doc+code paths audit both directly changed and discovered impacted documents. Plain-text output names every linked document that requires review and the declared dependency that matched. +Mixed doc+code paths audit both directly changed and discovered impacted documents. Plain-text output prints one `file: error:` diagnostic per failed document and a `changed:` line for the files that triggered it. ### Skill-body paths Skill bodies are not path-scoped on the docs lane. -**Owned** skill paths (alone or mixed with docs) exit non-zero without `--base` and point at `skeleton audit skills`. Under CI `--base`, global skill rules and (when relevant) owned skills prose prove still run. +**Owned** skill paths (alone or mixed with docs) run the full skills suite locally. Under CI `--base`, global skill rules and path-scoped owned-skill prose still run. **Foreign** skills (`skills-lock.json` entries with `sourceType` other than `local`, e.g. `github`) are skipped so consumer repos don't double-lint synced toolbox copies — including doc-meta on SSOT-bearing skill `references/**` paths. Override with `skillOwnership.ownedSlugs` / `foreignSlugs` — see [config](config.md#skillownership). @@ -78,12 +79,11 @@ Policy YAML is plugin-glob SSOT only (same as runtime `loadPlugins`): - Unwired `.skeleton/**/*.yaml` (not `config.yaml`) fails loud — wire it via a plugin `policies` glob or move it. - Wired policy changes need a full docs **and** skills prose pass for new patterns. -- Local / pre-commit: schema-check then fail-closed with a redirect to both audits (`audit self` alone does not cover excluded skill trees). -- CI `--base`: full `audit docs` plus path-scoped `audit skills` over **owned** skill-tree markdown (including `references/**` under `scan.exclude`; foreign lock skills stay ignored). +- Local / pre-commit and CI `--base` both run that prove. `audit self` alone does not cover excluded skill trees. ### CI two-pass -`validate:ci` (`--base`) runs **global rules first** (`deny.paths` via rule `banned`, coverage-gaps, scan-roots, skill-index, ssot, near-duplicate, ssot-summary), then path-scoped audit on changed files. When the diff includes **wired policy YAML**, CI also runs the full docs + skills prove described above instead of redirecting. Pre-commit stays path-scoped and still fail-closes on wired policy changes. +`validate:ci` (`--base`) runs **global rules first** (`deny.paths` via rule `banned`, coverage-gaps, review-coverage, scan-roots, skill-index, ssot, near-duplicate, ssot-summary), then path-scoped audit on changed files. Uncovered coverage-candidate paths fail here too. When the diff includes **wired policy YAML**, CI runs the full docs + skills prove. Pre-commit uses `--staged` and the same coverage and policy prove. ## Agent-readable result diff --git a/docs/tiers.md b/docs/tiers.md index 2dc5381..07b663e 100644 --- a/docs/tiers.md +++ b/docs/tiers.md @@ -2,7 +2,7 @@ - + diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 1b7ffe9..997a5f2 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -126,6 +126,23 @@ } } }, + "reviewCoverage": { + "type": "object", + "additionalProperties": false, + "description": "Require files matching include to appear in at least one scanned document's review-deps. Omit the section to use built-in code defaults. Set include to [] to disable.", + "properties": { + "include": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Repo-relative globs that require an owning paper. Empty array disables coverage and uncovered-changed-path." + }, + "exclude": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Globs removed from the coverage set (added to built-in test and fixture excludes)" + } + } + }, "reviewProof": { "type": "object", "additionalProperties": false, diff --git a/skeleton.toml b/skeleton.toml index a034d09..edea167 100644 --- a/skeleton.toml +++ b/skeleton.toml @@ -22,5 +22,13 @@ exclude = [ [reviewProof] mode = "hash" +[reviewCoverage] +include = ["src/**/*.ts", "package.json"] +exclude = [ + "src/**/__tests__/**", + "src/**/*.test.ts", + "src/**/fixtures/**", +] + [deny] paths = [] diff --git a/skeleton/SKILL.md b/skeleton/SKILL.md index e90250f..efbcbdc 100644 --- a/skeleton/SKILL.md +++ b/skeleton/SKILL.md @@ -9,13 +9,13 @@ description: Agent ops manual for skeleton-enabled repos — init, catalog, audi - + Ops manual for `catalog`, `audit`, `validate`, and `init` in a skeleton-enabled repo. ## Agent doc routing (token-cheap) -1. If `.skeleton/catalog.md` is missing, run `skeleton catalog`. +1. Local `audit` / `validate` writes `.skeleton/catalog.md` (skipped when `CI=true`). 2. Skim the catalog (path + one-line summary). 3. For a candidate, read only the `source-of-truth` line / first ~20 lines. 4. Open the full paper only if that line is truly relevant. @@ -40,7 +40,7 @@ Not for: normal feature work that only reads toolbox skills (optional customize ``` skeleton.toml # preferred root config (scan, stale, docsLint) .skeleton/ -├── catalog.md # generated, gitignored — run `skeleton catalog` +├── catalog.md # generated, gitignored — local audit writes it ├── review-lock.json # review hashes when reviewProof.mode = "hash" ├── plugins/ # optional audit plugins (.ts + .mjs) └── customize/ # per-slug overrides for toolbox-bound skills @@ -75,9 +75,9 @@ Edit `skeleton.toml` scan trees for this repo shape. ## Workflow 1. Add `` (or visible `source-of-truth: …`) to canonical docs -2. Run `skeleton catalog` +2. Run `skeleton audit docs` (or `validate changed`) so the local catalog is written 3. Add `review-deps` paths or globs where repository changes can invalidate the paper -4. Run `skeleton audit docs` (or `audit self`) +4. Run `skeleton catalog` only when you want a refresh without an audit After a complete human re-read, record review evidence for explicit paths only: @@ -99,7 +99,7 @@ Do not run this command as a mechanical date cleanup. Bare `--fix` changes ancho | `skeleton catalog` / `catalog --check --strict` | Write / check the gitignored agent catalog | | `skeleton build-plugin [--check]` | Build / verify plugin `.mjs` siblings | | `skeleton validate changed` | Changed-file validation + dependency-driven doc discovery | -| `skeleton validate changed --staged` | Pre-commit (optional) | +| `skeleton validate changed --staged` | Pre-commit hook (index bytes + coverage + owning papers) | | `skeleton validate changed --base origin/main` | CI / PR | | `skeleton customize resolve ` | Print merged customize for a skill slug | diff --git a/src/__tests__/catalog.test.ts b/src/__tests__/catalog.test.ts new file mode 100644 index 0000000..297bd16 --- /dev/null +++ b/src/__tests__/catalog.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { CATALOG_REL_PATH } from "../audit/core/shared.ts"; +import { evaluateAudit } from "../audit/run.ts"; +import { evaluateValidateChanged } from "../validate/changed.ts"; + +function withLocalCi(ci: string | undefined, run: () => Promise): Promise { + const previous = process.env.CI; + if (ci === undefined) delete process.env.CI; + else process.env.CI = ci; + return run().finally(() => { + if (previous === undefined) delete process.env.CI; + else process.env.CI = previous; + }); +} + +function makeDocsRepo(): string { + const root = mkdtempSync(join(tmpdir(), "skel-catalog-refresh-")); + mkdirSync(join(root, "docs"), { recursive: true }); + writeFileSync( + join(root, "skeleton.toml"), + 'daysUntilStale = 365\n[scan]\ninclude = ["docs/**"]\nexclude = []\n', + ); + writeFileSync( + join(root, "docs/a.md"), + `# Alpha + + + + + +Alpha body with topic words. +`, + ); + return root; +} + +function docsAudit(root: string) { + return evaluateAudit({ + suite: "docs", + strict: false, + json: false, + paths: ["docs/a.md"], + only: new Set(["doc-meta"]), + root, + }); +} + +describe("local catalog refresh", () => { + it("writes a missing catalog during local docs audit and does not warn", async () => { + const root = makeDocsRepo(); + await withLocalCi(undefined, async () => { + try { + const catalog = join(root, CATALOG_REL_PATH); + expect(existsSync(catalog)).toBe(false); + const result = await docsAudit(root); + expect(existsSync(catalog)).toBe(true); + expect(readFileSync(catalog, "utf8")).toContain("Alpha topic"); + expect(result.catalog.status).toBe("current"); + expect(result.diagnostics.some((item) => item.rule === "catalog")).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + it("refreshes a stale catalog during local docs audit", async () => { + const root = makeDocsRepo(); + await withLocalCi(undefined, async () => { + try { + const catalog = join(root, CATALOG_REL_PATH); + mkdirSync(join(root, ".skeleton"), { recursive: true }); + writeFileSync(catalog, "# stale catalog\n"); + await docsAudit(root); + expect(readFileSync(catalog, "utf8")).toContain("Alpha topic"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + it("skips catalog writes when CI=true", async () => { + const root = makeDocsRepo(); + await withLocalCi("true", async () => { + try { + const result = await docsAudit(root); + expect(existsSync(join(root, CATALOG_REL_PATH))).toBe(false); + expect(result.catalog.status).toBe("skipped-ci"); + expect(result.diagnostics.some((item) => item.rule === "catalog")).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + it("writes a missing catalog during local validate changed", async () => { + const root = makeDocsRepo(); + await withLocalCi(undefined, async () => { + try { + await evaluateValidateChanged({ root, paths: ["docs/a.md"] }); + expect(existsSync(join(root, CATALOG_REL_PATH))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/src/__tests__/cli/init.test.ts b/src/__tests__/cli/init.test.ts index e46ff11..ba09311 100644 --- a/src/__tests__/cli/init.test.ts +++ b/src/__tests__/cli/init.test.ts @@ -112,6 +112,18 @@ describeHooks("skeleton init hooks", () => { expect(existsSync(join(cwd, ".claude/settings.json"))).toBe(true); const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")); expect(pkg.scripts["validate:changed"]).toBe("skeleton validate changed"); + expect(existsSync(join(cwd, ".pre-commit-config.yaml"))).toBe(true); + const hook = readFileSync(join(cwd, ".pre-commit-config.yaml"), "utf8"); + expect(hook).toContain("validate changed --staged"); + expect(hook).not.toContain("bun test"); + expect(readFileSync(join(cwd, "skeleton.toml"), "utf8")).toContain('mode = "hash"'); + }); + + it("skips an existing skeleton pre-commit hook on re-init", () => { + const cwd = makeRepo(); + runInit({ cwd }); + const result = runInit({ cwd }); + expect(result.precommit).toBe("skipped"); }); it("idempotent re-run skips unchanged hooks and scaffold", () => { diff --git a/src/__tests__/cli/integration.test.ts b/src/__tests__/cli/integration.test.ts index d1d0ec2..39d0feb 100644 --- a/src/__tests__/cli/integration.test.ts +++ b/src/__tests__/cli/integration.test.ts @@ -1,5 +1,5 @@ -import { beforeAll, describe, expect, it, spyOn } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { afterEach, beforeAll, describe, expect, it, spyOn } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { attestDocuments } from "../../audit/core/review-proof.ts"; @@ -18,6 +18,15 @@ const NESTED_SKILLS_CUSTOMIZE = join(FIXTURES, "nested-skills-customize"); const FLAT_SKILL_ROOT = join(FIXTURES, "flat-skill-root"); const PLUGIN_CONSUMER = join(FIXTURES, "plugins/consumer"); +function removeFixtureCatalogs(): void { + for (const root of [FLAT_SKILL_ROOT, NESTED_SKILLS_CUSTOMIZE, PLUGIN_CONSUMER]) { + const catalog = join(root, ".skeleton/catalog.md"); + if (existsSync(catalog)) unlinkSync(catalog); + } +} + +afterEach(removeFixtureCatalogs); + describe("catalog", () => { it("can fail closed when a strict check finds no generated catalog", () => { const dir = mkdtempSync(join(tmpdir(), "skel-catalog-strict-")); @@ -165,11 +174,14 @@ describe("validate changed routing", () => { mkdirSync(dirname(tsPath), { recursive: true }); writeFileSync(tsPath, "export const n = 1;\n"); try { - const exit = await runValidateChanged({ + const result = await evaluateValidateChanged({ root: FLAT_SKILL_ROOT, paths: ["src/example.ts"], }); - expect(exit).toBe(1); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "uncovered-changed-path", file: "src/example.ts" }), + ); } finally { unlinkSync(tsPath); } @@ -274,8 +286,8 @@ Run the project commands through the package scripts. const log = spyOn(console, "log").mockImplementation((line) => lines.push(String(line))); try { expect(await runValidateChanged({ root, paths: ["package.json"] })).toBe(1); - expect(lines).toContain( - "validate changed: docs/commands.md requires review (dependency package.json matched package.json)", + expect(lines.join("\n")).toContain( + "docs/commands.md: error: review required\n changed: package.json", ); } finally { log.mockRestore(); @@ -317,80 +329,69 @@ The runThing export provides the example behavior. } }); - it("under --base, all-skipped code does not fail-closed before global rules", async () => { + it("under --base, uncovered code still runs global rules", async () => { const tsPath = join(FLAT_SKILL_ROOT, "src/example.ts"); mkdirSync(dirname(tsPath), { recursive: true }); writeFileSync(tsPath, "export const n = 1;\n"); - const lines: string[] = []; - const capture = (msg?: unknown, ...rest: unknown[]) => { - lines.push([msg, ...rest].map(String).join(" ")); - }; - const errSpy = spyOn(console, "error").mockImplementation(capture); - const logSpy = spyOn(console, "log").mockImplementation(capture); try { - await runValidateChanged({ + const result = await evaluateValidateChanged({ root: FLAT_SKILL_ROOT, paths: ["src/example.ts"], base: "HEAD", }); - const joined = lines.join("\n"); - expect(joined.includes("all paths were skipped")).toBe(false); - expect(joined.includes("Self audit")).toBe(true); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "uncovered-changed-path", file: "src/example.ts" }), + ); + expect(result.audits.some((audit) => audit.suite === "self")).toBe(true); + expect( + result.diagnostics.some((item) => item.message.includes("all paths were skipped")), + ).toBe(false); } finally { - errSpy.mockRestore(); - logSpy.mockRestore(); unlinkSync(tsPath); } }); - it("passes mixed docs and skipped ts", async () => { + it("fails mixed docs and uncovered ts", async () => { const tsPath = join(FLAT_SKILL_ROOT, "src/example.ts"); mkdirSync(dirname(tsPath), { recursive: true }); writeFileSync(tsPath, "export const n = 1;\n"); try { - const exit = await runValidateChanged({ + const result = await evaluateValidateChanged({ root: FLAT_SKILL_ROOT, paths: ["docs/README.md", "src/example.ts"], }); - expect(exit).toBe(0); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "uncovered-changed-path", file: "src/example.ts" }), + ); + expect(result.audits.some((audit) => audit.suite === "docs")).toBe(true); } finally { unlinkSync(tsPath); } }); - it("fails skill-only paths without --base and points at audit skills", async () => { - const err = spyOn(console, "error").mockImplementation(() => {}); - try { - const exit = await runValidateChanged({ - root: FLAT_SKILL_ROOT, - paths: ["multi/SKILL.md"], - }); - expect(exit).toBe(1); - const msg = err.mock.calls.flat().join("\n"); - expect(msg).toContain("audit skills"); - expect(msg).toContain("excluded skill trees still need audit skills"); - expect(msg).not.toMatch(/Or:\s+skeleton audit self/); - } finally { - err.mockRestore(); - } + it("runs the skills suite for skill-only paths without --base", async () => { + const result = await evaluateValidateChanged({ + root: FLAT_SKILL_ROOT, + paths: ["multi/SKILL.md"], + }); + expect(result.diagnostics.some((item) => item.code === "full-skills-audit-required")).toBe( + false, + ); + expect(result.audits.some((audit) => audit.suite === "skills")).toBe(true); }); - it("fails docs+owned-skill mixes without --base (path-scoped skills are not coverage)", async () => { - const err = spyOn(console, "error").mockImplementation(() => {}); - const log = spyOn(console, "log").mockImplementation(() => {}); - try { - const exit = await runValidateChanged({ - root: FLAT_SKILL_ROOT, - paths: ["docs/README.md", "multi/SKILL.md"], - }); - expect(exit).toBe(1); - const msg = [...err.mock.calls, ...log.mock.calls].flat().join("\n"); - expect(msg).toContain("audit skills"); - expect(msg).toMatch(/skill paths need the full skills suite/i); - } finally { - err.mockRestore(); - log.mockRestore(); - } + it("runs the skills suite for docs+owned-skill mixes without --base", async () => { + const result = await evaluateValidateChanged({ + root: FLAT_SKILL_ROOT, + paths: ["docs/README.md", "multi/SKILL.md"], + }); + expect(result.diagnostics.some((item) => item.code === "full-skills-audit-required")).toBe( + false, + ); + expect(result.audits.some((audit) => audit.suite === "skills")).toBe(true); + expect(result.audits.some((audit) => audit.suite === "docs")).toBe(true); }); it("fails skill+unwired-policy paths as orphan policy (not wired by plugin globs)", async () => { @@ -422,51 +423,40 @@ The runThing export provides the example behavior. expect(exit).toBe(1); }); - it("schema-checks wired policy YAML then fail-closes without --base", async () => { - const err = spyOn(console, "error").mockImplementation(() => {}); - try { - const exit = await runValidateChanged({ - root: PLUGIN_CONSUMER, - paths: [".skeleton/plugins/example/policies/sample-banned-phrase.yaml"], - }); - expect(exit).toBe(1); - expect(err.mock.calls.flat().join("\n")).toContain("audit docs"); - expect(err.mock.calls.flat().join("\n")).toContain("audit skills"); - } finally { - err.mockRestore(); - } + it("schema-checks wired policy YAML then runs full docs and skills without --base", async () => { + const result = await evaluateValidateChanged({ + root: PLUGIN_CONSUMER, + paths: [".skeleton/plugins/example/policies/sample-banned-phrase.yaml"], + }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics.some((item) => item.code === "full-policy-audit-required")).toBe( + false, + ); + expect(result.audits.some((audit) => audit.suite === "docs")).toBe(true); }); - it("fail-closes wired policy YAML even when docs co-change (path-scoped is not prose coverage)", async () => { - const err = spyOn(console, "error").mockImplementation(() => {}); - try { - const exit = await runValidateChanged({ - root: PLUGIN_CONSUMER, - paths: [".skeleton/plugins/example/policies/sample-banned-phrase.yaml", "docs/clean.md"], - }); - expect(exit).toBe(1); - expect(err.mock.calls.flat().join("\n")).toMatch(/full prose-policy pass|audit docs/); - expect(err.mock.calls.flat().join("\n")).toContain("audit skills"); - } finally { - err.mockRestore(); - } + it("runs full docs and skills for wired policy YAML even when docs co-change", async () => { + const result = await evaluateValidateChanged({ + root: PLUGIN_CONSUMER, + paths: [".skeleton/plugins/example/policies/sample-banned-phrase.yaml", "docs/clean.md"], + }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics.some((item) => item.code === "full-policy-audit-required")).toBe( + false, + ); + expect(result.audits.some((audit) => audit.suite === "docs")).toBe(true); }); - it("fail-closes ./prefixed wired policy paths the same as plain .skeleton/ paths", async () => { - const err = spyOn(console, "error").mockImplementation(() => {}); - try { - const exit = await runValidateChanged({ - root: PLUGIN_CONSUMER, - paths: [ - "./.skeleton/plugins/example/policies/sample-banned-phrase.yaml", - "./docs/clean.md", - ], - }); - expect(exit).toBe(1); - expect(err.mock.calls.flat().join("\n")).toMatch(/full prose-policy pass|audit docs/); - } finally { - err.mockRestore(); - } + it("treats ./prefixed wired policy paths the same as plain .skeleton/ paths", async () => { + const result = await evaluateValidateChanged({ + root: PLUGIN_CONSUMER, + paths: ["./.skeleton/plugins/example/policies/sample-banned-phrase.yaml", "./docs/clean.md"], + }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics.some((item) => item.code === "full-policy-audit-required")).toBe( + false, + ); + expect(result.audits.some((audit) => audit.suite === "docs")).toBe(true); }); it("fails orphan .skeleton/policies YAML not exported by a plugin", async () => { diff --git a/src/__tests__/cli/validate-hook.test.ts b/src/__tests__/cli/validate-hook.test.ts new file mode 100644 index 0000000..02cea9f --- /dev/null +++ b/src/__tests__/cli/validate-hook.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { attestDocuments } from "../../audit/core/review-proof.ts"; +import { evaluateValidateChanged } from "../../validate/changed.ts"; + +let tempDirs: string[] = []; + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), "skel-hook-")); + tempDirs.push(root); + return root; +} + +function writeToml(root: string, extra = ""): void { + writeFileSync( + join(root, "skeleton.toml"), + `daysUntilStale = 365 +[scan] +include = ["docs/**"] +exclude = [] +${extra} +`, + ); +} + +function writeOwnedDoc(root: string, extra = ""): void { + mkdirSync(join(root, "docs"), { recursive: true }); + writeFileSync( + join(root, "docs/example.md"), + `# Example + + + + + +${extra} +The runThing export provides the example behavior. +`, + ); +} + +function runGit(root: string, args: string[]): void { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + } +} + +afterEach(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + tempDirs = []; +}); + +describe("validate changed hook gates", () => { + it("fails mixed docs and uncovered code", async () => { + const root = makeRoot(); + writeToml(root); + writeOwnedDoc(root); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src/example.ts"), "export const n = 1;\n"); + + const result = await evaluateValidateChanged({ + root, + paths: ["docs/example.md", "src/example.ts"], + }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "uncovered-changed-path", file: "src/example.ts" }), + ); + expect(result.audits.some((audit) => audit.suite === "docs")).toBe(true); + }); + + it("fails mixed docs and uncovered production .mjs", async () => { + const root = makeRoot(); + writeToml(root); + writeOwnedDoc(root); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src/app.mjs"), "export const n = 1;\n"); + + const result = await evaluateValidateChanged({ + root, + paths: ["docs/example.md", "src/app.mjs"], + }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "uncovered-changed-path", file: "src/app.mjs" }), + ); + expect(result.audits.some((audit) => audit.suite === "docs")).toBe(true); + }); + + it("fails uncovered production .mjs under --base after global rules", async () => { + const root = makeRoot(); + writeToml(root); + writeOwnedDoc(root); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src/app.mjs"), "export const n = 1;\n"); + + const result = await evaluateValidateChanged({ + root, + paths: ["src/app.mjs"], + base: "HEAD", + }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "uncovered-changed-path", file: "src/app.mjs" }), + ); + expect(result.audits.some((audit) => audit.suite === "self")).toBe(true); + }); + + it("fails uncovered code under --base after global rules", async () => { + const root = makeRoot(); + writeToml(root); + writeOwnedDoc(root); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src/example.ts"), "export const n = 1;\n"); + + const result = await evaluateValidateChanged({ + root, + paths: ["src/example.ts"], + base: "HEAD", + }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "uncovered-changed-path", file: "src/example.ts" }), + ); + expect(result.audits.some((audit) => audit.suite === "self")).toBe(true); + }); + + it("does not flag uncovered code when reviewCoverage include is empty", async () => { + const root = makeRoot(); + writeToml(root, "[reviewCoverage]\ninclude = []\n"); + writeOwnedDoc(root); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src/example.ts"), "export const n = 1;\n"); + + const result = await evaluateValidateChanged({ + root, + paths: ["docs/example.md", "src/example.ts"], + }); + expect(result.diagnostics.some((item) => item.code === "uncovered-changed-path")).toBe(false); + }); + + it("does not treat owned code as uncovered", async () => { + const root = makeRoot(); + writeToml(root); + writeOwnedDoc(root, "\n"); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src/example.ts"), "export const n = 1;\n"); + + const result = await evaluateValidateChanged({ + root, + paths: ["src/example.ts"], + }); + expect(result.diagnostics.some((item) => item.code === "uncovered-changed-path")).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "impacted-document-review-required" }), + ); + }); + + it("fails --staged when attested lockfile and document stay unstaged", async () => { + const root = makeRoot(); + writeToml( + root, + `[reviewProof] +mode = "hash" +`, + ); + writeOwnedDoc(root, "\n"); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src/example.ts"), "export const runThing = 1;\n"); + attestDocuments({ root, paths: ["docs/example.md"], reviewedAt: "2026-08-01" }); + runGit(root, ["init"]); + runGit(root, ["add", "-A"]); + runGit(root, ["commit", "-m", "init"]); + + writeFileSync(join(root, "src/example.ts"), "export const runThing = 2;\n"); + runGit(root, ["add", "src/example.ts"]); + attestDocuments({ root, paths: ["docs/example.md"], reviewedAt: "2026-09-13" }); + + const result = await evaluateValidateChanged({ root, staged: true }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics.some((item) => item.code === "stage-required")).toBe(true); + expect( + result.audits + .flatMap((audit) => audit.diagnostics) + .some((item) => item.code === "review-dependency-changed"), + ).toBe(true); + }); +}); diff --git a/src/audit/__tests__/report.test.ts b/src/audit/__tests__/report.test.ts new file mode 100644 index 0000000..80b8af8 --- /dev/null +++ b/src/audit/__tests__/report.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import { issue, printReport } from "../core/report.ts"; + +function printedReport( + issues: Parameters[0], + options: Parameters[1] = { label: "Doc audit" }, +): string { + const lines: string[] = []; + const log = spyOn(console, "log").mockImplementation((value) => lines.push(String(value))); + try { + printReport(issues, options); + return lines.join("\n"); + } finally { + log.mockRestore(); + } +} + +describe("audit text report", () => { + it("prints a diagnostic per failed document with its changed files", () => { + const text = printedReport([ + issue("review-proof", "docs/b.md", { + code: "review-dependency-changed", + message: "review dependency changed after the recorded review: src/cli.ts", + link: "src/cli.ts", + }), + issue("review-proof", "docs/a.md", { + code: "review-dependency-changed", + message: "review dependency changed after the recorded review: src/cli.ts", + link: "src/cli.ts", + }), + ]); + expect(text).toContain("Doc audit failed:"); + expect(text).toContain("docs/a.md: error: review required\n changed: src/cli.ts"); + expect(text).toContain("docs/b.md: error: review required\n changed: src/cli.ts"); + expect(text).not.toContain("review dependency changed after the recorded review"); + }); + + it("keeps triggers on the same failed document", () => { + const text = printedReport([ + issue("review-proof", "AGENTS.md", { + code: "review-document-changed", + message: "document bytes changed after the recorded review", + }), + issue("review-proof", "AGENTS.md", { + code: "review-dependency-changed", + message: "review dependency changed after the recorded review: src/cli.ts", + link: "src/cli.ts", + }), + issue("review-proof", "README.md", { + code: "review-dependency-changed", + message: "review dependency changed after the recorded review: src/cli.ts", + link: "src/cli.ts", + }), + issue("links", "docs/other.md", "missing link target"), + ]); + expect(text).toContain("AGENTS.md: error: review required\n changed: AGENTS.md, src/cli.ts"); + expect(text).toContain("README.md: error: review required\n changed: src/cli.ts"); + expect(text).toContain("docs/other.md: error: missing link target"); + }); +}); diff --git a/src/audit/__tests__/review-coverage.test.ts b/src/audit/__tests__/review-coverage.test.ts new file mode 100644 index 0000000..9ecdfcc --- /dev/null +++ b/src/audit/__tests__/review-coverage.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../config/load.ts"; +import { pathRequiresReviewCoverage } from "../core/review-coverage.ts"; +import { evaluateAudit } from "../run.ts"; + +function makeRepo(marker: string): string { + const root = mkdtempSync(join(tmpdir(), "skeleton-review-coverage-")); + mkdirSync(join(root, "docs"), { recursive: true }); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync( + join(root, "skeleton.toml"), + `daysUntilStale = 365 +[scan] +include = ["docs/**"] +exclude = [] +[reviewCoverage] +include = ["src/**/*.ts"] +exclude = [] +`, + ); + writeFileSync(join(root, "src/owned.ts"), "export const owned = 1;\n"); + writeFileSync(join(root, "src/orphan.ts"), "export const orphan = 1;\n"); + writeFileSync( + join(root, "docs/example.md"), + `# Example + + + + + +${marker} +`, + ); + return root; +} + +describe("review-coverage", () => { + it("accepts reviewCoverage config", () => { + const root = makeRepo(""); + try { + expect(loadConfig(root).reviewCoverage).toEqual({ + include: ["src/**/*.ts"], + exclude: [], + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("treats production .mjs as a default coverage candidate", () => { + const root = makeRepo(""); + try { + writeFileSync( + join(root, "skeleton.toml"), + `daysUntilStale = 365 +[scan] +include = ["docs/**"] +exclude = [] +`, + ); + const config = loadConfig(root); + expect(pathRequiresReviewCoverage("src/app.mjs", config)).toBe(true); + expect(pathRequiresReviewCoverage(".skeleton/plugins/example/example.mjs", config)).toBe( + false, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("keeps production .mjs covered when include names only that extension", () => { + const root = makeRepo(""); + try { + writeFileSync( + join(root, "skeleton.toml"), + `daysUntilStale = 365 +[scan] +include = ["docs/**"] +exclude = [] +[reviewCoverage] +include = ["**/*.mjs"] +`, + ); + const config = loadConfig(root); + expect(pathRequiresReviewCoverage("src/app.mjs", config)).toBe(true); + expect(pathRequiresReviewCoverage(".skeleton/plugins/example/example.mjs", config)).toBe( + false, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("errors on files in the coverage include that no document claims", async () => { + const root = makeRepo(""); + try { + const result = await evaluateAudit({ + suite: "self", + strict: false, + json: false, + paths: [], + only: new Set(["review-coverage"]), + root, + }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "review-coverage-gap", file: "src/orphan.ts" }), + ); + expect(result.diagnostics.some((item) => item.file === "src/owned.ts")).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/audit/config/types.ts b/src/audit/config/types.ts index bdacf8d..1c470c0 100644 --- a/src/audit/config/types.ts +++ b/src/audit/config/types.ts @@ -28,6 +28,14 @@ export interface SkillOwnershipConfig { foreignSlugs?: string[]; } +/** Files that must appear in at least one scanned document's review-deps. */ +export interface ReviewCoverageConfig { + /** Repo-relative globs that require an owning paper. Empty include disables the gate. */ + include?: string[]; + /** Globs removed from the coverage set. */ + exclude?: string[]; +} + /** Verifiable evidence that a human review covered exact document and code bytes. */ export interface ReviewProofConfig { /** Hash mode stores reviewed document and code-target digests in a lockfile. */ @@ -60,6 +68,7 @@ export interface SkeletonConfig { customize?: CustomizeConfig; skillOwnership?: SkillOwnershipConfig; reviewProof?: ReviewProofConfig; + reviewCoverage?: ReviewCoverageConfig; docsLint?: DocsLintConfig; /** * Plugin entry paths relative to `.skeleton/` (e.g. `plugins/example.ts`). diff --git a/src/audit/core/context.ts b/src/audit/core/context.ts index 3d7db72..4a1ca03 100644 --- a/src/audit/core/context.ts +++ b/src/audit/core/context.ts @@ -8,6 +8,7 @@ import { filterToPaths, includeExplicitMarkdownPaths, } from "./collect.ts"; +import type { FileSource } from "./repo-files.ts"; import { buildSkillIndex, listSkillMarkdownPaths, type SkillIndex } from "./skill-roots.ts"; import type { SsotForm } from "./ssot.ts"; import { collectSsotEntries, type SsotFileEntry } from "./ssot-collect.ts"; @@ -39,6 +40,8 @@ export interface AuditContext { lockedSkillSlugs: Set; /** Compiled prose policies from plugins (empty when no plugins / no policy globs). */ policies: PolicyFile[]; + /** Read document and dependency bytes from the worktree or the git index. */ + fileSource?: FileSource; } export interface AuditOptions { @@ -52,6 +55,8 @@ export interface AuditOptions { * matches path-scoped / validate `--base` prove. */ includeExcludedSkillTrees?: boolean; + /** When `index`, review-proof and coverage reads use `git show :path`. */ + fileSource?: FileSource; } export function createContext(options: AuditOptions = {}): AuditContext { @@ -93,5 +98,6 @@ export function createContext(options: AuditOptions = {}): AuditContext { skillIndex, lockedSkillSlugs: new Set(skillIndex.foreignSlugs), policies: options.policies ?? [], + fileSource: options.fileSource, }; } diff --git a/src/audit/core/repo-files.ts b/src/audit/core/repo-files.ts new file mode 100644 index 0000000..33047a5 --- /dev/null +++ b/src/audit/core/repo-files.ts @@ -0,0 +1,38 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { normalizeRelPath } from "./shared.ts"; + +export type FileSource = "worktree" | "index"; + +function gitShow(root: string, spec: string): string | null { + const proc = spawnSync("git", ["show", spec], { + cwd: root, + encoding: "utf8", + maxBuffer: 20_000_000, + }); + if (proc.status !== 0) return null; + return proc.stdout; +} + +/** Read a repo-relative file from the worktree or the git index. */ +export function readRepoText( + root: string, + relPath: string, + source: FileSource = "worktree", +): string | null { + const normalized = normalizeRelPath(relPath); + if (source === "index") return gitShow(root, `:${normalized}`); + const abs = join(root, normalized); + if (!existsSync(abs)) return null; + return readFileSync(abs, "utf8"); +} + +/** True when worktree bytes differ from HEAD, including new untracked files. */ +export function pathDiffersFromHead(root: string, relPath: string): boolean { + const normalized = normalizeRelPath(relPath); + const head = gitShow(root, `HEAD:${normalized}`); + const worktree = readRepoText(root, normalized, "worktree"); + if (worktree === null) return head !== null; + return worktree !== (head ?? ""); +} diff --git a/src/audit/core/report.ts b/src/audit/core/report.ts index 7c5a7ab..67d9aec 100644 --- a/src/audit/core/report.ts +++ b/src/audit/core/report.ts @@ -97,9 +97,8 @@ function printJsonReport(ctx: ReportPrintContext): number { function printWarnings(label: string, warnings: Issue[]): void { if (warnings.length === 0) return; console.log(`${label} warnings:\n`); - for (const i of warnings) { - const linkPart = i.link ? ` (${i.link})` : ""; - console.log(`- ${i.file}${linkPart}: ${i.message}`); + for (const item of warnings) { + console.log(`${item.file}: warning: ${item.message}`); } console.log(""); } @@ -113,11 +112,75 @@ function printSuccess(label: string, options: ReportOptions, warnings: Issue[]): return 0; } +const REREAD_CODES = new Set([ + "review-dependency-changed", + "impacted-document-review-required", + "review-document-changed", + "review-dependency-set-changed", +]); + +export function isRereadIssue(item: Issue): boolean { + return Boolean(item.code && REREAD_CODES.has(item.code)); +} + +function rereadTrigger(item: Issue): string | null { + if (item.code === "review-document-changed") return item.file; + if ( + item.code === "review-dependency-changed" || + item.code === "impacted-document-review-required" + ) { + return item.link ?? null; + } + return null; +} + +function orderTriggers(file: string, triggers: Set): string[] { + const rest = [...triggers].filter((path) => path !== file).sort(); + return triggers.has(file) ? [file, ...rest] : rest; +} + +function collectRereadTriggers(errors: Issue[]): { + triggers: Map>; + rest: Issue[]; +} { + const triggers = new Map>(); + const rest: Issue[] = []; + for (const item of errors) { + if (!isRereadIssue(item)) { + rest.push(item); + continue; + } + const current = triggers.get(item.file) ?? new Set(); + const trigger = rereadTrigger(item); + if (trigger) current.add(trigger); + triggers.set(item.file, current); + } + return { triggers, rest }; +} + +function printRereadDiagnostic(file: string, triggers: Set): void { + console.log(`${file}: error: review required`); + const changed = orderTriggers(file, triggers); + if (changed.length > 0) { + console.log(` changed: ${changed.join(", ")}`); + return; + } + console.log(" changed: review dependency set"); +} + +function printRereadLists(errors: Issue[]): Issue[] { + const { triggers, rest } = collectRereadTriggers(errors); + for (const file of [...triggers.keys()].sort()) { + printRereadDiagnostic(file, triggers.get(file) ?? new Set()); + } + return rest; +} + function printErrors(label: string, errors: Issue[]): number { console.log(`${label} failed:\n`); - for (const i of errors) { - const linkPart = i.link ? ` (${i.link})` : ""; - console.log(`- ${i.file}${linkPart}: ${i.message}`); + const rest = printRereadLists(errors); + for (const item of rest) { + console.log(`${item.file}: error: ${item.message}`); } return 1; } diff --git a/src/audit/core/review-coverage.ts b/src/audit/core/review-coverage.ts new file mode 100644 index 0000000..6207a94 --- /dev/null +++ b/src/audit/core/review-coverage.ts @@ -0,0 +1,90 @@ +import { globSync } from "tinyglobby"; +import type { SkeletonConfig } from "../config/types.ts"; +import { collectScanFiles, relPath } from "./collect.ts"; +import { type FileSource, readRepoText } from "./repo-files.ts"; +import { reviewDependencyMatchesPath, reviewDependencyPatterns } from "./review-deps.ts"; +import { matchesGlobScope, normalizeRelPath } from "./shared.ts"; +import type { SkillIndex } from "./skill-roots.ts"; + +export const DEFAULT_REVIEW_COVERAGE_INCLUDE = [ + "**/*.{ts,tsx,js,jsx,mjs,cjs,py}", + "package.json", + "project.json", +]; + +export const DEFAULT_REVIEW_COVERAGE_EXCLUDE = [ + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*", + "**/fixtures/**", + "templates/**", + "dist/**", + "node_modules/**", + ".git/**", + ".skeleton/plugins/**", +]; + +export function reviewCoveragePatterns(config: SkeletonConfig): { + include: string[]; + exclude: string[]; +} { + const configured = config.reviewCoverage; + if (configured?.include && configured.include.length === 0) { + return { include: [], exclude: [] }; + } + return { + include: + configured?.include && configured.include.length > 0 + ? configured.include + : DEFAULT_REVIEW_COVERAGE_INCLUDE, + exclude: [...DEFAULT_REVIEW_COVERAGE_EXCLUDE, ...(configured?.exclude ?? [])], + }; +} + +export function pathRequiresReviewCoverage(relPath: string, config: SkeletonConfig): boolean { + const { include, exclude } = reviewCoveragePatterns(config); + if (include.length === 0) return false; + const path = normalizeRelPath(relPath); + if (exclude.some((pattern) => matchesGlobScope(path, pattern))) return false; + return include.some((pattern) => matchesGlobScope(path, pattern)); +} + +export function collectReviewDependencyPatterns(input: { + root: string; + config: SkeletonConfig; + skillIndex: SkillIndex; + fileSource?: FileSource; +}): string[] { + const patterns = new Set(); + const source = input.fileSource ?? "worktree"; + for (const abs of collectScanFiles(input.config, input.root, input.skillIndex)) { + const rel = relPath(abs, input.root); + const content = readRepoText(input.root, rel, source); + if (content === null) continue; + for (const pattern of reviewDependencyPatterns(content)) patterns.add(pattern); + } + return [...patterns].sort(); +} + +export function pathHasReviewOwner(relPath: string, patterns: string[]): boolean { + return patterns.some((pattern) => reviewDependencyMatchesPath(pattern, relPath)); +} + +export function collectReviewCoverageFiles(root: string, config: SkeletonConfig): string[] { + const { include, exclude } = reviewCoveragePatterns(config); + if (include.length === 0) return []; + const files = new Set(); + for (const pattern of include) { + for (const match of globSync(pattern, { + cwd: root, + onlyFiles: true, + dot: true, + ignore: exclude, + })) { + const rel = normalizeRelPath(match); + if (exclude.some((item) => matchesGlobScope(rel, item))) continue; + files.add(rel); + } + } + return [...files].sort(); +} diff --git a/src/audit/core/review-proof.ts b/src/audit/core/review-proof.ts index d6a10ba..6eed51f 100644 --- a/src/audit/core/review-proof.ts +++ b/src/audit/core/review-proof.ts @@ -1,9 +1,10 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { dirname, relative } from "node:path"; +import { dirname } from "node:path"; import type { AuditContext } from "./context.ts"; import { createContext } from "./context.ts"; import { resolveWritePath } from "./fix.ts"; +import { type FileSource, readRepoText } from "./repo-files.ts"; import { type Issue, issue } from "./report.ts"; import { resolveReviewDependencies, reviewDependencyPatterns } from "./review-deps.ts"; import { @@ -118,10 +119,14 @@ function parseLock(content: string): ReviewProofLock | null { } } -function loadLock(root: string, relPath: string): ReviewProofLock | null { - const abs = resolveWritePath(root, relPath); - if (!existsSync(abs)) return null; - return parseLock(readFileSync(abs, "utf8")); +function loadLock( + root: string, + relPath: string, + source: FileSource = "worktree", +): ReviewProofLock | null { + const content = readRepoText(root, relPath, source); + if (content === null) return null; + return parseLock(content); } function hashDependencies(root: string, targets: string[]): Record { @@ -267,7 +272,15 @@ function validateEntry(input: { ); return issues; } - issues.push(...dependencyHashIssues({ root: ctx.root, relPath, targets: currentTargets, entry })); + issues.push( + ...dependencyHashIssues({ + root: ctx.root, + relPath, + targets: currentTargets, + entry, + fileSource: ctx.fileSource, + }), + ); return issues; } @@ -318,12 +331,14 @@ function dependencyHashIssues(input: { relPath: string; targets: string[]; entry: ReviewProofEntry; + fileSource?: FileSource; }): Issue[] { const { root, relPath, targets, entry } = input; + const source = input.fileSource ?? "worktree"; const issues: Issue[] = []; for (const target of targets) { - const abs = resolveWritePath(root, target); - if (!existsSync(abs)) { + const content = readRepoText(root, target, source); + if (content === null) { issues.push( issue("review-proof", relPath, { code: "review-dependency-missing", @@ -331,7 +346,7 @@ function dependencyHashIssues(input: { link: target, }), ); - } else if (entry.reviewDependencies[target] !== hash(readFileSync(abs, "utf8"))) { + } else if (entry.reviewDependencies[target] !== hash(content)) { issues.push(changedDependencyIssue(relPath, target)); } } @@ -341,8 +356,9 @@ function dependencyHashIssues(input: { export function runReviewProofRule(ctx: AuditContext): Issue[] { if (!ctx.config.reviewProof) return []; const relLock = lockPath(ctx); - const absLock = resolveWritePath(ctx.root, relLock); - if (!existsSync(absLock)) { + const source = ctx.fileSource ?? "worktree"; + const lockContent = readRepoText(ctx.root, relLock, source); + if (lockContent === null) { return [ issue("review-proof", relLock, { code: "review-proof-lock-missing", @@ -350,7 +366,7 @@ export function runReviewProofRule(ctx: AuditContext): Issue[] { }), ]; } - const lock = parseLock(readFileSync(absLock, "utf8")); + const lock = parseLock(lockContent); if (!lock) { return [ issue("review-proof", relLock, { @@ -362,10 +378,9 @@ export function runReviewProofRule(ctx: AuditContext): Issue[] { const issues: Issue[] = []; for (const relPath of ctx.docMetaPaths) { - const abs = resolveWritePath(ctx.root, relPath); - if (!existsSync(abs)) continue; - const content = readFileSync(abs, "utf8"); - const entry = lock.documents[normalizeRelPath(relative(ctx.root, abs))]; + const content = readRepoText(ctx.root, relPath, source); + if (content === null) continue; + const entry = lock.documents[normalizeRelPath(relPath)]; if (!entry) { issues.push( issue("review-proof", relPath, { diff --git a/src/audit/rules/index.ts b/src/audit/rules/index.ts index 73538e1..5539f86 100644 --- a/src/audit/rules/index.ts +++ b/src/audit/rules/index.ts @@ -5,6 +5,7 @@ import { docMetaRule } from "./doc-meta.ts"; import { linksRule } from "./links.ts"; import { nearDuplicateRule } from "./near-duplicate.ts"; import { prosePolicyRule } from "./prose-policy.ts"; +import { reviewCoverageRule } from "./review-coverage.ts"; import { reviewDepsRule } from "./review-deps.ts"; import { reviewProofRule } from "./review-proof.ts"; import { coverageGapsRule } from "./scan-gaps.ts"; @@ -34,6 +35,7 @@ export const docsRules: AuditRule[] = [ { ...nearDuplicateRule, global: true }, { ...ssotSummaryRule, global: true }, { ...coverageGapsRule, global: true }, + { ...reviewCoverageRule, global: true }, linksRule, docMetaRule, reviewProofRule, diff --git a/src/audit/rules/review-coverage.ts b/src/audit/rules/review-coverage.ts new file mode 100644 index 0000000..749c8f6 --- /dev/null +++ b/src/audit/rules/review-coverage.ts @@ -0,0 +1,31 @@ +import type { AuditContext } from "../core/context.ts"; +import { type Issue, issue } from "../core/report.ts"; +import { + collectReviewCoverageFiles, + collectReviewDependencyPatterns, + pathHasReviewOwner, +} from "../core/review-coverage.ts"; + +export function runReviewCoverageRule(ctx: AuditContext): Issue[] { + const patterns = collectReviewDependencyPatterns({ + root: ctx.root, + config: ctx.config, + skillIndex: ctx.skillIndex, + fileSource: ctx.fileSource, + }); + return collectReviewCoverageFiles(ctx.root, ctx.config) + .filter((path) => !pathHasReviewOwner(path, patterns)) + .map((path) => + issue("review-coverage", path, { + code: "review-coverage-gap", + message: + "file has no owning document; add a review-deps path or glob on the paper that describes it", + }), + ); +} + +export const reviewCoverageRule = { + id: "review-coverage", + global: true, + run: runReviewCoverageRule, +}; diff --git a/src/audit/run.ts b/src/audit/run.ts index 635020f..3778d0b 100644 --- a/src/audit/run.ts +++ b/src/audit/run.ts @@ -1,13 +1,12 @@ -import process from "node:process"; -import { catalogAuditWarnings, checkCatalog } from "../catalog.ts"; +import { refreshLocalCatalog } from "../catalog.ts"; import { loadPlugins } from "../plugins/load.ts"; import type { AuditResult, CatalogStatus, ReviewProofResult } from "../result-types.ts"; import type { SkeletonConfig } from "./config/types.ts"; import { createContext } from "./core/context.ts"; import { applyFixes, fixKindsForOnly, parseFixKinds } from "./core/fix.ts"; +import type { FileSource } from "./core/repo-files.ts"; import { finalizeIssues, issue, printReport } from "./core/report.ts"; import { attestDocuments } from "./core/review-proof.ts"; -import { CATALOG_REL_PATH } from "./core/shared.ts"; import { rulesForSuite } from "./rules/index.ts"; import { skillAuditSuffix } from "./rules/skill-index.ts"; @@ -23,6 +22,7 @@ export interface AuditCliOptions { fix?: string | true | null; dryRun?: boolean; confirmReviewed?: boolean; + fileSource?: FileSource; } function parseFixArg(argv: string[], index: number): { fix: string | true; nextIndex: number } { @@ -150,11 +150,7 @@ function labelForSuite(suite: string): string { function catalogStatusFor(root: string, suite: string): CatalogStatus { if (suite !== "docs" && suite !== "self") return "not-applicable"; - if (process.env.CI === "true") return "skipped-ci"; - const result = checkCatalog(root); - if (result.missing) return "missing"; - if (result.stale) return "stale"; - return "current"; + return refreshLocalCatalog(root); } function buildAuditResult(input: { @@ -282,6 +278,7 @@ export async function evaluateAudit(options: AuditCliOptions): Promise rule.id); return buildAuditResult({ diff --git a/src/catalog.ts b/src/catalog.ts index 29738b5..aaa7eef 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -68,19 +68,11 @@ export function writeCatalog(root: string): { path: string; entries: SsotFileEnt return { path: normalizeRelPath(CATALOG_REL_PATH), entries }; } -/** Warn-only catalog drift for local audit; skip when CI=true. */ -export function catalogAuditWarnings(root: string): string[] { - if (process.env.CI === "true") return []; - const result = checkCatalog(root); - if (result.missing) { - return [ - `${CATALOG_REL_PATH} missing — run \`skeleton catalog\` so agents can skim SSOT summaries`, - ]; - } - if (result.stale) { - return [`${CATALOG_REL_PATH} outdated — run \`skeleton catalog\` to refresh`]; - } - return []; +/** Write the gitignored catalog on local machine runs. Skip when CI=true. */ +export function refreshLocalCatalog(root: string): "current" | "skipped-ci" { + if (process.env.CI === "true") return "skipped-ci"; + writeCatalog(root); + return "current"; } // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: write and strict/non-strict check modes share one CLI seam diff --git a/src/init/init.ts b/src/init/init.ts index ae4f5e3..03ed767 100644 --- a/src/init/init.ts +++ b/src/init/init.ts @@ -8,6 +8,7 @@ import { mergeHookConfigs, mergePackageJsonScripts, } from "./merge-hooks.ts"; +import { mergePrecommitConfig } from "./merge-precommit.ts"; import { resolvePackageRoot, resolveTemplatesDir } from "./package-paths.ts"; import { resolveHookCommand } from "./resolve-hook-command.ts"; import { skillsAddArgs } from "./skills-args.ts"; @@ -28,6 +29,7 @@ export interface InitResult { hooks: MergeHookResult[]; scripts: MergeAction; skills: "installed" | "skipped"; + precommit: MergeAction; } function writeScaffold(cwd: string): "created" | "skipped" { @@ -106,11 +108,12 @@ export function runInit(options: InitOptions = {}): InitResult { const hookCommand = resolveHookCommand(cwd); const hooks = mergeHookConfigs({ cwd, hookCommand, forceHooks: options.forceHooks }); const scripts = mergePackageJsonScripts(cwd); + const precommit = mergePrecommitConfig(cwd); for (const result of hooks) logHookMergeResult(result); if (scaffold === "created") { - console.log("init: wrote skeleton.toml (hooks optional — see docs)"); + console.log("init: wrote skeleton.toml (IDE customize hooks optional)"); } else { console.log("init: skeleton.toml or .skeleton/ already present — skipped scaffold write"); } @@ -119,6 +122,12 @@ export function runInit(options: InitOptions = {}): InitResult { console.log("init: merged validate/audit scripts into package.json"); } + if (precommit === "added") { + console.log("init: wrote .pre-commit-config.yaml (run pre-commit install once per machine)"); + } else if (precommit === "updated") { + console.log("init: added skeleton validate hook to .pre-commit-config.yaml"); + } + const skills = installSkillsIfRequested(options, cwd); - return { scaffold, hooks, scripts, skills }; + return { scaffold, hooks, scripts, skills, precommit }; } diff --git a/src/init/merge-precommit.ts b/src/init/merge-precommit.ts new file mode 100644 index 0000000..e4f48d9 --- /dev/null +++ b/src/init/merge-precommit.ts @@ -0,0 +1,31 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { MergeAction } from "./merge-hooks.ts"; +import { resolveTemplatesDir } from "./package-paths.ts"; + +const TEMPLATES_DIR = resolveTemplatesDir(); +const PRECOMMIT_NAME = ".pre-commit-config.yaml"; +const HOOK_ID = "id: skeleton-validate-staged"; + +const LOCAL_HOOK_BLOCK = ` - repo: local + hooks: + - id: skeleton-validate-staged + name: skeleton validate changed (staged) + entry: node node_modules/@csark0812/skeleton/dist/cli.js validate changed --staged + language: system + pass_filenames: false +`; + +export function mergePrecommitConfig(cwd: string): MergeAction { + const target = join(cwd, PRECOMMIT_NAME); + const template = readFileSync(join(TEMPLATES_DIR, "pre-commit-config.yaml"), "utf8"); + if (!existsSync(target)) { + writeFileSync(target, template, "utf8"); + return "added"; + } + const existing = readFileSync(target, "utf8"); + if (existing.includes(HOOK_ID)) return "skipped"; + const suffix = existing.includes("repos:") ? `\n${LOCAL_HOOK_BLOCK}` : `\n${template}`; + writeFileSync(target, `${existing.trimEnd()}${suffix}`, "utf8"); + return "updated"; +} diff --git a/src/validate/changed.ts b/src/validate/changed.ts index 976f3f5..ea89967 100644 --- a/src/validate/changed.ts +++ b/src/validate/changed.ts @@ -3,7 +3,13 @@ import { existsSync, readFileSync } from "node:fs"; import { basename, extname, join } from "node:path"; import { findRepoRoot, loadConfig } from "../audit/config/load.ts"; import { collectScanFiles, relPath as relPathFromAbs } from "../audit/core/collect.ts"; -import { type Issue, issue } from "../audit/core/report.ts"; +import { type FileSource, readRepoText } from "../audit/core/repo-files.ts"; +import { type Issue, isRereadIssue, issue, printReport } from "../audit/core/report.ts"; +import { + collectReviewDependencyPatterns, + pathHasReviewOwner, + pathRequiresReviewCoverage, +} from "../audit/core/review-coverage.ts"; import { reviewDependencyMatchesPath, reviewDependencyPatterns, @@ -19,9 +25,11 @@ import { } from "../audit/core/skill-roots.ts"; import { loadPolicyFile } from "../audit/policies/load.ts"; import { evaluateAudit, printAuditResult } from "../audit/run.ts"; +import { refreshLocalCatalog } from "../catalog.ts"; import { collectWiredPolicyRelPaths } from "../plugins/load.ts"; import type { AuditResult } from "../result-types.ts"; import { type ChangedGitPath, gitDiffChangedFiles } from "./git-diff.ts"; +import { stageRequiredDiagnostics } from "./staged.ts"; const DOC_EXTENSIONS = new Set([".md", ".mdc", ".yaml", ".yml"]); const POLICY_EXTENSIONS = new Set([".yaml", ".yml"]); @@ -172,27 +180,46 @@ function validationIssue(code: string, file: string, message: string): Issue { return issue("validate-changed", file, { code, message, severity: "error" }); } -function validateJson(relPath: string, root: string): Issue | null { - const abs = join(root, relPath); +function rereadValidationIssue(file: string, target: string): Issue { + return issue("validate-changed", file, { + code: "impacted-document-review-required", + message: + "a linked review dependency changed; re-read the entire document, then attest it with --fix=doc-meta --confirm-reviewed and include the document in validation", + severity: "error", + link: target, + }); +} + +function validateJson(relPath: string, root: string, fileSource: FileSource): Issue | null { + const content = readRepoText(root, relPath, fileSource); + if (content === null) return validationIssue("invalid-json", relPath, "path not found"); try { - parseJsonContent(readFileSync(abs, "utf8")); + parseJsonContent(content); return null; } catch (error) { return validationIssue("invalid-json", relPath, `invalid JSON: ${error}`); } } -function validatePolicy(relPath: string, root: string): Issue | null { - const abs = join(root, relPath); +function validatePolicy(relPath: string, root: string, fileSource: FileSource): Issue | null { + const content = readRepoText(root, relPath, fileSource); + if (content === null) return validationIssue("invalid-policy", relPath, "path not found"); try { - loadPolicyFile(abs, readFileSync(abs, "utf8")); + loadPolicyFile(join(root, relPath), content); return null; } catch (error) { return validationIssue("invalid-policy", relPath, `invalid policy: ${error}`); } } -function validateShell(relPath: string, root: string): Issue | null { +function validateShell(relPath: string, root: string, fileSource: FileSource): Issue | null { + if (fileSource === "index") { + const content = readRepoText(root, relPath, fileSource); + if (content === null) return validationIssue("invalid-shell", relPath, "path not found"); + const bash = spawnSync("bash", ["-n"], { input: content, encoding: "utf8" }); + if (bash.status === 0) return null; + return validationIssue("invalid-shell", relPath, `shell syntax check failed: ${bash.stderr}`); + } const abs = join(root, relPath); const shellcheck = spawnSync("shellcheck", [abs], { encoding: "utf8" }); if (shellcheck.status === 0) return null; @@ -335,18 +362,22 @@ function classifyPaths(ctx: ClassifyContext): PathClassification { return state; } -function validateLocalBuckets(buckets: Record, root: string): Issue[] { +function validateLocalBuckets( + buckets: Record, + root: string, + fileSource: FileSource, +): Issue[] { const diagnostics: Issue[] = []; for (const relPath of buckets.shell) { - const found = validateShell(relPath, root); + const found = validateShell(relPath, root, fileSource); if (found) diagnostics.push(found); } for (const relPath of buckets.json) { - const found = validateJson(relPath, root); + const found = validateJson(relPath, root, fileSource); if (found) diagnostics.push(found); } for (const relPath of buckets.policy) { - const found = validatePolicy(relPath, root); + const found = validatePolicy(relPath, root, fileSource); if (found) diagnostics.push(found); } return diagnostics; @@ -367,24 +398,33 @@ function discoverImpactedDocuments(input: { root: string; config: ReturnType; skillIndex: SkillIndex; + fileSource: FileSource; }): ImpactedDocument[] { const changed = new Set(input.relPaths.map(normalizeRelPath)); const impacted: ImpactedDocument[] = []; for (const abs of collectScanFiles(input.config, input.root, input.skillIndex)) { - const document = impactedDocumentForPath(abs, input.root, changed); + const document = impactedDocumentForPath({ + abs, + root: input.root, + changed, + fileSource: input.fileSource, + }); if (document) impacted.push(document); } return impacted.sort((a, b) => a.path.localeCompare(b.path)); } -function impactedDocumentForPath( - abs: string, - root: string, - changed: Set, -): ImpactedDocument | null { - const path = relPathFromAbs(abs, root); - const reviewDependencies = reviewDependencyPatterns(readFileSync(abs, "utf8")); - const reasons = impactReasons(path, reviewDependencies, changed); +function impactedDocumentForPath(input: { + abs: string; + root: string; + changed: Set; + fileSource: FileSource; +}): ImpactedDocument | null { + const path = relPathFromAbs(input.abs, input.root); + const content = readRepoText(input.root, path, input.fileSource); + if (content === null) return null; + const reviewDependencies = reviewDependencyPatterns(content); + const reasons = impactReasons(path, reviewDependencies, input.changed); return reasons.length > 0 ? { path, reviewDependencies, reasons } : null; } @@ -411,31 +451,71 @@ function dateModeImpactDiagnostics(input: { impactedDocuments: ImpactedDocument[]; relPaths: string[]; root: string; + fileSource: FileSource; }): Issue[] { if (input.config.reviewProof) return []; const changed = new Set(input.relPaths.map(normalizeRelPath)); const today = formatLocalReviewDate(new Date()); - const diagnostics: Issue[] = []; - for (const impacted of input.impactedDocuments) { - if (!impacted.reasons.some((reason) => reason.kind === "changed-review-dependency")) continue; - const content = readFileSync(join(input.root, impacted.path), "utf8"); - if (changed.has(impacted.path) && docMetaLastReviewed(content) === today) continue; - diagnostics.push( + return input.impactedDocuments.flatMap((impacted) => + dateModeIssuesForDocument({ + impacted, + changed, + today, + root: input.root, + fileSource: input.fileSource, + }), + ); +} + +function dateModeIssuesForDocument(input: { + impacted: ImpactedDocument; + changed: Set; + today: string; + root: string; + fileSource: FileSource; +}): Issue[] { + const content = readRepoText(input.root, input.impacted.path, input.fileSource); + if (!content) { + return input.impacted.reasons + .filter((reason) => reason.kind === "changed-review-dependency" && reason.target) + .map((reason) => rereadValidationIssue(input.impacted.path, reason.target ?? "")); + } + if (input.changed.has(input.impacted.path) && docMetaLastReviewed(content) === input.today) { + return []; + } + const issues: Issue[] = []; + for (const reason of input.impacted.reasons) { + if (reason.kind !== "changed-review-dependency" || !reason.target) continue; + issues.push(rereadValidationIssue(input.impacted.path, reason.target)); + } + return issues; +} + +function uncoveredChangedPathDiagnostics( + relPaths: string[], + config: ReturnType, + patterns: string[], +): Issue[] { + return relPaths + .map(normalizeRelPath) + .filter((path) => pathRequiresReviewCoverage(path, config)) + .filter((path) => !pathHasReviewOwner(path, patterns)) + .map((path) => validationIssue( - "impacted-document-review-required", - impacted.path, - "a linked review dependency changed; re-read the entire document, then attest it with --fix=doc-meta --confirm-reviewed and include the document in validation", + "uncovered-changed-path", + path, + "no scanned document claims this path with review-deps. Add a review-deps marker on the owning paper.", ), ); - } - return diagnostics; } -function classificationDiagnostics( - classification: PathClassification, - root: string, - base?: string, -): Issue[] { +function classificationDiagnostics(input: { + classification: PathClassification; + root: string; + base: string | undefined; + coverageCandidateCount: number; +}): Issue[] { + const { classification, root, base, coverageCandidateCount } = input; const diagnostics: Issue[] = []; for (const orphan of classification.orphans) { diagnostics.push( @@ -453,6 +533,7 @@ function classificationDiagnostics( if ( (classification.skipped.length > 0 || classification.buckets.code.length > 0) && audited === 0 && + coverageCandidateCount === 0 && !base ) { diagnostics.push( @@ -486,7 +567,7 @@ function auditOptions( suite: "docs" | "skills" | "self", root: string, paths: string[], - extra: { globalOnly?: boolean; pathScopedOnly?: boolean } = {}, + extra: { globalOnly?: boolean; pathScopedOnly?: boolean; fileSource?: FileSource } = {}, ) { return { suite, @@ -499,64 +580,87 @@ function auditOptions( }; } -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity lint/complexity/noExcessiveLinesPerFunction: branches correspond directly to public validation buckets +async function auditSkillChanges(input: { + skills: string[]; + root: string; + base?: string; + fileSource: FileSource; +}): Promise { + const sourceOpt = { fileSource: input.fileSource }; + if (input.skills.length === 0) return []; + if (!input.base) { + return [await evaluateAudit(auditOptions("skills", input.root, [], sourceOpt))]; + } + return [ + await evaluateAudit( + auditOptions("skills", input.root, input.skills, { + pathScopedOnly: true, + ...sourceOpt, + }), + ), + ]; +} + +async function auditPolicyChanges(input: { + root: string; + skillIndex: SkillIndex; + fileSource: FileSource; +}): Promise { + const sourceOpt = { fileSource: input.fileSource }; + const audits = [await evaluateAudit(auditOptions("docs", input.root, [], sourceOpt))]; + const skillPaths = listSkillMarkdownPaths(input.root, input.skillIndex); + if (skillPaths.length === 0) return audits; + audits.push( + await evaluateAudit( + auditOptions("skills", input.root, skillPaths, { + pathScopedOnly: true, + ...sourceOpt, + }), + ), + ); + return audits; +} + async function evaluateBucketAudits(input: { classification: PathClassification; root: string; skillIndex: SkillIndex; base?: string; + fileSource: FileSource; }): Promise<{ audits: AuditResult[]; diagnostics: Issue[] }> { - const { classification, root, skillIndex, base } = input; + const { classification, root, skillIndex, base, fileSource } = input; const audits: AuditResult[] = []; - const diagnostics = validateLocalBuckets(classification.buckets, root); + const diagnostics = validateLocalBuckets(classification.buckets, root, fileSource); + const sourceOpt = { fileSource }; if (base) { - audits.push(await evaluateAudit(auditOptions("self", root, [], { globalOnly: true }))); + audits.push( + await evaluateAudit(auditOptions("self", root, [], { globalOnly: true, ...sourceOpt })), + ); } if (classification.buckets.docs.length > 0) { audits.push( await evaluateAudit( - auditOptions("docs", root, classification.buckets.docs, { pathScopedOnly: true }), + auditOptions("docs", root, classification.buckets.docs, { + pathScopedOnly: true, + ...sourceOpt, + }), ), ); } - if (classification.buckets.skills.length > 0) { - if (!base) { - diagnostics.push( - validationIssue( - "full-skills-audit-required", - classification.buckets.skills[0] ?? ".", - "skill paths need the full skills suite; run skeleton audit skills (audit self covers docs and .skeleton; excluded skill trees still need audit skills)", - ), - ); - } else { - audits.push( - await evaluateAudit( - auditOptions("skills", root, classification.buckets.skills, { - pathScopedOnly: true, - }), - ), - ); - } - } - if (classification.buckets.policy.length > 0) { - if (!base) { - diagnostics.push( - validationIssue( - "full-policy-audit-required", - classification.buckets.policy[0] ?? ".", - "policy YAML changes need full prose passes; run skeleton audit docs and skeleton audit skills", - ), - ); - } else { - audits.push(await evaluateAudit(auditOptions("docs", root, []))); - const skillPaths = listSkillMarkdownPaths(root, skillIndex); - if (skillPaths.length > 0) { - audits.push( - await evaluateAudit(auditOptions("skills", root, skillPaths, { pathScopedOnly: true })), - ); - } - } + audits.push( + ...(await auditSkillChanges({ + skills: classification.buckets.skills, + root, + base, + fileSource, + })), + ); + if ( + classification.buckets.policy.length > 0 && + !diagnostics.some((item) => item.code === "invalid-policy") + ) { + audits.push(...(await auditPolicyChanges({ root, skillIndex, fileSource }))); } return { audits, diagnostics }; } @@ -609,6 +713,7 @@ export async function evaluateValidateChanged( options: ValidateChangedOptions = {}, ): Promise { const root = options.root ?? findRepoRoot(); + refreshLocalCatalog(root); const resolvedPaths = resolvePaths(options); const relPaths = resolvedPaths.paths; @@ -618,6 +723,7 @@ export async function evaluateValidateChanged( const config = loadConfig(root); const skillIndex = buildSkillIndex(root, config.skillOwnership); + const fileSource: FileSource = options.staged ? "index" : "worktree"; let wiredPolicies: Set; try { wiredPolicies = await collectWiredPolicyRelPaths(root, config); @@ -645,7 +751,13 @@ export async function evaluateValidateChanged( classification.missing = classification.missing.filter( (path) => !resolvedPaths.deleted.has(path), ); - const impactedDocuments = discoverImpactedDocuments({ relPaths, root, config, skillIndex }); + const impactedDocuments = discoverImpactedDocuments({ + relPaths, + root, + config, + skillIndex, + fileSource, + }); for (const impacted of impactedDocuments) { if (!classification.buckets.docs.includes(impacted.path)) { classification.buckets.docs.push(impacted.path); @@ -653,25 +765,41 @@ export async function evaluateValidateChanged( } classification.buckets.docs.sort(); - const diagnostics = classificationDiagnostics(classification, root, options.base); - if (diagnostics.length > 0) { - return resultFor({ - options, - relPaths, - classification: publicClassification(classification), - impactedDocuments, - diagnostics, - }); - } + const ownerPatterns = collectReviewDependencyPatterns({ + root, + config, + skillIndex, + fileSource, + }); + const coverageCandidateCount = relPaths.filter((path) => + pathRequiresReviewCoverage(path, config), + ).length; + const diagnostics = [ + ...classificationDiagnostics({ + classification, + root, + base: options.base, + coverageCandidateCount, + }), + ...uncoveredChangedPathDiagnostics(relPaths, config, ownerPatterns), + ...stageRequiredDiagnostics({ + staged: options.staged ?? false, + stagedPaths: relPaths, + impactedDocuments: impactedDocuments.map((item) => item.path), + config, + root, + }), + ]; const evaluated = await evaluateBucketAudits({ classification, root, skillIndex, base: options.base, + fileSource, }); evaluated.diagnostics.push( - ...dateModeImpactDiagnostics({ config, impactedDocuments, relPaths, root }), + ...dateModeImpactDiagnostics({ config, impactedDocuments, relPaths, root, fileSource }), ); return resultFor({ options, @@ -679,7 +807,7 @@ export async function evaluateValidateChanged( classification: publicClassification(classification), impactedDocuments, audits: evaluated.audits, - diagnostics: evaluated.diagnostics, + diagnostics: [...diagnostics, ...evaluated.diagnostics], }); } @@ -694,16 +822,19 @@ export function printValidateChangedResult(result: ValidateChangedResult): numbe `validate changed: skipping foreign skill ${path} (owned upstream; see skills-lock.json / skillOwnership)`, ); } - for (const audit of result.audits) printAuditResult(audit, false); - for (const impacted of result.impactedDocuments) { - for (const reason of impacted.reasons) { - if (reason.kind !== "changed-review-dependency") continue; - console.log( - `validate changed: ${impacted.path} requires review (dependency ${reason.dependency} matched ${reason.target})`, - ); + if (result.ok) { + for (const audit of result.audits) printAuditResult(audit, false); + } else { + for (const audit of result.audits.filter((item) => !item.ok)) { + printAuditResult(audit, false); } } - for (const diagnostic of result.diagnostics) { + const rereadDiagnostics = result.diagnostics.filter(isRereadIssue); + const otherDiagnostics = result.diagnostics.filter((item) => !isRereadIssue(item)); + if (rereadDiagnostics.length > 0) { + printReport(rereadDiagnostics, { label: "validate changed" }); + } + for (const diagnostic of otherDiagnostics) { const path = diagnostic.file === "." ? "" : `${diagnostic.file}: `; console.error(`validate changed: ${path}${diagnostic.message}`); } diff --git a/src/validate/staged.ts b/src/validate/staged.ts new file mode 100644 index 0000000..93b3309 --- /dev/null +++ b/src/validate/staged.ts @@ -0,0 +1,35 @@ +import type { SkeletonConfig } from "../audit/config/types.ts"; +import { pathDiffersFromHead } from "../audit/core/repo-files.ts"; +import { type Issue, issue } from "../audit/core/report.ts"; +import { DEFAULT_REVIEW_LOCKFILE } from "../audit/core/review-proof.ts"; +import { normalizeRelPath } from "../audit/core/shared.ts"; + +function stageRequiredIssue(file: string): Issue { + return issue("validate-changed", file, { + code: "stage-required", + message: + "worktree differs from HEAD and is not staged; stage the attested document and review-lock.json when hash mode is on", + }); +} + +export function stageRequiredDiagnostics(input: { + staged: boolean; + stagedPaths: string[]; + impactedDocuments: string[]; + config: SkeletonConfig; + root: string; +}): Issue[] { + if (!input.staged) return []; + const staged = new Set(input.stagedPaths.map(normalizeRelPath)); + const required = [...input.impactedDocuments]; + if (input.config.reviewProof) { + required.push(input.config.reviewProof.lockfile ?? DEFAULT_REVIEW_LOCKFILE); + } + const issues: Issue[] = []; + for (const path of [...new Set(required)].sort()) { + if (staged.has(path)) continue; + if (!pathDiffersFromHead(input.root, path)) continue; + issues.push(stageRequiredIssue(path)); + } + return issues; +} diff --git a/templates/skeleton-init/pre-commit-config.yaml b/templates/skeleton-init/pre-commit-config.yaml new file mode 100644 index 0000000..9b141e8 --- /dev/null +++ b/templates/skeleton-init/pre-commit-config.yaml @@ -0,0 +1,9 @@ +# Installed by `skeleton init`. Run `pre-commit install` once per machine. +repos: + - repo: local + hooks: + - id: skeleton-validate-staged + name: skeleton validate changed (staged) + entry: node node_modules/@csark0812/skeleton/dist/cli.js validate changed --staged + language: system + pass_filenames: false diff --git a/templates/skeleton-init/skeleton.toml b/templates/skeleton-init/skeleton.toml index f12cc41..e06f230 100644 --- a/templates/skeleton-init/skeleton.toml +++ b/templates/skeleton-init/skeleton.toml @@ -15,12 +15,15 @@ exclude = [ [deny] paths = [] -# Optional but recommended: make review claims verifiable against exact document -# and review-dependency bytes. Commit the generated lockfile. -# [reviewProof] -# mode = "hash" +[reviewProof] +mode = "hash" # lockfile = ".skeleton/review-lock.json" +# Defaults apply when this section is omitted. Empty include disables the gate. +# [reviewCoverage] +# include = ["**/*.{ts,tsx,js,jsx,mjs,cjs,py}", "package.json", "project.json"] +# exclude = ["**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/fixtures/**", "templates/**"] + # Optional: basenames under .skeleton/customize/ appended on every skill inject # [customize] # alwaysInclude = ["shared-agent-references.md"]