@skill-graph/cli 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/LICENSE +200 -0
  3. package/NOTICE +62 -0
  4. package/README.md +398 -0
  5. package/SKILL_GRAPH.md +443 -0
  6. package/bin/skill-graph.js +374 -0
  7. package/docs/ADOPTION.md +117 -0
  8. package/docs/CONFORMANCE.md +66 -0
  9. package/docs/PRIMER.md +384 -0
  10. package/docs/QUICKSTART-30MIN.md +333 -0
  11. package/docs/ROUTING-METRICS.md +120 -0
  12. package/docs/SKILL-MD-FORMAT-COMPATIBILITY.md +127 -0
  13. package/docs/SKILL_AUDIT_CHECKLIST.md +199 -0
  14. package/docs/SKILL_AUDIT_LOOP.md +195 -0
  15. package/docs/SKILL_METADATA_PROTOCOL.md +609 -0
  16. package/docs/_archived/marketplace-publication-priority-2026-05-18.md +239 -0
  17. package/docs/adr/0001-predicate-set.md +69 -0
  18. package/docs/adr/0002-json-ld-context.md +82 -0
  19. package/docs/adr/0003-ontoclean-rigidity-tags.md +65 -0
  20. package/docs/adr/0004-persistent-identifiers.md +74 -0
  21. package/docs/adr/0005-freshness-consolidation.md +70 -0
  22. package/docs/adr/0006-revise-predicate-rename.md +105 -0
  23. package/docs/adr/0007-audit-loop-cadence.md +99 -0
  24. package/docs/adr/0008-skill-surface-split-and-curation-policy.md +93 -0
  25. package/docs/category-consumers.md +168 -0
  26. package/docs/concept-map.md +194 -0
  27. package/docs/diagrams/drift-states.mmd +21 -0
  28. package/docs/diagrams/manifest-pipeline.mmd +25 -0
  29. package/docs/diagrams/routing-harness.mmd +41 -0
  30. package/docs/diagrams/starter-graph.mmd +53 -0
  31. package/docs/field-decision-guide.md +315 -0
  32. package/docs/field-rationale.md +211 -0
  33. package/docs/field-reference.generated.md +624 -0
  34. package/docs/field-reference.md +1426 -0
  35. package/docs/glossary.md +190 -0
  36. package/docs/head-noun-glossary.md +63 -0
  37. package/docs/images/audit-phases.png +0 -0
  38. package/docs/images/drift-states.png +0 -0
  39. package/docs/images/graded-mode.png +0 -0
  40. package/docs/images/manifest-pipeline.png +0 -0
  41. package/docs/images/routing-harness.png +0 -0
  42. package/docs/images/skill-anatomy.png +0 -0
  43. package/docs/images/starter-graph.png +0 -0
  44. package/docs/images/system-model.png +0 -0
  45. package/docs/integrations/github-actions.md +155 -0
  46. package/docs/manifest-field-mapping.md +443 -0
  47. package/docs/marketplace-publication-queue.generated.md +240 -0
  48. package/docs/marketplace-release-agent-prompt.md +82 -0
  49. package/docs/marketplace-skill-candidate-list.md +272 -0
  50. package/docs/marketplace-syndication.md +222 -0
  51. package/docs/migration-sample-review.md +155 -0
  52. package/docs/migrations/v4-to-v5.md +168 -0
  53. package/docs/migrations/v5-to-v6.md +221 -0
  54. package/docs/name-exceptions.yaml +37 -0
  55. package/docs/plans/marketplace-p1-public-migration-plan.md +41 -0
  56. package/docs/plans/multi-root-workspace.md +148 -0
  57. package/docs/plans/scripts-roadmap.md +107 -0
  58. package/docs/plans/v4-schema-bump.md +160 -0
  59. package/docs/plans/wave-2-extraction.md +122 -0
  60. package/docs/positioning-vs-marketplaces.md +175 -0
  61. package/docs/proposals/skill-audit-loop-positioning.md +160 -0
  62. package/docs/quality-doctrine.md +138 -0
  63. package/docs/recommended-skills.md +150 -0
  64. package/docs/research/skill-comprehension-eval-research.md +1830 -0
  65. package/docs/research/skill-retrieval-evidence.md +66 -0
  66. package/docs/skill-metadata-protocol.md +471 -0
  67. package/docs/skills-sh-maintainer-cleanup-request.md +80 -0
  68. package/examples/audits/a11y/findings.md +52 -0
  69. package/examples/audits/a11y/scorecard.md +21 -0
  70. package/examples/audits/a11y/verdict.md +44 -0
  71. package/examples/audits/debugging/findings.md +59 -0
  72. package/examples/audits/debugging/scorecard.md +22 -0
  73. package/examples/audits/debugging/verdict.md +33 -0
  74. package/examples/audits/documentation/findings.md +59 -0
  75. package/examples/audits/documentation/scorecard.md +22 -0
  76. package/examples/audits/documentation/verdict.md +33 -0
  77. package/examples/evals/a11y.json +140 -0
  78. package/examples/evals/api-design.json +52 -0
  79. package/examples/evals/code-review.json +52 -0
  80. package/examples/evals/data-modeling.json +52 -0
  81. package/examples/evals/database-migration.json +52 -0
  82. package/examples/evals/debugging.json +118 -0
  83. package/examples/evals/dependency-architecture.json +52 -0
  84. package/examples/evals/design-system-architecture.json +52 -0
  85. package/examples/evals/error-tracking.json +52 -0
  86. package/examples/evals/event-contract-design.json +52 -0
  87. package/examples/evals/form-ux-architecture.json +52 -0
  88. package/examples/evals/framework-fit-analysis.json +52 -0
  89. package/examples/evals/graph-audit.json +139 -0
  90. package/examples/evals/information-architecture.json +52 -0
  91. package/examples/evals/interaction-feedback.json +52 -0
  92. package/examples/evals/interaction-patterns.json +52 -0
  93. package/examples/evals/layout-composition.json +52 -0
  94. package/examples/evals/lint-overlay.json +117 -0
  95. package/examples/evals/microcopy.json +52 -0
  96. package/examples/evals/observability-modeling.json +52 -0
  97. package/examples/evals/pattern-recognition.json +96 -0
  98. package/examples/evals/performance-engineering.json +52 -0
  99. package/examples/evals/refactor.json +128 -0
  100. package/examples/evals/semiotics.json +52 -0
  101. package/examples/evals/skill-infrastructure.json +96 -0
  102. package/examples/evals/skill-router.json +140 -0
  103. package/examples/evals/skill-router.routing.json +113 -0
  104. package/examples/evals/system-interface-contracts.json +52 -0
  105. package/examples/evals/task-analysis.json +52 -0
  106. package/examples/evals/testing-strategy.json +118 -0
  107. package/examples/evals/type-safety.json +249 -0
  108. package/examples/evals/visual-design-foundations.json +52 -0
  109. package/examples/evals/webhook-integration.json +52 -0
  110. package/examples/exports/a11y.skill-md.md +80 -0
  111. package/examples/exports/debugging.skill-md.md +80 -0
  112. package/examples/exports/refactor.skill-md.md +78 -0
  113. package/examples/exports/testing-strategy.skill-md.md +81 -0
  114. package/examples/projects/markdown-static-site/README.md +115 -0
  115. package/examples/projects/markdown-static-site/skills/content-source-router/SKILL.md +131 -0
  116. package/examples/projects/markdown-static-site/skills/image-optimization-pipeline-config/SKILL.md +132 -0
  117. package/examples/projects/markdown-static-site/skills/link-rot-detection/SKILL.md +103 -0
  118. package/examples/projects/markdown-static-site/skills/markdown-post-frontmatter-validation/SKILL.md +133 -0
  119. package/examples/projects/markdown-static-site/skills/migrate-posts-to-v2-frontmatter/SKILL.md +140 -0
  120. package/examples/projects/saas-stripe-postgres/README.md +208 -0
  121. package/examples/projects/saas-stripe-postgres/db/migrations/0004_canonicalize_orders.sql +37 -0
  122. package/examples/projects/saas-stripe-postgres/db/schema.sql +112 -0
  123. package/examples/projects/saas-stripe-postgres/skills/migrate-orders-to-canonical-schema/SKILL.md +149 -0
  124. package/examples/projects/saas-stripe-postgres/skills/nextjs-server-action-validation/SKILL.md +154 -0
  125. package/examples/projects/saas-stripe-postgres/skills/payment-provider-router/SKILL.md +153 -0
  126. package/examples/projects/saas-stripe-postgres/skills/postgres-rls-pattern/SKILL.md +163 -0
  127. package/examples/projects/saas-stripe-postgres/skills/stripe-webhook-signature-verification/SKILL.md +137 -0
  128. package/examples/protocol/skill-metadata-template.md +301 -0
  129. package/examples/protocol/skills.manifest.sample.json +13245 -0
  130. package/examples/skill-metadata-template.md +317 -0
  131. package/examples/skills.manifest.sample.json +13519 -0
  132. package/examples/tests/v3-1-skos-fixture/SKILL.md +93 -0
  133. package/marketplace/README.md +17 -0
  134. package/marketplace/skills/a11y/SKILL.md +66 -0
  135. package/marketplace/skills/acid-fundamentals/SKILL.md +106 -0
  136. package/marketplace/skills/agent-engineering/SKILL.md +386 -0
  137. package/marketplace/skills/agent-eval-design/SKILL.md +55 -0
  138. package/marketplace/skills/ai-native-development/SKILL.md +294 -0
  139. package/marketplace/skills/api-design/SKILL.md +60 -0
  140. package/marketplace/skills/architecture-decision-records/SKILL.md +55 -0
  141. package/marketplace/skills/background-jobs/SKILL.md +265 -0
  142. package/marketplace/skills/bounded-context-mapping/SKILL.md +55 -0
  143. package/marketplace/skills/cap-theorem-tradeoffs/SKILL.md +127 -0
  144. package/marketplace/skills/client-server-boundary/SKILL.md +187 -0
  145. package/marketplace/skills/code-review/SKILL.md +120 -0
  146. package/marketplace/skills/color-system-design/SKILL.md +43 -0
  147. package/marketplace/skills/component-architecture/SKILL.md +126 -0
  148. package/marketplace/skills/compression/SKILL.md +112 -0
  149. package/marketplace/skills/conceptual-modeling/SKILL.md +181 -0
  150. package/marketplace/skills/connection-pooling/SKILL.md +105 -0
  151. package/marketplace/skills/constraint-awareness/SKILL.md +287 -0
  152. package/marketplace/skills/content-monitor/SKILL.md +209 -0
  153. package/marketplace/skills/context-engineering/SKILL.md +320 -0
  154. package/marketplace/skills/context-graph/SKILL.md +174 -0
  155. package/marketplace/skills/context-management/SKILL.md +174 -0
  156. package/marketplace/skills/context-window/SKILL.md +239 -0
  157. package/marketplace/skills/contract-testing/SKILL.md +120 -0
  158. package/marketplace/skills/cron-scheduling/SKILL.md +223 -0
  159. package/marketplace/skills/dark-mode-implementation/SKILL.md +47 -0
  160. package/marketplace/skills/data-modeling/SKILL.md +59 -0
  161. package/marketplace/skills/data-modeling-fundamentals/SKILL.md +117 -0
  162. package/marketplace/skills/database-migration/SKILL.md +429 -0
  163. package/marketplace/skills/debugging/SKILL.md +67 -0
  164. package/marketplace/skills/dependency-architecture/SKILL.md +58 -0
  165. package/marketplace/skills/design-module-composition/SKILL.md +43 -0
  166. package/marketplace/skills/design-system-architecture/SKILL.md +61 -0
  167. package/marketplace/skills/design-thinking/SKILL.md +44 -0
  168. package/marketplace/skills/diagnosis/SKILL.md +296 -0
  169. package/marketplace/skills/diff-analysis/SKILL.md +188 -0
  170. package/marketplace/skills/e2e-test-design/SKILL.md +113 -0
  171. package/marketplace/skills/entity-relationship-modeling/SKILL.md +218 -0
  172. package/marketplace/skills/epistemic-grounding/SKILL.md +112 -0
  173. package/marketplace/skills/error-boundary/SKILL.md +235 -0
  174. package/marketplace/skills/error-tracking/SKILL.md +261 -0
  175. package/marketplace/skills/eval-driven-development/SKILL.md +147 -0
  176. package/marketplace/skills/evaluation/SKILL.md +113 -0
  177. package/marketplace/skills/event-contract-design/SKILL.md +60 -0
  178. package/marketplace/skills/event-storming/SKILL.md +56 -0
  179. package/marketplace/skills/form-ux-architecture/SKILL.md +60 -0
  180. package/marketplace/skills/framework-fit-analysis/SKILL.md +59 -0
  181. package/marketplace/skills/frontend-architecture/SKILL.md +43 -0
  182. package/marketplace/skills/generative-ui/SKILL.md +118 -0
  183. package/marketplace/skills/graph-audit/SKILL.md +81 -0
  184. package/marketplace/skills/guardrails/SKILL.md +118 -0
  185. package/marketplace/skills/hooks-patterns/SKILL.md +185 -0
  186. package/marketplace/skills/http-semantics/SKILL.md +136 -0
  187. package/marketplace/skills/ideation/SKILL.md +41 -0
  188. package/marketplace/skills/indexing-strategy/SKILL.md +108 -0
  189. package/marketplace/skills/information-architecture/SKILL.md +59 -0
  190. package/marketplace/skills/integration-test-design/SKILL.md +111 -0
  191. package/marketplace/skills/intent-recognition/SKILL.md +136 -0
  192. package/marketplace/skills/interaction-feedback/SKILL.md +59 -0
  193. package/marketplace/skills/interaction-patterns/SKILL.md +59 -0
  194. package/marketplace/skills/journey-mapping/SKILL.md +41 -0
  195. package/marketplace/skills/keywords/SKILL.md +213 -0
  196. package/marketplace/skills/knowledge-modeling/SKILL.md +232 -0
  197. package/marketplace/skills/layout-composition/SKILL.md +59 -0
  198. package/marketplace/skills/linguistics/SKILL.md +429 -0
  199. package/marketplace/skills/lint-overlay/SKILL.md +76 -0
  200. package/marketplace/skills/mental-models/SKILL.md +126 -0
  201. package/marketplace/skills/merge-queue/SKILL.md +94 -0
  202. package/marketplace/skills/methodology/SKILL.md +317 -0
  203. package/marketplace/skills/microcopy/SKILL.md +232 -0
  204. package/marketplace/skills/middleware-patterns/SKILL.md +363 -0
  205. package/marketplace/skills/mobile-responsive-ux/SKILL.md +287 -0
  206. package/marketplace/skills/mutation-testing/SKILL.md +112 -0
  207. package/marketplace/skills/naming-conventions/SKILL.md +112 -0
  208. package/marketplace/skills/observability-modeling/SKILL.md +59 -0
  209. package/marketplace/skills/ontology-modeling/SKILL.md +67 -0
  210. package/marketplace/skills/owasp-security/SKILL.md +153 -0
  211. package/marketplace/skills/pattern-recognition/SKILL.md +472 -0
  212. package/marketplace/skills/performance-budgets/SKILL.md +185 -0
  213. package/marketplace/skills/performance-engineering/SKILL.md +58 -0
  214. package/marketplace/skills/performance-testing/SKILL.md +125 -0
  215. package/marketplace/skills/printify/SKILL.md +42 -0
  216. package/marketplace/skills/prioritization/SKILL.md +118 -0
  217. package/marketplace/skills/problem-framing/SKILL.md +41 -0
  218. package/marketplace/skills/problem-locating-solving/SKILL.md +203 -0
  219. package/marketplace/skills/project-knowledge-extraction/SKILL.md +54 -0
  220. package/marketplace/skills/prompt-craft/SKILL.md +134 -0
  221. package/marketplace/skills/prompt-injection-defense/SKILL.md +132 -0
  222. package/marketplace/skills/property-based-testing/SKILL.md +100 -0
  223. package/marketplace/skills/prototyping/SKILL.md +43 -0
  224. package/marketplace/skills/query-optimization/SKILL.md +144 -0
  225. package/marketplace/skills/real-time-updates/SKILL.md +324 -0
  226. package/marketplace/skills/ref-patterns/SKILL.md +284 -0
  227. package/marketplace/skills/refactor/SKILL.md +65 -0
  228. package/marketplace/skills/rendering-models/SKILL.md +142 -0
  229. package/marketplace/skills/replication-patterns/SKILL.md +110 -0
  230. package/marketplace/skills/research-synthesis/SKILL.md +41 -0
  231. package/marketplace/skills/route-handler-design/SKILL.md +347 -0
  232. package/marketplace/skills/schema-evolution/SKILL.md +140 -0
  233. package/marketplace/skills/security-fundamentals/SKILL.md +139 -0
  234. package/marketplace/skills/semantic-center/SKILL.md +194 -0
  235. package/marketplace/skills/semantic-relations/SKILL.md +250 -0
  236. package/marketplace/skills/semantics/SKILL.md +366 -0
  237. package/marketplace/skills/semiotics/SKILL.md +230 -0
  238. package/marketplace/skills/seo-strategy/SKILL.md +260 -0
  239. package/marketplace/skills/server-actions-design/SKILL.md +243 -0
  240. package/marketplace/skills/server-components-design/SKILL.md +190 -0
  241. package/marketplace/skills/sharding-strategy/SKILL.md +123 -0
  242. package/marketplace/skills/shopify/SKILL.md +42 -0
  243. package/marketplace/skills/skill-infrastructure/SKILL.md +320 -0
  244. package/marketplace/skills/skill-router/SKILL.md +71 -0
  245. package/marketplace/skills/skill-scaffold/SKILL.md +105 -0
  246. package/marketplace/skills/snapshot-testing/SKILL.md +120 -0
  247. package/marketplace/skills/spec-driven-development/SKILL.md +148 -0
  248. package/marketplace/skills/state-machine-modeling/SKILL.md +56 -0
  249. package/marketplace/skills/state-management/SKILL.md +134 -0
  250. package/marketplace/skills/streaming-architecture/SKILL.md +194 -0
  251. package/marketplace/skills/summarization/SKILL.md +156 -0
  252. package/marketplace/skills/suspense-patterns/SKILL.md +265 -0
  253. package/marketplace/skills/system-interface-contracts/SKILL.md +59 -0
  254. package/marketplace/skills/task-analysis/SKILL.md +201 -0
  255. package/marketplace/skills/taxonomy-design/SKILL.md +66 -0
  256. package/marketplace/skills/test-coverage-strategy/SKILL.md +108 -0
  257. package/marketplace/skills/test-doubles-design/SKILL.md +98 -0
  258. package/marketplace/skills/test-driven-development/SKILL.md +96 -0
  259. package/marketplace/skills/testing-strategy/SKILL.md +67 -0
  260. package/marketplace/skills/theme-system-design/SKILL.md +43 -0
  261. package/marketplace/skills/tool-call-flow/SKILL.md +229 -0
  262. package/marketplace/skills/tool-call-strategy/SKILL.md +292 -0
  263. package/marketplace/skills/transaction-isolation/SKILL.md +98 -0
  264. package/marketplace/skills/type-safety/SKILL.md +177 -0
  265. package/marketplace/skills/typography-system/SKILL.md +43 -0
  266. package/marketplace/skills/usability-testing/SKILL.md +43 -0
  267. package/marketplace/skills/user-research/SKILL.md +43 -0
  268. package/marketplace/skills/vercel-composition-patterns/SKILL.md +157 -0
  269. package/marketplace/skills/version-control/SKILL.md +233 -0
  270. package/marketplace/skills/visual-design-foundations/SKILL.md +59 -0
  271. package/marketplace/skills/visual-hierarchy/SKILL.md +43 -0
  272. package/marketplace/skills/webhook-integration/SKILL.md +331 -0
  273. package/marketplace/skills/writing-humanizer/SKILL.md +380 -0
  274. package/package.json +67 -0
  275. package/schemas/manifest.schema.json +811 -0
  276. package/schemas/manifest.v2.schema.json +164 -0
  277. package/schemas/manifest.v3.schema.json +758 -0
  278. package/schemas/manifest.v4.schema.json +755 -0
  279. package/schemas/manifest.v5.schema.json +755 -0
  280. package/schemas/manifest.v6.schema.json +811 -0
  281. package/schemas/skill.context.jsonld +279 -0
  282. package/schemas/skill.schema.json +919 -0
  283. package/schemas/skill.v2.schema.json +201 -0
  284. package/schemas/skill.v3.schema.json +827 -0
  285. package/schemas/skill.v4.schema.json +822 -0
  286. package/schemas/skill.v5.schema.json +830 -0
  287. package/schemas/skill.v6.schema.json +946 -0
  288. package/schemas/vocabulary/keywords.json +180 -0
  289. package/schemas/vocabulary/workspace_tags.json +23 -0
  290. package/scripts/__tests__/migrate-skill-v2-to-v3.test.js +161 -0
  291. package/scripts/__tests__/migrate-skill-v3-to-v4.test.js +158 -0
  292. package/scripts/__tests__/test-export-parser-drift.js +149 -0
  293. package/scripts/__tests__/test-marketplace-export.js +114 -0
  294. package/scripts/__tests__/test-router-paths.js +82 -0
  295. package/scripts/__tests__/test-stability-promotion.js +244 -0
  296. package/scripts/__tests__/test-v3-1-alias-contract.js +109 -0
  297. package/scripts/__tests__/test-v3-1-skos-runtime.js +116 -0
  298. package/scripts/backfill-schema-version.js +198 -0
  299. package/scripts/build-field-reference.js +160 -0
  300. package/scripts/build-retrieval-baseline.js +511 -0
  301. package/scripts/check-markdown-links.js +211 -0
  302. package/scripts/check-protocol-consistency.js +979 -0
  303. package/scripts/export-marketplace-skills.js +610 -0
  304. package/scripts/export-skill.js +374 -0
  305. package/scripts/generate-manifest.js +787 -0
  306. package/scripts/lib/alias-contract.js +83 -0
  307. package/scripts/lib/audit-prompt-builder.js +771 -0
  308. package/scripts/lib/mock-grader.js +134 -0
  309. package/scripts/lib/parse-frontmatter.js +429 -0
  310. package/scripts/lib/roots.js +119 -0
  311. package/scripts/lint/check-archetype-sections.js +185 -0
  312. package/scripts/lint/check-category-enum.js +83 -0
  313. package/scripts/lint/check-routing-eval.js +146 -0
  314. package/scripts/lint/check-routing-quality.js +211 -0
  315. package/scripts/lint/check-stability-promotion.js +220 -0
  316. package/scripts/lint/format-code-frame.js +206 -0
  317. package/scripts/marketplace-install.js +125 -0
  318. package/scripts/migrate-category-to-enum.js +169 -0
  319. package/scripts/migrate-skill-v2-to-v3.js +424 -0
  320. package/scripts/migrate-skill-v3-to-v4.js +200 -0
  321. package/scripts/migrate-skill-v5-to-v6.js +304 -0
  322. package/scripts/restructure-by-category.js +85 -0
  323. package/scripts/seed-publication-classification.js +282 -0
  324. package/scripts/skill-audit.js +893 -0
  325. package/scripts/skill-graph-drift.js +483 -0
  326. package/scripts/skill-graph-route.js +766 -0
  327. package/scripts/skill-graph-routing-eval.js +393 -0
  328. package/scripts/skill-lint.js +1317 -0
  329. package/scripts/skill-overlap.js +213 -0
  330. package/scripts/verify-skill-md-export.js +201 -0
@@ -0,0 +1,1317 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Skill Graph lint tool.
4
+ *
5
+ * Validates every `skills/<name>/SKILL.md` (and optionally
6
+ * `examples/skill-metadata-template.md`) against the frontmatter schema. Runs:
7
+ *
8
+ * 1. Schema validation against `schemas/skill.schema.json`
9
+ * 2. Parent-directory-matches-name check (SKILL.md compatibility)
10
+ * 3. Relation target existence check (adjacent, boundary, verify_with,
11
+ * depends_on targets must be real sibling skills in the repo)
12
+ * 4. Eval artifact coherence check (eval_artifacts: present requires at
13
+ * least one eval file targeting the skill)
14
+ * 5. scope: codebase → require grounding (conditional from schema)
15
+ * 6. Cross-schema parity (runs once): every property and required field
16
+ * of skill.schema.json#grounding must be representable in
17
+ * manifest.schema.json#grounding, and the documented loss-policy
18
+ * fields (routing_bundles, license, compatibility, allowed-tools) must
19
+ * exist as top-level manifest skill-item properties. Prevents the
20
+ * SH-5776 regression where the manifest silently dropped
21
+ * domain_object and four optional top-level fields.
22
+ * 7. Sample manifest conformance (runs once): every skill entry in
23
+ * examples/skills.manifest.sample.json validates against
24
+ * manifest.schema.json#skills.items, so the hand-written sample
25
+ * cannot drift out of step with the schema.
26
+ * 8. Generator parity (runs once): re-runs scripts/generate-manifest.js
27
+ * and compares its output (minus generated_at) against
28
+ * examples/skills.manifest.sample.json (also minus generated_at).
29
+ * Fails if the sample is out of step with the generator. This locks
30
+ * the sample as generator-produced output — not hand-maintained.
31
+ * Skipped when --skip-generator-parity is passed (useful during
32
+ * initial setup before the sample has been regenerated).
33
+ * 9. schema_version 2 migration (runs per file): WARN on the v1 field
34
+ * names eval_status, portability.level, portability.exports, and
35
+ * route_groups during the migration window. The old enum values for
36
+ * `scope` (generic, operational) are hard errors already — the schema
37
+ * enum only lists the v2 values (portable, codebase, reference).
38
+ * 10. Archetype-aware section validator (runs per file): errors on missing
39
+ * required H2 sections per archetype (capability, workflow, router,
40
+ * overlay); warns on sections that exist but are empty (< 50 non-
41
+ * whitespace characters). See scripts/lint/check-archetype-sections.js.
42
+ * 11. Routing quality — narrow (runs per file): errors when keywords: []
43
+ * for scope: codebase or routing_bundles-having skills; warns when
44
+ * description text appears verbatim in ## Coverage.
45
+ * See scripts/lint/check-routing-quality.js.
46
+ * 12. Routing-eval integrity (runs per file): errors when routing_eval:
47
+ * present but examples / anti_examples are empty, OR when the routing
48
+ * harness (scripts/skill-graph-routing-eval.js) reports any FAIL case
49
+ * for the skill. Turns the `present` assertion into a verifiable
50
+ * claim — a skill can only ship `present` when every positive example
51
+ * routes to itself and every anti_example routes away (ideally to a
52
+ * skill named in its relations.boundary[]).
53
+ * See scripts/lint/check-routing-eval.js.
54
+ * 13. Category-enum enforcement (runs per file): errors when `category` is
55
+ * set to a value not in the 6-value canonical enum (foundations,
56
+ * engineering, design, quality, agent, product). Redundant-but-correct
57
+ * second layer on top of the schema enum — provides a more descriptive
58
+ * error message with the authoritative definitions inline.
59
+ * See scripts/lint/check-category-enum.js.
60
+ * 14. Stability promotion gate (runs per file): warns when a skill declares
61
+ * `stability: stable` without meeting all five promotion criteria:
62
+ * (1) eval_state ≠ unverified, (2) eval_score ≥ 4.0, (3) routing_eval:
63
+ * present, (4) drift_check.last_verified within 90 days, (5)
64
+ * grounding.truth_sources populated (codebase/reference scope) or scope
65
+ * is portable (exempt). All criteria are checked independently — no
66
+ * short-circuit. WARN level only — never ERRORs.
67
+ * See scripts/lint/check-stability-promotion.js.
68
+ *
69
+ * Error output uses file:line:column + 5-line code frame + caret + help
70
+ * line for actionable diagnostics. Use --no-color for plain CI output.
71
+ *
72
+ * Self-contained. Only uses Node built-ins — no external dependencies.
73
+ * Exit 0 on success, 1 on any failure.
74
+ *
75
+ * Usage:
76
+ * node scripts/skill-lint.js # lint all skills
77
+ * node scripts/skill-lint.js skills/a11y # lint one skill
78
+ * node scripts/skill-lint.js --include-template # also lint the example template
79
+ * node scripts/skill-lint.js --skip-generator-parity # skip check 8
80
+ * node scripts/skill-lint.js --relation-hygiene # include broad relation-graph hygiene warnings
81
+ * node scripts/skill-lint.js --strict # promote warnings to errors
82
+ * node scripts/skill-lint.js --no-color # plain output for CI
83
+ */
84
+
85
+ const fs = require('fs');
86
+ const path = require('path');
87
+ const { execFileSync } = require('child_process');
88
+ const { parseFrontmatter, normalizeFrontmatter } = require('./lib/parse-frontmatter');
89
+ const { checkAliasParity } = require('./lib/alias-contract');
90
+ const { loadWorkspaceConfig, resolveSchemaPath, resolveSkillRoots, resolveTruthSourcePath, workspaceRoot } = require('./lib/roots');
91
+ const { formatCodeFrame, locateYamlKey, locateH2Section } = require('./lint/format-code-frame');
92
+ const { checkArchetypeSections } = require('./lint/check-archetype-sections');
93
+ const { checkRoutingQuality } = require('./lint/check-routing-quality');
94
+ const { checkRoutingEval } = require('./lint/check-routing-eval');
95
+ const { checkCategoryEnum } = require('./lint/check-category-enum');
96
+ const { checkStabilityPromotion } = require('./lint/check-stability-promotion');
97
+
98
+ const REPO_ROOT = workspaceRoot();
99
+ const TEMPLATE_PATH = path.join(REPO_ROOT, 'examples', 'skill-metadata-template.md');
100
+ const SCHEMA_PATH = resolveSchemaPath(REPO_ROOT, 'skill.schema.json');
101
+ const MANIFEST_SCHEMA_PATH = resolveSchemaPath(REPO_ROOT, 'manifest.schema.json');
102
+ const SAMPLE_MANIFEST_PATH = path.join(REPO_ROOT, 'examples', 'skills.manifest.sample.json');
103
+ const EVALS_DIR = path.join(REPO_ROOT, 'examples', 'evals');
104
+ const WORKSPACE_CONFIG = loadWorkspaceConfig(REPO_ROOT, msg => process.stderr.write(`WARN ${msg}\n`));
105
+ const SKILL_ROOTS = resolveSkillRoots(REPO_ROOT, WORKSPACE_CONFIG);
106
+ const SKILL_ROOT_LABEL = SKILL_ROOTS
107
+ .map(root => path.relative(REPO_ROOT, root.absPath).split(path.sep).join('/') || '.')
108
+ .join(', ');
109
+
110
+ // Explicit "loss policy" list. Each entry is an authored top-level field in
111
+ // skill.schema.json that must have a representation in the manifest skill-item
112
+ // schema. If a future edit deletes one of these without documenting it in
113
+ // docs/skill-metadata-protocol.md or docs/manifest-field-mapping.md, lint fails loudly.
114
+ //
115
+ // This closes the regression window that shipped SH-5776: the original
116
+ // manifest.schema.json silently dropped domain_object, routing_bundles, license,
117
+ // compatibility, and allowed-tools. Adding a field here is cheap and makes
118
+ // the mapping auditable without a separate contract doc.
119
+ //
120
+ // Updated for schema_version 4: `browse_category` became `category`,
121
+ // `category` became `domain`, `project_tags` became `workspace_tags`, and
122
+ // `routing_groups` became `routing_bundles`. `lifecycle` and `runtime_telemetry` project
123
+ // under `health.*` — see AUTHORED_FIELDS_MUST_FLOW_HEALTH below for the
124
+ // parallel parity guard.
125
+ const AUTHORED_FIELDS_MUST_FLOW = [
126
+ 'urn',
127
+ 'archetype',
128
+ 'domain',
129
+ 'routing_bundles',
130
+ 'license',
131
+ 'compatibility',
132
+ 'allowed-tools',
133
+ 'allowed_tools',
134
+ 'category',
135
+ 'workspace_tags',
136
+ 'concept',
137
+ 'superseded_by',
138
+ ];
139
+
140
+ // v0.5.0: separate parity guard for authored fields that the manifest
141
+ // groups under the `health.*` parent object. Closes the symmetric gap
142
+ // discovered by the doctrine-grounded audit: the prior comment on
143
+ // AUTHORED_FIELDS_MUST_FLOW claimed `lifecycle` and `runtime_telemetry`
144
+ // were covered, but they are not top-level manifest fields — they
145
+ // project into `health.lifecycle` and `health.runtime_telemetry` per
146
+ // docs/manifest-field-mapping.md § rename-map rows 28–29.
147
+ const AUTHORED_FIELDS_MUST_FLOW_HEALTH = [
148
+ 'freshness',
149
+ 'drift_check',
150
+ 'eval_artifacts',
151
+ 'eval_state',
152
+ 'routing_eval',
153
+ 'comprehension_state',
154
+ 'eval_last_run',
155
+ 'eval',
156
+ 'reviewed_at',
157
+ 'lifecycle',
158
+ 'runtime_telemetry',
159
+ ];
160
+
161
+ // Deprecated field names from prior schema versions. Authors using these
162
+ // fields receive a WARN from the lint script during the migration window.
163
+ // Hard-error enum/shape changes (scope values, drift_check scalar form,
164
+ // compatibility scalar form) are rejected by the schema via
165
+ // additionalProperties: false and type: object; the warnings below point
166
+ // to the rename so the schema error is actionable.
167
+ const DEPRECATED_V1_FIELDS = [
168
+ 'eval_status',
169
+ 'route_groups',
170
+ 'domain_frame',
171
+ 'family',
172
+ ];
173
+
174
+ function loadSchema() {
175
+ return JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
176
+ }
177
+
178
+ function loadManifestSchema() {
179
+ return JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
180
+ }
181
+
182
+ // Frontmatter → manifest parity check. Runs once per lint invocation, not
183
+ // per file. Guarantees:
184
+ // 1. Every property of skill.schema.json#grounding is representable in
185
+ // manifest.schema.json#skills.items.properties.grounding (prevents the
186
+ // original SH-5776 bug where domain_object was silently dropped).
187
+ // 2. Every field in grounding.required is also required in manifest grounding.
188
+ // 3. The documented loss-policy fields (AUTHORED_FIELDS_MUST_FLOW) exist
189
+ // as top-level properties on the manifest skill-item schema.
190
+ function checkSchemaParity(skillSchema, manifestSchema) {
191
+ const errors = [];
192
+
193
+ const groundingSchema = skillSchema.properties && skillSchema.properties.grounding;
194
+ const skillItem = manifestSchema.properties
195
+ && manifestSchema.properties.skills
196
+ && manifestSchema.properties.skills.items;
197
+ const grounding = skillItem && skillItem.properties && skillItem.properties.grounding;
198
+
199
+ if (!groundingSchema) {
200
+ errors.push('skill.schema.json: missing properties.grounding (cannot run parity check)');
201
+ return errors;
202
+ }
203
+ if (!grounding) {
204
+ errors.push('manifest.schema.json: missing properties.skills.items.properties.grounding');
205
+ return errors;
206
+ }
207
+
208
+ const dfProps = Object.keys(groundingSchema.properties || {});
209
+ const gProps = Object.keys(grounding.properties || {});
210
+ for (const prop of dfProps) {
211
+ if (!gProps.includes(prop)) {
212
+ errors.push(`parity: skill.schema.json#grounding.${prop} is not representable in manifest.schema.json#grounding.properties`);
213
+ }
214
+ }
215
+
216
+ const dfRequired = groundingSchema.required || [];
217
+ const gRequired = grounding.required || [];
218
+ for (const req of dfRequired) {
219
+ if (!gRequired.includes(req)) {
220
+ errors.push(`parity: skill.schema.json#grounding.required contains "${req}" but manifest.schema.json#grounding.required does not`);
221
+ }
222
+ }
223
+
224
+ const itemProps = Object.keys((skillItem && skillItem.properties) || {});
225
+ for (const f of AUTHORED_FIELDS_MUST_FLOW) {
226
+ if (!itemProps.includes(f)) {
227
+ errors.push(`loss-policy: authored field "${f}" is listed in AUTHORED_FIELDS_MUST_FLOW but manifest.schema.json has no property for it on skills.items`);
228
+ }
229
+ }
230
+
231
+ // v0.5.0: verify health-nested authored fields. These project under
232
+ // skills.items.properties.health.properties.* in the manifest.
233
+ const healthSchema = skillItem && skillItem.properties && skillItem.properties.health;
234
+ const healthProps = Object.keys((healthSchema && healthSchema.properties) || {});
235
+ for (const f of AUTHORED_FIELDS_MUST_FLOW_HEALTH) {
236
+ if (!healthProps.includes(f)) {
237
+ errors.push(`loss-policy: authored field "${f}" is listed in AUTHORED_FIELDS_MUST_FLOW_HEALTH but manifest.schema.json has no property for it on skills.items.health`);
238
+ }
239
+ }
240
+
241
+ return errors;
242
+ }
243
+
244
+ // Minimal schema validator covering the subset used by skill.schema.json:
245
+ // required fields, type checks, enum constraints, pattern constraints,
246
+ // and the two conditional rules (overlay → extends, operational → grounding).
247
+ function validateAgainstSchema(fm, schema) {
248
+ const errors = [];
249
+ const props = schema.properties || {};
250
+
251
+ for (const req of schema.required || []) {
252
+ if (!(req in fm)) errors.push(`missing required field: ${req}`);
253
+ }
254
+ for (const key of Object.keys(fm)) {
255
+ if (!(key in props)) errors.push(`unknown field: ${key}`);
256
+ }
257
+
258
+ function checkField(key, value, spec) {
259
+ if (value === null || value === undefined) return;
260
+ if (spec.enum && !spec.enum.includes(value)) {
261
+ errors.push(`${key}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(spec.enum)}`);
262
+ }
263
+ if (spec.pattern && typeof value === 'string' && !new RegExp(spec.pattern).test(value)) {
264
+ errors.push(`${key}: value "${value}" does not match pattern ${spec.pattern}`);
265
+ }
266
+ if (spec.minLength && typeof value === 'string' && value.length < spec.minLength) {
267
+ errors.push(`${key}: length ${value.length} < minLength ${spec.minLength}`);
268
+ }
269
+ if (spec.type === 'array' && !Array.isArray(value)) {
270
+ errors.push(`${key}: expected array, got ${typeof value}`);
271
+ }
272
+ if (spec.type === 'object' && typeof value === 'object' && !Array.isArray(value)) {
273
+ if (spec.required) {
274
+ for (const r of spec.required) {
275
+ if (!(r in (value || {}))) errors.push(`${key}.${r}: missing required sub-field`);
276
+ }
277
+ }
278
+ if (spec.properties) {
279
+ for (const sk of Object.keys(value || {})) {
280
+ if (!(sk in spec.properties)) errors.push(`${key}.${sk}: unknown sub-field`);
281
+ }
282
+ }
283
+ }
284
+ if (spec.oneOf) {
285
+ // schema_version uses oneOf — accept any match.
286
+ // BUG FIX (v0.5.0): when a variant declares `const`, the value MUST equal
287
+ // that const. The prior implementation fell through to the type-only check
288
+ // on non-matching const, allowing any integer to pass `{type: integer, const: 3}`.
289
+ const anyMatch = spec.oneOf.some(variant => {
290
+ if (variant.const !== undefined) {
291
+ // Variant is a const literal — value must equal it exactly (after type coercion for
292
+ // the common "3 vs '3'" case used by schema_version).
293
+ return value === variant.const;
294
+ }
295
+ if (variant.type === 'integer' && Number.isInteger(value)) return true;
296
+ if (variant.type === 'string' && typeof value === 'string' && (!variant.pattern || new RegExp(variant.pattern).test(value))) return true;
297
+ return false;
298
+ });
299
+ if (!anyMatch) errors.push(`${key}: value ${JSON.stringify(value)} does not match any oneOf variant`);
300
+ }
301
+ }
302
+
303
+ for (const [key, value] of Object.entries(fm)) {
304
+ if (props[key]) checkField(key, value, props[key]);
305
+ }
306
+
307
+ // Conditional rules from allOf
308
+ if (fm.type === 'overlay' && !fm.extends) {
309
+ errors.push(`type: overlay requires extends field`);
310
+ }
311
+ // v0.5.0: enforce the reverse — extends is overlay-only. The schema documents
312
+ // this rule in docs/field-reference.md:492-509 but earlier versions only
313
+ // enforced the forward direction (overlay → requires extends), silently
314
+ // allowing extends on non-overlay skills.
315
+ if (fm.type && fm.type !== 'overlay' && fm.extends) {
316
+ errors.push(`extends is only valid on type: overlay (got type: ${JSON.stringify(fm.type)})`);
317
+ }
318
+ if (fm.scope === 'codebase' && !fm.grounding) {
319
+ errors.push(`scope: codebase requires grounding field`);
320
+ }
321
+ if (fm.stability === 'deprecated' && !fm.superseded_by) {
322
+ errors.push(`stability: deprecated requires superseded_by field`);
323
+ }
324
+ if (fm.comprehension_state === 'present' && !fm.concept) {
325
+ errors.push(`comprehension_state: present requires concept field`);
326
+ }
327
+
328
+ return errors;
329
+ }
330
+
331
+ function checkParentDirMatchesName(filePath, fm) {
332
+ // Only applies to <skill-root>/<name>/SKILL.md, not the example template.
333
+ if (path.basename(filePath) !== 'SKILL.md') return [];
334
+ const parentDir = path.basename(path.dirname(filePath));
335
+ if (parentDir !== fm.name) {
336
+ return [`parent directory "${parentDir}" does not match name "${fm.name}" (SKILL.md compatibility rule)`];
337
+ }
338
+ return [];
339
+ }
340
+
341
+ function checkRelationTargets(fm, knownSkillNames) {
342
+ const errors = [];
343
+ const rel = fm.relations || {};
344
+
345
+ // v3: relations.boundary, relations.disjoint_with, and relations.depends_on
346
+ // items may be `{skill, reason}` or `{skill, min_version}` objects. Extract
347
+ // the skill name from either shape.
348
+ function targetName(t) {
349
+ if (typeof t === 'string') return t;
350
+ if (t && typeof t === 'object' && typeof t.skill === 'string') return t.skill;
351
+ return null;
352
+ }
353
+
354
+ // Predicate set per ADR 0001 (v3.1 SKOS additions: related/broader/narrower)
355
+ // and ADR 0006 (boundary stays canonical for routing-layer handoff;
356
+ // disjoint_with is a separate orthogonal relation for formal OWL
357
+ // class-disjointness — both targets must exist as known skills).
358
+ const predicateKinds = [
359
+ // v3.1 SKOS additions (preferred names; ADR 0001 Decisions #1 + #3)
360
+ 'related', 'broader', 'narrower',
361
+ // v3.0 stable + canonical (ADR 0006: boundary stays canonical)
362
+ 'adjacent', 'boundary', 'verify_with', 'depends_on',
363
+ // v3.1 separate orthogonal relation per ADR 0006 Option B
364
+ 'disjoint_with',
365
+ ];
366
+
367
+ for (const kind of predicateKinds) {
368
+ const targets = rel[kind] || [];
369
+ for (const t of targets) {
370
+ const name = targetName(t);
371
+ if (name === null) {
372
+ errors.push(`relations.${kind}: item is not a string or object with "skill" property — got ${JSON.stringify(t)}`);
373
+ continue;
374
+ }
375
+ if (!knownSkillNames.has(name)) {
376
+ errors.push(`relations.${kind}: "${name}" does not match any known skill in configured roots (${SKILL_ROOT_LABEL})`);
377
+ }
378
+ }
379
+ }
380
+ return errors;
381
+ }
382
+
383
+ /**
384
+ * Detect double-declarations across deprecated/preferred predicate aliases.
385
+ * Per ADR 0001, `adjacent` is a deprecated alias for `related`. Authors who
386
+ * declare the same target under both names are duplicating intent; the manifest
387
+ * will emit both keys and downstream consumers will see the relation twice.
388
+ *
389
+ * Per ADR 0006, `boundary` and `disjoint_with` are NOT aliases (they have
390
+ * distinct semantics — routing-layer vs formal OWL class-disjointness), so
391
+ * declaring the same target under both is legitimate and is NOT flagged.
392
+ *
393
+ * Returns warning records (not errors).
394
+ */
395
+ function checkRelationDoubleDeclarations(fm) {
396
+ const warnings = [];
397
+ const rel = fm.relations || {};
398
+
399
+ function targetName(t) {
400
+ if (typeof t === 'string') return t;
401
+ if (t && typeof t === 'object' && typeof t.skill === 'string') return t.skill;
402
+ return null;
403
+ }
404
+
405
+ function targetSet(arr) {
406
+ if (!Array.isArray(arr)) return new Set();
407
+ const out = new Set();
408
+ for (const t of arr) {
409
+ const n = targetName(t);
410
+ if (n) out.add(n);
411
+ }
412
+ return out;
413
+ }
414
+
415
+ // adjacent (deprecated) vs related (preferred) — same SKOS mapping per ADR 0001
416
+ const adjacentTargets = targetSet(rel.adjacent);
417
+ const relatedTargets = targetSet(rel.related);
418
+ for (const name of adjacentTargets) {
419
+ if (relatedTargets.has(name)) {
420
+ warnings.push({
421
+ message: `relations: "${name}" appears in both "adjacent" (deprecated) and "related" (preferred) — drop the "adjacent" entry to avoid double-counting in the manifest. See ADR 0001.`,
422
+ });
423
+ }
424
+ }
425
+
426
+ return warnings;
427
+ }
428
+
429
+ function checkEvalCoherence(filePath, fm) {
430
+ // eval_artifacts: present requires a real eval artifact.
431
+ // Only `present` demands an artifact on disk — `planned` and `none` do not.
432
+ const results = { errors: [], warnings: [] };
433
+
434
+ // v0.5.0 (from doctrine-grounded audit): guard against the
435
+ // `eval_artifacts: planned` staleness exploit. If a skill has been in
436
+ // `planned` state longer than its lifecycle.stale_after_days (default 180),
437
+ // emit a warning so the state doesn't sit there indefinitely.
438
+ if (fm.eval_artifacts === 'planned' && fm.freshness) {
439
+ const freshnessDate = new Date(fm.freshness);
440
+ if (!Number.isNaN(freshnessDate.getTime())) {
441
+ const daysOld = (Date.now() - freshnessDate.getTime()) / (24 * 60 * 60 * 1000);
442
+ const threshold = (fm.lifecycle && fm.lifecycle.stale_after_days) || 180;
443
+ if (daysOld > threshold) {
444
+ results.warnings.push(`eval_artifacts: planned has been set for ${Math.round(daysOld)} days (threshold: ${threshold}). Either ship an eval artifact and move to "present", or move to "none" if evals are genuinely not planned.`);
445
+ }
446
+ }
447
+ }
448
+
449
+ if (fm.eval_artifacts !== 'present') return results;
450
+ if (!fs.existsSync(EVALS_DIR)) {
451
+ results.errors.push(`eval_artifacts: present declared but ${EVALS_DIR} does not exist`);
452
+ return results;
453
+ }
454
+ const evalFiles = fs.readdirSync(EVALS_DIR).filter(f => f.endsWith('.json'));
455
+ for (const evalFile of evalFiles) {
456
+ try {
457
+ const data = JSON.parse(fs.readFileSync(path.join(EVALS_DIR, evalFile), 'utf8'));
458
+ if (data.skill_name === fm.name) return results;
459
+ } catch (e) {
460
+ // ignore malformed eval files here; they are out of scope for this check
461
+ }
462
+ }
463
+ results.errors.push(`eval_artifacts: present declared but no file in ${EVALS_DIR} has skill_name: "${fm.name}"`);
464
+ return results;
465
+ }
466
+
467
+ // A1 + A2: relation graph semantics.
468
+ //
469
+ // Validates the adjacency/boundary graph across every authored skill.
470
+ // Two invariants are checked:
471
+ //
472
+ // 1. Adjacency symmetry (WARNING). `adjacent` models "siblings often used
473
+ // together" — a symmetric relationship. If A says B is adjacent but B
474
+ // does not name A, either one author forgot the return edge or adjacency
475
+ // is being used directionally (which the contract does not endorse).
476
+ // Warning — not error — because historical skills may have drifted and
477
+ // fixing the reciprocal is a one-line author change.
478
+ //
479
+ // 2. adjacent ↔ boundary contradiction (ERROR). `boundary` is negative
480
+ // routing ("hand off, do NOT activate"). If A lists B as adjacent and B
481
+ // lists A as boundary (or vice versa), the two authors disagree on
482
+ // whether the skills belong together. This is a real contract
483
+ // violation and will mis-route prompts. Errors so CI fails.
484
+ //
485
+ // Polymorphism: v3 `boundary` items are `{skill, reason}` objects;
486
+ // `adjacent` items are plain strings. The `rel()` helper extracts the skill
487
+ // name from either shape (same logic as checkRelationTargets above).
488
+ function checkRelationSemantics(fm, knownFrontmatters) {
489
+ const results = { errors: [], warnings: [] };
490
+ if (!fm || !fm.name || !fm.relations) return results;
491
+
492
+ function rel(kind, targets) {
493
+ const out = [];
494
+ for (const t of targets || []) {
495
+ if (typeof t === 'string') out.push(t);
496
+ else if (t && typeof t === 'object' && typeof t.skill === 'string') out.push(t.skill);
497
+ }
498
+ return out;
499
+ }
500
+
501
+ const selfName = fm.name;
502
+ const myAdjacent = rel('adjacent', fm.relations.adjacent);
503
+ const myBoundary = rel('boundary', fm.relations.boundary);
504
+
505
+ for (const target of myAdjacent) {
506
+ const peer = knownFrontmatters.get(target);
507
+ if (!peer || !peer.relations) continue;
508
+
509
+ const peerAdjacent = rel('adjacent', peer.relations.adjacent);
510
+ const peerRelatedPreferred = rel('related', peer.relations.related);
511
+ const peerRelated = [...new Set([...peerAdjacent, ...peerRelatedPreferred])];
512
+ const peerBoundary = rel('boundary', peer.relations.boundary);
513
+
514
+ // ERROR: adjacent ↔ boundary contradiction.
515
+ if (peerBoundary.includes(selfName)) {
516
+ results.errors.push(
517
+ `relations: this skill lists "${target}" as adjacent, but "${target}" lists this skill as boundary — adjacency/boundary contradiction (routing will mis-fire). Either remove from my adjacent or remove from ${target}.boundary.`
518
+ );
519
+ continue; // contradiction supersedes the asymmetry warning
520
+ }
521
+
522
+ // WARNING: adjacency asymmetry (reciprocal edge missing).
523
+ if (!peerRelated.includes(selfName)) {
524
+ results.warnings.push(
525
+ `relations.adjacent: "${target}" does not reciprocate adjacency — adjacency is symmetric ("often used together"). Either add "${selfName}" to ${target}.relations.adjacent, or promote one side to relations.verify_with / relations.depends_on if the link is directional.`
526
+ );
527
+ }
528
+ }
529
+
530
+ // Also catch the mirror case: I list X as boundary while X lists me as adjacent.
531
+ // Covers asymmetric authoring where the contradiction lives on the boundary side.
532
+ for (const target of myBoundary) {
533
+ const peer = knownFrontmatters.get(target);
534
+ if (!peer || !peer.relations) continue;
535
+ const peerAdjacent = rel('adjacent', peer.relations.adjacent);
536
+ if (peerAdjacent.includes(selfName)) {
537
+ results.errors.push(
538
+ `relations: this skill lists "${target}" as boundary, but "${target}" lists this skill as adjacent — adjacency/boundary contradiction (routing will mis-fire). Either remove "${target}" from my boundary or remove "${selfName}" from ${target}.adjacent.`
539
+ );
540
+ }
541
+ }
542
+
543
+ return results;
544
+ }
545
+
546
+ // D2: truth-source range validator.
547
+ //
548
+ // Scans every eval artifact under examples/evals/ and validates each
549
+ // `truth_sources` reference: the referenced file exists AND the end line is
550
+ // within file bounds. Catches the silent-drift class where SKILL.md gets
551
+ // rewritten but truth_sources still cite the old line ranges, leading
552
+ // graders to read the wrong content.
553
+ //
554
+ // Reference formats:
555
+ // `path` — whole-file reference
556
+ // `path:start-end` — line range (or `path:line` for a single line)
557
+ // `path#anchor` — heading anchor; the file must contain a heading
558
+ // whose slug equals `anchor`. Anchors are
559
+ // drift-resistant: renaming a section requires
560
+ // updating the anchor, which the linter catches,
561
+ // whereas editing section content around fixed
562
+ // line numbers silently drifts. Use anchor form
563
+ // alongside a line range for defense in depth.
564
+ // Malformed references are surfaced as errors (the grader has nothing to do
565
+ // with a broken pointer).
566
+ //
567
+ // Runs once per invocation, not per file. Returns plain error strings.
568
+ const TRUTH_SOURCE_RE = /^([^:#]+)(?:(?::(\d+)(?:-(\d+))?)|(?:#([a-z0-9][a-z0-9-]*)))?$/;
569
+
570
+ // Slugify a heading the same way common markdown renderers do: strip leading
571
+ // `#` markers, lowercase, replace non-alphanumerics with hyphens, collapse
572
+ // consecutive hyphens, trim leading/trailing hyphens.
573
+ function slugifyHeading(headingText) {
574
+ return headingText
575
+ .replace(/^#+\s*/, '')
576
+ .toLowerCase()
577
+ .replace(/[^a-z0-9]+/g, '-')
578
+ .replace(/-+/g, '-')
579
+ .replace(/^-|-$/g, '');
580
+ }
581
+
582
+ // Extract the set of heading anchors present in a markdown file. Cached per
583
+ // file path to avoid repeated re-parsing when many evals cite the same file.
584
+ const headingAnchorsCache = new Map();
585
+ function getHeadingAnchors(absPath) {
586
+ if (headingAnchorsCache.has(absPath)) return headingAnchorsCache.get(absPath);
587
+ const content = fs.readFileSync(absPath, 'utf8');
588
+ const anchors = new Set();
589
+ const lines = content.split('\n');
590
+ let inCodeFence = false;
591
+ for (const line of lines) {
592
+ if (/^```/.test(line)) { inCodeFence = !inCodeFence; continue; }
593
+ if (inCodeFence) continue;
594
+ const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
595
+ if (m) anchors.add(slugifyHeading(m[2]));
596
+ }
597
+ headingAnchorsCache.set(absPath, anchors);
598
+ return anchors;
599
+ }
600
+
601
+ function checkEvalTruthSourceRanges() {
602
+ const errors = [];
603
+ if (!fs.existsSync(EVALS_DIR)) return errors;
604
+
605
+ const evalFiles = fs.readdirSync(EVALS_DIR).filter(f => f.endsWith('.json')).sort();
606
+ const lineCountCache = new Map();
607
+
608
+ for (const evalFile of evalFiles) {
609
+ const evalPath = path.join(EVALS_DIR, evalFile);
610
+ let data;
611
+ try {
612
+ data = JSON.parse(fs.readFileSync(evalPath, 'utf8'));
613
+ } catch (e) {
614
+ // Malformed JSON files surface elsewhere; skip here rather than double-report.
615
+ continue;
616
+ }
617
+ const cases = Array.isArray(data.evals) ? data.evals : [];
618
+ for (const c of cases) {
619
+ const refs = Array.isArray(c.truth_sources) ? c.truth_sources : [];
620
+ for (const raw of refs) {
621
+ const s = String(raw);
622
+ const m = s.match(TRUTH_SOURCE_RE);
623
+ if (!m) {
624
+ errors.push(`examples/evals/${evalFile} eval id=${c.id}: truth_source "${s}" is malformed — expected "path", "path:start-end", or "path#anchor"`);
625
+ continue;
626
+ }
627
+ const [, relPath, start, end, anchor] = m;
628
+ const abs = resolveTruthSourcePath(relPath, REPO_ROOT, SKILL_ROOTS);
629
+ if (!fs.existsSync(abs)) {
630
+ errors.push(`examples/evals/${evalFile} eval id=${c.id}: truth_source "${s}" — file ${relPath} does not exist`);
631
+ continue;
632
+ }
633
+
634
+ if (anchor) {
635
+ const anchors = getHeadingAnchors(abs);
636
+ if (!anchors.has(anchor)) {
637
+ errors.push(`examples/evals/${evalFile} eval id=${c.id}: truth_source "${s}" — no heading in ${relPath} slugifies to "${anchor}" (known anchors: ${[...anchors].slice(0, 8).join(', ')}${anchors.size > 8 ? ', ...' : ''})`);
638
+ }
639
+ continue;
640
+ }
641
+
642
+ // Line-range bounds check intentionally removed: it measured file
643
+ // shape (does line N exist), not skill quality (does this eval test
644
+ // the skill's concepts well). When skill bodies are edited — the
645
+ // normal mode for improving a skill — line ranges drift and the lint
646
+ // fires false alarms unrelated to whether the truth source is real
647
+ // or whether the eval is meaningful. The file-existence check above
648
+ // (line ~613) still catches genuine dead citations.
649
+ }
650
+ }
651
+ }
652
+ return errors;
653
+ }
654
+
655
+ function isRemoteTruthSourcePath(value) {
656
+ return /^https?:\/\//i.test(String(value));
657
+ }
658
+
659
+ function validateLocalTruthSourcePointer({ owner, source, relPath, lineRange, anchor }) {
660
+ const errors = [];
661
+ if (isRemoteTruthSourcePath(relPath)) {
662
+ if (lineRange) errors.push(`${owner}: truth_sources ${source}: line_range is only supported for local file paths`);
663
+ return errors;
664
+ }
665
+
666
+ const abs = resolveTruthSourcePath(relPath, REPO_ROOT, SKILL_ROOTS);
667
+ if (!fs.existsSync(abs)) {
668
+ errors.push(`${owner}: truth_sources ${source}: file ${relPath} does not exist`);
669
+ return errors;
670
+ }
671
+
672
+ const text = fs.readFileSync(abs, 'utf8');
673
+ const lines = text.split(/\r?\n/);
674
+ if (lineRange) {
675
+ const start = lineRange.start;
676
+ const end = lineRange.end || start;
677
+ if (!Number.isInteger(start) || start < 1) {
678
+ errors.push(`${owner}: truth_sources ${source}: line_range.start must be an integer >= 1`);
679
+ } else if (!Number.isInteger(end) || end < start) {
680
+ errors.push(`${owner}: truth_sources ${source}: line_range.end must be an integer >= line_range.start`);
681
+ } else if (end > lines.length) {
682
+ errors.push(`${owner}: truth_sources ${source}: line_range.end ${end} out of range (${relPath} has ${lines.length} lines)`);
683
+ }
684
+ }
685
+ if (anchor) {
686
+ const anchors = getHeadingAnchors(abs);
687
+ if (!anchors.has(anchor) && !text.includes(anchor)) {
688
+ errors.push(`${owner}: truth_sources ${source}: anchor "${anchor}" is neither a heading slug nor literal text in ${relPath}`);
689
+ }
690
+ }
691
+ return errors;
692
+ }
693
+
694
+ function checkGroundingTruthSources(fm) {
695
+ const errors = [];
696
+ const grounding = fm && fm.grounding;
697
+ if (!grounding || !Array.isArray(grounding.truth_sources)) return errors;
698
+
699
+ for (const raw of grounding.truth_sources) {
700
+ if (typeof raw === 'string') {
701
+ if (raw.trim().length === 0) {
702
+ errors.push(`${fm.name}: grounding.truth_sources contains an empty string`);
703
+ continue;
704
+ }
705
+ errors.push(...validateLocalTruthSourcePointer({
706
+ owner: fm.name,
707
+ source: JSON.stringify(raw),
708
+ relPath: raw,
709
+ lineRange: null,
710
+ anchor: null,
711
+ }));
712
+ continue;
713
+ }
714
+
715
+ if (!raw || typeof raw !== 'object' || typeof raw.path !== 'string' || raw.path.trim().length === 0) {
716
+ errors.push(`${fm.name}: grounding.truth_sources entries must be strings or objects with a non-empty path`);
717
+ continue;
718
+ }
719
+ const lineRange = raw.line_range === undefined ? null : raw.line_range;
720
+ const anchor = typeof raw.anchor === 'string' && raw.anchor.length > 0 ? raw.anchor : null;
721
+ errors.push(...validateLocalTruthSourcePointer({
722
+ owner: fm.name,
723
+ source: JSON.stringify(raw),
724
+ relPath: raw.path,
725
+ lineRange,
726
+ anchor,
727
+ }));
728
+ }
729
+
730
+ return errors;
731
+ }
732
+
733
+ // H3: description sentence-count check.
734
+ //
735
+ // The routing contract in `docs/skill-metadata-protocol.md § Semantic layer discipline`
736
+ // caps descriptions at ≤3 sentences — descriptions longer than that drift from
737
+ // pure routing signal into scope-map restatement. Counts sentence terminators
738
+ // (`.`, `!`, `?`) followed by whitespace or end-of-string; ignores trailing
739
+ // punctuation. Warning severity because the "rule" is a style convention, not
740
+ // a contract failure.
741
+ function checkDescriptionLength(fm) {
742
+ const warnings = [];
743
+ const desc = fm && fm.description;
744
+ if (typeof desc !== 'string' || desc.trim().length === 0) return warnings;
745
+
746
+ // Split on sentence boundaries. A sentence terminator is one of . ! ? followed
747
+ // by whitespace-or-end-of-string. Strip trailing terminator on the last segment.
748
+ const parts = desc
749
+ .replace(/\s+/g, ' ')
750
+ .split(/(?<=[.!?])\s+/)
751
+ .map(s => s.trim())
752
+ .filter(s => s.length > 0);
753
+ const sentenceCount = parts.length;
754
+ if (sentenceCount > 3) {
755
+ warnings.push(`description: ${sentenceCount} sentences exceeds the ≤3 sentence routing-contract cap. Move scope detail into ## Coverage; keep description a pure "use when / covers / do NOT use" signal.`);
756
+ }
757
+ return warnings;
758
+ }
759
+
760
+ // v0.5.0: guard against `paths` that consist only of negation patterns.
761
+ // Such a list matches nothing (negations only subtract from prior includes).
762
+ // This is a dead-routing trap found during audit review.
763
+ function checkPathsNegation(fm) {
764
+ const paths = fm.paths;
765
+ if (!Array.isArray(paths) || paths.length === 0) return [];
766
+ const allNegation = paths.every(p => typeof p === 'string' && p.startsWith('!'));
767
+ if (allNegation) {
768
+ return [`paths: list consists only of negation patterns (starting with "!") — this matches nothing. Include at least one positive pattern.`];
769
+ }
770
+ return [];
771
+ }
772
+
773
+ // Validate every skill entry in examples/skills.manifest.sample.json against
774
+ // the manifest skill-item schema. Uses the same minimal validator as the
775
+ // SKILL.md frontmatter check, applied to each array element. Prevents the
776
+ // sample manifest from drifting out of step with the manifest schema (which
777
+ // was one of the SH-5776 acceptance criteria).
778
+ function checkSampleManifest(manifestSchema) {
779
+ if (!fs.existsSync(SAMPLE_MANIFEST_PATH)) return [];
780
+ let sample;
781
+ try {
782
+ sample = JSON.parse(fs.readFileSync(SAMPLE_MANIFEST_PATH, 'utf8'));
783
+ } catch (e) {
784
+ return [`sample manifest parse error: ${e.message}`];
785
+ }
786
+ const itemSchema = manifestSchema.properties
787
+ && manifestSchema.properties.skills
788
+ && manifestSchema.properties.skills.items;
789
+ if (!itemSchema) {
790
+ return ['manifest.schema.json: missing properties.skills.items (cannot validate sample)'];
791
+ }
792
+ const errors = [];
793
+ const skills = Array.isArray(sample.skills) ? sample.skills : [];
794
+ for (let i = 0; i < skills.length; i++) {
795
+ const skill = skills[i];
796
+ const label = skill && skill.id ? skill.id : `[${i}]`;
797
+ const skillErrors = validateAgainstSchema(skill, itemSchema);
798
+ for (const e of skillErrors) errors.push(`skills[${label}]: ${e}`);
799
+ }
800
+ return errors;
801
+ }
802
+
803
+ // Generator parity check (check 8).
804
+ //
805
+ // Runs `node scripts/generate-manifest.js --include-template` and compares the
806
+ // result against `examples/skills.manifest.sample.json`. Both manifests are
807
+ // normalized (generated_at removed, keys sorted) before comparison so that
808
+ // the live timestamp in the generator output does not cause spurious failures.
809
+ //
810
+ // Returns an array of error strings (empty = parity holds).
811
+ //
812
+ // Why include-template? The sample manifest was generated with --include-template
813
+ // so the skill-metadata-template entry is part of the canonical sample. The parity check
814
+ // must use the same flags that were used to generate the sample, otherwise the
815
+ // skill count will always differ.
816
+ function checkGeneratorParity() {
817
+ if (!fs.existsSync(SAMPLE_MANIFEST_PATH)) {
818
+ return [];
819
+ }
820
+
821
+ const generatorScript = path.join(__dirname, 'generate-manifest.js');
822
+ if (!fs.existsSync(generatorScript)) {
823
+ return ['generator parity: scripts/generate-manifest.js does not exist'];
824
+ }
825
+
826
+ let generatedJson;
827
+ try {
828
+ generatedJson = execFileSync(
829
+ process.execPath,
830
+ [generatorScript, '--include-template', '--timestamp', '1970-01-01T00:00:00Z'],
831
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
832
+ );
833
+ } catch (e) {
834
+ return [`generator parity: generate-manifest.js failed — ${e.stderr || e.message}`];
835
+ }
836
+
837
+ let generated;
838
+ try {
839
+ generated = JSON.parse(generatedJson);
840
+ } catch (e) {
841
+ return [`generator parity: failed to parse generator output — ${e.message}`];
842
+ }
843
+
844
+ let sample;
845
+ try {
846
+ sample = JSON.parse(fs.readFileSync(SAMPLE_MANIFEST_PATH, 'utf8'));
847
+ } catch (e) {
848
+ return [`generator parity: failed to parse sample manifest — ${e.message}`];
849
+ }
850
+
851
+ // Normalize both manifests: remove generated_at (changes on every run),
852
+ // then sort all object keys recursively for stable JSON.stringify comparison.
853
+ //
854
+ // Note: JSON.stringify(obj, keyArray) filters keys at all levels, not just
855
+ // the top level — do NOT use that pattern for recursive key sorting. Instead,
856
+ // use a recursive sortKeys pass that visits every nested object.
857
+ function sortKeys(value) {
858
+ if (Array.isArray(value)) return value.map(sortKeys);
859
+ if (value !== null && typeof value === 'object') {
860
+ const sorted = {};
861
+ for (const key of Object.keys(value).sort()) sorted[key] = sortKeys(value[key]);
862
+ return sorted;
863
+ }
864
+ return value;
865
+ }
866
+
867
+ function normalize(manifest) {
868
+ const m = Object.assign({}, manifest);
869
+ delete m.generated_at;
870
+ return sortKeys(m);
871
+ }
872
+
873
+ const normalizedGenerated = JSON.stringify(normalize(generated), null, 2);
874
+ const normalizedSample = JSON.stringify(normalize(sample), null, 2);
875
+
876
+ if (normalizedGenerated !== normalizedSample) {
877
+ // Produce a line-level diff summary: find first diverging line
878
+ const genLines = normalizedGenerated.split('\n');
879
+ const sampleLines = normalizedSample.split('\n');
880
+ const maxLen = Math.max(genLines.length, sampleLines.length);
881
+ let firstDiffLine = -1;
882
+ for (let i = 0; i < maxLen; i++) {
883
+ if (genLines[i] !== sampleLines[i]) { firstDiffLine = i + 1; break; }
884
+ }
885
+ const hint = firstDiffLine > 0
886
+ ? ` (first difference at normalized line ${firstDiffLine}: sample has "${sampleLines[firstDiffLine - 1]}", generator produces "${genLines[firstDiffLine - 1]}")`
887
+ : '';
888
+ return [
889
+ `generator parity: examples/skills.manifest.sample.json is out of step with generator output${hint}`,
890
+ 'generator parity: run `node scripts/generate-manifest.js --include-template --timestamp <ISO> --output examples/skills.manifest.sample.json` to regenerate',
891
+ ];
892
+ }
893
+
894
+ return [];
895
+ }
896
+
897
+ function collectSkillFilesFromRoot(rootDir, depth = 0) {
898
+ const files = [];
899
+ if (!fs.existsSync(rootDir)) return files;
900
+ // Recurse up to 3 levels deep (root → category → optional domain → skill).
901
+ // Stops descending once a SKILL.md is found in a directory.
902
+ if (depth > 3) return files;
903
+ for (const name of fs.readdirSync(rootDir)) {
904
+ if (name.startsWith('_') || name.startsWith('.')) continue;
905
+ const entryPath = path.join(rootDir, name);
906
+ if (!fs.statSync(entryPath).isDirectory()) continue;
907
+ const skillMd = path.join(entryPath, 'SKILL.md');
908
+ if (fs.existsSync(skillMd)) {
909
+ files.push(skillMd);
910
+ } else {
911
+ // Not a skill folder — recurse into it as a category/domain container.
912
+ files.push(...collectSkillFilesFromRoot(entryPath, depth + 1));
913
+ }
914
+ }
915
+ return files;
916
+ }
917
+
918
+ function collectSkillFilesFromRoots(roots) {
919
+ return roots.flatMap(root => collectSkillFilesFromRoot(root.absPath));
920
+ }
921
+
922
+ function collectSkillFilesFromExplicitArg(arg) {
923
+ const abs = path.resolve(arg);
924
+ if (!fs.existsSync(abs)) return [];
925
+ if (fs.statSync(abs).isDirectory()) {
926
+ const directSkillMd = path.join(abs, 'SKILL.md');
927
+ if (fs.existsSync(directSkillMd)) return [directSkillMd];
928
+ return collectSkillFilesFromRoot(abs);
929
+ }
930
+ if (abs.endsWith('SKILL.md') || abs.endsWith('.md')) return [abs];
931
+ return [];
932
+ }
933
+
934
+ function collectSkillFiles(args) {
935
+ const includeTemplate = args.includes('--include-template');
936
+ const explicit = args.filter(a => !a.startsWith('--'));
937
+ const files = [];
938
+ if (explicit.length > 0) {
939
+ for (const arg of explicit) {
940
+ files.push(...collectSkillFilesFromExplicitArg(arg));
941
+ }
942
+ } else {
943
+ files.push(...collectSkillFilesFromRoots(SKILL_ROOTS));
944
+ }
945
+ if (includeTemplate && fs.existsSync(TEMPLATE_PATH)) files.push(TEMPLATE_PATH);
946
+
947
+ return files;
948
+ }
949
+
950
+ function main() {
951
+ const args = process.argv.slice(2);
952
+ const schema = loadSchema();
953
+ const manifestSchema = loadManifestSchema();
954
+ const files = collectSkillFiles(args);
955
+ const skipGeneratorParity = args.includes('--skip-generator-parity');
956
+ const relationHygiene = args.includes('--relation-hygiene');
957
+ // --strict: promote warnings to errors so CI can enforce a zero-warning bar.
958
+ const strict = args.includes('--strict');
959
+ // --no-color: suppress ANSI escape codes (useful in CI environments).
960
+ const noColor = args.includes('--no-color');
961
+
962
+ if (files.length === 0) {
963
+ console.error('No skill files found to lint.');
964
+ process.exit(1);
965
+ }
966
+
967
+ // Cross-schema parity: frontmatter → manifest. Runs once per invocation.
968
+ // Fails the lint early if either schema has drifted from the authored-to-
969
+ // generated mapping in docs/skill-metadata-protocol.md.
970
+ //
971
+ // Tier label legend (see SKILL_GRAPH.md):
972
+ // [T1] Tier 1 — binding schema (schemas/)
973
+ // [T1↔T3] Tier 1 ↔ Tier 3 parity check (authored schema ↔ manifest schema)
974
+ // [T3↔T5] Tier 3 ↔ Tier 5 parity check (generator output ↔ sample manifest)
975
+ // [T5] Tier 5 — specimen (starter skill or template)
976
+ // [T5 sample] Tier 5 specimen — the sample manifest
977
+ const parityErrors = checkSchemaParity(schema, manifestSchema);
978
+ if (parityErrors.length > 0) {
979
+ console.error('FAIL [T1↔T3] schemas/ (cross-schema parity)');
980
+ for (const e of parityErrors) console.error(` - ${e}`);
981
+ } else {
982
+ console.log('OK [T1↔T3] schemas/ (cross-schema parity)');
983
+ }
984
+
985
+ // Sample manifest conformance. Validates each skill entry in
986
+ // examples/skills.manifest.sample.json against manifest.schema.json#skills.items.
987
+ const hasSampleManifest = fs.existsSync(SAMPLE_MANIFEST_PATH);
988
+ const sampleErrors = checkSampleManifest(manifestSchema);
989
+ if (sampleErrors.length > 0) {
990
+ console.error('FAIL [T5 sample] examples/skills.manifest.sample.json');
991
+ for (const e of sampleErrors) console.error(` - ${e}`);
992
+ } else if (hasSampleManifest) {
993
+ console.log('OK [T5 sample] examples/skills.manifest.sample.json');
994
+ } else {
995
+ console.log('SKIP [T5 sample] examples/skills.manifest.sample.json (not present in workspace)');
996
+ }
997
+
998
+ // Generator parity: re-run the manifest generator and verify the output
999
+ // matches examples/skills.manifest.sample.json (ignoring generated_at).
1000
+ // This ensures the sample is always kept in sync with the generator —
1001
+ // a hand-edited sample will fail this check. Skippable via --skip-generator-parity.
1002
+ let generatorParityErrors = [];
1003
+ if (!skipGeneratorParity) {
1004
+ if (!hasSampleManifest) {
1005
+ console.log('SKIP [T3↔T5] examples/skills.manifest.sample.json (not present in workspace)');
1006
+ } else {
1007
+ generatorParityErrors = checkGeneratorParity();
1008
+ if (generatorParityErrors.length > 0) {
1009
+ console.error('FAIL [T3↔T5] examples/skills.manifest.sample.json (generator parity)');
1010
+ for (const e of generatorParityErrors) console.error(` - ${e}`);
1011
+ } else {
1012
+ console.log('OK [T3↔T5] examples/skills.manifest.sample.json (generator parity)');
1013
+ }
1014
+ }
1015
+
1016
+ }
1017
+
1018
+ // Build the known-skill set (names) + frontmatter map (for relation
1019
+ // semantic checks that need to look across sibling skills' relation blocks).
1020
+ const knownSkillNames = new Set();
1021
+ const knownFrontmatters = new Map();
1022
+ const knownFiles = new Set([...collectSkillFilesFromRoots(SKILL_ROOTS), ...files]);
1023
+ for (const skillMd of knownFiles) {
1024
+ if (fs.existsSync(skillMd)) {
1025
+ const fm = normalizeFrontmatter(parseFrontmatter(fs.readFileSync(skillMd, 'utf8')));
1026
+ if (fm && fm.name) {
1027
+ knownSkillNames.add(fm.name);
1028
+ knownFrontmatters.set(fm.name, fm);
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ // Truth-source range validator (D2): runs once over every eval file and
1034
+ // reports broken `truth_sources` references before any per-file work. The
1035
+ // result is a flat error list tied to a synthetic "[truth-sources]" label
1036
+ // in the summary so authors see it prominently even on mass runs.
1037
+ const truthSourceErrors = checkEvalTruthSourceRanges();
1038
+ if (truthSourceErrors.length > 0) {
1039
+ console.error('FAIL [T5 evals] examples/evals/ (truth_source ranges)');
1040
+ for (const e of truthSourceErrors) console.error(` - ${e}`);
1041
+ } else if (fs.existsSync(EVALS_DIR)) {
1042
+ console.log('OK [T5 evals] examples/evals/ (truth_source ranges)');
1043
+ } else {
1044
+ console.log('SKIP [T5 evals] examples/evals/ (not present in workspace)');
1045
+ }
1046
+
1047
+ let totalErrors = parityErrors.length + sampleErrors.length + generatorParityErrors.length + truthSourceErrors.length;
1048
+ let totalWarnings = 0;
1049
+
1050
+ for (const file of files) {
1051
+ const relPath = path.relative(REPO_ROOT, file);
1052
+ const text = fs.readFileSync(file, 'utf8');
1053
+ const fm = normalizeFrontmatter(parseFrontmatter(text));
1054
+ if (!fm) {
1055
+ console.error(`FAIL ${relPath}: no frontmatter found`);
1056
+ totalErrors++;
1057
+ continue;
1058
+ }
1059
+
1060
+ // ----------------------------------------------------------------
1061
+ // Migration warnings (v1 → v2 field renames). These emit WARN lines
1062
+ // above the per-file OK/FAIL summary so authors see them even when the
1063
+ // file otherwise passes.
1064
+ // ----------------------------------------------------------------
1065
+
1066
+ // Migration warnings for v1 → v2 field renames.
1067
+ if (fm.domain_frame) {
1068
+ emitWarning(relPath, text, 'domain_frame', '"domain_frame" is deprecated — rename to "grounding"', {
1069
+ help: 'Rename "domain_frame:" to "grounding:". See docs/manifest-field-mapping.md § Migration Note — v1 → v2.',
1070
+ noColor,
1071
+ });
1072
+ }
1073
+ if (fm.eval_status) {
1074
+ emitWarning(relPath, text, 'eval_status', '"eval_status" is deprecated — split into "eval_artifacts", "eval_state", and "routing_eval"', {
1075
+ help: 'See docs/manifest-field-mapping.md § Migration Note — v1 → v2.',
1076
+ noColor,
1077
+ });
1078
+ }
1079
+ if (fm.route_groups) {
1080
+ emitWarning(relPath, text, 'route_groups', '"route_groups" is deprecated — rename to "routing_bundles"', {
1081
+ help: 'Rename "route_groups:" to "routing_bundles:". Values are unchanged.',
1082
+ noColor,
1083
+ });
1084
+ }
1085
+ if (fm.portability && typeof fm.portability === 'object') {
1086
+ if (fm.portability.level) {
1087
+ emitWarning(relPath, text, 'level', '"portability.level" is deprecated — rename to "portability.readiness"', {
1088
+ help: 'See docs/field-reference.md § portability.',
1089
+ noColor,
1090
+ });
1091
+ }
1092
+ if (fm.portability.exports) {
1093
+ emitWarning(relPath, text, 'exports', '"portability.exports" is deprecated — rename to "portability.targets"', {
1094
+ help: 'See docs/field-reference.md § portability.',
1095
+ noColor,
1096
+ });
1097
+ }
1098
+ }
1099
+
1100
+ // Migration warnings for v2 → v3 field changes.
1101
+ if (fm.family) {
1102
+ emitWarning(relPath, text, 'family', '"family" is deprecated in v3 — rename to "browse_category" before migrating to v4 "category"', {
1103
+ help: 'Run `node scripts/migrate-skill-v2-to-v3.js <skill>` to apply. See docs/manifest-field-mapping.md § Migration Note — v2 → v3.',
1104
+ noColor,
1105
+ });
1106
+ }
1107
+ // Migration warnings for v3 → v4 field changes.
1108
+ if (fm.browse_category) {
1109
+ emitWarning(relPath, text, 'browse_category', '"browse_category" is deprecated in v4 — rename to "category"', {
1110
+ help: 'Run `node scripts/migrate-skill-v3-to-v4.js <skill>` to apply.',
1111
+ noColor,
1112
+ });
1113
+ }
1114
+ if (fm.project_tags) {
1115
+ emitWarning(relPath, text, 'project_tags', '"project_tags" is deprecated in v4 — rename to "workspace_tags"', {
1116
+ help: 'Run `node scripts/migrate-skill-v3-to-v4.js <skill>` to apply.',
1117
+ noColor,
1118
+ });
1119
+ }
1120
+ if (fm.routing_groups) {
1121
+ emitWarning(relPath, text, 'routing_groups', '"routing_groups" is deprecated in v4 — rename to "routing_bundles"', {
1122
+ help: 'Run `node scripts/migrate-skill-v3-to-v4.js <skill>` to apply.',
1123
+ noColor,
1124
+ });
1125
+ }
1126
+ if (typeof fm.drift_check === 'string') {
1127
+ emitWarning(relPath, text, 'drift_check', 'scalar "drift_check" is deprecated in v3 — use an object with "last_verified"', {
1128
+ help: 'Run `node scripts/migrate-skill-v2-to-v3.js <skill>` to apply. See docs/field-reference.md § drift_check.',
1129
+ noColor,
1130
+ });
1131
+ }
1132
+ if (typeof fm.compatibility === 'string') {
1133
+ emitWarning(relPath, text, 'compatibility', 'scalar "compatibility" is deprecated in v3 — use an object with "runtimes"/"node"/"notes"', {
1134
+ help: 'Run `node scripts/migrate-skill-v2-to-v3.js <skill>` to apply. See docs/field-reference.md § compatibility.',
1135
+ noColor,
1136
+ });
1137
+ }
1138
+
1139
+ // Migration warnings for v3.0 → v3.1 predicate rename (SKOS alignment, ADR 0001 Decision #1).
1140
+ // `adjacent` remains valid through v3.x but will be removed in v4 in favor of `related`.
1141
+ //
1142
+ // Note: ADR 0006 reverted the parallel `boundary -> disjoint_with` rename. `boundary` stays
1143
+ // canonical for routing-layer asymmetric handoff; `disjoint_with` is a separate orthogonal
1144
+ // relation for formal OWL class-disjointness. No deprecation warning is emitted on
1145
+ // `boundary` because it is not deprecated.
1146
+ if (relationHygiene && fm.relations && typeof fm.relations === 'object') {
1147
+ if (Array.isArray(fm.relations.adjacent) && fm.relations.adjacent.length > 0) {
1148
+ emitWarning(relPath, text, 'adjacent', '"relations.adjacent" is deprecated in v3.1 — rename to "relations.related" (SKOS-aligned)', {
1149
+ help: 'See docs/adr/0001-predicate-set.md. Removal target: v4. Both names validate through v3.x.',
1150
+ noColor,
1151
+ });
1152
+ }
1153
+ }
1154
+
1155
+ // Double-declaration detection across deprecated/preferred alias pairs (ADR 0001 Decision #1).
1156
+ // Authors who write the same target under both `adjacent` and `related` produce duplicate
1157
+ // entries in the manifest; lint nudges them to drop the deprecated entry.
1158
+ if (relationHygiene && fm.relations && typeof fm.relations === 'object') {
1159
+ const doubles = checkRelationDoubleDeclarations(fm);
1160
+ for (const w of doubles) {
1161
+ emitWarning(relPath, text, 'relations', w.message, {
1162
+ help: 'Drop the deprecated entry; the preferred name carries the same SKOS mapping.',
1163
+ noColor,
1164
+ });
1165
+ }
1166
+ }
1167
+
1168
+ // ----------------------------------------------------------------
1169
+ // Collect errors from all per-file checks.
1170
+ // ----------------------------------------------------------------
1171
+ const evalResult = checkEvalCoherence(file, fm);
1172
+ const relationSemanticsResult = checkRelationSemantics(fm, knownFrontmatters);
1173
+ const rawErrors = [
1174
+ ...validateAgainstSchema(fm, schema),
1175
+ ...checkAliasParity(fm),
1176
+ ...checkParentDirMatchesName(file, fm),
1177
+ ...checkRelationTargets(fm, knownSkillNames),
1178
+ ...evalResult.errors,
1179
+ ...relationSemanticsResult.errors,
1180
+ ...checkGroundingTruthSources(fm),
1181
+ ...checkPathsNegation(fm),
1182
+ ];
1183
+ const rawWarnings = [
1184
+ ...evalResult.warnings,
1185
+ ...(relationHygiene ? relationSemanticsResult.warnings : []),
1186
+ ...checkDescriptionLength(fm),
1187
+ ];
1188
+
1189
+ // Archetype-aware section check (check 10).
1190
+ const archetypeResult = checkArchetypeSections({ filePath: relPath, sourceText: text, fm });
1191
+
1192
+ // Routing quality check (check 11).
1193
+ const routingResult = checkRoutingQuality({ filePath: relPath, sourceText: text, fm });
1194
+
1195
+ // Routing-eval check (check 12). Only fires when routing_eval: present.
1196
+ const routingEvalResult = checkRoutingEval({ filePath: relPath, sourceText: text, fm });
1197
+
1198
+ // Category-enum check (check 13). Enforces the 6-value canonical category set.
1199
+ const categoryEnumResult = checkCategoryEnum({ filePath: relPath, sourceText: text, fm });
1200
+
1201
+ // Stability promotion gate (check 14). Warns when stability: stable is
1202
+ // declared without meeting all five promotion criteria. WARN level only —
1203
+ // never contributes to fileErrors.
1204
+ const stabilityPromotionResult = checkStabilityPromotion({ filePath: relPath, sourceText: text, fm });
1205
+
1206
+ // Promote warnings to errors when --strict is active.
1207
+ const fileErrors = [
1208
+ ...rawErrors.map(msg => ({ msg, line: null, column: null, help: null })),
1209
+ ...archetypeResult.errors.map(e => ({ msg: e.message, line: e.line, column: e.column, help: e.help })),
1210
+ ...routingResult.errors.map(e => ({ msg: e.message, line: e.line, column: e.column, help: e.help })),
1211
+ ...routingEvalResult.errors.map(e => ({ msg: e.message, line: e.line, column: e.column, help: e.help })),
1212
+ ...categoryEnumResult.errors.map(e => ({ msg: e.message, line: e.line, column: e.column, help: e.help })),
1213
+ ...(strict ? [
1214
+ ...rawWarnings.map(msg => ({ msg: `[promoted from warn] ${msg}`, line: null, column: null, help: null })),
1215
+ ...archetypeResult.warnings.map(w => ({ msg: `[promoted from warn] ${w.message}`, line: w.line, column: w.column, help: w.help })),
1216
+ ...routingResult.warnings.map(w => ({ msg: `[promoted from warn] ${w.message}`, line: w.line, column: w.column, help: w.help })),
1217
+ ...stabilityPromotionResult.warnings.map(w => ({ msg: `[promoted from warn] ${w.message}`, line: w.line, column: w.column, help: w.help })),
1218
+ ] : []),
1219
+ ];
1220
+
1221
+ const fileWarnings = strict ? [] : [
1222
+ ...rawWarnings.map(msg => ({ message: msg, line: null, column: null, help: null })),
1223
+ ...archetypeResult.warnings,
1224
+ ...routingResult.warnings,
1225
+ ...stabilityPromotionResult.warnings,
1226
+ ];
1227
+
1228
+ // Tier label per file: template + starter skills are all Tier 5 specimens.
1229
+ const tierLabel = '[T5] ';
1230
+ if (fileErrors.length === 0 && fileWarnings.length === 0) {
1231
+ console.log(`OK ${tierLabel}${relPath}`);
1232
+ } else if (fileErrors.length === 0) {
1233
+ // Only warnings — file passes but annotate with WARN prefix.
1234
+ console.log(`OK ${tierLabel}${relPath} (${fileWarnings.length} warning(s))`);
1235
+ } else {
1236
+ console.error(`FAIL ${tierLabel}${relPath}`);
1237
+ }
1238
+
1239
+ // Print errors with code frames.
1240
+ for (const e of fileErrors) {
1241
+ if (e.line != null) {
1242
+ process.stderr.write(formatCodeFrame({
1243
+ filePath: relPath,
1244
+ line: e.line,
1245
+ column: e.column || 1,
1246
+ message: e.msg,
1247
+ help: e.help,
1248
+ sourceText: text,
1249
+ severity: 'error',
1250
+ noColor,
1251
+ }));
1252
+ } else {
1253
+ // Legacy plain-string errors from schema/relation checks. Locate the
1254
+ // field name in frontmatter for a better-than-nothing code frame.
1255
+ const fieldMatch = e.msg.match(/^([a-zA-Z_][a-zA-Z0-9_.[\]-]*):/);
1256
+ const fieldKey = fieldMatch ? fieldMatch[1].split('.')[0] : null;
1257
+ const loc = fieldKey ? locateYamlKey(text, fieldKey) : { line: 1, column: 1 };
1258
+ process.stderr.write(formatCodeFrame({
1259
+ filePath: relPath,
1260
+ line: loc.line,
1261
+ column: loc.column,
1262
+ message: e.msg,
1263
+ sourceText: text,
1264
+ severity: 'error',
1265
+ noColor,
1266
+ }));
1267
+ }
1268
+ totalErrors++;
1269
+ }
1270
+
1271
+ // Print warnings with code frames.
1272
+ for (const w of fileWarnings) {
1273
+ process.stderr.write(formatCodeFrame({
1274
+ filePath: relPath,
1275
+ line: w.line,
1276
+ column: w.column || 1,
1277
+ message: w.message,
1278
+ help: w.help,
1279
+ sourceText: text,
1280
+ severity: 'warn',
1281
+ noColor,
1282
+ }));
1283
+ totalWarnings++;
1284
+ }
1285
+ }
1286
+
1287
+ const warnSuffix = totalWarnings > 0 ? `, ${totalWarnings} warning(s)` : '';
1288
+ const strictNote = strict && totalWarnings === 0 ? '' : strict ? ' (--strict: warnings promoted to errors)' : '';
1289
+ console.log(`\n${files.length} file(s) checked, ${totalErrors} error(s)${warnSuffix}.${strictNote}`);
1290
+ process.exit(totalErrors > 0 ? 1 : 0);
1291
+ }
1292
+
1293
+ /**
1294
+ * Emit a migration-warning line with a code frame, located at the given YAML
1295
+ * key in the frontmatter.
1296
+ *
1297
+ * @param {string} relPath - File path relative to repo root.
1298
+ * @param {string} text - Full file text.
1299
+ * @param {string} key - YAML key to locate (for the frame position).
1300
+ * @param {string} message - Warning message.
1301
+ * @param {object} [opts] - Optional: { help, noColor }.
1302
+ */
1303
+ function emitWarning(relPath, text, key, message, opts = {}) {
1304
+ const loc = locateYamlKey(text, key) || { line: 1, column: 1 };
1305
+ process.stderr.write(formatCodeFrame({
1306
+ filePath: relPath,
1307
+ line: loc.line,
1308
+ column: loc.column,
1309
+ message,
1310
+ help: opts.help,
1311
+ sourceText: text,
1312
+ severity: 'warn',
1313
+ noColor: opts.noColor || false,
1314
+ }));
1315
+ }
1316
+
1317
+ main();