@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.
Files changed (40) hide show
  1. package/README.md +89 -0
  2. package/bin/attest.mjs +4 -0
  3. package/bin/check.mjs +4 -0
  4. package/bin/doctor.mjs +4 -0
  5. package/bin/generate.mjs +4 -0
  6. package/bin/governance.mjs +4 -0
  7. package/bin/hook.mjs +4 -0
  8. package/bin/upgrade.mjs +4 -0
  9. package/canonical/gates.json +42 -0
  10. package/canonical/manifest.json +476 -0
  11. package/canonical/plugin-aliases.json +25 -0
  12. package/canonical/provider-lifecycle.json +48 -0
  13. package/canonical/providers.json +455 -0
  14. package/canonical/roles.json +12 -0
  15. package/canonical/rules.json +81 -0
  16. package/canonical/schemas/attestation.schema.json +61 -0
  17. package/canonical/schemas/diagnostic.schema.json +37 -0
  18. package/canonical/schemas/gates.schema.json +27 -0
  19. package/canonical/schemas/lock.schema.json +189 -0
  20. package/canonical/schemas/manifest.schema.json +103 -0
  21. package/canonical/schemas/plugin-aliases.schema.json +59 -0
  22. package/canonical/schemas/provider-hook-coverage.schema.json +173 -0
  23. package/canonical/schemas/provider-lifecycle.schema.json +126 -0
  24. package/canonical/schemas/providers.schema.json +917 -0
  25. package/canonical/schemas/roles.schema.json +27 -0
  26. package/canonical/schemas/rules.schema.json +46 -0
  27. package/canonical/schemas/upgrade-plan.schema.json +31 -0
  28. package/package.json +42 -0
  29. package/src/authority-decision-evidence.mjs +413 -0
  30. package/src/canonical-order.mjs +8 -0
  31. package/src/carrier-projection.mjs +407 -0
  32. package/src/cli.mjs +114 -0
  33. package/src/closed-tool-execution.mjs +1001 -0
  34. package/src/common.mjs +278 -0
  35. package/src/contract.mjs +760 -0
  36. package/src/hook-api.mjs +107 -0
  37. package/src/index.mjs +14 -0
  38. package/src/provider-hook-normalization.mjs +1646 -0
  39. package/src/provider-review-binding.mjs +2377 -0
  40. package/src/snapshot.mjs +520 -0
@@ -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
+ }