@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.
- package/CHANGELOG.md +247 -0
- package/LICENSE +200 -0
- package/NOTICE +62 -0
- package/README.md +398 -0
- package/SKILL_GRAPH.md +443 -0
- package/bin/skill-graph.js +374 -0
- package/docs/ADOPTION.md +117 -0
- package/docs/CONFORMANCE.md +66 -0
- package/docs/PRIMER.md +384 -0
- package/docs/QUICKSTART-30MIN.md +333 -0
- package/docs/ROUTING-METRICS.md +120 -0
- package/docs/SKILL-MD-FORMAT-COMPATIBILITY.md +127 -0
- package/docs/SKILL_AUDIT_CHECKLIST.md +199 -0
- package/docs/SKILL_AUDIT_LOOP.md +195 -0
- package/docs/SKILL_METADATA_PROTOCOL.md +609 -0
- package/docs/_archived/marketplace-publication-priority-2026-05-18.md +239 -0
- package/docs/adr/0001-predicate-set.md +69 -0
- package/docs/adr/0002-json-ld-context.md +82 -0
- package/docs/adr/0003-ontoclean-rigidity-tags.md +65 -0
- package/docs/adr/0004-persistent-identifiers.md +74 -0
- package/docs/adr/0005-freshness-consolidation.md +70 -0
- package/docs/adr/0006-revise-predicate-rename.md +105 -0
- package/docs/adr/0007-audit-loop-cadence.md +99 -0
- package/docs/adr/0008-skill-surface-split-and-curation-policy.md +93 -0
- package/docs/category-consumers.md +168 -0
- package/docs/concept-map.md +194 -0
- package/docs/diagrams/drift-states.mmd +21 -0
- package/docs/diagrams/manifest-pipeline.mmd +25 -0
- package/docs/diagrams/routing-harness.mmd +41 -0
- package/docs/diagrams/starter-graph.mmd +53 -0
- package/docs/field-decision-guide.md +315 -0
- package/docs/field-rationale.md +211 -0
- package/docs/field-reference.generated.md +624 -0
- package/docs/field-reference.md +1426 -0
- package/docs/glossary.md +190 -0
- package/docs/head-noun-glossary.md +63 -0
- package/docs/images/audit-phases.png +0 -0
- package/docs/images/drift-states.png +0 -0
- package/docs/images/graded-mode.png +0 -0
- package/docs/images/manifest-pipeline.png +0 -0
- package/docs/images/routing-harness.png +0 -0
- package/docs/images/skill-anatomy.png +0 -0
- package/docs/images/starter-graph.png +0 -0
- package/docs/images/system-model.png +0 -0
- package/docs/integrations/github-actions.md +155 -0
- package/docs/manifest-field-mapping.md +443 -0
- package/docs/marketplace-publication-queue.generated.md +240 -0
- package/docs/marketplace-release-agent-prompt.md +82 -0
- package/docs/marketplace-skill-candidate-list.md +272 -0
- package/docs/marketplace-syndication.md +222 -0
- package/docs/migration-sample-review.md +155 -0
- package/docs/migrations/v4-to-v5.md +168 -0
- package/docs/migrations/v5-to-v6.md +221 -0
- package/docs/name-exceptions.yaml +37 -0
- package/docs/plans/marketplace-p1-public-migration-plan.md +41 -0
- package/docs/plans/multi-root-workspace.md +148 -0
- package/docs/plans/scripts-roadmap.md +107 -0
- package/docs/plans/v4-schema-bump.md +160 -0
- package/docs/plans/wave-2-extraction.md +122 -0
- package/docs/positioning-vs-marketplaces.md +175 -0
- package/docs/proposals/skill-audit-loop-positioning.md +160 -0
- package/docs/quality-doctrine.md +138 -0
- package/docs/recommended-skills.md +150 -0
- package/docs/research/skill-comprehension-eval-research.md +1830 -0
- package/docs/research/skill-retrieval-evidence.md +66 -0
- package/docs/skill-metadata-protocol.md +471 -0
- package/docs/skills-sh-maintainer-cleanup-request.md +80 -0
- package/examples/audits/a11y/findings.md +52 -0
- package/examples/audits/a11y/scorecard.md +21 -0
- package/examples/audits/a11y/verdict.md +44 -0
- package/examples/audits/debugging/findings.md +59 -0
- package/examples/audits/debugging/scorecard.md +22 -0
- package/examples/audits/debugging/verdict.md +33 -0
- package/examples/audits/documentation/findings.md +59 -0
- package/examples/audits/documentation/scorecard.md +22 -0
- package/examples/audits/documentation/verdict.md +33 -0
- package/examples/evals/a11y.json +140 -0
- package/examples/evals/api-design.json +52 -0
- package/examples/evals/code-review.json +52 -0
- package/examples/evals/data-modeling.json +52 -0
- package/examples/evals/database-migration.json +52 -0
- package/examples/evals/debugging.json +118 -0
- package/examples/evals/dependency-architecture.json +52 -0
- package/examples/evals/design-system-architecture.json +52 -0
- package/examples/evals/error-tracking.json +52 -0
- package/examples/evals/event-contract-design.json +52 -0
- package/examples/evals/form-ux-architecture.json +52 -0
- package/examples/evals/framework-fit-analysis.json +52 -0
- package/examples/evals/graph-audit.json +139 -0
- package/examples/evals/information-architecture.json +52 -0
- package/examples/evals/interaction-feedback.json +52 -0
- package/examples/evals/interaction-patterns.json +52 -0
- package/examples/evals/layout-composition.json +52 -0
- package/examples/evals/lint-overlay.json +117 -0
- package/examples/evals/microcopy.json +52 -0
- package/examples/evals/observability-modeling.json +52 -0
- package/examples/evals/pattern-recognition.json +96 -0
- package/examples/evals/performance-engineering.json +52 -0
- package/examples/evals/refactor.json +128 -0
- package/examples/evals/semiotics.json +52 -0
- package/examples/evals/skill-infrastructure.json +96 -0
- package/examples/evals/skill-router.json +140 -0
- package/examples/evals/skill-router.routing.json +113 -0
- package/examples/evals/system-interface-contracts.json +52 -0
- package/examples/evals/task-analysis.json +52 -0
- package/examples/evals/testing-strategy.json +118 -0
- package/examples/evals/type-safety.json +249 -0
- package/examples/evals/visual-design-foundations.json +52 -0
- package/examples/evals/webhook-integration.json +52 -0
- package/examples/exports/a11y.skill-md.md +80 -0
- package/examples/exports/debugging.skill-md.md +80 -0
- package/examples/exports/refactor.skill-md.md +78 -0
- package/examples/exports/testing-strategy.skill-md.md +81 -0
- package/examples/projects/markdown-static-site/README.md +115 -0
- package/examples/projects/markdown-static-site/skills/content-source-router/SKILL.md +131 -0
- package/examples/projects/markdown-static-site/skills/image-optimization-pipeline-config/SKILL.md +132 -0
- package/examples/projects/markdown-static-site/skills/link-rot-detection/SKILL.md +103 -0
- package/examples/projects/markdown-static-site/skills/markdown-post-frontmatter-validation/SKILL.md +133 -0
- package/examples/projects/markdown-static-site/skills/migrate-posts-to-v2-frontmatter/SKILL.md +140 -0
- package/examples/projects/saas-stripe-postgres/README.md +208 -0
- package/examples/projects/saas-stripe-postgres/db/migrations/0004_canonicalize_orders.sql +37 -0
- package/examples/projects/saas-stripe-postgres/db/schema.sql +112 -0
- package/examples/projects/saas-stripe-postgres/skills/migrate-orders-to-canonical-schema/SKILL.md +149 -0
- package/examples/projects/saas-stripe-postgres/skills/nextjs-server-action-validation/SKILL.md +154 -0
- package/examples/projects/saas-stripe-postgres/skills/payment-provider-router/SKILL.md +153 -0
- package/examples/projects/saas-stripe-postgres/skills/postgres-rls-pattern/SKILL.md +163 -0
- package/examples/projects/saas-stripe-postgres/skills/stripe-webhook-signature-verification/SKILL.md +137 -0
- package/examples/protocol/skill-metadata-template.md +301 -0
- package/examples/protocol/skills.manifest.sample.json +13245 -0
- package/examples/skill-metadata-template.md +317 -0
- package/examples/skills.manifest.sample.json +13519 -0
- package/examples/tests/v3-1-skos-fixture/SKILL.md +93 -0
- package/marketplace/README.md +17 -0
- package/marketplace/skills/a11y/SKILL.md +66 -0
- package/marketplace/skills/acid-fundamentals/SKILL.md +106 -0
- package/marketplace/skills/agent-engineering/SKILL.md +386 -0
- package/marketplace/skills/agent-eval-design/SKILL.md +55 -0
- package/marketplace/skills/ai-native-development/SKILL.md +294 -0
- package/marketplace/skills/api-design/SKILL.md +60 -0
- package/marketplace/skills/architecture-decision-records/SKILL.md +55 -0
- package/marketplace/skills/background-jobs/SKILL.md +265 -0
- package/marketplace/skills/bounded-context-mapping/SKILL.md +55 -0
- package/marketplace/skills/cap-theorem-tradeoffs/SKILL.md +127 -0
- package/marketplace/skills/client-server-boundary/SKILL.md +187 -0
- package/marketplace/skills/code-review/SKILL.md +120 -0
- package/marketplace/skills/color-system-design/SKILL.md +43 -0
- package/marketplace/skills/component-architecture/SKILL.md +126 -0
- package/marketplace/skills/compression/SKILL.md +112 -0
- package/marketplace/skills/conceptual-modeling/SKILL.md +181 -0
- package/marketplace/skills/connection-pooling/SKILL.md +105 -0
- package/marketplace/skills/constraint-awareness/SKILL.md +287 -0
- package/marketplace/skills/content-monitor/SKILL.md +209 -0
- package/marketplace/skills/context-engineering/SKILL.md +320 -0
- package/marketplace/skills/context-graph/SKILL.md +174 -0
- package/marketplace/skills/context-management/SKILL.md +174 -0
- package/marketplace/skills/context-window/SKILL.md +239 -0
- package/marketplace/skills/contract-testing/SKILL.md +120 -0
- package/marketplace/skills/cron-scheduling/SKILL.md +223 -0
- package/marketplace/skills/dark-mode-implementation/SKILL.md +47 -0
- package/marketplace/skills/data-modeling/SKILL.md +59 -0
- package/marketplace/skills/data-modeling-fundamentals/SKILL.md +117 -0
- package/marketplace/skills/database-migration/SKILL.md +429 -0
- package/marketplace/skills/debugging/SKILL.md +67 -0
- package/marketplace/skills/dependency-architecture/SKILL.md +58 -0
- package/marketplace/skills/design-module-composition/SKILL.md +43 -0
- package/marketplace/skills/design-system-architecture/SKILL.md +61 -0
- package/marketplace/skills/design-thinking/SKILL.md +44 -0
- package/marketplace/skills/diagnosis/SKILL.md +296 -0
- package/marketplace/skills/diff-analysis/SKILL.md +188 -0
- package/marketplace/skills/e2e-test-design/SKILL.md +113 -0
- package/marketplace/skills/entity-relationship-modeling/SKILL.md +218 -0
- package/marketplace/skills/epistemic-grounding/SKILL.md +112 -0
- package/marketplace/skills/error-boundary/SKILL.md +235 -0
- package/marketplace/skills/error-tracking/SKILL.md +261 -0
- package/marketplace/skills/eval-driven-development/SKILL.md +147 -0
- package/marketplace/skills/evaluation/SKILL.md +113 -0
- package/marketplace/skills/event-contract-design/SKILL.md +60 -0
- package/marketplace/skills/event-storming/SKILL.md +56 -0
- package/marketplace/skills/form-ux-architecture/SKILL.md +60 -0
- package/marketplace/skills/framework-fit-analysis/SKILL.md +59 -0
- package/marketplace/skills/frontend-architecture/SKILL.md +43 -0
- package/marketplace/skills/generative-ui/SKILL.md +118 -0
- package/marketplace/skills/graph-audit/SKILL.md +81 -0
- package/marketplace/skills/guardrails/SKILL.md +118 -0
- package/marketplace/skills/hooks-patterns/SKILL.md +185 -0
- package/marketplace/skills/http-semantics/SKILL.md +136 -0
- package/marketplace/skills/ideation/SKILL.md +41 -0
- package/marketplace/skills/indexing-strategy/SKILL.md +108 -0
- package/marketplace/skills/information-architecture/SKILL.md +59 -0
- package/marketplace/skills/integration-test-design/SKILL.md +111 -0
- package/marketplace/skills/intent-recognition/SKILL.md +136 -0
- package/marketplace/skills/interaction-feedback/SKILL.md +59 -0
- package/marketplace/skills/interaction-patterns/SKILL.md +59 -0
- package/marketplace/skills/journey-mapping/SKILL.md +41 -0
- package/marketplace/skills/keywords/SKILL.md +213 -0
- package/marketplace/skills/knowledge-modeling/SKILL.md +232 -0
- package/marketplace/skills/layout-composition/SKILL.md +59 -0
- package/marketplace/skills/linguistics/SKILL.md +429 -0
- package/marketplace/skills/lint-overlay/SKILL.md +76 -0
- package/marketplace/skills/mental-models/SKILL.md +126 -0
- package/marketplace/skills/merge-queue/SKILL.md +94 -0
- package/marketplace/skills/methodology/SKILL.md +317 -0
- package/marketplace/skills/microcopy/SKILL.md +232 -0
- package/marketplace/skills/middleware-patterns/SKILL.md +363 -0
- package/marketplace/skills/mobile-responsive-ux/SKILL.md +287 -0
- package/marketplace/skills/mutation-testing/SKILL.md +112 -0
- package/marketplace/skills/naming-conventions/SKILL.md +112 -0
- package/marketplace/skills/observability-modeling/SKILL.md +59 -0
- package/marketplace/skills/ontology-modeling/SKILL.md +67 -0
- package/marketplace/skills/owasp-security/SKILL.md +153 -0
- package/marketplace/skills/pattern-recognition/SKILL.md +472 -0
- package/marketplace/skills/performance-budgets/SKILL.md +185 -0
- package/marketplace/skills/performance-engineering/SKILL.md +58 -0
- package/marketplace/skills/performance-testing/SKILL.md +125 -0
- package/marketplace/skills/printify/SKILL.md +42 -0
- package/marketplace/skills/prioritization/SKILL.md +118 -0
- package/marketplace/skills/problem-framing/SKILL.md +41 -0
- package/marketplace/skills/problem-locating-solving/SKILL.md +203 -0
- package/marketplace/skills/project-knowledge-extraction/SKILL.md +54 -0
- package/marketplace/skills/prompt-craft/SKILL.md +134 -0
- package/marketplace/skills/prompt-injection-defense/SKILL.md +132 -0
- package/marketplace/skills/property-based-testing/SKILL.md +100 -0
- package/marketplace/skills/prototyping/SKILL.md +43 -0
- package/marketplace/skills/query-optimization/SKILL.md +144 -0
- package/marketplace/skills/real-time-updates/SKILL.md +324 -0
- package/marketplace/skills/ref-patterns/SKILL.md +284 -0
- package/marketplace/skills/refactor/SKILL.md +65 -0
- package/marketplace/skills/rendering-models/SKILL.md +142 -0
- package/marketplace/skills/replication-patterns/SKILL.md +110 -0
- package/marketplace/skills/research-synthesis/SKILL.md +41 -0
- package/marketplace/skills/route-handler-design/SKILL.md +347 -0
- package/marketplace/skills/schema-evolution/SKILL.md +140 -0
- package/marketplace/skills/security-fundamentals/SKILL.md +139 -0
- package/marketplace/skills/semantic-center/SKILL.md +194 -0
- package/marketplace/skills/semantic-relations/SKILL.md +250 -0
- package/marketplace/skills/semantics/SKILL.md +366 -0
- package/marketplace/skills/semiotics/SKILL.md +230 -0
- package/marketplace/skills/seo-strategy/SKILL.md +260 -0
- package/marketplace/skills/server-actions-design/SKILL.md +243 -0
- package/marketplace/skills/server-components-design/SKILL.md +190 -0
- package/marketplace/skills/sharding-strategy/SKILL.md +123 -0
- package/marketplace/skills/shopify/SKILL.md +42 -0
- package/marketplace/skills/skill-infrastructure/SKILL.md +320 -0
- package/marketplace/skills/skill-router/SKILL.md +71 -0
- package/marketplace/skills/skill-scaffold/SKILL.md +105 -0
- package/marketplace/skills/snapshot-testing/SKILL.md +120 -0
- package/marketplace/skills/spec-driven-development/SKILL.md +148 -0
- package/marketplace/skills/state-machine-modeling/SKILL.md +56 -0
- package/marketplace/skills/state-management/SKILL.md +134 -0
- package/marketplace/skills/streaming-architecture/SKILL.md +194 -0
- package/marketplace/skills/summarization/SKILL.md +156 -0
- package/marketplace/skills/suspense-patterns/SKILL.md +265 -0
- package/marketplace/skills/system-interface-contracts/SKILL.md +59 -0
- package/marketplace/skills/task-analysis/SKILL.md +201 -0
- package/marketplace/skills/taxonomy-design/SKILL.md +66 -0
- package/marketplace/skills/test-coverage-strategy/SKILL.md +108 -0
- package/marketplace/skills/test-doubles-design/SKILL.md +98 -0
- package/marketplace/skills/test-driven-development/SKILL.md +96 -0
- package/marketplace/skills/testing-strategy/SKILL.md +67 -0
- package/marketplace/skills/theme-system-design/SKILL.md +43 -0
- package/marketplace/skills/tool-call-flow/SKILL.md +229 -0
- package/marketplace/skills/tool-call-strategy/SKILL.md +292 -0
- package/marketplace/skills/transaction-isolation/SKILL.md +98 -0
- package/marketplace/skills/type-safety/SKILL.md +177 -0
- package/marketplace/skills/typography-system/SKILL.md +43 -0
- package/marketplace/skills/usability-testing/SKILL.md +43 -0
- package/marketplace/skills/user-research/SKILL.md +43 -0
- package/marketplace/skills/vercel-composition-patterns/SKILL.md +157 -0
- package/marketplace/skills/version-control/SKILL.md +233 -0
- package/marketplace/skills/visual-design-foundations/SKILL.md +59 -0
- package/marketplace/skills/visual-hierarchy/SKILL.md +43 -0
- package/marketplace/skills/webhook-integration/SKILL.md +331 -0
- package/marketplace/skills/writing-humanizer/SKILL.md +380 -0
- package/package.json +67 -0
- package/schemas/manifest.schema.json +811 -0
- package/schemas/manifest.v2.schema.json +164 -0
- package/schemas/manifest.v3.schema.json +758 -0
- package/schemas/manifest.v4.schema.json +755 -0
- package/schemas/manifest.v5.schema.json +755 -0
- package/schemas/manifest.v6.schema.json +811 -0
- package/schemas/skill.context.jsonld +279 -0
- package/schemas/skill.schema.json +919 -0
- package/schemas/skill.v2.schema.json +201 -0
- package/schemas/skill.v3.schema.json +827 -0
- package/schemas/skill.v4.schema.json +822 -0
- package/schemas/skill.v5.schema.json +830 -0
- package/schemas/skill.v6.schema.json +946 -0
- package/schemas/vocabulary/keywords.json +180 -0
- package/schemas/vocabulary/workspace_tags.json +23 -0
- package/scripts/__tests__/migrate-skill-v2-to-v3.test.js +161 -0
- package/scripts/__tests__/migrate-skill-v3-to-v4.test.js +158 -0
- package/scripts/__tests__/test-export-parser-drift.js +149 -0
- package/scripts/__tests__/test-marketplace-export.js +114 -0
- package/scripts/__tests__/test-router-paths.js +82 -0
- package/scripts/__tests__/test-stability-promotion.js +244 -0
- package/scripts/__tests__/test-v3-1-alias-contract.js +109 -0
- package/scripts/__tests__/test-v3-1-skos-runtime.js +116 -0
- package/scripts/backfill-schema-version.js +198 -0
- package/scripts/build-field-reference.js +160 -0
- package/scripts/build-retrieval-baseline.js +511 -0
- package/scripts/check-markdown-links.js +211 -0
- package/scripts/check-protocol-consistency.js +979 -0
- package/scripts/export-marketplace-skills.js +610 -0
- package/scripts/export-skill.js +374 -0
- package/scripts/generate-manifest.js +787 -0
- package/scripts/lib/alias-contract.js +83 -0
- package/scripts/lib/audit-prompt-builder.js +771 -0
- package/scripts/lib/mock-grader.js +134 -0
- package/scripts/lib/parse-frontmatter.js +429 -0
- package/scripts/lib/roots.js +119 -0
- package/scripts/lint/check-archetype-sections.js +185 -0
- package/scripts/lint/check-category-enum.js +83 -0
- package/scripts/lint/check-routing-eval.js +146 -0
- package/scripts/lint/check-routing-quality.js +211 -0
- package/scripts/lint/check-stability-promotion.js +220 -0
- package/scripts/lint/format-code-frame.js +206 -0
- package/scripts/marketplace-install.js +125 -0
- package/scripts/migrate-category-to-enum.js +169 -0
- package/scripts/migrate-skill-v2-to-v3.js +424 -0
- package/scripts/migrate-skill-v3-to-v4.js +200 -0
- package/scripts/migrate-skill-v5-to-v6.js +304 -0
- package/scripts/restructure-by-category.js +85 -0
- package/scripts/seed-publication-classification.js +282 -0
- package/scripts/skill-audit.js +893 -0
- package/scripts/skill-graph-drift.js +483 -0
- package/scripts/skill-graph-route.js +766 -0
- package/scripts/skill-graph-routing-eval.js +393 -0
- package/scripts/skill-lint.js +1317 -0
- package/scripts/skill-overlap.js +213 -0
- 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();
|