@qijenchen/governance 0.1.0-beta.94
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/README.md +89 -0
- package/bin/attest.mjs +4 -0
- package/bin/check.mjs +4 -0
- package/bin/doctor.mjs +4 -0
- package/bin/generate.mjs +4 -0
- package/bin/governance.mjs +4 -0
- package/bin/hook.mjs +4 -0
- package/bin/upgrade.mjs +4 -0
- package/canonical/gates.json +42 -0
- package/canonical/manifest.json +476 -0
- package/canonical/plugin-aliases.json +25 -0
- package/canonical/provider-lifecycle.json +48 -0
- package/canonical/providers.json +455 -0
- package/canonical/roles.json +12 -0
- package/canonical/rules.json +81 -0
- package/canonical/schemas/attestation.schema.json +61 -0
- package/canonical/schemas/diagnostic.schema.json +37 -0
- package/canonical/schemas/gates.schema.json +27 -0
- package/canonical/schemas/lock.schema.json +189 -0
- package/canonical/schemas/manifest.schema.json +103 -0
- package/canonical/schemas/plugin-aliases.schema.json +59 -0
- package/canonical/schemas/provider-hook-coverage.schema.json +173 -0
- package/canonical/schemas/provider-lifecycle.schema.json +126 -0
- package/canonical/schemas/providers.schema.json +917 -0
- package/canonical/schemas/roles.schema.json +27 -0
- package/canonical/schemas/rules.schema.json +46 -0
- package/canonical/schemas/upgrade-plan.schema.json +31 -0
- package/package.json +42 -0
- package/src/authority-decision-evidence.mjs +413 -0
- package/src/canonical-order.mjs +8 -0
- package/src/carrier-projection.mjs +407 -0
- package/src/cli.mjs +114 -0
- package/src/closed-tool-execution.mjs +1001 -0
- package/src/common.mjs +278 -0
- package/src/contract.mjs +760 -0
- package/src/hook-api.mjs +107 -0
- package/src/index.mjs +14 -0
- package/src/provider-hook-normalization.mjs +1646 -0
- package/src/provider-review-binding.mjs +2377 -0
- package/src/snapshot.mjs +520 -0
package/src/contract.mjs
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { lstat, readFile, readdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import Ajv2020 from 'ajv/dist/2020.js'
|
|
5
|
+
import addFormats from 'ajv-formats'
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MANIFEST,
|
|
8
|
+
assertNoSymlinkWithin,
|
|
9
|
+
assertRegularFileWithin,
|
|
10
|
+
canonicalize,
|
|
11
|
+
compareUtf8Bytes,
|
|
12
|
+
compareInventories,
|
|
13
|
+
diagnostic,
|
|
14
|
+
digest,
|
|
15
|
+
exists,
|
|
16
|
+
inventoryTree,
|
|
17
|
+
modeString,
|
|
18
|
+
readJson,
|
|
19
|
+
relativePath,
|
|
20
|
+
resolveWithin,
|
|
21
|
+
stableJson,
|
|
22
|
+
treeDigest,
|
|
23
|
+
unique,
|
|
24
|
+
} from './common.mjs'
|
|
25
|
+
import {
|
|
26
|
+
assertCanonicalGovernanceCarrierProjectionMap,
|
|
27
|
+
governanceCarrierProjectionMap,
|
|
28
|
+
governanceCarrierProjectionsForSource,
|
|
29
|
+
projectGovernanceCarrierBytes,
|
|
30
|
+
} from './carrier-projection.mjs'
|
|
31
|
+
|
|
32
|
+
const PROVIDER_HOOK_OUTPUT_TRANSPORT = 'scripts/lib/provider-hook-output-transport.mjs'
|
|
33
|
+
|
|
34
|
+
const contractRule = (kind, path, message, severity = 'critical', expected, actual) => diagnostic({
|
|
35
|
+
ruleId: 'GOV-CONTRACT-001',
|
|
36
|
+
severity,
|
|
37
|
+
kind,
|
|
38
|
+
path,
|
|
39
|
+
expected,
|
|
40
|
+
actual,
|
|
41
|
+
message,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
async function readRegistry(manifestDirectory, value, name, diagnostics) {
|
|
45
|
+
try {
|
|
46
|
+
const registryPath = resolve(manifestDirectory, relativePath(value, `registries.${name}`))
|
|
47
|
+
await assertRegularFileWithin(manifestDirectory, registryPath, `registries.${name}`)
|
|
48
|
+
const body = await readJson(registryPath)
|
|
49
|
+
return { path: registryPath, body, hash: digest(stableJson(body)) }
|
|
50
|
+
} catch (error) {
|
|
51
|
+
diagnostics.push(contractRule('registry-read-error', value || name, `Cannot read ${name} registry: ${error.message}`))
|
|
52
|
+
return { path: null, body: {}, hash: null }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function duplicates(values) {
|
|
57
|
+
const seen = new Set()
|
|
58
|
+
const repeated = new Set()
|
|
59
|
+
for (const value of values) seen.has(value) ? repeated.add(value) : seen.add(value)
|
|
60
|
+
return [...repeated].sort()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateUnique(items, key, label, diagnostics) {
|
|
64
|
+
for (const value of duplicates(items.map((item) => item?.[key]).filter(Boolean))) {
|
|
65
|
+
diagnostics.push(contractRule('duplicate-id', label, `Duplicate ${label} identifier: ${value}`, 'critical', 'unique', value))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sourceRelativeExcludes(source) {
|
|
70
|
+
const sourcePath = relativePath(source.path, `source ${source.id} path`)
|
|
71
|
+
const raw = source.excludes || []
|
|
72
|
+
if (!Array.isArray(raw)) throw new Error(`source ${source.id} excludes must be an array`)
|
|
73
|
+
if (JSON.stringify(raw) !== JSON.stringify([...new Set(raw)].sort())) {
|
|
74
|
+
throw new Error(`source ${source.id} excludes must be sorted and unique`)
|
|
75
|
+
}
|
|
76
|
+
const absoluteExcludes = raw.map((value, index) => {
|
|
77
|
+
if (typeof value !== 'string' || !value.endsWith('/') || value.includes('\\') || value.includes('\0')) {
|
|
78
|
+
throw new Error(`source ${source.id} exclusion ${index} must be a repository-relative POSIX directory ending in /`)
|
|
79
|
+
}
|
|
80
|
+
const exclusion = relativePath(value, `source ${source.id} exclusion ${index}`)
|
|
81
|
+
if (exclusion === sourcePath || !exclusion.startsWith(`${sourcePath}/`)) {
|
|
82
|
+
throw new Error(`source ${source.id} exclusion is outside its source tree:${value}`)
|
|
83
|
+
}
|
|
84
|
+
return exclusion
|
|
85
|
+
})
|
|
86
|
+
for (let left = 0; left < absoluteExcludes.length; left += 1) for (let right = left + 1; right < absoluteExcludes.length; right += 1) {
|
|
87
|
+
if (absoluteExcludes[right].startsWith(`${absoluteExcludes[left]}/`)) {
|
|
88
|
+
throw new Error(`source ${source.id} exclusions overlap:${raw[left]}:${raw[right]}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return absoluteExcludes.map(exclusion => relative(sourcePath, exclusion).replaceAll('\\', '/'))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveRegisteredMaterializer(providerRegistry, kind, materializerId) {
|
|
95
|
+
const catalog = providerRegistry?.canonical?.materializers?.[kind]
|
|
96
|
+
if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog) || typeof materializerId !== 'string' || !Object.hasOwn(catalog, materializerId)) {
|
|
97
|
+
throw new Error(`${kind} materializer is unregistered:${String(materializerId)}`)
|
|
98
|
+
}
|
|
99
|
+
const materializer = catalog[materializerId]
|
|
100
|
+
if (!materializer || typeof materializer !== 'object' || Array.isArray(materializer)) throw new Error(`${kind} materializer is invalid:${materializerId}`)
|
|
101
|
+
return materializer
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function providerHookInvocations(config, materializer) {
|
|
105
|
+
const invocations = []
|
|
106
|
+
const malformed = []
|
|
107
|
+
if (materializer.engine === 'json-hook-map-v1') {
|
|
108
|
+
const eventMap = config?.[materializer.hooksField]
|
|
109
|
+
if (!eventMap || typeof eventMap !== 'object' || Array.isArray(eventMap)) return { invocations, malformed: ['hook map is missing'] }
|
|
110
|
+
for (const [event, groups] of Object.entries(eventMap)) {
|
|
111
|
+
if (!Array.isArray(groups)) { malformed.push(`${event}:groups`); continue }
|
|
112
|
+
for (const [groupIndex, group] of groups.entries()) {
|
|
113
|
+
if (!Array.isArray(group?.hooks)) { malformed.push(`${event}:${groupIndex}:hooks`); continue }
|
|
114
|
+
if (group.hooks.length !== 1) malformed.push(`${event}:${groupIndex}:handler-count`)
|
|
115
|
+
for (const [handlerIndex, handler] of group.hooks.entries()) {
|
|
116
|
+
if (handler?.type !== 'command' || typeof handler.command !== 'string') malformed.push(`${event}:${groupIndex}:${handlerIndex}:command`)
|
|
117
|
+
else invocations.push({ event, groupIndex, transport: 'command-string', value: handler.command })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { invocations, malformed }
|
|
122
|
+
}
|
|
123
|
+
if (materializer.engine === 'json-hook-event-list-v1') {
|
|
124
|
+
const events = config?.[materializer.eventsField]
|
|
125
|
+
if (!Array.isArray(events)) return { invocations, malformed: ['event list is missing'] }
|
|
126
|
+
for (const [eventIndex, record] of events.entries()) {
|
|
127
|
+
const event = record?.[materializer.eventField]
|
|
128
|
+
const groups = record?.[materializer.groupsField]
|
|
129
|
+
if (typeof event !== 'string' || !Array.isArray(groups)) { malformed.push(`${eventIndex}:event/groups`); continue }
|
|
130
|
+
for (const [groupIndex, group] of groups.entries()) {
|
|
131
|
+
const handlers = group?.[materializer.handlersField]
|
|
132
|
+
if (!Array.isArray(handlers)) { malformed.push(`${event}:${groupIndex}:handlers`); continue }
|
|
133
|
+
if (handlers.length !== 1) malformed.push(`${event}:${groupIndex}:handler-count`)
|
|
134
|
+
for (const [handlerIndex, handler] of handlers.entries()) {
|
|
135
|
+
const argv = handler?.[materializer.handlerArgvField]
|
|
136
|
+
if (!Array.isArray(argv) || !argv.every((token) => typeof token === 'string')) malformed.push(`${event}:${groupIndex}:${handlerIndex}:argv`)
|
|
137
|
+
else invocations.push({ event, groupIndex, transport: 'structured-argv', value: argv })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { invocations, malformed }
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`unsupported hook materializer engine:${materializer.engine}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function loadProviderHookGroupLaunchBuilder(repoRoot) {
|
|
147
|
+
const modulePath = resolveWithin(
|
|
148
|
+
repoRoot,
|
|
149
|
+
PROVIDER_HOOK_OUTPUT_TRANSPORT,
|
|
150
|
+
'provider hook output transport',
|
|
151
|
+
)
|
|
152
|
+
await assertRegularFileWithin(repoRoot, modulePath, 'provider hook output transport')
|
|
153
|
+
const moduleBody = await readFile(modulePath, 'utf8')
|
|
154
|
+
const moduleUrl = `${pathToFileURL(modulePath).href}?governance-hook-launch=${digest(moduleBody).slice(7)}`
|
|
155
|
+
const loaded = await import(moduleUrl)
|
|
156
|
+
if (typeof loaded.buildProviderHookGroupLaunchArgv !== 'function') {
|
|
157
|
+
throw new Error('provider hook output transport has no event-group launch builder')
|
|
158
|
+
}
|
|
159
|
+
return loaded.buildProviderHookGroupLaunchArgv
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function expectedProviderHookInvocation(invocation, provider, buildProviderHookGroupLaunchArgv) {
|
|
163
|
+
const argv = buildProviderHookGroupLaunchArgv({
|
|
164
|
+
provider,
|
|
165
|
+
nativeEvent: invocation.event,
|
|
166
|
+
groupIndex: invocation.groupIndex,
|
|
167
|
+
})
|
|
168
|
+
return {
|
|
169
|
+
event: invocation.event,
|
|
170
|
+
groupIndex: invocation.groupIndex,
|
|
171
|
+
transport: invocation.transport,
|
|
172
|
+
value: invocation.transport === 'command-string' ? argv.join(' ') : argv,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isExactProviderHookInvocation(invocation, expected) {
|
|
177
|
+
if (invocation.transport !== expected.transport) return false
|
|
178
|
+
if (invocation.transport === 'command-string') return invocation.value === expected.value
|
|
179
|
+
if (invocation.transport === 'structured-argv') {
|
|
180
|
+
return invocation.value.length === expected.value.length
|
|
181
|
+
&& invocation.value.every((token, index) => token === expected.value[index])
|
|
182
|
+
}
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function expectedInstructionProjection({ repoRoot, provider, materializer }) {
|
|
187
|
+
if (materializer.engine === 'shared-instruction-v1') {
|
|
188
|
+
if (provider.instructionEntry !== provider.sharedInstructionEntry || provider.adapter.repositoryInstructionSource !== null) {
|
|
189
|
+
throw new Error('shared-instruction-v1 requires one shared entry and forbids a supplement')
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
if (materializer.engine !== 'markdown-relative-at-import-v1') throw new Error(`unsupported instruction materializer engine:${materializer.engine}`)
|
|
194
|
+
if (provider.instructionEntry === provider.sharedInstructionEntry) throw new Error('relative import requires a distinct instruction entry')
|
|
195
|
+
const importPath = relative(dirname(provider.instructionEntry), provider.sharedInstructionEntry).replaceAll('\\', '/')
|
|
196
|
+
if (!importPath || importPath.includes('\0') || importPath.includes('\n') || importPath.includes('\r')) throw new Error('relative instruction import path is unsafe')
|
|
197
|
+
let supplement = ''
|
|
198
|
+
if (provider.adapter.repositoryInstructionSource !== null) {
|
|
199
|
+
const source = resolveWithin(repoRoot, provider.adapter.repositoryInstructionSource, `${provider.id} repositoryInstructionSource`)
|
|
200
|
+
await assertRegularFileWithin(repoRoot, source, `${provider.id} repositoryInstructionSource`)
|
|
201
|
+
supplement = await readFile(source, 'utf8')
|
|
202
|
+
if (/^\s*@[A-Za-z0-9_./@-]+\.md\s*$/mu.test(supplement)) throw new Error('instruction supplement embeds an import directive')
|
|
203
|
+
} else if (materializer.supplementPolicy === 'required') {
|
|
204
|
+
throw new Error('instruction materializer requires a supplement')
|
|
205
|
+
}
|
|
206
|
+
return `@${importPath}\n${supplement ? `\n${supplement}` : ''}`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function validateProviderWiring({ repoRoot, role, providers, providerRegistry, sourcePaths, diagnostics }) {
|
|
210
|
+
if (!role) return
|
|
211
|
+
for (const providerId of role.requiredProviders || []) {
|
|
212
|
+
const provider = providers.find((item) => item.id === providerId)
|
|
213
|
+
if (!provider) continue
|
|
214
|
+
const authorityPointers = [
|
|
215
|
+
['sharedInstructionEntry', provider.sharedInstructionEntry],
|
|
216
|
+
['repositoryInstructionSource', provider.adapter?.repositoryInstructionSource],
|
|
217
|
+
['hookEntrypoint', provider.hookEntrypoint],
|
|
218
|
+
]
|
|
219
|
+
for (const [field, authority] of authorityPointers) {
|
|
220
|
+
if (authority && !sourcePaths.has(authority)) {
|
|
221
|
+
diagnostics.push(contractRule('provider-path-unbound', `${provider.id}:${field}`, `Provider ${field} authority must be a digest-locked canonical source.`, 'critical', 'manifest.sources entry', authority))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const instructionMaterializer = resolveRegisteredMaterializer(providerRegistry, 'instructionViews', provider.adapter?.instructionView?.materializerId)
|
|
227
|
+
const instruction = resolveWithin(repoRoot, provider.instructionEntry, `${provider.id} instructionEntry`)
|
|
228
|
+
const shared = resolveWithin(repoRoot, provider.sharedInstructionEntry, `${provider.id} sharedInstructionEntry`)
|
|
229
|
+
await assertRegularFileWithin(repoRoot, instruction, `${provider.id} instructionEntry`)
|
|
230
|
+
await assertRegularFileWithin(repoRoot, shared, `${provider.id} sharedInstructionEntry`)
|
|
231
|
+
const expected = await expectedInstructionProjection({ repoRoot, provider, materializer: instructionMaterializer })
|
|
232
|
+
if (expected !== null) {
|
|
233
|
+
const actual = await readFile(instruction, 'utf8')
|
|
234
|
+
if (actual !== expected) diagnostics.push(contractRule('provider-instruction-projection-drift', provider.instructionEntry, `${provider.id} instruction view differs from its registered materializer projection.`, 'critical', expected, actual))
|
|
235
|
+
}
|
|
236
|
+
} catch (error) {
|
|
237
|
+
diagnostics.push(contractRule('provider-instruction-invalid', provider.instructionEntry || provider.id, `Provider instruction wiring is invalid: ${error.message}`))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const skillDirectory = resolveWithin(repoRoot, provider.skillDirectory, `${provider.id} skillDirectory`)
|
|
242
|
+
await assertNoSymlinkWithin(repoRoot, skillDirectory, `${provider.id} skillDirectory`, { allowMissing: false })
|
|
243
|
+
const info = await lstat(skillDirectory)
|
|
244
|
+
if (!info.isDirectory()) throw new Error('skillDirectory must be a regular directory')
|
|
245
|
+
} catch (error) {
|
|
246
|
+
diagnostics.push(contractRule('provider-skill-directory-invalid', provider.skillDirectory || provider.id, `Provider skill discovery directory is invalid: ${error.message}`))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!provider.capabilities?.nativeHooks) {
|
|
250
|
+
if (provider.hookConfig !== null || provider.hookConfigFormat !== null || provider.hookEntrypoint !== null) {
|
|
251
|
+
diagnostics.push(contractRule('provider-hook-capability-mismatch', provider.id, 'A provider without native hooks must not declare native hook wiring.'))
|
|
252
|
+
}
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
if (!provider.hookConfig || provider.hookConfigFormat !== 'json' || !provider.hookEntrypoint) {
|
|
256
|
+
diagnostics.push(contractRule('provider-hook-wiring-missing', provider.id, 'A native-hook provider requires a JSON hook config and shared hook entrypoint.'))
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const hookConfig = resolveWithin(repoRoot, provider.hookConfig, `${provider.id} hookConfig`)
|
|
261
|
+
const hookEntrypoint = resolveWithin(repoRoot, provider.hookEntrypoint, `${provider.id} hookEntrypoint`)
|
|
262
|
+
await assertRegularFileWithin(repoRoot, hookConfig, `${provider.id} hookConfig`)
|
|
263
|
+
await assertRegularFileWithin(repoRoot, hookEntrypoint, `${provider.id} hookEntrypoint`)
|
|
264
|
+
const entrypointInfo = await lstat(hookEntrypoint)
|
|
265
|
+
if ((entrypointInfo.mode & 0o111) === 0) throw new Error('shared hook entrypoint is not executable')
|
|
266
|
+
const materializer = resolveRegisteredMaterializer(providerRegistry, 'hookViews', provider.adapter.hookView.materializerId)
|
|
267
|
+
const config = JSON.parse(await readFile(hookConfig, 'utf8'))
|
|
268
|
+
const { invocations, malformed } = providerHookInvocations(config, materializer)
|
|
269
|
+
const buildProviderHookGroupLaunchArgv = await loadProviderHookGroupLaunchBuilder(repoRoot)
|
|
270
|
+
const expectedInvocations = invocations.map((invocation) => expectedProviderHookInvocation(
|
|
271
|
+
invocation,
|
|
272
|
+
provider,
|
|
273
|
+
buildProviderHookGroupLaunchArgv,
|
|
274
|
+
))
|
|
275
|
+
const wired = invocations.length > 0
|
|
276
|
+
&& malformed.length === 0
|
|
277
|
+
&& invocations.every((invocation, index) => isExactProviderHookInvocation(invocation, expectedInvocations[index]))
|
|
278
|
+
if (!wired) {
|
|
279
|
+
diagnostics.push(contractRule(
|
|
280
|
+
'provider-hook-entrypoint-missing',
|
|
281
|
+
provider.hookConfig,
|
|
282
|
+
`${provider.id} hook config does not invoke the shared provider-normalized entrypoint through the exact transport declared by its materializer.`,
|
|
283
|
+
'critical',
|
|
284
|
+
expectedInvocations,
|
|
285
|
+
{ invocations, malformed },
|
|
286
|
+
))
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
diagnostics.push(contractRule('provider-hook-config-invalid', provider.hookConfig, `Provider hook wiring is invalid: ${error.message}`))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function schemaPointerDigest(registry, label, diagnostics) {
|
|
295
|
+
const pointer = registry.body?.$schema
|
|
296
|
+
if (!pointer || !registry.path) {
|
|
297
|
+
diagnostics.push(contractRule('schema-pointer-missing', registry.path || '', 'Registry has no $schema pointer.'))
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const schemaPath = resolve(dirname(registry.path), relativePath(pointer, `${label} schema`))
|
|
302
|
+
await assertRegularFileWithin(dirname(registry.path), schemaPath, `${label} schema`)
|
|
303
|
+
const schema = await readJson(schemaPath)
|
|
304
|
+
return digest(stableJson(schema))
|
|
305
|
+
} catch (error) {
|
|
306
|
+
diagnostics.push(contractRule('schema-read-error', pointer, `Cannot read ${label} schema: ${error.message}`))
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function validateDocumentSchema(body, documentPath, label, diagnostics) {
|
|
312
|
+
const pointer = body?.$schema
|
|
313
|
+
if (!pointer) return
|
|
314
|
+
try {
|
|
315
|
+
const schemaPath = resolve(dirname(documentPath), relativePath(pointer, `${label} schema`))
|
|
316
|
+
await assertRegularFileWithin(dirname(documentPath), schemaPath, `${label} schema`)
|
|
317
|
+
const schema = await readJson(schemaPath)
|
|
318
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true })
|
|
319
|
+
addFormats(ajv)
|
|
320
|
+
const validate = ajv.compile(schema)
|
|
321
|
+
if (!validate(body)) {
|
|
322
|
+
diagnostics.push(contractRule(
|
|
323
|
+
'schema-validation-failed',
|
|
324
|
+
documentPath,
|
|
325
|
+
`${label} violates its JSON Schema: ${ajv.errorsText(validate.errors, { separator: '; ' })}`,
|
|
326
|
+
'critical',
|
|
327
|
+
'schema-valid',
|
|
328
|
+
validate.errors,
|
|
329
|
+
))
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
diagnostics.push(contractRule('schema-validation-error', documentPath, `Cannot validate ${label} against its JSON Schema: ${error.message}`))
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function artifactSchemaDigests(manifestDirectory, manifest, diagnostics) {
|
|
337
|
+
const records = {}
|
|
338
|
+
const configured = manifest.artifactSchemas || {}
|
|
339
|
+
for (const name of ['diagnostic', 'lock', 'attestation', 'upgradePlan']) {
|
|
340
|
+
if (!configured[name]) diagnostics.push(contractRule('artifact-schema-missing', `artifactSchemas.${name}`, `Manifest must register the ${name} artifact schema.`))
|
|
341
|
+
}
|
|
342
|
+
for (const [name, pointer] of Object.entries(configured)) {
|
|
343
|
+
try {
|
|
344
|
+
const path = resolve(manifestDirectory, relativePath(pointer, `artifactSchemas.${name}`))
|
|
345
|
+
await assertRegularFileWithin(manifestDirectory, path, `artifactSchemas.${name}`)
|
|
346
|
+
const schema = await readJson(path)
|
|
347
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: false })
|
|
348
|
+
addFormats(ajv)
|
|
349
|
+
ajv.compile(schema)
|
|
350
|
+
records[name] = digest(stableJson(schema))
|
|
351
|
+
} catch (error) {
|
|
352
|
+
diagnostics.push(contractRule('schema-read-error', pointer || name, `Cannot read or compile ${name} artifact schema: ${error.message}`))
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return canonicalize(records)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function assertArtifactSchema(contract, name, value) {
|
|
359
|
+
const manifestDirectory = dirname(contract.manifestPath)
|
|
360
|
+
const pointer = contract.manifest?.artifactSchemas?.[name]
|
|
361
|
+
if (typeof pointer !== 'string' || pointer.length === 0) throw new Error(`Artifact schema is not registered: ${name}`)
|
|
362
|
+
const path = resolve(manifestDirectory, relativePath(pointer, `artifactSchemas.${name}`))
|
|
363
|
+
await assertRegularFileWithin(manifestDirectory, path, `artifactSchemas.${name}`)
|
|
364
|
+
const schema = await readJson(path)
|
|
365
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: false })
|
|
366
|
+
addFormats(ajv)
|
|
367
|
+
const validate = ajv.compile(schema)
|
|
368
|
+
if (!validate(value)) {
|
|
369
|
+
throw new Error(`${name} artifact violates its JSON Schema: ${ajv.errorsText(validate.errors, { separator: '; ' })}`)
|
|
370
|
+
}
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function resolveRoleProviderSelection(role, providers) {
|
|
375
|
+
if (!role) return role
|
|
376
|
+
const requiredProviders = role.providerSelection === 'all-registered'
|
|
377
|
+
? providers.map((provider) => provider.id).sort()
|
|
378
|
+
: []
|
|
379
|
+
return { ...role, requiredProviders }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function resolveRuleProviderCoverage(rule, providers) {
|
|
383
|
+
const classification = rule.providerCoverage?.['*']
|
|
384
|
+
const providerCoverage = classification
|
|
385
|
+
? Object.fromEntries(providers.map((provider) => [provider.id, classification]).sort(([left], [right]) => compareUtf8Bytes(left, right)))
|
|
386
|
+
: {}
|
|
387
|
+
return { ...rule, providerCoverage }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function resolveSkillParityTargets(item, providerRegistry) {
|
|
391
|
+
const providers = Array.isArray(providerRegistry?.providers) ? providerRegistry.providers : []
|
|
392
|
+
const targets = item.targetSelection === 'all-generated-skill-views'
|
|
393
|
+
? providers
|
|
394
|
+
.filter((provider) => provider.adapter?.generate && provider.adapter?.skillView)
|
|
395
|
+
.map((provider) => {
|
|
396
|
+
const materializer = providerRegistry.canonical?.materializers?.skillViews?.[provider.adapter.skillView.materializerId]
|
|
397
|
+
return {
|
|
398
|
+
provider: provider.id,
|
|
399
|
+
targetDirectory: provider.adapter.skillView.directory,
|
|
400
|
+
entryFile: materializer?.entryFile || null,
|
|
401
|
+
requiredTargetExtras: [...new Set((provider.adapter.skillAliases || []).map((alias) => alias.name))].sort(),
|
|
402
|
+
allowUnexpectedTargetExtras: false,
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
.sort((left, right) => compareUtf8Bytes(left.provider, right.provider))
|
|
406
|
+
: []
|
|
407
|
+
return { ...item, targets }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function inspectContract({
|
|
411
|
+
repoRoot = process.cwd(),
|
|
412
|
+
manifestPath = DEFAULT_MANIFEST,
|
|
413
|
+
role: roleOverride,
|
|
414
|
+
outputDirectory: outputOverride,
|
|
415
|
+
lockFile: lockOverride,
|
|
416
|
+
verifySources = true,
|
|
417
|
+
} = {}) {
|
|
418
|
+
const diagnostics = []
|
|
419
|
+
let manifest
|
|
420
|
+
const absoluteManifest = resolve(manifestPath)
|
|
421
|
+
try {
|
|
422
|
+
await assertRegularFileWithin(dirname(absoluteManifest), absoluteManifest, 'manifest')
|
|
423
|
+
manifest = await readJson(absoluteManifest)
|
|
424
|
+
} catch (error) {
|
|
425
|
+
diagnostics.push(contractRule('manifest-read-error', absoluteManifest, `Cannot read canonical manifest: ${error.message}`))
|
|
426
|
+
return { contract: null, diagnostics }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const manifestDirectory = dirname(absoluteManifest)
|
|
430
|
+
const registryEntries = {}
|
|
431
|
+
await validateDocumentSchema(manifest, absoluteManifest, 'manifest', diagnostics)
|
|
432
|
+
const schemaDigests = {}
|
|
433
|
+
schemaDigests.manifest = await schemaPointerDigest({ body: manifest, path: absoluteManifest }, 'manifest', diagnostics)
|
|
434
|
+
for (const name of ['rules', 'roles', 'providers', 'gates']) {
|
|
435
|
+
registryEntries[name] = await readRegistry(manifestDirectory, manifest.registries?.[name], name, diagnostics)
|
|
436
|
+
if (registryEntries[name].path) await validateDocumentSchema(registryEntries[name].body, registryEntries[name].path, `${name} registry`, diagnostics)
|
|
437
|
+
schemaDigests[name] = await schemaPointerDigest(registryEntries[name], name, diagnostics)
|
|
438
|
+
}
|
|
439
|
+
schemaDigests.artifacts = await artifactSchemaDigests(manifestDirectory, manifest, diagnostics)
|
|
440
|
+
|
|
441
|
+
let outputDirectory
|
|
442
|
+
let lockFile
|
|
443
|
+
try {
|
|
444
|
+
outputDirectory = relativePath(outputOverride || manifest.outputDirectory, 'outputDirectory')
|
|
445
|
+
lockFile = relativePath(lockOverride || manifest.lockFile, 'lockFile')
|
|
446
|
+
if (lockFile === outputDirectory || lockFile.startsWith(`${outputDirectory}/`)) {
|
|
447
|
+
diagnostics.push(contractRule('unsafe-layout', lockFile, 'lockFile must be outside outputDirectory so the lock is not self-referential.'))
|
|
448
|
+
}
|
|
449
|
+
await assertNoSymlinkWithin(repoRoot, resolveWithin(repoRoot, outputDirectory, 'outputDirectory'), 'outputDirectory')
|
|
450
|
+
await assertNoSymlinkWithin(repoRoot, resolveWithin(repoRoot, lockFile, 'lockFile'), 'lockFile')
|
|
451
|
+
} catch (error) {
|
|
452
|
+
diagnostics.push(contractRule('unsafe-path', '', error.message))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const rawRules = Array.isArray(registryEntries.rules.body?.rules) ? registryEntries.rules.body.rules : []
|
|
456
|
+
const rawRoles = Array.isArray(registryEntries.roles.body?.roles) ? registryEntries.roles.body.roles : []
|
|
457
|
+
const providers = Array.isArray(registryEntries.providers.body?.providers) ? registryEntries.providers.body.providers : []
|
|
458
|
+
const gates = Array.isArray(registryEntries.gates.body?.gates) ? registryEntries.gates.body.gates : []
|
|
459
|
+
const sources = Array.isArray(manifest.sources) ? manifest.sources : []
|
|
460
|
+
const rawSkillParityContracts = Array.isArray(manifest.skillParityContracts) ? manifest.skillParityContracts : []
|
|
461
|
+
const rules = rawRules.map((rule) => resolveRuleProviderCoverage(rule, providers))
|
|
462
|
+
const roles = rawRoles.map((role) => resolveRoleProviderSelection(role, providers))
|
|
463
|
+
const skillParityContracts = rawSkillParityContracts.map((item) => resolveSkillParityTargets(item, registryEntries.providers.body))
|
|
464
|
+
|
|
465
|
+
validateUnique(rules, 'ruleId', 'rule', diagnostics)
|
|
466
|
+
validateUnique(roles, 'id', 'role', diagnostics)
|
|
467
|
+
validateUnique(providers, 'id', 'provider', diagnostics)
|
|
468
|
+
validateUnique(gates, 'id', 'gate', diagnostics)
|
|
469
|
+
validateUnique(sources, 'id', 'source', diagnostics)
|
|
470
|
+
validateUnique(sources, 'path', 'source-path', diagnostics)
|
|
471
|
+
validateUnique(skillParityContracts, 'id', 'skill-parity-contract', diagnostics)
|
|
472
|
+
for (const source of sources) {
|
|
473
|
+
try { sourceRelativeExcludes(source) } catch (error) {
|
|
474
|
+
diagnostics.push(contractRule('canonical-source-exclusion', source.path || source.id, error.message))
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
let carrierProjectionBindings = new Map()
|
|
478
|
+
try {
|
|
479
|
+
carrierProjectionBindings = governanceCarrierProjectionMap(sources)
|
|
480
|
+
if (manifest.id === 'qijenchen-provider-neutral-governance') {
|
|
481
|
+
assertCanonicalGovernanceCarrierProjectionMap(carrierProjectionBindings)
|
|
482
|
+
}
|
|
483
|
+
} catch (error) {
|
|
484
|
+
diagnostics.push(contractRule('canonical-source-carrier-projection', 'manifest.sources', error.message))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const roleId = roleOverride || manifest.defaultRole
|
|
488
|
+
const role = roles.find((item) => item.id === roleId)
|
|
489
|
+
if (!role) diagnostics.push(contractRule('unknown-role', roleId || '', `Role is not registered: ${roleId || '<empty>'}`))
|
|
490
|
+
|
|
491
|
+
const providerIds = new Set(providers.map((item) => item.id))
|
|
492
|
+
const gateIds = new Set(gates.map((item) => item.id))
|
|
493
|
+
const sourceIds = new Set(sources.map((item) => item.id))
|
|
494
|
+
if (role) {
|
|
495
|
+
for (const id of role.requiredProviders || []) if (!providerIds.has(id)) diagnostics.push(contractRule('unknown-provider', id, `Role ${role.id} requires an unknown provider.`))
|
|
496
|
+
for (const id of role.requiredGateIds || []) if (!gateIds.has(id)) diagnostics.push(contractRule('unknown-gate', id, `Role ${role.id} requires an unknown gate.`))
|
|
497
|
+
for (const id of role.requiredSourceIds || []) if (!sourceIds.has(id)) diagnostics.push(contractRule('unknown-source', id, `Role ${role.id} requires an unknown canonical source.`))
|
|
498
|
+
}
|
|
499
|
+
const sourcePaths = new Set(sources.map((item) => item.path))
|
|
500
|
+
const productSharedInstructionSource = registryEntries.providers.body?.canonical?.productSharedInstructionSource
|
|
501
|
+
if (productSharedInstructionSource && !sourcePaths.has(productSharedInstructionSource)) {
|
|
502
|
+
diagnostics.push(contractRule('provider-path-unbound', 'canonical:productSharedInstructionSource', 'Common product instruction authority must be a digest-locked canonical source.', 'critical', 'manifest.sources entry', productSharedInstructionSource))
|
|
503
|
+
}
|
|
504
|
+
for (const item of skillParityContracts) {
|
|
505
|
+
for (const target of item.targets || []) {
|
|
506
|
+
if (!providerIds.has(target.provider)) diagnostics.push(contractRule('unknown-provider', `${item.id}:${target.provider}`, 'Skill parity target provider is not registered.'))
|
|
507
|
+
}
|
|
508
|
+
if (item.projection?.module && !sourcePaths.has(item.projection.module)) diagnostics.push(contractRule('projection-source-unbound', item.projection.module, 'Skill projection module must also be a canonical source so its digest is locked.'))
|
|
509
|
+
}
|
|
510
|
+
await validateProviderWiring({ repoRoot, role, providers, providerRegistry: registryEntries.providers.body, sourcePaths, diagnostics })
|
|
511
|
+
|
|
512
|
+
for (const gate of gates) {
|
|
513
|
+
if (gate.hard && !gate.hookIndependent) diagnostics.push(contractRule('hook-dependent-hard-gate', gate.id, 'A hard gate must remain effective with native hooks disabled.'))
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
for (const rule of rules) {
|
|
517
|
+
const applies = rule.appliesToRoles?.includes('*') || rule.appliesToRoles?.includes(roleId)
|
|
518
|
+
if (!applies || !role) continue
|
|
519
|
+
for (const providerId of role.requiredProviders || []) {
|
|
520
|
+
const coverage = rule.providerCoverage?.[providerId]
|
|
521
|
+
if (!coverage) diagnostics.push(contractRule('provider-coverage-missing', `${rule.ruleId}:${providerId}`, 'Applicable rule has no provider coverage classification.'))
|
|
522
|
+
if (rule.severity === 'critical' && coverage === 'unsupported') diagnostics.push(contractRule('critical-provider-unsupported', `${rule.ruleId}:${providerId}`, 'Critical rule cannot be unsupported on a required provider.'))
|
|
523
|
+
}
|
|
524
|
+
for (const gateId of rule.gateIds || []) if (!gateIds.has(gateId)) diagnostics.push(contractRule('rule-gate-missing', `${rule.ruleId}:${gateId}`, 'Rule references an unknown hard gate.'))
|
|
525
|
+
const sourceFile = String(rule.source || '').split('#')[0]
|
|
526
|
+
if (sourceFile && verifySources && !(await exists(resolveWithin(repoRoot, sourceFile, `rule ${rule.ruleId} source`)))) diagnostics.push(contractRule('rule-source-missing', sourceFile, `Rule ${rule.ruleId} source pointer does not exist.`))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (verifySources && role) {
|
|
530
|
+
const required = new Set(role.requiredSourceIds || [])
|
|
531
|
+
for (const source of sources) {
|
|
532
|
+
if (!(source.required || required.has(source.id))) continue
|
|
533
|
+
try {
|
|
534
|
+
const absolute = resolveWithin(repoRoot, source.path, `source ${source.id}`)
|
|
535
|
+
await assertNoSymlinkWithin(repoRoot, absolute, `source ${source.id}`)
|
|
536
|
+
if (!(await exists(absolute))) diagnostics.push(contractRule('canonical-source-missing', source.path, `Required canonical source is missing: ${source.id}`))
|
|
537
|
+
else if ((source.excludes || []).length && !(await lstat(absolute)).isDirectory()) {
|
|
538
|
+
diagnostics.push(contractRule('canonical-source-exclusion', source.path, `Source exclusions require a directory source: ${source.id}`))
|
|
539
|
+
}
|
|
540
|
+
} catch (error) {
|
|
541
|
+
diagnostics.push(contractRule('canonical-source-path', source.path || source.id, error.message))
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const contractPayload = {
|
|
547
|
+
manifest: canonicalize(manifest),
|
|
548
|
+
registries: canonicalize(Object.fromEntries(Object.entries(registryEntries).map(([name, entry]) => [name, entry.body]))),
|
|
549
|
+
role: roleId,
|
|
550
|
+
outputDirectory,
|
|
551
|
+
lockFile,
|
|
552
|
+
schemaDigests,
|
|
553
|
+
}
|
|
554
|
+
const contract = {
|
|
555
|
+
repoRoot: resolve(repoRoot),
|
|
556
|
+
manifestPath: absoluteManifest,
|
|
557
|
+
manifest,
|
|
558
|
+
rules,
|
|
559
|
+
roles,
|
|
560
|
+
providers,
|
|
561
|
+
gates,
|
|
562
|
+
sources,
|
|
563
|
+
carrierProjectionBindings,
|
|
564
|
+
skillParityContracts,
|
|
565
|
+
role,
|
|
566
|
+
roleId,
|
|
567
|
+
outputDirectory,
|
|
568
|
+
lockFile,
|
|
569
|
+
registryDigests: canonicalize(Object.fromEntries(Object.entries(registryEntries).map(([name, entry]) => [name, entry.hash]))),
|
|
570
|
+
schemaDigests: canonicalize(schemaDigests),
|
|
571
|
+
contractDigest: digest(contractPayload),
|
|
572
|
+
}
|
|
573
|
+
return { contract, diagnostics }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function sourceRecords(contract) {
|
|
577
|
+
const records = []
|
|
578
|
+
const bindings = contract.carrierProjectionBindings instanceof Map
|
|
579
|
+
? contract.carrierProjectionBindings
|
|
580
|
+
: governanceCarrierProjectionMap(contract.sources)
|
|
581
|
+
for (const source of contract.sources) {
|
|
582
|
+
const absolute = resolveWithin(contract.repoRoot, source.path, `source ${source.id}`)
|
|
583
|
+
if (!(await exists(absolute))) continue
|
|
584
|
+
await assertNoSymlinkWithin(contract.repoRoot, absolute, `source ${source.id}`, { allowMissing: false })
|
|
585
|
+
const info = await lstat(absolute)
|
|
586
|
+
if (info.isDirectory()) {
|
|
587
|
+
const excludes = sourceRelativeExcludes(source)
|
|
588
|
+
const sourcePath = source.path.replace(/\/+$/, '')
|
|
589
|
+
const carrierProjections = governanceCarrierProjectionsForSource(source, bindings)
|
|
590
|
+
const entries = await inventoryTree(absolute, {
|
|
591
|
+
excludes,
|
|
592
|
+
projectFile: ({ path, bytes }) => {
|
|
593
|
+
const repositoryPath = `${sourcePath}/${path}`
|
|
594
|
+
const binding = bindings.get(repositoryPath)
|
|
595
|
+
return binding
|
|
596
|
+
? projectGovernanceCarrierBytes({
|
|
597
|
+
path: repositoryPath,
|
|
598
|
+
projectionId: binding.projection,
|
|
599
|
+
bytes,
|
|
600
|
+
})
|
|
601
|
+
: bytes
|
|
602
|
+
},
|
|
603
|
+
})
|
|
604
|
+
records.push({
|
|
605
|
+
id: source.id,
|
|
606
|
+
ownerRepo: source.ownerRepo,
|
|
607
|
+
path: source.path,
|
|
608
|
+
excludes: [...(source.excludes || [])],
|
|
609
|
+
type: 'directory',
|
|
610
|
+
mode: modeString(info.mode),
|
|
611
|
+
hash: treeDigest(entries),
|
|
612
|
+
files: entries.length,
|
|
613
|
+
carrierProjections,
|
|
614
|
+
})
|
|
615
|
+
} else if (info.isFile()) {
|
|
616
|
+
const body = await readFile(absolute)
|
|
617
|
+
const binding = bindings.get(source.path)
|
|
618
|
+
const projected = binding
|
|
619
|
+
? projectGovernanceCarrierBytes({
|
|
620
|
+
path: source.path,
|
|
621
|
+
projectionId: binding.projection,
|
|
622
|
+
bytes: body,
|
|
623
|
+
})
|
|
624
|
+
: body
|
|
625
|
+
records.push({
|
|
626
|
+
id: source.id,
|
|
627
|
+
ownerRepo: source.ownerRepo,
|
|
628
|
+
path: source.path,
|
|
629
|
+
type: 'file',
|
|
630
|
+
mode: modeString(info.mode),
|
|
631
|
+
hash: digest(projected),
|
|
632
|
+
size: projected.length,
|
|
633
|
+
...(binding ? { carrierProjection: binding.projection } : {}),
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return records.sort((a, b) => compareUtf8Bytes(a.id, b.id))
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function skillNames(directory, entryFile = 'SKILL.md') {
|
|
641
|
+
if (!(await exists(directory))) return []
|
|
642
|
+
if (typeof entryFile !== 'string' || !entryFile || entryFile.includes('/') || entryFile.includes('\\')) return []
|
|
643
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
644
|
+
const names = []
|
|
645
|
+
for (const entry of entries) {
|
|
646
|
+
if (entry.isDirectory() && await exists(resolve(directory, entry.name, entryFile))) names.push(entry.name)
|
|
647
|
+
}
|
|
648
|
+
return names.sort()
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function loadSkillProjection(contract, item, diagnostics) {
|
|
652
|
+
if (!item.projection) return null
|
|
653
|
+
let modulePath
|
|
654
|
+
try {
|
|
655
|
+
modulePath = resolveWithin(contract.repoRoot, item.projection.module, `skill parity ${item.id} projection.module`)
|
|
656
|
+
await assertRegularFileWithin(contract.repoRoot, modulePath, `skill parity ${item.id} projection.module`)
|
|
657
|
+
const moduleBody = await readFile(modulePath)
|
|
658
|
+
const moduleUrl = `${pathToFileURL(modulePath).href}?governance=${digest(moduleBody).slice(7)}`
|
|
659
|
+
const namespace = await import(moduleUrl)
|
|
660
|
+
const projector = namespace[item.projection.export]
|
|
661
|
+
if (typeof projector !== 'function') throw new TypeError(`export ${item.projection.export} is not a function`)
|
|
662
|
+
return { projector, files: new Set(item.projection.files || []) }
|
|
663
|
+
} catch (error) {
|
|
664
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'critical', kind: 'skill-projection-invalid', path: item.projection.module || item.id, message: `Cannot load canonical skill projection: ${error.message}` }))
|
|
665
|
+
return false
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function projectedInventory(contract, item, target, skillName, sourceDirectory, entries, projection, diagnostics) {
|
|
670
|
+
const projected = []
|
|
671
|
+
for (const entry of entries) {
|
|
672
|
+
const targetPath = entry.path === 'SKILL.md' ? target.entryFile : entry.path
|
|
673
|
+
if (!projection || entry.type !== 'file' || !projection.files.has(entry.path)) {
|
|
674
|
+
projected.push({ ...entry, path: targetPath })
|
|
675
|
+
continue
|
|
676
|
+
}
|
|
677
|
+
const sourceRel = `${item.sourceDirectory}/${skillName}/${entry.path}`
|
|
678
|
+
try {
|
|
679
|
+
const content = await readFile(resolve(sourceDirectory, skillName, entry.path), 'utf8')
|
|
680
|
+
const output = await projection.projector(content, sourceRel, target.provider)
|
|
681
|
+
if (typeof output !== 'string' && !Buffer.isBuffer(output)) throw new TypeError('projection must return a string or Buffer')
|
|
682
|
+
const body = Buffer.isBuffer(output) ? output : Buffer.from(output)
|
|
683
|
+
projected.push({ ...entry, path: targetPath, hash: digest(body), size: body.length })
|
|
684
|
+
} catch (error) {
|
|
685
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'critical', kind: 'skill-projection-error', path: sourceRel, message: `Canonical skill projection failed: ${error.message}` }))
|
|
686
|
+
return null
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return projected
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export async function inspectSkillParity(contract) {
|
|
693
|
+
const diagnostics = []
|
|
694
|
+
const records = []
|
|
695
|
+
for (const item of contract.skillParityContracts) {
|
|
696
|
+
let sourceDirectory
|
|
697
|
+
try {
|
|
698
|
+
sourceDirectory = resolveWithin(contract.repoRoot, item.sourceDirectory, `skill parity ${item.id} sourceDirectory`)
|
|
699
|
+
await assertNoSymlinkWithin(contract.repoRoot, sourceDirectory, `skill parity ${item.id} sourceDirectory`)
|
|
700
|
+
} catch (error) {
|
|
701
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'critical', kind: 'skill-path-invalid', path: item.id, message: error.message }))
|
|
702
|
+
continue
|
|
703
|
+
}
|
|
704
|
+
if (item.required && !(await exists(sourceDirectory))) diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'critical', kind: 'skill-source-missing', path: item.sourceDirectory, message: 'Canonical skill directory is missing.' }))
|
|
705
|
+
const sourceNames = await skillNames(sourceDirectory, 'SKILL.md')
|
|
706
|
+
const projection = await loadSkillProjection(contract, item, diagnostics)
|
|
707
|
+
for (const target of item.targets || []) {
|
|
708
|
+
if (typeof target.entryFile !== 'string' || !target.entryFile || target.entryFile.includes('/') || target.entryFile.includes('\\')) {
|
|
709
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'critical', kind: 'skill-materializer-invalid', path: `${item.id}:${target.provider}`, message: 'Provider skill materializer must resolve one safe entry file.' }))
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
let targetDirectory
|
|
713
|
+
try {
|
|
714
|
+
targetDirectory = resolveWithin(contract.repoRoot, target.targetDirectory, `skill parity ${item.id}:${target.provider} targetDirectory`)
|
|
715
|
+
await assertNoSymlinkWithin(contract.repoRoot, targetDirectory, `skill parity ${item.id}:${target.provider} targetDirectory`)
|
|
716
|
+
} catch (error) {
|
|
717
|
+
diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'critical', kind: 'skill-path-invalid', path: `${item.id}:${target.provider}`, message: error.message }))
|
|
718
|
+
continue
|
|
719
|
+
}
|
|
720
|
+
if (item.required && !(await exists(targetDirectory))) diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'critical', kind: 'skill-target-missing', path: target.targetDirectory, message: 'Provider skill directory is missing.' }))
|
|
721
|
+
const targetNames = await skillNames(targetDirectory, target.entryFile)
|
|
722
|
+
const extras = [...(target.requiredTargetExtras || [])].sort()
|
|
723
|
+
const requiredTarget = unique([...sourceNames, ...extras]).sort()
|
|
724
|
+
const missing = requiredTarget.filter((name) => !targetNames.includes(name))
|
|
725
|
+
const unexpected = targetNames.filter((name) => !requiredTarget.includes(name))
|
|
726
|
+
for (const name of missing) diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'critical', kind: 'skill-missing', path: `${target.targetDirectory}/${name}`, expected: 'present', actual: 'missing', message: `Required ${target.provider} skill is missing.` }))
|
|
727
|
+
if (!target.allowUnexpectedTargetExtras) {
|
|
728
|
+
for (const name of unexpected) diagnostics.push(diagnostic({ ruleId: 'GOV-PROVIDER-001', severity: 'major', kind: 'skill-extra', path: `${target.targetDirectory}/${name}`, expected: null, actual: 'present', message: `Unexpected ${target.provider} skill is not classified by the parity contract.` }))
|
|
729
|
+
}
|
|
730
|
+
const sharedDigests = []
|
|
731
|
+
if (item.compare !== 'names') {
|
|
732
|
+
for (const name of sourceNames.filter((value) => targetNames.includes(value))) {
|
|
733
|
+
const source = await inventoryTree(resolve(sourceDirectory, name))
|
|
734
|
+
const expected = projection === false ? null : await projectedInventory(contract, item, target, name, sourceDirectory, source, projection, diagnostics)
|
|
735
|
+
const actual = await inventoryTree(resolve(targetDirectory, name))
|
|
736
|
+
if (!expected) continue
|
|
737
|
+
let differences = compareInventories(expected, actual, { prefix: `${target.targetDirectory}/${name}`, ruleId: 'GOV-PROVIDER-001' })
|
|
738
|
+
if (item.compare === 'content') differences = differences.filter((entry) => entry.evidence.kind !== 'mode-mismatch')
|
|
739
|
+
diagnostics.push(...differences)
|
|
740
|
+
sharedDigests.push({ name, sourceDigest: treeDigest(source), expectedTargetDigest: treeDigest(expected), targetDigest: treeDigest(actual) })
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
records.push({
|
|
744
|
+
id: `${item.id}:${target.provider}`,
|
|
745
|
+
sourceProvider: 'canonical',
|
|
746
|
+
targetProvider: target.provider,
|
|
747
|
+
sourceDirectory: item.sourceDirectory,
|
|
748
|
+
targetDirectory: target.targetDirectory,
|
|
749
|
+
compare: item.compare,
|
|
750
|
+
projection: item.projection || null,
|
|
751
|
+
sourceSkills: sourceNames,
|
|
752
|
+
targetSkills: targetNames,
|
|
753
|
+
requiredTargetExtras: extras,
|
|
754
|
+
targetEntryFile: target.entryFile,
|
|
755
|
+
sharedDigests,
|
|
756
|
+
})
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return { diagnostics, records }
|
|
760
|
+
}
|