@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.
Files changed (168) hide show
  1. package/.turbo/turbo-build.log +65 -1
  2. package/build.mjs +57 -7
  3. package/dist/agentic/guides/backend-ui.md +7 -1
  4. package/dist/agentic/guides/module-facts.json +565 -224
  5. package/dist/agentic/guides/module-facts.v2.json +122933 -0
  6. package/dist/agentic/guides/modules/ai_assistant.md +2 -2
  7. package/dist/agentic/guides/modules/api_docs.md +2 -2
  8. package/dist/agentic/guides/modules/api_keys.md +2 -2
  9. package/dist/agentic/guides/modules/attachments.md +2 -2
  10. package/dist/agentic/guides/modules/audit_logs.md +2 -2
  11. package/dist/agentic/guides/modules/auth.md +2 -2
  12. package/dist/agentic/guides/modules/business_rules.md +2 -2
  13. package/dist/agentic/guides/modules/catalog.md +11 -4
  14. package/dist/agentic/guides/modules/channel_gmail.md +2 -2
  15. package/dist/agentic/guides/modules/channel_imap.md +2 -2
  16. package/dist/agentic/guides/modules/checkout.md +8 -2
  17. package/dist/agentic/guides/modules/communication_channels.md +6 -6
  18. package/dist/agentic/guides/modules/configs.md +2 -2
  19. package/dist/agentic/guides/modules/content.md +2 -2
  20. package/dist/agentic/guides/modules/currencies.md +2 -2
  21. package/dist/agentic/guides/modules/customer_accounts.md +2 -2
  22. package/dist/agentic/guides/modules/customers.md +8 -8
  23. package/dist/agentic/guides/modules/dashboards.md +12 -12
  24. package/dist/agentic/guides/modules/data_sync.md +4 -4
  25. package/dist/agentic/guides/modules/design_system.md +2 -2
  26. package/dist/agentic/guides/modules/dictionaries.md +2 -2
  27. package/dist/agentic/guides/modules/directory.md +2 -2
  28. package/dist/agentic/guides/modules/entities.md +3 -3
  29. package/dist/agentic/guides/modules/eudr.md +3 -3
  30. package/dist/agentic/guides/modules/events.md +2 -2
  31. package/dist/agentic/guides/modules/feature_toggles.md +2 -2
  32. package/dist/agentic/guides/modules/gateway_stripe.md +2 -2
  33. package/dist/agentic/guides/modules/generators.md +2 -2
  34. package/dist/agentic/guides/modules/inbox_ops.md +2 -2
  35. package/dist/agentic/guides/modules/integrations.md +5 -2
  36. package/dist/agentic/guides/modules/messages.md +2 -2
  37. package/dist/agentic/guides/modules/notifications.md +2 -2
  38. package/dist/agentic/guides/modules/onboarding.md +2 -2
  39. package/dist/agentic/guides/modules/payment_gateways.md +7 -3
  40. package/dist/agentic/guides/modules/perspectives.md +2 -2
  41. package/dist/agentic/guides/modules/planner.md +2 -2
  42. package/dist/agentic/guides/modules/portal.md +2 -2
  43. package/dist/agentic/guides/modules/progress.md +2 -2
  44. package/dist/agentic/guides/modules/query_index.md +2 -2
  45. package/dist/agentic/guides/modules/record_locks.md +3 -3
  46. package/dist/agentic/guides/modules/resources.md +2 -2
  47. package/dist/agentic/guides/modules/sales.md +13 -4
  48. package/dist/agentic/guides/modules/scheduler.md +2 -2
  49. package/dist/agentic/guides/modules/search.md +4 -4
  50. package/dist/agentic/guides/modules/security.md +3 -3
  51. package/dist/agentic/guides/modules/shipping_carriers.md +2 -2
  52. package/dist/agentic/guides/modules/sso.md +2 -2
  53. package/dist/agentic/guides/modules/staff.md +7 -4
  54. package/dist/agentic/guides/modules/storage_s3.md +2 -2
  55. package/dist/agentic/guides/modules/sync_akeneo.md +4 -4
  56. package/dist/agentic/guides/modules/sync_excel.md +2 -2
  57. package/dist/agentic/guides/modules/system_status_overlays.md +2 -2
  58. package/dist/agentic/guides/modules/translations.md +2 -2
  59. package/dist/agentic/guides/modules/webhooks.md +2 -2
  60. package/dist/agentic/guides/modules/wms.md +31 -4
  61. package/dist/agentic/guides/modules/workflows.md +2 -2
  62. package/dist/agentic/guides/reference-module-facts.json +8335 -0
  63. package/dist/agentic/guides/reference-modules/example.md +489 -0
  64. package/dist/agentic/guides/spec-delivery.md +34 -0
  65. package/dist/agentic/guides/testing-debugging.md +2 -1
  66. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +3 -2
  67. package/dist/agentic/guides/upstream/manifest.json +2 -2
  68. package/dist/agentic/shared/AGENTS.md.template +10 -14
  69. package/dist/agentic/shared/ai/harness/README.md +4 -4
  70. package/dist/agentic/shared/ai/harness/RELEASE.md +4 -4
  71. package/dist/agentic/shared/ai/harness/cases.json +11350 -5088
  72. package/dist/agentic/shared/ai/harness/cases.schema.json +70 -7
  73. package/dist/agentic/shared/ai/harness/design-system-inventory.json +6697 -0
  74. package/dist/agentic/shared/ai/harness/fixtures/index.json +3 -1
  75. package/dist/agentic/shared/ai/harness/fixtures/seeds.json +4 -0
  76. package/dist/agentic/shared/ai/harness/knowledge-change.schema.json +228 -0
  77. package/dist/agentic/shared/ai/harness/release-matrix.json +9 -4
  78. package/dist/agentic/shared/ai/harness/result.schema.json +60 -0
  79. package/dist/agentic/shared/ai/harness/routing-response.schema.json +10 -0
  80. package/dist/agentic/shared/ai/harness/source-link-inventory.json +3231 -0
  81. package/dist/agentic/shared/ai/harness/validators.json +8 -4
  82. package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +5 -2
  83. package/dist/agentic/shared/ai/harness/writable-behavior-oracles.mjs +3 -0
  84. package/dist/agentic/shared/ai/harness/writable-spec-oracles.mjs +879 -0
  85. package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.6.7-to-0.7.0/SKILL.md +102 -0
  86. package/dist/agentic/shared/ai/skills/om-backend-ui-design/SKILL.md +1 -0
  87. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/crud-surfaces.md +15 -0
  88. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/frontend-and-design-system.md +6 -0
  89. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/page-and-navigation.md +4 -0
  90. package/dist/agentic/shared/ai/skills/om-data-model-design/SKILL.md +1 -0
  91. package/dist/agentic/shared/ai/skills/om-data-model-design/references/integrity-and-concurrency.md +2 -0
  92. package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +2 -0
  93. package/dist/agentic/shared/ai/skills/om-data-model-design/references/schema-design.md +3 -1
  94. package/dist/agentic/shared/ai/skills/om-eject-and-customize/SKILL.md +1 -0
  95. package/dist/agentic/shared/ai/skills/om-eject-and-customize/references/decision-and-procedure.md +2 -0
  96. package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +2 -0
  97. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +3 -3
  98. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +1 -1
  99. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/knowledge-change.md +133 -0
  100. package/dist/agentic/shared/ai/skills/om-integration-builder/SKILL.md +1 -0
  101. package/dist/agentic/shared/ai/skills/om-integration-builder/references/provider-families.md +11 -0
  102. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +1 -1
  103. package/dist/agentic/shared/ai/skills/om-module-scaffold/SKILL.md +2 -2
  104. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +14 -1
  105. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/data-and-migrations.md +2 -0
  106. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +19 -0
  107. package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +1 -4
  108. package/dist/agentic/shared/ai/skills/om-system-extension/SKILL.md +1 -0
  109. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-branches.md +24 -0
  110. package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +3 -1
  111. package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +1 -0
  112. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnosis-map.md +13 -0
  113. package/dist/agentic/shared/ai/skills/tiers.json +2 -1
  114. package/dist/agentic/shared/ai/specs/2026-08-06-reference-module-activation.md +248 -0
  115. package/dist/agentic/shared/scripts/agent-harness-tool-server.mjs +39 -2
  116. package/dist/agentic/shared/scripts/design-source-contract.mjs +108 -0
  117. package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +935 -28
  118. package/dist/agentic/shared/scripts/framework-context.mjs +4 -1
  119. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1 -1
  120. package/dist/agentic/shared/scripts/validate-knowledge-change.mjs +1511 -0
  121. package/dist/lib/__integration__/TC-INT-008.spec.js +1 -0
  122. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  123. package/dist/lib/agentic-setup.js +9 -1
  124. package/dist/lib/agentic-setup.js.map +2 -2
  125. package/dist/lib/generators/module-extension-facts.js +93 -26
  126. package/dist/lib/generators/module-extension-facts.js.map +3 -3
  127. package/dist/lib/generators/module-facts-discovery.js +23 -0
  128. package/dist/lib/generators/module-facts-discovery.js.map +2 -2
  129. package/dist/lib/generators/module-facts.js +969 -41
  130. package/dist/lib/generators/module-facts.js.map +2 -2
  131. package/dist/lib/generators/module-override-targets.js +35 -11
  132. package/dist/lib/generators/module-override-targets.js.map +2 -2
  133. package/dist/lib/generators/module-registry.js +105 -2
  134. package/dist/lib/generators/module-registry.js.map +2 -2
  135. package/dist/lib/resolver.js +5 -0
  136. package/dist/lib/resolver.js.map +2 -2
  137. package/dist/lib/testing/integration-discovery.js +34 -0
  138. package/dist/lib/testing/integration-discovery.js.map +2 -2
  139. package/dist/lib/testing/integration.js +0 -2
  140. package/dist/lib/testing/integration.js.map +2 -2
  141. package/dist/mercato.js +4 -4
  142. package/dist/mercato.js.map +2 -2
  143. package/package.json +6 -6
  144. package/src/lib/__integration__/TC-INT-008.spec.ts +4 -2
  145. package/src/lib/__tests__/resolve-environment.test.ts +18 -0
  146. package/src/lib/agentic-setup.ts +10 -2
  147. package/src/lib/generators/__tests__/helpers/override-target-diagnostic-fixtures.ts +150 -0
  148. package/src/lib/generators/__tests__/module-extension-facts.test.ts +290 -0
  149. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +72 -7
  150. package/src/lib/generators/__tests__/module-facts.computed-di-token.test.ts +88 -0
  151. package/src/lib/generators/__tests__/module-facts.example-fact-coverage.test.ts +502 -0
  152. package/src/lib/generators/__tests__/module-facts.local-reference.test.ts +414 -0
  153. package/src/lib/generators/__tests__/module-facts.search-entity-id.test.ts +151 -0
  154. package/src/lib/generators/__tests__/module-facts.source-links.test.ts +42 -1
  155. package/src/lib/generators/__tests__/module-override-targets.diagnostic-wiring.test.ts +92 -0
  156. package/src/lib/generators/__tests__/module-override-targets.test.ts +24 -0
  157. package/src/lib/generators/__tests__/module-override-targets.unknown-mode.test.ts +117 -0
  158. package/src/lib/generators/__tests__/module-subset.test.ts +60 -0
  159. package/src/lib/generators/module-extension-facts.ts +181 -25
  160. package/src/lib/generators/module-facts-discovery.ts +46 -0
  161. package/src/lib/generators/module-facts.ts +1330 -86
  162. package/src/lib/generators/module-override-targets.ts +70 -26
  163. package/src/lib/generators/module-registry.ts +135 -2
  164. package/src/lib/resolver.ts +6 -1
  165. package/src/lib/testing/__tests__/integration-discovery.test.ts +35 -0
  166. package/src/lib/testing/integration-discovery.ts +50 -0
  167. package/src/lib/testing/integration.ts +0 -2
  168. 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()