@open-mercato/cli 0.6.8-develop.6931.1.4a1e1c786b → 0.6.8-develop.6940.1.177ea30c6e
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/.turbo/turbo-build.log +65 -1
- package/build.mjs +57 -7
- package/dist/agentic/guides/backend-ui.md +7 -1
- package/dist/agentic/guides/module-facts.json +565 -224
- package/dist/agentic/guides/module-facts.v2.json +122933 -0
- package/dist/agentic/guides/modules/ai_assistant.md +2 -2
- package/dist/agentic/guides/modules/api_docs.md +2 -2
- package/dist/agentic/guides/modules/api_keys.md +2 -2
- package/dist/agentic/guides/modules/attachments.md +2 -2
- package/dist/agentic/guides/modules/audit_logs.md +2 -2
- package/dist/agentic/guides/modules/auth.md +2 -2
- package/dist/agentic/guides/modules/business_rules.md +2 -2
- package/dist/agentic/guides/modules/catalog.md +11 -4
- package/dist/agentic/guides/modules/channel_gmail.md +2 -2
- package/dist/agentic/guides/modules/channel_imap.md +2 -2
- package/dist/agentic/guides/modules/checkout.md +8 -2
- package/dist/agentic/guides/modules/communication_channels.md +6 -6
- package/dist/agentic/guides/modules/configs.md +2 -2
- package/dist/agentic/guides/modules/content.md +2 -2
- package/dist/agentic/guides/modules/currencies.md +2 -2
- package/dist/agentic/guides/modules/customer_accounts.md +2 -2
- package/dist/agentic/guides/modules/customers.md +8 -8
- package/dist/agentic/guides/modules/dashboards.md +12 -12
- package/dist/agentic/guides/modules/data_sync.md +4 -4
- package/dist/agentic/guides/modules/design_system.md +2 -2
- package/dist/agentic/guides/modules/dictionaries.md +2 -2
- package/dist/agentic/guides/modules/directory.md +2 -2
- package/dist/agentic/guides/modules/entities.md +3 -3
- package/dist/agentic/guides/modules/eudr.md +3 -3
- package/dist/agentic/guides/modules/events.md +2 -2
- package/dist/agentic/guides/modules/feature_toggles.md +2 -2
- package/dist/agentic/guides/modules/gateway_stripe.md +2 -2
- package/dist/agentic/guides/modules/generators.md +2 -2
- package/dist/agentic/guides/modules/inbox_ops.md +2 -2
- package/dist/agentic/guides/modules/integrations.md +5 -2
- package/dist/agentic/guides/modules/messages.md +2 -2
- package/dist/agentic/guides/modules/notifications.md +2 -2
- package/dist/agentic/guides/modules/onboarding.md +2 -2
- package/dist/agentic/guides/modules/payment_gateways.md +7 -3
- package/dist/agentic/guides/modules/perspectives.md +2 -2
- package/dist/agentic/guides/modules/planner.md +2 -2
- package/dist/agentic/guides/modules/portal.md +2 -2
- package/dist/agentic/guides/modules/progress.md +2 -2
- package/dist/agentic/guides/modules/query_index.md +2 -2
- package/dist/agentic/guides/modules/record_locks.md +3 -3
- package/dist/agentic/guides/modules/resources.md +2 -2
- package/dist/agentic/guides/modules/sales.md +13 -4
- package/dist/agentic/guides/modules/scheduler.md +2 -2
- package/dist/agentic/guides/modules/search.md +4 -4
- package/dist/agentic/guides/modules/security.md +3 -3
- package/dist/agentic/guides/modules/shipping_carriers.md +2 -2
- package/dist/agentic/guides/modules/sso.md +2 -2
- package/dist/agentic/guides/modules/staff.md +7 -4
- package/dist/agentic/guides/modules/storage_s3.md +2 -2
- package/dist/agentic/guides/modules/sync_akeneo.md +4 -4
- package/dist/agentic/guides/modules/sync_excel.md +2 -2
- package/dist/agentic/guides/modules/system_status_overlays.md +2 -2
- package/dist/agentic/guides/modules/translations.md +2 -2
- package/dist/agentic/guides/modules/webhooks.md +2 -2
- package/dist/agentic/guides/modules/wms.md +31 -4
- package/dist/agentic/guides/modules/workflows.md +2 -2
- package/dist/agentic/guides/reference-module-facts.json +8335 -0
- package/dist/agentic/guides/reference-modules/example.md +489 -0
- package/dist/agentic/guides/spec-delivery.md +34 -0
- package/dist/agentic/guides/testing-debugging.md +2 -1
- package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +3 -2
- package/dist/agentic/guides/upstream/manifest.json +2 -2
- package/dist/agentic/shared/AGENTS.md.template +10 -14
- package/dist/agentic/shared/ai/harness/README.md +4 -4
- package/dist/agentic/shared/ai/harness/RELEASE.md +4 -4
- package/dist/agentic/shared/ai/harness/cases.json +11350 -5088
- package/dist/agentic/shared/ai/harness/cases.schema.json +70 -7
- package/dist/agentic/shared/ai/harness/design-system-inventory.json +6697 -0
- package/dist/agentic/shared/ai/harness/fixtures/index.json +3 -1
- package/dist/agentic/shared/ai/harness/fixtures/seeds.json +4 -0
- package/dist/agentic/shared/ai/harness/knowledge-change.schema.json +228 -0
- package/dist/agentic/shared/ai/harness/release-matrix.json +9 -4
- package/dist/agentic/shared/ai/harness/result.schema.json +60 -0
- package/dist/agentic/shared/ai/harness/routing-response.schema.json +10 -0
- package/dist/agentic/shared/ai/harness/source-link-inventory.json +3231 -0
- package/dist/agentic/shared/ai/harness/validators.json +8 -4
- package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +5 -2
- package/dist/agentic/shared/ai/harness/writable-behavior-oracles.mjs +3 -0
- package/dist/agentic/shared/ai/harness/writable-spec-oracles.mjs +879 -0
- package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.6.7-to-0.7.0/SKILL.md +102 -0
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/SKILL.md +1 -0
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/crud-surfaces.md +15 -0
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/frontend-and-design-system.md +6 -0
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/page-and-navigation.md +4 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/SKILL.md +1 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/integrity-and-concurrency.md +2 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +2 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/schema-design.md +3 -1
- package/dist/agentic/shared/ai/skills/om-eject-and-customize/SKILL.md +1 -0
- package/dist/agentic/shared/ai/skills/om-eject-and-customize/references/decision-and-procedure.md +2 -0
- package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +2 -0
- package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +3 -3
- package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +1 -1
- package/dist/agentic/shared/ai/skills/om-evolve-harness/references/knowledge-change.md +133 -0
- package/dist/agentic/shared/ai/skills/om-integration-builder/SKILL.md +1 -0
- package/dist/agentic/shared/ai/skills/om-integration-builder/references/provider-families.md +11 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +1 -1
- package/dist/agentic/shared/ai/skills/om-module-scaffold/SKILL.md +2 -2
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +14 -1
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/data-and-migrations.md +2 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +19 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +1 -4
- package/dist/agentic/shared/ai/skills/om-system-extension/SKILL.md +1 -0
- package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-branches.md +24 -0
- package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +3 -1
- package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +1 -0
- package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnosis-map.md +13 -0
- package/dist/agentic/shared/ai/skills/tiers.json +2 -1
- package/dist/agentic/shared/ai/specs/2026-08-06-reference-module-activation.md +248 -0
- package/dist/agentic/shared/scripts/agent-harness-tool-server.mjs +39 -2
- package/dist/agentic/shared/scripts/design-source-contract.mjs +108 -0
- package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +935 -28
- package/dist/agentic/shared/scripts/framework-context.mjs +4 -1
- package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1 -1
- package/dist/agentic/shared/scripts/validate-knowledge-change.mjs +1511 -0
- package/dist/lib/__integration__/TC-INT-008.spec.js +1 -0
- package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
- package/dist/lib/agentic-setup.js +9 -1
- package/dist/lib/agentic-setup.js.map +2 -2
- package/dist/lib/generators/module-extension-facts.js +93 -26
- package/dist/lib/generators/module-extension-facts.js.map +3 -3
- package/dist/lib/generators/module-facts-discovery.js +23 -0
- package/dist/lib/generators/module-facts-discovery.js.map +2 -2
- package/dist/lib/generators/module-facts.js +969 -41
- package/dist/lib/generators/module-facts.js.map +2 -2
- package/dist/lib/generators/module-override-targets.js +35 -11
- package/dist/lib/generators/module-override-targets.js.map +2 -2
- package/dist/lib/generators/module-registry.js +105 -2
- package/dist/lib/generators/module-registry.js.map +2 -2
- package/dist/lib/resolver.js +5 -0
- package/dist/lib/resolver.js.map +2 -2
- package/dist/lib/testing/integration-discovery.js +34 -0
- package/dist/lib/testing/integration-discovery.js.map +2 -2
- package/dist/lib/testing/integration.js +0 -2
- package/dist/lib/testing/integration.js.map +2 -2
- package/dist/mercato.js +4 -4
- package/dist/mercato.js.map +2 -2
- package/package.json +6 -6
- package/src/lib/__integration__/TC-INT-008.spec.ts +4 -2
- package/src/lib/__tests__/resolve-environment.test.ts +18 -0
- package/src/lib/agentic-setup.ts +10 -2
- package/src/lib/generators/__tests__/helpers/override-target-diagnostic-fixtures.ts +150 -0
- package/src/lib/generators/__tests__/module-extension-facts.test.ts +290 -0
- package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +72 -7
- package/src/lib/generators/__tests__/module-facts.computed-di-token.test.ts +88 -0
- package/src/lib/generators/__tests__/module-facts.example-fact-coverage.test.ts +502 -0
- package/src/lib/generators/__tests__/module-facts.local-reference.test.ts +414 -0
- package/src/lib/generators/__tests__/module-facts.search-entity-id.test.ts +151 -0
- package/src/lib/generators/__tests__/module-facts.source-links.test.ts +42 -1
- package/src/lib/generators/__tests__/module-override-targets.diagnostic-wiring.test.ts +92 -0
- package/src/lib/generators/__tests__/module-override-targets.test.ts +24 -0
- package/src/lib/generators/__tests__/module-override-targets.unknown-mode.test.ts +117 -0
- package/src/lib/generators/__tests__/module-subset.test.ts +60 -0
- package/src/lib/generators/module-extension-facts.ts +181 -25
- package/src/lib/generators/module-facts-discovery.ts +46 -0
- package/src/lib/generators/module-facts.ts +1330 -86
- package/src/lib/generators/module-override-targets.ts +70 -26
- package/src/lib/generators/module-registry.ts +135 -2
- package/src/lib/resolver.ts +6 -1
- package/src/lib/testing/__tests__/integration-discovery.test.ts +35 -0
- package/src/lib/testing/integration-discovery.ts +50 -0
- package/src/lib/testing/integration.ts +0 -2
- package/src/mercato.ts +3 -3
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import process from 'node:process'
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const EXIT_PASS = 0
|
|
12
|
+
const EXIT_FAILURE = 1
|
|
13
|
+
const EXIT_INVALID = 2
|
|
14
|
+
|
|
15
|
+
export const CHANGE_CLASSES = Object.freeze(['knowledge-contract', 'asset-sync'])
|
|
16
|
+
export const CHANGED_CONTRACTS = Object.freeze([
|
|
17
|
+
'routing', 'skill-link', 'source-link', 'example-source', 'installed-source',
|
|
18
|
+
'discovery', 'context-read', 'evaluator', 'oracle',
|
|
19
|
+
])
|
|
20
|
+
export const RELEASE_LANE_IDS = Object.freeze([
|
|
21
|
+
'deterministic', 'routing', 'writable', 'generatedCodeReview', 'generativeJudge', 'generatedTests',
|
|
22
|
+
])
|
|
23
|
+
export const CONTROLLER_OWNED_FIELDS = Object.freeze(['resolvedBaseSha', 'headSha', 'focusedExecutions'])
|
|
24
|
+
export const SCHEMA_BASENAME = 'knowledge-change.schema.json'
|
|
25
|
+
|
|
26
|
+
// Sibling of the baseline and topics registry, which live OUTSIDE the shipped
|
|
27
|
+
// `agentic/shared/**` tree on purpose: that tree is copied wholesale into every generated
|
|
28
|
+
// app, and these are monorepo-only assets (the baseline pins monorepo SHAs and validates
|
|
29
|
+
// monorepo files). A forward reference to the old location would have sent the inventory
|
|
30
|
+
// back into every scaffold when it lands.
|
|
31
|
+
export const SOURCE_LINK_INVENTORY_PATH =
|
|
32
|
+
'packages/create-app/scripts/source-links/source-link-inventory.json'
|
|
33
|
+
// The spec's finite `main` parity ledger ships as `source-link-baseline.json`. Its path is pinned
|
|
34
|
+
// here rather than read out of the inventory's `inputs.baseline`, because that string lives inside
|
|
35
|
+
// an asset the same author is editing: anchoring the manifest to it would let an author point both
|
|
36
|
+
// at a ledger they fabricated. The inventory may now only agree with this literal or fail.
|
|
37
|
+
// `validate-source-links.mjs` hardcodes the same path for the same reason.
|
|
38
|
+
export const SOURCE_LINK_BASELINE_PATH =
|
|
39
|
+
'packages/create-app/scripts/source-links/source-link-baseline.json'
|
|
40
|
+
// The schema pinning that ledger's shape is the ledger's exact sibling, so `baselineSchemaPath` is
|
|
41
|
+
// derived from the pinned ledger path rather than spelled out a second time.
|
|
42
|
+
export const SOURCE_LINK_BASELINE_SCHEMA_BASENAME = 'source-link-baseline.schema.json'
|
|
43
|
+
export const CANON_C_REASON =
|
|
44
|
+
`${SOURCE_LINK_INVENTORY_PATH} not present — CANON-C (canonical-example source-link inventory) is a ` +
|
|
45
|
+
'monorepo-only asset and is absent from this tree, so source-link, example-source, and ' +
|
|
46
|
+
'installed-source contracts cannot be resolved and fail closed'
|
|
47
|
+
|
|
48
|
+
const EXAMPLE_AUTHORING_ROOT = 'apps/mercato/src/modules/example'
|
|
49
|
+
const EXAMPLE_TEMPLATE_ROOT = 'packages/create-app/template/src/modules/example'
|
|
50
|
+
const EXAMPLE_QA_ONLY_SEGMENTS = Object.freeze(['__tests__', '__integration__'])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pinned exactly the way `SOURCE_LINK_INVENTORY_PATH` is. The capability inventory is an asset the
|
|
54
|
+
* same author edits, so no string in the manifest — or inside the inventory itself — may decide
|
|
55
|
+
* which file this gate reads.
|
|
56
|
+
*/
|
|
57
|
+
const EXAMPLE_SURFACE_INVENTORY_PATH = `${EXAMPLE_AUTHORING_ROOT}/references/surface-inventory.json`
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Inventory rows address files the way the emitted app sees them (`src/modules/example/...`), not
|
|
61
|
+
* the way the monorepo does, so every path out of that asset resolves against the owning app root
|
|
62
|
+
* rather than the repository root. Resolving from the repository root instead makes all 66 shipped
|
|
63
|
+
* declarations look missing.
|
|
64
|
+
*/
|
|
65
|
+
const EXAMPLE_APP_ROOT = EXAMPLE_AUTHORING_ROOT.slice(0, EXAMPLE_AUTHORING_ROOT.indexOf('/src/'))
|
|
66
|
+
|
|
67
|
+
const FOCUSED_TEST_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/
|
|
68
|
+
|
|
69
|
+
// One argv token may not carry a shell metacharacter. The controller never interpolates a shell,
|
|
70
|
+
// and the recorded evidence must stay reproducible as an exact argv vector.
|
|
71
|
+
const SHELL_METACHARACTER_PATTERN = /[`$;&|<>\n]/
|
|
72
|
+
|
|
73
|
+
export const DEFAULT_EXECUTION_TIMEOUT_MS = 600_000
|
|
74
|
+
|
|
75
|
+
// Ordered; the first matching runner owns the focused test. Runner argv is controller-derived so an
|
|
76
|
+
// author can never substitute a weaker command for the test they declared.
|
|
77
|
+
const FOCUSED_RUNNERS = Object.freeze([
|
|
78
|
+
{ runner: 'node-test', pattern: /\.(?:test|spec)\.[cm]?jsx?$/, args: (relativePath) => ['--test', relativePath] },
|
|
79
|
+
{ runner: 'node-test-tsx', pattern: /\.(?:test|spec)\.[cm]?tsx?$/, args: (relativePath) => ['--import', 'tsx', '--test', relativePath] },
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
// Ordered; the first matching rule owns the path. `contract: null` marks a path that
|
|
83
|
+
// carries no knowledge contract of its own — a generated/materialized copy or a docs
|
|
84
|
+
// snapshot — which is the only shape an `asset-sync` run may contain.
|
|
85
|
+
const CLASSIFICATION_RULES = Object.freeze([
|
|
86
|
+
{
|
|
87
|
+
rule: 'canonical-example-tree',
|
|
88
|
+
contract: 'example-source',
|
|
89
|
+
changeClass: 'knowledge-contract',
|
|
90
|
+
pattern: /^(?:apps\/mercato\/src|packages\/create-app\/template\/src|src)\/modules\/example\/.+$/,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
// The derived inventory, the topic registry it derives from, the finite `main` parity ledger
|
|
94
|
+
// (`source-link-baseline.json` — the spec names it a parity ledger, it ships under this name),
|
|
95
|
+
// and the schema pinning that ledger's shape.
|
|
96
|
+
rule: 'source-link-inventory',
|
|
97
|
+
contract: 'source-link',
|
|
98
|
+
changeClass: 'knowledge-contract',
|
|
99
|
+
pattern: /(?:^|\/)(?:source-link-inventory\.json|source-link-topics\.json|source-link-baseline\.json|source-link-baseline\.schema\.json)$/,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
rule: 'installed-package-manifest',
|
|
103
|
+
contract: 'installed-source',
|
|
104
|
+
changeClass: 'knowledge-contract',
|
|
105
|
+
pattern: /(?:^|\/)(?:package\.json|package\.json\.template|yarn\.lock|package-lock\.json|pnpm-lock\.yaml)$/,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
rule: 'installed-skill-closure',
|
|
109
|
+
contract: 'installed-source',
|
|
110
|
+
changeClass: 'knowledge-contract',
|
|
111
|
+
pattern: /(?:^|\/)skills\/tiers\.json$/,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
rule: 'evaluator-implementation',
|
|
115
|
+
contract: 'evaluator',
|
|
116
|
+
changeClass: 'knowledge-contract',
|
|
117
|
+
pattern: /(?:^|\/)scripts\/(?:evaluate-agent-harness|agent-harness-tool-server|run-agent-harness-release|prepare-agent-harness-fixture|execution-sandbox|framework-context|validate-knowledge-change)\.mjs$/,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
rule: 'writable-oracle',
|
|
121
|
+
contract: 'oracle',
|
|
122
|
+
changeClass: 'knowledge-contract',
|
|
123
|
+
pattern: /(?:^|\/)harness\/(?:writable-ast-oracles\.mjs|writable-behavior-oracles\.mjs|validators\.json)$/,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
rule: 'case-context-policy',
|
|
127
|
+
contract: 'context-read',
|
|
128
|
+
changeClass: 'knowledge-contract',
|
|
129
|
+
pattern: /(?:^|\/)harness\/(?:cases\.json|release-matrix\.json|fixtures\/.+|[^/]+\.schema\.json)$/,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
rule: 'agent-instruction-owner',
|
|
133
|
+
contract: 'routing',
|
|
134
|
+
changeClass: 'knowledge-contract',
|
|
135
|
+
pattern: /(?:^|\/)(?:AGENTS\.md|AGENTS\.md\.template|CLAUDE\.md|CLAUDE\.md\.template)$/,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
rule: 'generated-fact-copy',
|
|
139
|
+
contract: null,
|
|
140
|
+
changeClass: 'asset-sync',
|
|
141
|
+
pattern: /(?:^|\/)(?:\.ai|ai)\/guides\/(?:modules|upstream)\/.+$/,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
rule: 'routing-guide-owner',
|
|
145
|
+
contract: 'routing',
|
|
146
|
+
changeClass: 'knowledge-contract',
|
|
147
|
+
pattern: /(?:^|\/)(?:\.ai|ai)\/guides\/[^/]+\.md$/,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
rule: 'skill-authority',
|
|
151
|
+
contract: 'skill-link',
|
|
152
|
+
changeClass: 'knowledge-contract',
|
|
153
|
+
pattern: /(?:^|\/)skills\/(?:[^/]+\/(?:SKILL\.md|references\/.+|scripts\/.+)|tiers\.schema\.json)$/,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
rule: 'discovery-generator-contract',
|
|
157
|
+
contract: 'discovery',
|
|
158
|
+
changeClass: 'knowledge-contract',
|
|
159
|
+
pattern: /^(?:packages\/cli\/src\/lib\/generators\/.+|packages\/create-app\/src\/setup\/.+|packages\/create-app\/build\.mjs)$/,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
rule: 'materialized-copy',
|
|
163
|
+
contract: null,
|
|
164
|
+
changeClass: 'asset-sync',
|
|
165
|
+
pattern: /^(?:packages\/create-app\/dist\/.+|apps\/docs\/.+|\.ai\/specs\/.+\.md)$/,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
rule: 'docs-count-snapshot',
|
|
169
|
+
contract: null,
|
|
170
|
+
changeClass: 'asset-sync',
|
|
171
|
+
pattern: /(?:^|\/)(?:README\.md|RELEASE\.md|CHANGELOG\.md)$/,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
rule: 'focused-test',
|
|
175
|
+
contract: 'evaluator',
|
|
176
|
+
changeClass: 'knowledge-contract',
|
|
177
|
+
pattern: FOCUSED_TEST_PATTERN,
|
|
178
|
+
},
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
export function normalizeRelativePath(value) {
|
|
182
|
+
return String(value ?? '').split('\\').join('/').replace(/^\.\//, '')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function isFocusedTestPath(value) {
|
|
186
|
+
return FOCUSED_TEST_PATTERN.test(normalizeRelativePath(value))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function classifyChangedPath(value) {
|
|
190
|
+
const relativePath = normalizeRelativePath(value)
|
|
191
|
+
for (const rule of CLASSIFICATION_RULES) {
|
|
192
|
+
if (!rule.pattern.test(relativePath)) continue
|
|
193
|
+
return {
|
|
194
|
+
path: relativePath,
|
|
195
|
+
rule: rule.rule,
|
|
196
|
+
contract: rule.contract,
|
|
197
|
+
changeClass: rule.changeClass,
|
|
198
|
+
focusedTest: isFocusedTestPath(relativePath),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
path: relativePath,
|
|
203
|
+
rule: 'unclassified-fails-closed',
|
|
204
|
+
contract: null,
|
|
205
|
+
changeClass: 'knowledge-contract',
|
|
206
|
+
focusedTest: false,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function deriveChangeClassification(paths) {
|
|
211
|
+
const records = [...new Set((paths ?? []).map(normalizeRelativePath))].sort().map(classifyChangedPath)
|
|
212
|
+
const contracts = new Set()
|
|
213
|
+
let changeClass = 'asset-sync'
|
|
214
|
+
for (const record of records) {
|
|
215
|
+
if (record.contract) contracts.add(record.contract)
|
|
216
|
+
if (record.changeClass === 'knowledge-contract') changeClass = 'knowledge-contract'
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
changeClass,
|
|
220
|
+
changedContracts: CHANGED_CONTRACTS.filter((contract) => contracts.has(contract)),
|
|
221
|
+
records,
|
|
222
|
+
unclassifiedPaths: records.filter((record) => record.rule === 'unclassified-fails-closed').map((record) => record.path),
|
|
223
|
+
focusedTestPaths: records.filter((record) => record.focusedTest).map((record) => record.path),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function resolveSchemaReference(rootSchema, reference) {
|
|
228
|
+
if (typeof reference !== 'string' || !reference.startsWith('#/')) return null
|
|
229
|
+
let node = rootSchema
|
|
230
|
+
for (const rawSegment of reference.slice(2).split('/')) {
|
|
231
|
+
const segment = rawSegment.replaceAll('~1', '/').replaceAll('~0', '~')
|
|
232
|
+
if (node === null || typeof node !== 'object' || !Object.hasOwn(node, segment)) return null
|
|
233
|
+
node = node[segment]
|
|
234
|
+
}
|
|
235
|
+
return node
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isPlainObject(value) {
|
|
239
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function matchesSchemaType(value, type) {
|
|
243
|
+
if (type === 'object') return isPlainObject(value)
|
|
244
|
+
if (type === 'array') return Array.isArray(value)
|
|
245
|
+
if (type === 'string') return typeof value === 'string'
|
|
246
|
+
if (type === 'integer') return Number.isInteger(value)
|
|
247
|
+
if (type === 'number') return typeof value === 'number' && Number.isFinite(value)
|
|
248
|
+
if (type === 'boolean') return typeof value === 'boolean'
|
|
249
|
+
if (type === 'null') return value === null
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function validateJsonSchema(value, schema, location = '$', rootSchema = schema) {
|
|
254
|
+
if (schema === true) return []
|
|
255
|
+
if (schema === false) return [`${location} is not allowed by its schema`]
|
|
256
|
+
const errors = []
|
|
257
|
+
if (schema.$ref) {
|
|
258
|
+
const resolved = resolveSchemaReference(rootSchema, schema.$ref)
|
|
259
|
+
if (!resolved) return [`${location} has an unresolved schema reference ${schema.$ref}`]
|
|
260
|
+
errors.push(...validateJsonSchema(value, resolved, location, rootSchema))
|
|
261
|
+
}
|
|
262
|
+
for (const branch of schema.allOf ?? []) errors.push(...validateJsonSchema(value, branch, location, rootSchema))
|
|
263
|
+
if (Array.isArray(schema.anyOf) && !schema.anyOf.some((branch) => validateJsonSchema(value, branch, location, rootSchema).length === 0)) {
|
|
264
|
+
errors.push(`${location} does not match any allowed schema branch`)
|
|
265
|
+
}
|
|
266
|
+
if (Array.isArray(schema.oneOf)) {
|
|
267
|
+
const matched = schema.oneOf.filter((branch) => validateJsonSchema(value, branch, location, rootSchema).length === 0)
|
|
268
|
+
if (matched.length !== 1) errors.push(`${location} must match exactly one schema branch`)
|
|
269
|
+
}
|
|
270
|
+
if (Object.hasOwn(schema, 'not') && validateJsonSchema(value, schema.not, location, rootSchema).length === 0) {
|
|
271
|
+
errors.push(`${location} matches a forbidden schema branch${schema.description ? `: ${schema.description}` : ''}`)
|
|
272
|
+
}
|
|
273
|
+
const expectedTypes = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []
|
|
274
|
+
if (expectedTypes.length && !expectedTypes.some((type) => matchesSchemaType(value, type))) {
|
|
275
|
+
errors.push(`${location} must be ${expectedTypes.join(' or ')}`)
|
|
276
|
+
return errors
|
|
277
|
+
}
|
|
278
|
+
if (Object.hasOwn(schema, 'const') && value !== schema.const) errors.push(`${location} must equal its schema constant`)
|
|
279
|
+
if (schema.enum && !schema.enum.some((item) => JSON.stringify(item) === JSON.stringify(value))) {
|
|
280
|
+
errors.push(`${location} is not in its schema enum`)
|
|
281
|
+
}
|
|
282
|
+
if (typeof value === 'string') {
|
|
283
|
+
if (Number.isInteger(schema.minLength) && value.length < schema.minLength) errors.push(`${location} is below minLength ${schema.minLength}`)
|
|
284
|
+
if (Number.isInteger(schema.maxLength) && value.length > schema.maxLength) errors.push(`${location} exceeds maxLength ${schema.maxLength}`)
|
|
285
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) errors.push(`${location} does not match its schema pattern`)
|
|
286
|
+
}
|
|
287
|
+
if (typeof value === 'number') {
|
|
288
|
+
if (Number.isFinite(schema.minimum) && value < schema.minimum) errors.push(`${location} is below minimum ${schema.minimum}`)
|
|
289
|
+
if (Number.isFinite(schema.maximum) && value > schema.maximum) errors.push(`${location} exceeds maximum ${schema.maximum}`)
|
|
290
|
+
}
|
|
291
|
+
if (Array.isArray(value)) {
|
|
292
|
+
if (Number.isInteger(schema.minItems) && value.length < schema.minItems) errors.push(`${location} is below minItems ${schema.minItems}`)
|
|
293
|
+
if (Number.isInteger(schema.maxItems) && value.length > schema.maxItems) errors.push(`${location} exceeds maxItems ${schema.maxItems}`)
|
|
294
|
+
if (schema.uniqueItems && new Set(value.map((item) => JSON.stringify(item))).size !== value.length) errors.push(`${location} must contain unique items`)
|
|
295
|
+
if (schema.items) value.forEach((item, index) => errors.push(...validateJsonSchema(item, schema.items, `${location}[${index}]`, rootSchema)))
|
|
296
|
+
}
|
|
297
|
+
if (isPlainObject(value)) {
|
|
298
|
+
for (const required of schema.required ?? []) if (!Object.hasOwn(value, required)) errors.push(`${location}.${required} is required`)
|
|
299
|
+
if (schema.additionalProperties === false) {
|
|
300
|
+
for (const key of Object.keys(value)) if (!Object.hasOwn(schema.properties ?? {}, key)) errors.push(`${location}.${key} is not allowed`)
|
|
301
|
+
}
|
|
302
|
+
for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
|
|
303
|
+
if (Object.hasOwn(value, key)) errors.push(...validateJsonSchema(value[key], childSchema, `${location}.${key}`, rootSchema))
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return errors
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function hashFile(absolutePath) {
|
|
310
|
+
return createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isRegularFile(absolutePath) {
|
|
314
|
+
try { return fs.statSync(absolutePath).isFile() } catch { return false }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readJsonIfPresent(absolutePath) {
|
|
318
|
+
if (!isRegularFile(absolutePath)) return null
|
|
319
|
+
try { return JSON.parse(fs.readFileSync(absolutePath, 'utf8')) } catch { return null }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function expandCaseRange(rangeId) {
|
|
323
|
+
const match = /^OMH-([0-9]{3})\.\.OMH-([0-9]{3})$/.exec(String(rangeId ?? ''))
|
|
324
|
+
if (!match) return null
|
|
325
|
+
const low = Number(match[1])
|
|
326
|
+
const high = Number(match[2])
|
|
327
|
+
if (low > high) return null
|
|
328
|
+
const ids = []
|
|
329
|
+
for (let index = low; index <= high; index += 1) ids.push(`OMH-${String(index).padStart(3, '0')}`)
|
|
330
|
+
return ids
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function git(root, args) {
|
|
334
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' })
|
|
335
|
+
if (result.status !== 0) {
|
|
336
|
+
throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`)
|
|
337
|
+
}
|
|
338
|
+
return result.stdout
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function resolveGitSha(root, ref) {
|
|
342
|
+
return git(root, ['rev-parse', '--verify', `${ref}^{commit}`]).trim()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function deriveChangedPaths(root, baseSha) {
|
|
346
|
+
const tracked = git(root, ['diff', '--name-only', '--no-renames', baseSha]).split('\n')
|
|
347
|
+
const untracked = git(root, ['ls-files', '--others', '--exclude-standard']).split('\n')
|
|
348
|
+
return [...new Set([...tracked, ...untracked].map((line) => line.trim()).filter(Boolean).map(normalizeRelativePath))].sort()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function exampleMirrorPath(relativePath) {
|
|
352
|
+
if (relativePath.startsWith(`${EXAMPLE_AUTHORING_ROOT}/`)) {
|
|
353
|
+
return `${EXAMPLE_TEMPLATE_ROOT}/${relativePath.slice(EXAMPLE_AUTHORING_ROOT.length + 1)}`
|
|
354
|
+
}
|
|
355
|
+
if (relativePath.startsWith(`${EXAMPLE_TEMPLATE_ROOT}/`)) {
|
|
356
|
+
return `${EXAMPLE_AUTHORING_ROOT}/${relativePath.slice(EXAMPLE_TEMPLATE_ROOT.length + 1)}`
|
|
357
|
+
}
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function exampleReadStatus(relativePath) {
|
|
362
|
+
const segments = normalizeRelativePath(relativePath).split('/')
|
|
363
|
+
return segments.some((segment) => EXAMPLE_QA_ONLY_SEGMENTS.includes(segment)) ? 'qa-only' : 'readable'
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function checkExampleSourceMirrors(root, records, errors, derived) {
|
|
367
|
+
const mirrors = []
|
|
368
|
+
for (const record of records) {
|
|
369
|
+
if (record.contract !== 'example-source') continue
|
|
370
|
+
const mirror = exampleMirrorPath(record.path)
|
|
371
|
+
if (!mirror) continue
|
|
372
|
+
const sourceAbsolute = path.join(root, record.path)
|
|
373
|
+
const mirrorAbsolute = path.join(root, mirror)
|
|
374
|
+
const sourceExists = isRegularFile(sourceAbsolute)
|
|
375
|
+
const mirrorExists = isRegularFile(mirrorAbsolute)
|
|
376
|
+
const entry = { path: record.path, mirrorPath: mirror, readStatus: exampleReadStatus(record.path) }
|
|
377
|
+
if (!sourceExists && !mirrorExists) {
|
|
378
|
+
entry.state = 'deleted-on-both-sides'
|
|
379
|
+
} else if (!sourceExists || !mirrorExists) {
|
|
380
|
+
entry.state = 'mirror-missing'
|
|
381
|
+
errors.push(
|
|
382
|
+
`example-source ${record.path} was moved or deleted without its byte-identical mirror ${mirror}; ` +
|
|
383
|
+
'run `yarn template:sync:fix` and update the surface inventory',
|
|
384
|
+
)
|
|
385
|
+
} else if (hashFile(sourceAbsolute) !== hashFile(mirrorAbsolute)) {
|
|
386
|
+
entry.state = 'mirror-drift'
|
|
387
|
+
errors.push(`example-source ${record.path} is not byte-identical to its mirror ${mirror}; run \`yarn template:sync:fix\``)
|
|
388
|
+
} else {
|
|
389
|
+
entry.state = 'mirrored'
|
|
390
|
+
entry.sha256 = hashFile(sourceAbsolute)
|
|
391
|
+
}
|
|
392
|
+
mirrors.push(entry)
|
|
393
|
+
}
|
|
394
|
+
derived.exampleSourceMirrors = mirrors
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function checkFileRecords(root, label, records, errors, options = {}) {
|
|
398
|
+
for (const record of records) {
|
|
399
|
+
const absolute = path.join(root, record.path)
|
|
400
|
+
if (!isRegularFile(absolute)) {
|
|
401
|
+
errors.push(`${label} ${record.path} is not an exact regular file in the working tree`)
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
const actual = hashFile(absolute)
|
|
405
|
+
if (actual !== record.sha256) {
|
|
406
|
+
errors.push(`${label} ${record.path} sha256 is stale: manifest ${record.sha256}, working tree ${actual}`)
|
|
407
|
+
}
|
|
408
|
+
if (!options.sourcePathRequired) continue
|
|
409
|
+
const sourceAbsolute = path.join(root, record.sourcePath)
|
|
410
|
+
if (!isRegularFile(sourceAbsolute)) {
|
|
411
|
+
errors.push(`${label} ${record.path} names sourcePath ${record.sourcePath}, which is not an exact regular file`)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Re-derive the source-link facts a run manifest declares, from the inventory's own records
|
|
418
|
+
* rather than from its summary block: an author who edits the summary must not be able to
|
|
419
|
+
* make a wrong declaration agree with it.
|
|
420
|
+
*/
|
|
421
|
+
export function deriveSourceLinkInventoryFacts(inventory) {
|
|
422
|
+
const records = isPlainObject(inventory) && Array.isArray(inventory.records) ? inventory.records : null
|
|
423
|
+
if (records === null) return null
|
|
424
|
+
return {
|
|
425
|
+
ownerCount: new Set(records.map((record) => record?.originAsset)).size,
|
|
426
|
+
topicCount: new Set(records.map((record) => record?.topicId)).size,
|
|
427
|
+
resolvedLinkCount: records.reduce(
|
|
428
|
+
(total, record) => total + (Number.isInteger(record?.renderedLinkCount) ? record.renderedLinkCount : 0),
|
|
429
|
+
0,
|
|
430
|
+
),
|
|
431
|
+
declaredBaselinePath: typeof inventory.inputs?.baseline === 'string' ? inventory.inputs.baseline : null,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const EMPTY_BASELINE_FACTS = Object.freeze({
|
|
436
|
+
baselineAssetCount: null,
|
|
437
|
+
baselineDispositionCount: null,
|
|
438
|
+
baselineSha: null,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
export function deriveSourceLinkBaselineFacts(baseline) {
|
|
442
|
+
if (!isPlainObject(baseline)) return null
|
|
443
|
+
return {
|
|
444
|
+
baselineAssetCount: Array.isArray(baseline.baselineAssets) ? baseline.baselineAssets.length : null,
|
|
445
|
+
baselineDispositionCount: Array.isArray(baseline.blocks) ? baseline.blocks.length : null,
|
|
446
|
+
baselineSha: typeof baseline.baselineSha === 'string' ? baseline.baselineSha : null,
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function siblingPath(relativePath, basename) {
|
|
451
|
+
const segments = normalizeRelativePath(relativePath).split('/')
|
|
452
|
+
segments[segments.length - 1] = basename
|
|
453
|
+
return segments.join('/')
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* A minimal, schema-valid parity ledger. It is never written anywhere: it exists only so the
|
|
458
|
+
* declared ledger schema can be probed before the real ledger is trusted to it.
|
|
459
|
+
*/
|
|
460
|
+
const BASELINE_SCHEMA_PROBE_LEDGER = Object.freeze({
|
|
461
|
+
baselineSha: '0'.repeat(40),
|
|
462
|
+
baselineAssets: [{ path: 'AGENTS.md', sha256: '1'.repeat(64), expectedFenceCount: 1 }],
|
|
463
|
+
blocks: [{
|
|
464
|
+
id: 'main:AGENTS.md#fence:1',
|
|
465
|
+
asset: 'AGENTS.md',
|
|
466
|
+
ordinal: 1,
|
|
467
|
+
heading: null,
|
|
468
|
+
openingLine: '```ts',
|
|
469
|
+
info: 'ts',
|
|
470
|
+
contentSha256: '2'.repeat(64),
|
|
471
|
+
topicIds: [],
|
|
472
|
+
disposition: 'linked',
|
|
473
|
+
}],
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
function probeLedger(mutate) {
|
|
477
|
+
const ledger = structuredClone(BASELINE_SCHEMA_PROBE_LEDGER)
|
|
478
|
+
mutate(ledger)
|
|
479
|
+
return ledger
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The clauses the gate relies on, each as a ledger the schema must reject. They are a readable
|
|
484
|
+
* checklist of what the pinned schema is trusted to assert — and a second line of defence for the
|
|
485
|
+
* one moment the SHA pin below is open, namely a change that legitimately updates the pin: guttings
|
|
486
|
+
* that the new bytes would smuggle in still have to survive every probe.
|
|
487
|
+
*/
|
|
488
|
+
export const BASELINE_SCHEMA_PROBES = Object.freeze([
|
|
489
|
+
['a non-hex baselineSha', probeLedger((ledger) => { ledger.baselineSha = 'not-a-commit' })],
|
|
490
|
+
['a ledger missing its blocks array', probeLedger((ledger) => { delete ledger.blocks })],
|
|
491
|
+
['a ledger missing its baselineAssets array', probeLedger((ledger) => { delete ledger.baselineAssets })],
|
|
492
|
+
['an undeclared top-level ledger field', probeLedger((ledger) => { ledger.supersededBy = 'anything' })],
|
|
493
|
+
['an absolute baseline asset path', probeLedger((ledger) => { ledger.baselineAssets[0].path = '/etc/passwd' })],
|
|
494
|
+
['a parent-traversal baseline asset path', probeLedger((ledger) => { ledger.baselineAssets[0].path = '../outside.md' })],
|
|
495
|
+
['a baseline asset missing its sha256', probeLedger((ledger) => { delete ledger.baselineAssets[0].sha256 })],
|
|
496
|
+
['a baseline asset missing its expectedFenceCount', probeLedger((ledger) => { delete ledger.baselineAssets[0].expectedFenceCount })],
|
|
497
|
+
['a non-hex baseline asset sha256', probeLedger((ledger) => { ledger.baselineAssets[0].sha256 = 'not-a-hash' })],
|
|
498
|
+
['an undeclared baseline asset field', probeLedger((ledger) => { ledger.baselineAssets[0].note = 'anything' })],
|
|
499
|
+
['a parent-traversal block asset path', probeLedger((ledger) => { ledger.blocks[0].asset = '../outside.md' })],
|
|
500
|
+
['a block id that is not main:<asset>#fence:<ordinal>', probeLedger((ledger) => { ledger.blocks[0].id = 'AGENTS.md:1' })],
|
|
501
|
+
['a block missing its contentSha256', probeLedger((ledger) => { delete ledger.blocks[0].contentSha256 })],
|
|
502
|
+
['a block missing its topicIds', probeLedger((ledger) => { delete ledger.blocks[0].topicIds })],
|
|
503
|
+
['a block missing its disposition', probeLedger((ledger) => { delete ledger.blocks[0].disposition })],
|
|
504
|
+
['a block missing its ordinal', probeLedger((ledger) => { delete ledger.blocks[0].ordinal })],
|
|
505
|
+
['a non-integer block ordinal', probeLedger((ledger) => { ledger.blocks[0].ordinal = '1' })],
|
|
506
|
+
['a non-hex block contentSha256', probeLedger((ledger) => { ledger.blocks[0].contentSha256 = 'not-a-hash' })],
|
|
507
|
+
['an invented block disposition', probeLedger((ledger) => { ledger.blocks[0].disposition = 'invented-disposition' })],
|
|
508
|
+
['an undeclared block field', probeLedger((ledger) => { ledger.blocks[0].supersededBy = 'anything' })],
|
|
509
|
+
['repeated block topic IDs', probeLedger((ledger) => { ledger.blocks[0].topicIds = ['guides/a:one.ts', 'guides/a:one.ts'] })],
|
|
510
|
+
])
|
|
511
|
+
|
|
512
|
+
export const SOURCE_LINK_BASELINE_SCHEMA_ID = 'https://open-mercato.dev/schemas/source-link-baseline.schema.json'
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The exact bytes of `source-link-baseline.schema.json`. Behavioural probing alone is not a pin:
|
|
516
|
+
* this engine supports `anyOf`/`oneOf`/`not`, so a schema carrying the pinned `$id`, the real body in
|
|
517
|
+
* one branch and an escape branch no probe ever matches passes every probe while accepting a lawless
|
|
518
|
+
* ledger. Pinning the file itself is what makes "a gutted or swapped schema fails the run" true, and
|
|
519
|
+
* pinning a baseline by SHA is what the rest of this subsystem already does — `source-link-baseline.json`
|
|
520
|
+
* pins eight asset SHAs the same way. Updating this constant when the schema legitimately changes is
|
|
521
|
+
* the normal path: that edit lands in the `evaluator` contract, not in the `source-link` assets under
|
|
522
|
+
* audit, so it can never be made by the ledger change it would be waving through.
|
|
523
|
+
* Recompute with `sha256sum packages/create-app/scripts/source-links/source-link-baseline.schema.json`.
|
|
524
|
+
*/
|
|
525
|
+
export const SOURCE_LINK_BASELINE_SCHEMA_SHA256 =
|
|
526
|
+
'b1ce4c0845b078b317cd0f699921628a2c23edb7271c883d0597de73dd401cc8'
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Prove the declared schema is the pinned one and still asserts what the gate relies on. Returns the
|
|
530
|
+
* reasons it is not; a non-empty result means the ledger must not be validated against it.
|
|
531
|
+
*/
|
|
532
|
+
export function baselineSchemaPinErrors(baselineSchema, schemaSha256) {
|
|
533
|
+
const reasons = []
|
|
534
|
+
if (schemaSha256 !== SOURCE_LINK_BASELINE_SCHEMA_SHA256) {
|
|
535
|
+
reasons.push(
|
|
536
|
+
`its sha256 is ${JSON.stringify(schemaSha256 ?? null)} rather than the pinned ${SOURCE_LINK_BASELINE_SCHEMA_SHA256}`,
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
if (!isPlainObject(baselineSchema)) {
|
|
540
|
+
reasons.push('it is not a JSON schema object')
|
|
541
|
+
return reasons
|
|
542
|
+
}
|
|
543
|
+
if (baselineSchema.$id !== SOURCE_LINK_BASELINE_SCHEMA_ID) {
|
|
544
|
+
reasons.push(`its $id is ${JSON.stringify(baselineSchema.$id)} rather than ${SOURCE_LINK_BASELINE_SCHEMA_ID}`)
|
|
545
|
+
}
|
|
546
|
+
const acceptErrors = validateJsonSchema(BASELINE_SCHEMA_PROBE_LEDGER, baselineSchema, '$', baselineSchema)
|
|
547
|
+
if (acceptErrors.length) reasons.push(`it rejects a canonical minimal ledger: ${acceptErrors.join('; ')}`)
|
|
548
|
+
for (const [label, ledger] of BASELINE_SCHEMA_PROBES) {
|
|
549
|
+
if (!validateJsonSchema(ledger, baselineSchema, '$', baselineSchema).length) reasons.push(`it accepts ${label}`)
|
|
550
|
+
}
|
|
551
|
+
return reasons
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* `uniqueItems` compares whole serialized records, so two blocks sharing an `id` and differing in
|
|
556
|
+
* any other field satisfy it. Block IDs are the ledger's join key, so uniqueness is checked here
|
|
557
|
+
* rather than left to a schema vocabulary that cannot express it.
|
|
558
|
+
*/
|
|
559
|
+
function duplicateBlockIds(baseline) {
|
|
560
|
+
const seen = new Set()
|
|
561
|
+
const duplicates = new Set()
|
|
562
|
+
for (const block of Array.isArray(baseline?.blocks) ? baseline.blocks : []) {
|
|
563
|
+
if (typeof block?.id !== 'string') continue
|
|
564
|
+
if (seen.has(block.id)) duplicates.add(block.id)
|
|
565
|
+
seen.add(block.id)
|
|
566
|
+
}
|
|
567
|
+
return [...duplicates].sort()
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function checkDeclaredCount(errors, field, declared, actual, subject) {
|
|
571
|
+
if (!Number.isInteger(declared) || declared === actual) return
|
|
572
|
+
errors.push(`sourceLinkInventory.${field} declares ${declared}, but ${subject} resolves ${actual}`)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Enforce every field of the manifest's `sourceLinkInventory` block against the checked assets it
|
|
577
|
+
* names. Declaring a count is worthless unless the count is compared with the real one, so each
|
|
578
|
+
* field is re-derived here: owners, topics and rendered links from the inventory records, assets,
|
|
579
|
+
* dispositions and the pinned baseline SHA from the parity ledger, and the ledger's whole shape
|
|
580
|
+
* from the schema the manifest names.
|
|
581
|
+
*/
|
|
582
|
+
function checkSourceLinkInventory(root, declared, errors, derived) {
|
|
583
|
+
const declaredInventoryPath = declared?.path
|
|
584
|
+
if (typeof declaredInventoryPath === 'string' && declaredInventoryPath !== SOURCE_LINK_INVENTORY_PATH) {
|
|
585
|
+
errors.push(`sourceLinkInventory.path must be ${SOURCE_LINK_INVENTORY_PATH}`)
|
|
586
|
+
}
|
|
587
|
+
const inventoryPath = typeof declaredInventoryPath === 'string' ? declaredInventoryPath : SOURCE_LINK_INVENTORY_PATH
|
|
588
|
+
if (!isRegularFile(path.join(root, inventoryPath))) {
|
|
589
|
+
derived.sourceLinkInventoryStatus = 'absent'
|
|
590
|
+
errors.push(CANON_C_REASON)
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
derived.sourceLinkInventoryStatus = 'present'
|
|
594
|
+
|
|
595
|
+
const inventoryFacts = deriveSourceLinkInventoryFacts(readJsonIfPresent(path.join(root, inventoryPath)))
|
|
596
|
+
if (inventoryFacts === null) {
|
|
597
|
+
errors.push(`sourceLinkInventory.path ${inventoryPath} is not a readable inventory: it carries no records array`)
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
derived.sourceLinkInventoryFacts = { ...inventoryFacts, ...EMPTY_BASELINE_FACTS }
|
|
601
|
+
checkDeclaredCount(errors, 'expectedOwnerCount', declared.expectedOwnerCount, inventoryFacts.ownerCount, `${inventoryPath} distinct originAsset owners`)
|
|
602
|
+
checkDeclaredCount(errors, 'expectedTopicCount', declared.expectedTopicCount, inventoryFacts.topicCount, `${inventoryPath} distinct topic IDs`)
|
|
603
|
+
checkDeclaredCount(errors, 'resolvedLinkCount', declared.resolvedLinkCount, inventoryFacts.resolvedLinkCount, `${inventoryPath} rendered links`)
|
|
604
|
+
|
|
605
|
+
if (inventoryFacts.declaredBaselinePath !== SOURCE_LINK_BASELINE_PATH) {
|
|
606
|
+
errors.push(
|
|
607
|
+
`sourceLinkInventory: ${inventoryPath} declares inputs.baseline `
|
|
608
|
+
+ `${JSON.stringify(inventoryFacts.declaredBaselinePath)}, but the parity ledger is pinned to `
|
|
609
|
+
+ `${SOURCE_LINK_BASELINE_PATH}, so no asset under audit may redirect which ledger is checked`,
|
|
610
|
+
)
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
const declaredBaselinePath = declared?.baselinePath
|
|
614
|
+
if (typeof declaredBaselinePath !== 'string') return
|
|
615
|
+
if (declaredBaselinePath !== SOURCE_LINK_BASELINE_PATH) {
|
|
616
|
+
errors.push(`sourceLinkInventory.baselinePath must be ${SOURCE_LINK_BASELINE_PATH}`)
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
if (!isRegularFile(path.join(root, declaredBaselinePath))) {
|
|
620
|
+
errors.push(`sourceLinkInventory.baselinePath ${declaredBaselinePath} is not an exact regular file in the working tree`)
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
const baseline = readJsonIfPresent(path.join(root, declaredBaselinePath))
|
|
624
|
+
const baselineFacts = deriveSourceLinkBaselineFacts(baseline)
|
|
625
|
+
if (baselineFacts === null) {
|
|
626
|
+
errors.push(`sourceLinkInventory.baselinePath ${declaredBaselinePath} is not a readable JSON object`)
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
derived.sourceLinkInventoryFacts = { ...inventoryFacts, ...baselineFacts }
|
|
630
|
+
const repeatedBlockIds = duplicateBlockIds(baseline)
|
|
631
|
+
if (repeatedBlockIds.length) {
|
|
632
|
+
errors.push(`sourceLinkInventory ${declaredBaselinePath} repeats block ids: ${repeatedBlockIds.join(', ')}`)
|
|
633
|
+
}
|
|
634
|
+
checkDeclaredCount(errors, 'baselineAssetCount', declared.baselineAssetCount, baselineFacts.baselineAssetCount, `${declaredBaselinePath} pinned assets`)
|
|
635
|
+
checkDeclaredCount(
|
|
636
|
+
errors,
|
|
637
|
+
'baselineDispositionCount',
|
|
638
|
+
declared.baselineDispositionCount,
|
|
639
|
+
baselineFacts.baselineDispositionCount,
|
|
640
|
+
`${declaredBaselinePath} disposition records`,
|
|
641
|
+
)
|
|
642
|
+
if (typeof declared.baselineRef === 'string' && declared.baselineRef !== baselineFacts.baselineSha) {
|
|
643
|
+
errors.push(
|
|
644
|
+
`sourceLinkInventory.baselineRef declares ${declared.baselineRef}, but ${declaredBaselinePath} pins `
|
|
645
|
+
+ `${String(baselineFacts.baselineSha)}`,
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const expectedSchemaPath = siblingPath(SOURCE_LINK_BASELINE_PATH, SOURCE_LINK_BASELINE_SCHEMA_BASENAME)
|
|
650
|
+
const declaredSchemaPath = declared?.baselineSchemaPath
|
|
651
|
+
if (typeof declaredSchemaPath !== 'string') return
|
|
652
|
+
if (declaredSchemaPath !== expectedSchemaPath) {
|
|
653
|
+
errors.push(`sourceLinkInventory.baselineSchemaPath declares ${declaredSchemaPath}, but the ledger's schema is ${expectedSchemaPath}`)
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
const schemaAbsolutePath = path.join(root, declaredSchemaPath)
|
|
657
|
+
if (!isRegularFile(schemaAbsolutePath)) {
|
|
658
|
+
errors.push(`sourceLinkInventory.baselineSchemaPath ${declaredSchemaPath} is not an exact regular file in the working tree`)
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
const baselineSchema = readJsonIfPresent(schemaAbsolutePath)
|
|
662
|
+
const pinErrors = baselineSchemaPinErrors(baselineSchema, hashFile(schemaAbsolutePath))
|
|
663
|
+
if (pinErrors.length) {
|
|
664
|
+
for (const reason of pinErrors) {
|
|
665
|
+
errors.push(`sourceLinkInventory.baselineSchemaPath ${declaredSchemaPath} no longer pins the ledger contract: ${reason}`)
|
|
666
|
+
}
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
for (const error of validateJsonSchema(baseline, baselineSchema, '$', baselineSchema)) {
|
|
670
|
+
errors.push(`sourceLinkInventory ${declaredBaselinePath} violates ${declaredSchemaPath}: ${error}`)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Reads the capability rows out of the canonical example's `surface-inventory.json`. Returns
|
|
676
|
+
* `null` — never an empty list — when the asset is unreadable or misshapen, so a caller cannot
|
|
677
|
+
* mistake "could not read it" for "it declares nothing" and pass a change that should fail closed.
|
|
678
|
+
*/
|
|
679
|
+
export function readExampleCapabilityRows(inventory) {
|
|
680
|
+
if (!isPlainObject(inventory) || !Array.isArray(inventory.capabilities)) return null
|
|
681
|
+
return inventory.capabilities.filter(isPlainObject)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The CANON gate, derived rather than declared — spec
|
|
686
|
+
* `.ai/specs/2026-07-31-standalone-canonical-example-module.md`: "Any future added/materially
|
|
687
|
+
* changed runtime or discovery surface with `coverageKind: "example"` is rejected unless it
|
|
688
|
+
* declares its focused integration-test paths and dependency modules."
|
|
689
|
+
*
|
|
690
|
+
* The manifest deliberately carries no field for this. A declared field would be a waiver surface,
|
|
691
|
+
* and the spec is explicit that a manifest cannot waive a required surface — so the rows are read
|
|
692
|
+
* from the pinned inventory and compared against the same inventory at the base commit.
|
|
693
|
+
*
|
|
694
|
+
* Three separable failures, because they rot in different ways:
|
|
695
|
+
* 1. a declared path that is not a test file, or does not exist (declaration decays into fiction);
|
|
696
|
+
* 2. a NEW example capability row that declares no integration coverage (the gate itself);
|
|
697
|
+
* 3. a row that HAD coverage and dropped it (the ratchet — silent coverage loss).
|
|
698
|
+
*
|
|
699
|
+
* `baseRows` of `null` means the inventory did not exist at the base commit, so every row is new.
|
|
700
|
+
*/
|
|
701
|
+
export function exampleCoverageErrors(headRows, baseRows, options = {}) {
|
|
702
|
+
const errors = []
|
|
703
|
+
const pathExists = options.pathExists ?? (() => true)
|
|
704
|
+
if (headRows === null) {
|
|
705
|
+
errors.push(
|
|
706
|
+
`${EXAMPLE_SURFACE_INVENTORY_PATH} is missing or carries no "capabilities" array, so declared ` +
|
|
707
|
+
'integration coverage for the canonical example cannot be verified and fails closed',
|
|
708
|
+
)
|
|
709
|
+
return errors
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const baseCoverage = new Map()
|
|
713
|
+
for (const row of baseRows ?? []) {
|
|
714
|
+
if (typeof row.capabilityId !== 'string') continue
|
|
715
|
+
baseCoverage.set(row.capabilityId, Array.isArray(row.integrationTestPaths) ? row.integrationTestPaths.length : 0)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
for (const row of headRows) {
|
|
719
|
+
const id = typeof row.capabilityId === 'string' ? row.capabilityId : '(unnamed row)'
|
|
720
|
+
if (row.coverageKind !== 'example') continue
|
|
721
|
+
|
|
722
|
+
if (!Array.isArray(row.integrationTestPaths)) {
|
|
723
|
+
errors.push(`surface-inventory ${id} has no integrationTestPaths array`)
|
|
724
|
+
continue
|
|
725
|
+
}
|
|
726
|
+
if (!Array.isArray(row.dependencyModules)) {
|
|
727
|
+
errors.push(`surface-inventory ${id} has no dependencyModules array`)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
for (const testPath of row.integrationTestPaths) {
|
|
731
|
+
const normalized = normalizeRelativePath(String(testPath))
|
|
732
|
+
if (!isFocusedTestPath(normalized)) {
|
|
733
|
+
errors.push(`surface-inventory ${id} declares ${normalized}, which is not a focused test file`)
|
|
734
|
+
continue
|
|
735
|
+
}
|
|
736
|
+
if (!pathExists(normalized)) {
|
|
737
|
+
errors.push(`surface-inventory ${id} declares ${normalized}, which is not a file in the working tree`)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const declaredNow = row.integrationTestPaths.length
|
|
742
|
+
const hasSource = Array.isArray(row.sourcePaths) && row.sourcePaths.length > 0
|
|
743
|
+
if (!baseCoverage.has(id)) {
|
|
744
|
+
if (hasSource && declaredNow === 0) {
|
|
745
|
+
errors.push(
|
|
746
|
+
`surface-inventory ${id} is a new coverageKind:"example" capability with source paths but ` +
|
|
747
|
+
'declares no integrationTestPaths; a new example surface must name its own integration coverage',
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
continue
|
|
751
|
+
}
|
|
752
|
+
const declaredBefore = baseCoverage.get(id)
|
|
753
|
+
if (declaredBefore > 0 && declaredNow === 0) {
|
|
754
|
+
errors.push(
|
|
755
|
+
`surface-inventory ${id} declared ${declaredBefore} integration test(s) at the base commit and now ` +
|
|
756
|
+
'declares none; declared coverage may not be dropped',
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return errors
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Generated/template/packed-target hashes against their authoritative source — spec
|
|
765
|
+
* `.ai/specs/2026-08-01-standalone-harness-knowledge-governance.md` line 97.
|
|
766
|
+
*
|
|
767
|
+
* `checkFileRecords` hashes a generated file against *itself*, which always matches, so a mirror
|
|
768
|
+
* that has drifted from its source passes today. The obvious fix — demand the copy equal its source
|
|
769
|
+
* — is wrong here: not every declared pair is a byte mirror.
|
|
770
|
+
* `template/scripts/validate-knowledge-change.mjs` is a four-line "run agentic:init first" stub
|
|
771
|
+
* standing in for a 1300-line source, and it is supposed to be.
|
|
772
|
+
*
|
|
773
|
+
* So mirror-ness is *derived from the base commit* rather than assumed, giving two rules:
|
|
774
|
+
*
|
|
775
|
+
* 1. **Stale copy.** The source changed in this diff and the generated target did not. This is the
|
|
776
|
+
* forgotten `yarn template:sync`, and it needs no hashes at all — only the diff.
|
|
777
|
+
* 2. **Mirror ratchet.** A pair whose bytes were identical at base must still be identical at head.
|
|
778
|
+
* A pair that was never identical is left alone, so deliberate stubs and transformed outputs are
|
|
779
|
+
* not forced into a byte equality they never had.
|
|
780
|
+
*
|
|
781
|
+
* `hashAt('base'|'head', relativePath)` is injected so both rules stay unit-testable without git.
|
|
782
|
+
*/
|
|
783
|
+
export function generatedTargetErrors(generatedFiles, changedPaths, hashAt) {
|
|
784
|
+
const errors = []
|
|
785
|
+
const changed = new Set((changedPaths ?? []).map((value) => normalizeRelativePath(String(value))))
|
|
786
|
+
for (const record of generatedFiles ?? []) {
|
|
787
|
+
if (!isPlainObject(record)) continue
|
|
788
|
+
const target = normalizeRelativePath(String(record.path ?? ''))
|
|
789
|
+
const source = normalizeRelativePath(String(record.sourcePath ?? ''))
|
|
790
|
+
if (!target || !source) continue
|
|
791
|
+
|
|
792
|
+
if (changed.has(source) && !changed.has(target)) {
|
|
793
|
+
errors.push(
|
|
794
|
+
`generatedFiles ${target} declares sourcePath ${source}, which changed in this diff while the ` +
|
|
795
|
+
'generated target did not; regenerate the target or it ships stale',
|
|
796
|
+
)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const baseTarget = hashAt('base', target)
|
|
800
|
+
const baseSource = hashAt('base', source)
|
|
801
|
+
if (baseTarget === null || baseSource === null || baseTarget !== baseSource) continue
|
|
802
|
+
const headTarget = hashAt('head', target)
|
|
803
|
+
const headSource = hashAt('head', source)
|
|
804
|
+
if (headTarget === null || headSource === null || headTarget === headSource) continue
|
|
805
|
+
errors.push(
|
|
806
|
+
`generatedFiles ${target} was byte-identical to its source ${source} at the base commit and is ` +
|
|
807
|
+
'no longer; a mirror may not silently diverge from the file it copies',
|
|
808
|
+
)
|
|
809
|
+
}
|
|
810
|
+
return errors
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function baseFileHash(root, baseSha, relativePath) {
|
|
814
|
+
const result = spawnSync('git', ['-C', root, 'show', `${baseSha}:${relativePath}`], { encoding: 'buffer' })
|
|
815
|
+
if (result.status !== 0) return null
|
|
816
|
+
return createHash('sha256').update(result.stdout).digest('hex')
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function baseFileBuffer(root, baseSha, relativePath) {
|
|
820
|
+
const result = spawnSync('git', ['-C', root, 'show', `${baseSha}:${relativePath}`], { encoding: 'buffer' })
|
|
821
|
+
return result.status === 0 ? result.stdout : null
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Which case modes can produce a writable artifact. An oracle inspects what a case *wrote*, so the
|
|
826
|
+
* split is what makes "which validators may this mode declare" answerable at all.
|
|
827
|
+
*
|
|
828
|
+
* Deliberately a closed classification rather than a heuristic: an unrecognised mode is an error,
|
|
829
|
+
* not a pass. A new mode added to `cases.schema.json` without a decision here would otherwise sail
|
|
830
|
+
* through every membership rule below, which is the exact silence this check exists to remove.
|
|
831
|
+
*/
|
|
832
|
+
export const READ_ONLY_CASE_MODES = Object.freeze(['analysis', 'review'])
|
|
833
|
+
export const WRITABLE_CASE_MODES = Object.freeze(['one-shot', 'spec', 'bugfix'])
|
|
834
|
+
const ARTIFACT_VALIDATOR_ID = 'oracle.artifacts'
|
|
835
|
+
const TRUSTED_EXECUTABLE = 'trusted-executable'
|
|
836
|
+
const WRITABLE_PATHS_VALIDATOR_ID = 'writable.allowed-paths'
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Per-case-mode validator/oracle membership — spec
|
|
840
|
+
* `.ai/specs/2026-08-01-standalone-harness-knowledge-governance.md`: the validator "checks
|
|
841
|
+
* validator/oracle membership required by each case mode".
|
|
842
|
+
*
|
|
843
|
+
* Five failures, each catching a different way the case catalog and its validator registry drift
|
|
844
|
+
* apart while every file still parses:
|
|
845
|
+
*
|
|
846
|
+
* 1. a case declares a validator id the registry does not define (it silently never runs);
|
|
847
|
+
* 2. an `oracle`-kind registry entry names no runner, or names a runner file that is not there
|
|
848
|
+
* (the oracle is unreachable, so the case is graded by nothing);
|
|
849
|
+
* 3. a case declares an oracle but not `oracle.artifacts` (an oracle with no artifact contract
|
|
850
|
+
* has nothing to bind to);
|
|
851
|
+
* 4. a case declares an oracle but not `writable.allowed-paths` (nothing it may legally write);
|
|
852
|
+
* 5. a read-only mode declares an oracle or writable paths at all, or a mode is unclassified.
|
|
853
|
+
*
|
|
854
|
+
* `runnerExists` is injected so the rules stay unit-testable without a harness directory on disk.
|
|
855
|
+
*/
|
|
856
|
+
/**
|
|
857
|
+
* The three surfaces whose movement can orphan a case from the validator that grades it: the case
|
|
858
|
+
* catalog, the validator registry, and the oracle runner modules. Matched on basename so a harness
|
|
859
|
+
* that lives at a different relative directory — a scaffolded app's, or a test fixture's — is still
|
|
860
|
+
* in scope.
|
|
861
|
+
*/
|
|
862
|
+
export function touchesCaseMembershipSurface(changedPaths) {
|
|
863
|
+
return (changedPaths ?? []).some((changedPath) => {
|
|
864
|
+
const basename = normalizeRelativePath(String(changedPath)).split('/').pop() ?? ''
|
|
865
|
+
return basename === 'cases.json' || basename === 'validators.json' || basename.endsWith('-oracles.mjs')
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export function caseModeMembershipErrors(cases, registry, options = {}) {
|
|
870
|
+
const errors = []
|
|
871
|
+
const runnerExists = options.runnerExists ?? (() => true)
|
|
872
|
+
if (!Array.isArray(cases)) return ['the case catalog is not an array, so validator membership cannot be checked']
|
|
873
|
+
if (!isPlainObject(registry)) return ['the validator registry is not an object, so validator membership cannot be checked']
|
|
874
|
+
|
|
875
|
+
for (const [validatorId, entry] of Object.entries(registry)) {
|
|
876
|
+
if (!isPlainObject(entry) || entry.kind !== 'oracle') continue
|
|
877
|
+
const runners = Array.isArray(entry.runners) ? entry.runners : []
|
|
878
|
+
// Two legitimate oracle implementations, and the runner rule differs between them: a
|
|
879
|
+
// `trusted-executable` is executed from a runner file, while the rest name an in-evaluator
|
|
880
|
+
// function (`validateAllowedWrites`, `validateExpectedArtifacts`) and correctly carry none.
|
|
881
|
+
if (entry.implementation === TRUSTED_EXECUTABLE) {
|
|
882
|
+
if (!runners.length) {
|
|
883
|
+
errors.push(`validator ${validatorId} is a ${TRUSTED_EXECUTABLE} oracle but names no runners, so nothing executes it`)
|
|
884
|
+
continue
|
|
885
|
+
}
|
|
886
|
+
for (const runner of runners) {
|
|
887
|
+
if (!runnerExists(String(runner))) {
|
|
888
|
+
errors.push(`validator ${validatorId} names runner ${runner}, which is not a file in the harness directory`)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
continue
|
|
892
|
+
}
|
|
893
|
+
if (typeof entry.implementation !== 'string' || entry.implementation === '') {
|
|
894
|
+
errors.push(`validator ${validatorId} is an oracle with no implementation, so nothing grades it`)
|
|
895
|
+
}
|
|
896
|
+
if (runners.length) {
|
|
897
|
+
errors.push(
|
|
898
|
+
`validator ${validatorId} names an in-evaluator implementation "${entry.implementation}" and runners ` +
|
|
899
|
+
`(${runners.join(', ')}); only a ${TRUSTED_EXECUTABLE} oracle is run from a runner file`,
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
for (const caseRecord of cases) {
|
|
905
|
+
if (!isPlainObject(caseRecord)) continue
|
|
906
|
+
const caseId = typeof caseRecord.id === 'string' ? caseRecord.id : '(unnamed case)'
|
|
907
|
+
const mode = caseRecord.mode
|
|
908
|
+
const declared = Array.isArray(caseRecord.validators) ? caseRecord.validators : []
|
|
909
|
+
|
|
910
|
+
const readOnly = READ_ONLY_CASE_MODES.includes(mode)
|
|
911
|
+
const writable = WRITABLE_CASE_MODES.includes(mode)
|
|
912
|
+
if (!readOnly && !writable) {
|
|
913
|
+
errors.push(
|
|
914
|
+
`${caseId} declares mode "${mode}", which is classified neither read-only nor writable; ` +
|
|
915
|
+
'classify the mode before shipping cases in it',
|
|
916
|
+
)
|
|
917
|
+
continue
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const oracles = []
|
|
921
|
+
for (const validatorId of declared) {
|
|
922
|
+
const entry = registry[validatorId]
|
|
923
|
+
if (!entry) {
|
|
924
|
+
errors.push(`${caseId} declares validator ${validatorId}, which the validator registry does not define`)
|
|
925
|
+
continue
|
|
926
|
+
}
|
|
927
|
+
if (isPlainObject(entry) && entry.kind === 'oracle') oracles.push(validatorId)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const gradingOracles = oracles.filter(
|
|
931
|
+
(validatorId) => registry[validatorId]?.implementation === TRUSTED_EXECUTABLE,
|
|
932
|
+
)
|
|
933
|
+
if (readOnly) {
|
|
934
|
+
if (oracles.length) {
|
|
935
|
+
errors.push(`${caseId} is a read-only "${mode}" case but declares oracle(s) ${oracles.join(', ')}, which grade written artifacts`)
|
|
936
|
+
}
|
|
937
|
+
if (declared.includes(WRITABLE_PATHS_VALIDATOR_ID)) {
|
|
938
|
+
errors.push(`${caseId} is a read-only "${mode}" case but declares ${WRITABLE_PATHS_VALIDATOR_ID}`)
|
|
939
|
+
}
|
|
940
|
+
continue
|
|
941
|
+
}
|
|
942
|
+
if (!gradingOracles.length) continue
|
|
943
|
+
if (!declared.includes(ARTIFACT_VALIDATOR_ID)) {
|
|
944
|
+
errors.push(`${caseId} declares ${gradingOracles.join(', ')} without ${ARTIFACT_VALIDATOR_ID}, so its oracles have no artifact contract to bind to`)
|
|
945
|
+
}
|
|
946
|
+
if (!declared.includes(WRITABLE_PATHS_VALIDATOR_ID)) {
|
|
947
|
+
errors.push(`${caseId} declares ${gradingOracles.join(', ')} without ${WRITABLE_PATHS_VALIDATOR_ID}, so it may not legally write what its oracles grade`)
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return errors
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const NODE_TEST_SCRIPT_PATTERN = /(?:^|\s)--test(?:[=\s]|$)/
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* The controller only drives an exact `node --test` argv. It reads the nearest owning package's
|
|
957
|
+
* declared test script so a project on another runner is refused with its real script name instead
|
|
958
|
+
* of being handed a command that would not run its suite.
|
|
959
|
+
*/
|
|
960
|
+
export function resolveTestRunnerFamily(root, testFile) {
|
|
961
|
+
const segments = normalizeRelativePath(testFile).split('/').slice(0, -1)
|
|
962
|
+
const directories = ['']
|
|
963
|
+
let accumulated = ''
|
|
964
|
+
for (const segment of segments) {
|
|
965
|
+
accumulated = accumulated ? `${accumulated}/${segment}` : segment
|
|
966
|
+
directories.push(accumulated)
|
|
967
|
+
}
|
|
968
|
+
for (const directory of directories.reverse()) {
|
|
969
|
+
const manifest = readJsonIfPresent(path.join(root, directory, 'package.json'))
|
|
970
|
+
const declared = manifest?.scripts?.test
|
|
971
|
+
if (typeof declared !== 'string' || !declared.trim()) continue
|
|
972
|
+
return NODE_TEST_SCRIPT_PATTERN.test(declared)
|
|
973
|
+
? { family: 'node-test', declaredScript: declared }
|
|
974
|
+
: { family: 'unsupported', declaredScript: declared }
|
|
975
|
+
}
|
|
976
|
+
return { family: 'node-test', declaredScript: null }
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export function deriveFocusedCommand(testFile, runnerFamily = { family: 'node-test', declaredScript: null }) {
|
|
980
|
+
const relativePath = normalizeRelativePath(testFile)
|
|
981
|
+
if (runnerFamily.family !== 'node-test') {
|
|
982
|
+
return {
|
|
983
|
+
error:
|
|
984
|
+
`the owning package of ${relativePath} declares the test script "${runnerFamily.declaredScript}", which the ` +
|
|
985
|
+
'controller cannot drive as an exact argv; it only drives a `node --test` runner today',
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
const runner = FOCUSED_RUNNERS.find((candidate) => candidate.pattern.test(relativePath))
|
|
989
|
+
if (!runner) return { error: `no controller runner is registered for ${relativePath}` }
|
|
990
|
+
const argv = [process.execPath, ...runner.args(relativePath)]
|
|
991
|
+
const offending = argv.find((token) => SHELL_METACHARACTER_PATTERN.test(token))
|
|
992
|
+
if (offending !== undefined) {
|
|
993
|
+
return { error: `the command derived for ${relativePath} carries the shell metacharacter token "${offending}"` }
|
|
994
|
+
}
|
|
995
|
+
return { runner: runner.runner, argv }
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function isContainedRelativePath(relativePath) {
|
|
999
|
+
const normalized = normalizeRelativePath(relativePath)
|
|
1000
|
+
if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) return false
|
|
1001
|
+
return !normalized.split('/').includes('..')
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function overlayWorkingTreePaths(root, worktree, relativePaths) {
|
|
1005
|
+
for (const relativePath of relativePaths) {
|
|
1006
|
+
if (!isContainedRelativePath(relativePath)) continue
|
|
1007
|
+
const source = path.join(root, relativePath)
|
|
1008
|
+
const target = path.join(worktree, relativePath)
|
|
1009
|
+
if (isRegularFile(source)) {
|
|
1010
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
1011
|
+
fs.copyFileSync(source, target)
|
|
1012
|
+
} else if (fs.existsSync(target)) {
|
|
1013
|
+
fs.rmSync(target, { force: true, recursive: true })
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function linkResolutionRoots(root, worktree, relativePath) {
|
|
1019
|
+
const segments = normalizeRelativePath(relativePath).split('/').slice(0, -1)
|
|
1020
|
+
const directories = ['']
|
|
1021
|
+
let accumulated = ''
|
|
1022
|
+
for (const segment of segments) {
|
|
1023
|
+
accumulated = accumulated ? `${accumulated}/${segment}` : segment
|
|
1024
|
+
directories.push(accumulated)
|
|
1025
|
+
}
|
|
1026
|
+
for (const directory of directories) {
|
|
1027
|
+
const target = path.join(root, directory, 'node_modules')
|
|
1028
|
+
const link = path.join(worktree, directory, 'node_modules')
|
|
1029
|
+
if (!fs.existsSync(target) || fs.existsSync(link)) continue
|
|
1030
|
+
fs.mkdirSync(path.dirname(link), { recursive: true })
|
|
1031
|
+
try { fs.symlinkSync(target, link, 'junction') } catch { /* resolution stays worktree-local */ }
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// A controller invoked from inside a test runner would otherwise leak its runner context into the
|
|
1036
|
+
// child, which suppresses the child's own exit status and makes every focused run look like a pass.
|
|
1037
|
+
export const STRIPPED_EXECUTION_ENV_KEYS = Object.freeze([
|
|
1038
|
+
'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'NODE_V8_COVERAGE', 'TEST_RUNNER_CONCURRENCY',
|
|
1039
|
+
])
|
|
1040
|
+
|
|
1041
|
+
export function sanitizeExecutionEnv(sourceEnv) {
|
|
1042
|
+
const env = { ...sourceEnv }
|
|
1043
|
+
for (const key of STRIPPED_EXECUTION_ENV_KEYS) delete env[key]
|
|
1044
|
+
return env
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function runControlledCommand(cwd, argv, timeoutMs) {
|
|
1048
|
+
const env = sanitizeExecutionEnv(process.env)
|
|
1049
|
+
const result = spawnSync(argv[0], argv.slice(1), { cwd, env, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 })
|
|
1050
|
+
if (result.error || result.signal || typeof result.status !== 'number') {
|
|
1051
|
+
return { failed: true, reason: result.signal ? `terminated by signal ${result.signal}` : String(result.error ?? 'no exit status') }
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
exitCode: result.status,
|
|
1055
|
+
stdoutSha256: createHash('sha256').update(result.stdout ?? Buffer.alloc(0)).digest('hex'),
|
|
1056
|
+
stderrSha256: createHash('sha256').update(result.stderr ?? Buffer.alloc(0)).digest('hex'),
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function addWorktree(root, sha, directory) {
|
|
1061
|
+
git(root, ['worktree', 'add', '--detach', '--quiet', directory, sha])
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Returns true when the clean removal failed and left an administrative entry behind. The caller
|
|
1065
|
+
// prunes only then: an unconditional prune would also drop sibling worktrees of the same repository
|
|
1066
|
+
// whose directories happen to be unavailable, which is not this controller's to decide.
|
|
1067
|
+
function removeWorktree(root, directory) {
|
|
1068
|
+
try {
|
|
1069
|
+
git(root, ['worktree', 'remove', '--force', directory])
|
|
1070
|
+
return false
|
|
1071
|
+
} catch {
|
|
1072
|
+
fs.rmSync(directory, { recursive: true, force: true })
|
|
1073
|
+
return true
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Controller-owned focused execution. For each declared focused test the controller builds two
|
|
1079
|
+
* isolated worktrees: the base commit with ONLY that test's own head content applied, and the head
|
|
1080
|
+
* commit with the whole working-tree diff applied. It runs the derived argv exactly once per side —
|
|
1081
|
+
* there is no retry — and records exit codes plus output hashes as evidence.
|
|
1082
|
+
*/
|
|
1083
|
+
export function runFocusedExecutions(input) {
|
|
1084
|
+
const root = path.resolve(input.root)
|
|
1085
|
+
const baseSha = input.baseSha
|
|
1086
|
+
const changedPaths = [...new Set((input.changedPaths ?? []).map(normalizeRelativePath))]
|
|
1087
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS
|
|
1088
|
+
const executions = []
|
|
1089
|
+
const errors = []
|
|
1090
|
+
const testFiles = [...new Set((input.testFiles ?? []).map(normalizeRelativePath))]
|
|
1091
|
+
if (!testFiles.length) return { executions, errors }
|
|
1092
|
+
|
|
1093
|
+
const scratch = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'om-knowledge-exec-'))
|
|
1094
|
+
let removalFellBack = false
|
|
1095
|
+
try {
|
|
1096
|
+
for (const [index, testFile] of testFiles.entries()) {
|
|
1097
|
+
const headAbsolute = path.join(root, testFile)
|
|
1098
|
+
if (!isContainedRelativePath(testFile) || !isRegularFile(headAbsolute)) {
|
|
1099
|
+
errors.push(`focusedExecutions ${testFile} is not an exact regular file inside the repository, so it cannot be executed`)
|
|
1100
|
+
continue
|
|
1101
|
+
}
|
|
1102
|
+
const derivedCommand = deriveFocusedCommand(testFile, resolveTestRunnerFamily(root, testFile))
|
|
1103
|
+
if (derivedCommand.error) { errors.push(`focusedExecutions ${derivedCommand.error}`); continue }
|
|
1104
|
+
|
|
1105
|
+
const headContents = fs.readFileSync(headAbsolute)
|
|
1106
|
+
const baseContents = baseFileBuffer(root, baseSha, testFile)
|
|
1107
|
+
if (baseContents !== null && baseContents.equals(headContents)) {
|
|
1108
|
+
errors.push(
|
|
1109
|
+
`focusedExecutions ${testFile} has no test-only diff against the base; a knowledge-contract change must add ` +
|
|
1110
|
+
'or repair the regression that proves the new behavior',
|
|
1111
|
+
)
|
|
1112
|
+
continue
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const baseWorktree = path.join(scratch, `base-${index}`)
|
|
1116
|
+
const headWorktree = path.join(scratch, `head-${index}`)
|
|
1117
|
+
let baseOutcome
|
|
1118
|
+
let headOutcome
|
|
1119
|
+
try {
|
|
1120
|
+
addWorktree(root, baseSha, baseWorktree)
|
|
1121
|
+
overlayWorkingTreePaths(root, baseWorktree, [testFile])
|
|
1122
|
+
linkResolutionRoots(root, baseWorktree, testFile)
|
|
1123
|
+
baseOutcome = runControlledCommand(baseWorktree, derivedCommand.argv, timeoutMs)
|
|
1124
|
+
|
|
1125
|
+
addWorktree(root, input.headSha, headWorktree)
|
|
1126
|
+
overlayWorkingTreePaths(root, headWorktree, changedPaths)
|
|
1127
|
+
linkResolutionRoots(root, headWorktree, testFile)
|
|
1128
|
+
headOutcome = runControlledCommand(headWorktree, derivedCommand.argv, timeoutMs)
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
errors.push(`focusedExecutions ${testFile} could not be executed in an isolated worktree: ${error.message}`)
|
|
1131
|
+
continue
|
|
1132
|
+
} finally {
|
|
1133
|
+
removalFellBack = removeWorktree(root, baseWorktree) || removalFellBack
|
|
1134
|
+
removalFellBack = removeWorktree(root, headWorktree) || removalFellBack
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (baseOutcome.failed || headOutcome.failed) {
|
|
1138
|
+
const failing = baseOutcome.failed ? `base-plus-test-only (${baseOutcome.reason})` : `head (${headOutcome.reason})`
|
|
1139
|
+
errors.push(`focusedExecutions ${testFile} produced no usable ${failing} exit status`)
|
|
1140
|
+
continue
|
|
1141
|
+
}
|
|
1142
|
+
executions.push({
|
|
1143
|
+
testFile,
|
|
1144
|
+
command: derivedCommand.argv,
|
|
1145
|
+
baseWithTestPatch: baseOutcome,
|
|
1146
|
+
head: headOutcome,
|
|
1147
|
+
})
|
|
1148
|
+
}
|
|
1149
|
+
} finally {
|
|
1150
|
+
fs.rmSync(scratch, { recursive: true, force: true })
|
|
1151
|
+
if (removalFellBack) { try { git(root, ['worktree', 'prune']) } catch { /* pruning is best effort */ } }
|
|
1152
|
+
}
|
|
1153
|
+
return { executions, errors }
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export function assertFocusedExecutionEvidence(declaredTests, evidence) {
|
|
1157
|
+
const errors = []
|
|
1158
|
+
if (!evidence) {
|
|
1159
|
+
return ['the controller produced no focused base/head execution evidence; a knowledge-contract change cannot be certified without it']
|
|
1160
|
+
}
|
|
1161
|
+
errors.push(...(evidence.errors ?? []))
|
|
1162
|
+
const executions = evidence.executions ?? []
|
|
1163
|
+
for (const testFile of declaredTests) {
|
|
1164
|
+
const execution = executions.find((entry) => entry.testFile === normalizeRelativePath(testFile))
|
|
1165
|
+
if (!execution) {
|
|
1166
|
+
errors.push(`focusedExecutions has no base/head execution record for declared focused test ${testFile}`)
|
|
1167
|
+
continue
|
|
1168
|
+
}
|
|
1169
|
+
if (execution.baseWithTestPatch.exitCode === 0) {
|
|
1170
|
+
errors.push(
|
|
1171
|
+
`focusedExecutions ${testFile} exited 0 on base-plus-test-only; the focused test must fail for the old behavior`,
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
if (execution.head.exitCode !== 0) {
|
|
1175
|
+
errors.push(`focusedExecutions ${testFile} exited ${execution.head.exitCode} at head; the focused test must pass after the change`)
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
for (const execution of executions) {
|
|
1179
|
+
if (!declaredTests.map(normalizeRelativePath).includes(execution.testFile)) {
|
|
1180
|
+
errors.push(`focusedExecutions carries an execution for ${execution.testFile}, which the manifest does not declare`)
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return errors
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Validates one knowledge-change run manifest against the derived classification of
|
|
1188
|
+
* its own diff. `changedPaths` is supplied by the caller so the derivation stays
|
|
1189
|
+
* testable without a shell; the CLI derives it from git.
|
|
1190
|
+
*/
|
|
1191
|
+
export function validateKnowledgeChange(input) {
|
|
1192
|
+
const root = path.resolve(input.root)
|
|
1193
|
+
const manifest = input.manifest
|
|
1194
|
+
const schema = input.schema
|
|
1195
|
+
const errors = []
|
|
1196
|
+
const derived = {}
|
|
1197
|
+
|
|
1198
|
+
const schemaErrors = validateJsonSchema(manifest, schema.$defs.authoredManifest, '$', schema)
|
|
1199
|
+
for (const field of CONTROLLER_OWNED_FIELDS) {
|
|
1200
|
+
if (isPlainObject(manifest) && Object.hasOwn(manifest, field)) {
|
|
1201
|
+
errors.push(`${field} is controller-owned output; author-supplied evidence fails validation`)
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
errors.push(...schemaErrors.map((error) => `manifest schema: ${error}`))
|
|
1205
|
+
if (!isPlainObject(manifest)) return { ok: false, errors, derived }
|
|
1206
|
+
|
|
1207
|
+
const classification = deriveChangeClassification(input.changedPaths)
|
|
1208
|
+
derived.changeClass = classification.changeClass
|
|
1209
|
+
derived.changedContracts = classification.changedContracts
|
|
1210
|
+
derived.classifiedPaths = classification.records
|
|
1211
|
+
derived.unclassifiedPaths = classification.unclassifiedPaths
|
|
1212
|
+
|
|
1213
|
+
if (manifest.changeClass !== classification.changeClass) {
|
|
1214
|
+
errors.push(
|
|
1215
|
+
`declared changeClass "${manifest.changeClass}" differs from the class derived from the diff ` +
|
|
1216
|
+
`"${classification.changeClass}"`,
|
|
1217
|
+
)
|
|
1218
|
+
}
|
|
1219
|
+
const declaredContracts = Array.isArray(manifest.changedContracts) ? manifest.changedContracts : []
|
|
1220
|
+
const missingContracts = classification.changedContracts.filter((contract) => !declaredContracts.includes(contract))
|
|
1221
|
+
const extraContracts = declaredContracts.filter((contract) => !classification.changedContracts.includes(contract))
|
|
1222
|
+
if (missingContracts.length) errors.push(`changedContracts omits derived contracts: ${missingContracts.join(', ')}`)
|
|
1223
|
+
if (extraContracts.length) errors.push(`changedContracts declares contracts the diff does not touch: ${extraContracts.join(', ')}`)
|
|
1224
|
+
|
|
1225
|
+
checkFileRecords(root, 'authoritativeFiles', manifest.authoritativeFiles ?? [], errors)
|
|
1226
|
+
checkFileRecords(root, 'generatedFiles', manifest.generatedFiles ?? [], errors, { sourcePathRequired: true })
|
|
1227
|
+
// Generated/template/packed targets against the source they copy. checkFileRecords above hashes
|
|
1228
|
+
// each target against itself, so drift from the authoritative source is invisible to it.
|
|
1229
|
+
errors.push(...generatedTargetErrors(manifest.generatedFiles ?? [], input.changedPaths ?? [], (ref, relativePath) => {
|
|
1230
|
+
if (ref === 'head') {
|
|
1231
|
+
const absolute = path.join(root, relativePath)
|
|
1232
|
+
return isRegularFile(absolute) ? hashFile(absolute) : null
|
|
1233
|
+
}
|
|
1234
|
+
return input.baseSha ? baseFileHash(root, input.baseSha, relativePath) : null
|
|
1235
|
+
}))
|
|
1236
|
+
for (const documentationPath of manifest.documentationFiles ?? []) {
|
|
1237
|
+
if (!isRegularFile(path.join(root, documentationPath))) {
|
|
1238
|
+
errors.push(`documentationFiles ${documentationPath} is not an exact regular file in the working tree`)
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Declared-integration-test check. Applies whenever the diff touches the canonical example or its
|
|
1243
|
+
// inventory, independently of change class: a coverage declaration that has rotted is a failure in
|
|
1244
|
+
// an asset-sync run exactly as much as in a knowledge-contract one.
|
|
1245
|
+
const touchesExample = (input.changedPaths ?? []).some((changedPath) => {
|
|
1246
|
+
const normalized = normalizeRelativePath(changedPath)
|
|
1247
|
+
return normalized.startsWith(`${EXAMPLE_AUTHORING_ROOT}/`) || normalized.startsWith(`${EXAMPLE_TEMPLATE_ROOT}/`)
|
|
1248
|
+
})
|
|
1249
|
+
if (touchesExample) {
|
|
1250
|
+
const headRows = readExampleCapabilityRows(readJsonIfPresent(path.join(root, EXAMPLE_SURFACE_INVENTORY_PATH)))
|
|
1251
|
+
let baseRows = null
|
|
1252
|
+
if (input.baseSha) {
|
|
1253
|
+
const buffer = baseFileBuffer(root, input.baseSha, EXAMPLE_SURFACE_INVENTORY_PATH)
|
|
1254
|
+
if (buffer) {
|
|
1255
|
+
try {
|
|
1256
|
+
baseRows = readExampleCapabilityRows(JSON.parse(buffer.toString('utf8')))
|
|
1257
|
+
} catch {
|
|
1258
|
+
baseRows = null
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
const coverageErrors = exampleCoverageErrors(headRows, baseRows, {
|
|
1263
|
+
pathExists: (relativePath) => isRegularFile(path.join(root, EXAMPLE_APP_ROOT, relativePath)),
|
|
1264
|
+
})
|
|
1265
|
+
derived.exampleCapabilityCount = headRows?.length ?? 0
|
|
1266
|
+
derived.exampleCoverageErrorCount = coverageErrors.length
|
|
1267
|
+
errors.push(...coverageErrors)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (classification.changeClass === 'asset-sync' || manifest.changeClass === 'asset-sync') {
|
|
1271
|
+
for (const record of classification.records) {
|
|
1272
|
+
if (record.contract) errors.push(`asset-sync run touches ${record.path}, which carries the ${record.contract} contract`)
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (classification.changeClass === 'asset-sync') {
|
|
1276
|
+
if (input.baseSha) {
|
|
1277
|
+
for (const generated of manifest.generatedFiles ?? []) {
|
|
1278
|
+
const baseHash = baseFileHash(root, input.baseSha, generated.sourcePath)
|
|
1279
|
+
const headAbsolute = path.join(root, generated.sourcePath)
|
|
1280
|
+
const headHash = isRegularFile(headAbsolute) ? hashFile(headAbsolute) : null
|
|
1281
|
+
if (baseHash !== null && headHash !== null && baseHash !== headHash) {
|
|
1282
|
+
errors.push(
|
|
1283
|
+
`asset-sync run changed authoritative source ${generated.sourcePath}; ` +
|
|
1284
|
+
'a semantic source change is a knowledge-contract change',
|
|
1285
|
+
)
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (classification.changeClass === 'knowledge-contract') {
|
|
1292
|
+
const declaredTests = Array.isArray(manifest.focusedTestFiles) ? manifest.focusedTestFiles.map(normalizeRelativePath) : []
|
|
1293
|
+
const changedTests = declaredTests.filter((testPath) => classification.focusedTestPaths.includes(testPath))
|
|
1294
|
+
if (!declaredTests.length) {
|
|
1295
|
+
errors.push('a knowledge-contract change requires at least one focused test that demonstrates the affected contract')
|
|
1296
|
+
}
|
|
1297
|
+
for (const testPath of declaredTests) {
|
|
1298
|
+
if (!isFocusedTestPath(testPath)) errors.push(`focusedTestFiles ${testPath} is not a test file`)
|
|
1299
|
+
if (!isRegularFile(path.join(root, testPath))) errors.push(`focusedTestFiles ${testPath} is not an exact regular file in the working tree`)
|
|
1300
|
+
}
|
|
1301
|
+
if (declaredTests.length && !changedTests.length) {
|
|
1302
|
+
errors.push('no declared focused test appears in the diff; a knowledge-contract change must add or repair its own regression')
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const cases = input.cases ?? readJsonIfPresent(path.join(root, input.harnessRelativeDir ?? '', 'cases.json'))
|
|
1306
|
+
if (!Array.isArray(cases)) {
|
|
1307
|
+
errors.push('the case catalog could not be read, so affected cases and the catalog count cannot be verified')
|
|
1308
|
+
} else {
|
|
1309
|
+
const knownCaseIds = new Set(cases.map((entry) => entry?.id))
|
|
1310
|
+
for (const caseId of manifest.affectedCaseIds ?? []) {
|
|
1311
|
+
if (!knownCaseIds.has(caseId)) errors.push(`affectedCaseIds ${caseId} does not exist in the case catalog`)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Per-case-mode validator/oracle membership. Scoped to diffs that touch the catalog, the
|
|
1315
|
+
// registry or an oracle runner — the three surfaces whose movement can orphan a case — and
|
|
1316
|
+
// then checked over the WHOLE catalog rather than only the affected cases, because a registry
|
|
1317
|
+
// edit that orphans an untouched case is exactly the drift this exists to catch.
|
|
1318
|
+
const harnessDir = path.join(root, input.harnessRelativeDir ?? '')
|
|
1319
|
+
const registryDocument = input.validators ?? readJsonIfPresent(path.join(harnessDir, 'validators.json'))
|
|
1320
|
+
const registry = isPlainObject(registryDocument) ? registryDocument.validators : null
|
|
1321
|
+
if (!touchesCaseMembershipSurface(input.changedPaths ?? [])) {
|
|
1322
|
+
// out of scope for this diff
|
|
1323
|
+
} else if (!isPlainObject(registry)) {
|
|
1324
|
+
errors.push('the validator registry could not be read, so per-case-mode validator membership fails closed')
|
|
1325
|
+
} else {
|
|
1326
|
+
const membershipErrors = caseModeMembershipErrors(cases, registry, {
|
|
1327
|
+
runnerExists: (runner) => isRegularFile(path.join(harnessDir, runner)),
|
|
1328
|
+
})
|
|
1329
|
+
derived.caseModeMembershipErrorCount = membershipErrors.length
|
|
1330
|
+
errors.push(...membershipErrors)
|
|
1331
|
+
}
|
|
1332
|
+
if (manifest.expectedCatalogCount !== cases.length) {
|
|
1333
|
+
errors.push(`expectedCatalogCount ${manifest.expectedCatalogCount} does not match the catalog's ${cases.length} cases`)
|
|
1334
|
+
}
|
|
1335
|
+
for (const rangeId of manifest.affectedRanges ?? []) {
|
|
1336
|
+
const expanded = expandCaseRange(rangeId)
|
|
1337
|
+
if (!expanded) { errors.push(`affectedRanges ${rangeId} is not an ascending OMH range`); continue }
|
|
1338
|
+
const missing = expanded.filter((caseId) => !knownCaseIds.has(caseId))
|
|
1339
|
+
if (missing.length) errors.push(`affectedRanges ${rangeId} covers cases absent from the catalog: ${missing.join(', ')}`)
|
|
1340
|
+
}
|
|
1341
|
+
const rangeMembers = new Set((manifest.affectedRanges ?? []).flatMap((rangeId) => expandCaseRange(rangeId) ?? []))
|
|
1342
|
+
for (const caseId of manifest.affectedCaseIds ?? []) {
|
|
1343
|
+
if (rangeMembers.size && !rangeMembers.has(caseId)) {
|
|
1344
|
+
errors.push(`affectedCaseIds ${caseId} falls outside every declared affected range`)
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const releaseMatrix = input.releaseMatrix ?? readJsonIfPresent(path.join(root, input.harnessRelativeDir ?? '', 'release-matrix.json'))
|
|
1350
|
+
if (!isPlainObject(releaseMatrix)) {
|
|
1351
|
+
errors.push('the release matrix could not be read, so required lanes cannot be verified')
|
|
1352
|
+
} else {
|
|
1353
|
+
for (const lane of manifest.requiredReleaseLanes ?? []) {
|
|
1354
|
+
if (!Object.hasOwn(releaseMatrix, lane)) errors.push(`requiredReleaseLanes ${lane} is not a lane in the release matrix`)
|
|
1355
|
+
}
|
|
1356
|
+
if (!(manifest.requiredReleaseLanes ?? []).length) {
|
|
1357
|
+
errors.push('a knowledge-contract change must name at least one affected certified release lane')
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const executionErrors = assertFocusedExecutionEvidence(declaredTests, input.executionEvidence)
|
|
1362
|
+
derived.focusedExecutions = input.executionEvidence?.executions ?? []
|
|
1363
|
+
errors.push(...executionErrors)
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const linkedContracts = ['source-link', 'example-source', 'installed-source']
|
|
1367
|
+
const needsSourceLinkInventory = classification.changedContracts.some((contract) => linkedContracts.includes(contract))
|
|
1368
|
+
derived.sourceLinkInventoryRequired = needsSourceLinkInventory
|
|
1369
|
+
if (needsSourceLinkInventory) {
|
|
1370
|
+
checkSourceLinkInventory(root, isPlainObject(manifest.sourceLinkInventory) ? manifest.sourceLinkInventory : {}, errors, derived)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
checkExampleSourceMirrors(root, classification.records, errors, derived)
|
|
1374
|
+
|
|
1375
|
+
return { ok: errors.length === 0, errors, derived }
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function writeJsonAtomic(absolutePath, value) {
|
|
1379
|
+
const directory = path.dirname(absolutePath)
|
|
1380
|
+
fs.mkdirSync(directory, { recursive: true })
|
|
1381
|
+
const temporaryPath = path.join(directory, `.${path.basename(absolutePath)}.${process.pid}.tmp`)
|
|
1382
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`)
|
|
1383
|
+
fs.renameSync(temporaryPath, absolutePath)
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function usage() {
|
|
1387
|
+
return [
|
|
1388
|
+
'Usage: validate-knowledge-change --manifest <path> --base <ref> [--root <dir>] [--harness-dir <dir>]',
|
|
1389
|
+
' [--out <path>] [--execution-timeout <ms>]',
|
|
1390
|
+
'',
|
|
1391
|
+
'Derives the knowledge-contract/asset-sync class from the diff between <ref> and the working tree,',
|
|
1392
|
+
'validates the authored run manifest against knowledge-change.schema.json, and — for a knowledge-contract',
|
|
1393
|
+
'change — runs each declared focused test once in an isolated base-plus-test-only worktree and once at head,',
|
|
1394
|
+
'recording exit codes and output hashes as controller-owned evidence. The result is written atomically.',
|
|
1395
|
+
].join('\n')
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function parseArgs(argv) {
|
|
1399
|
+
const options = { root: process.cwd(), help: false }
|
|
1400
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1401
|
+
const token = argv[index]
|
|
1402
|
+
if (token === '--help' || token === '-h') { options.help = true; continue }
|
|
1403
|
+
const value = argv[index + 1]
|
|
1404
|
+
if (token === '--manifest') { options.manifest = value; index += 1; continue }
|
|
1405
|
+
if (token === '--base') { options.base = value; index += 1; continue }
|
|
1406
|
+
if (token === '--root') { options.root = value; index += 1; continue }
|
|
1407
|
+
if (token === '--harness-dir') { options.harnessDir = value; index += 1; continue }
|
|
1408
|
+
if (token === '--out') { options.out = value; index += 1; continue }
|
|
1409
|
+
if (token === '--execution-timeout') {
|
|
1410
|
+
const parsed = Number(value)
|
|
1411
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error('--execution-timeout must be a positive integer of milliseconds')
|
|
1412
|
+
options.executionTimeoutMs = parsed
|
|
1413
|
+
index += 1
|
|
1414
|
+
continue
|
|
1415
|
+
}
|
|
1416
|
+
throw new Error(`unknown argument: ${token}`)
|
|
1417
|
+
}
|
|
1418
|
+
return options
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function defaultHarnessRelativeDir(root) {
|
|
1422
|
+
const candidates = ['packages/create-app/agentic/shared/ai/harness', 'agentic/shared/ai/harness', '.ai/harness']
|
|
1423
|
+
return candidates.find((candidate) => isRegularFile(path.join(root, candidate, 'cases.json'))) ?? '.ai/harness'
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
export function main(argv = process.argv.slice(2)) {
|
|
1427
|
+
let options
|
|
1428
|
+
try { options = parseArgs(argv) } catch (error) { console.error(error.message); console.error(usage()); return EXIT_INVALID }
|
|
1429
|
+
if (options.help) { console.log(usage()); return EXIT_PASS }
|
|
1430
|
+
if (!options.manifest || !options.base) { console.error('--manifest and --base are required'); console.error(usage()); return EXIT_INVALID }
|
|
1431
|
+
|
|
1432
|
+
const root = path.resolve(options.root)
|
|
1433
|
+
const harnessRelativeDir = options.harnessDir ?? defaultHarnessRelativeDir(root)
|
|
1434
|
+
const schemaCandidates = [
|
|
1435
|
+
path.join(root, harnessRelativeDir, SCHEMA_BASENAME),
|
|
1436
|
+
fileURLToPath(new URL(`../ai/harness/${SCHEMA_BASENAME}`, import.meta.url)),
|
|
1437
|
+
fileURLToPath(new URL(`../.ai/harness/${SCHEMA_BASENAME}`, import.meta.url)),
|
|
1438
|
+
]
|
|
1439
|
+
const schemaPath = schemaCandidates.find((candidate) => isRegularFile(candidate))
|
|
1440
|
+
if (!schemaPath) { console.error(`knowledge-change schema not found: ${schemaCandidates.join(', ')}`); return EXIT_INVALID }
|
|
1441
|
+
const schema = readJsonIfPresent(schemaPath)
|
|
1442
|
+
if (!schema) { console.error(`knowledge-change schema is not valid JSON: ${schemaPath}`); return EXIT_INVALID }
|
|
1443
|
+
|
|
1444
|
+
const manifestPath = path.resolve(root, options.manifest)
|
|
1445
|
+
const manifest = readJsonIfPresent(manifestPath)
|
|
1446
|
+
if (manifest === null) { console.error(`run manifest not found or not valid JSON: ${manifestPath}`); return EXIT_INVALID }
|
|
1447
|
+
|
|
1448
|
+
let baseSha
|
|
1449
|
+
let headSha
|
|
1450
|
+
let changedPaths
|
|
1451
|
+
try {
|
|
1452
|
+
baseSha = resolveGitSha(root, options.base)
|
|
1453
|
+
headSha = resolveGitSha(root, 'HEAD')
|
|
1454
|
+
changedPaths = deriveChangedPaths(root, baseSha)
|
|
1455
|
+
} catch (error) { console.error(error.message); return EXIT_INVALID }
|
|
1456
|
+
|
|
1457
|
+
let baseRefError = null
|
|
1458
|
+
if (manifest.baseRef !== options.base && manifest.baseRef !== baseSha) {
|
|
1459
|
+
let authoredSha = null
|
|
1460
|
+
try { authoredSha = resolveGitSha(root, manifest.baseRef) } catch { authoredSha = null }
|
|
1461
|
+
if (authoredSha !== baseSha) {
|
|
1462
|
+
baseRefError = `authored baseRef "${manifest.baseRef}" does not resolve to the --base SHA ${baseSha}`
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const classification = deriveChangeClassification(changedPaths)
|
|
1467
|
+
let executionEvidence = null
|
|
1468
|
+
if (classification.changeClass === 'knowledge-contract') {
|
|
1469
|
+
executionEvidence = baseRefError
|
|
1470
|
+
? { executions: [], errors: ['focused base/head execution was not attempted because the authored baseRef does not resolve to --base'] }
|
|
1471
|
+
: runFocusedExecutions({
|
|
1472
|
+
root,
|
|
1473
|
+
baseSha,
|
|
1474
|
+
headSha,
|
|
1475
|
+
changedPaths,
|
|
1476
|
+
testFiles: Array.isArray(manifest.focusedTestFiles) ? manifest.focusedTestFiles : [],
|
|
1477
|
+
timeoutMs: options.executionTimeoutMs,
|
|
1478
|
+
})
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const result = validateKnowledgeChange({
|
|
1482
|
+
root, manifest, schema, changedPaths, baseSha, harnessRelativeDir, executionEvidence,
|
|
1483
|
+
})
|
|
1484
|
+
if (baseRefError) { result.errors.unshift(baseRefError); result.ok = false }
|
|
1485
|
+
|
|
1486
|
+
const completed = {
|
|
1487
|
+
...manifest,
|
|
1488
|
+
resolvedBaseSha: baseSha,
|
|
1489
|
+
headSha,
|
|
1490
|
+
focusedExecutions: executionEvidence?.executions ?? [],
|
|
1491
|
+
}
|
|
1492
|
+
const completedErrors = validateJsonSchema(completed, schema.$defs.completedManifest, '$', schema)
|
|
1493
|
+
if (completedErrors.length) {
|
|
1494
|
+
result.errors.push(...completedErrors.map((error) => `completed manifest schema: ${error}`))
|
|
1495
|
+
result.ok = false
|
|
1496
|
+
}
|
|
1497
|
+
const outputPath = path.resolve(root, options.out ?? `${options.manifest}.result.json`)
|
|
1498
|
+
writeJsonAtomic(outputPath, {
|
|
1499
|
+
status: result.ok ? 'pass' : 'fail',
|
|
1500
|
+
manifest: completed,
|
|
1501
|
+
derived: result.derived,
|
|
1502
|
+
changedPaths,
|
|
1503
|
+
errors: result.errors,
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
for (const error of result.errors) console.error(`knowledge-change: ${error}`)
|
|
1507
|
+
console.log(`${result.ok ? 'PASS' : 'FAIL'} knowledge-change ${result.derived.changeClass} — ${outputPath}`)
|
|
1508
|
+
return result.ok ? EXIT_PASS : EXIT_FAILURE
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) process.exitCode = main()
|