session-orchestrator 3.19.0 → 3.20.0

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -0,0 +1,1148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate the required-export contract of `armGuard()` head fallbacks.
4
+ *
5
+ * A head fallback can execute either the working-tree module or the committed
6
+ * HEAD copy. This check keeps the three surfaces in lockstep: the static
7
+ * `requires` array, the namespace members used by the hook, and callable named
8
+ * exports available in both module versions.
9
+ *
10
+ * Import-safety: importing this module only exposes the inspector and runner;
11
+ * the CLI path is guarded at the bottom of the file.
12
+ */
13
+
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+ import { parse } from '@babel/parser';
19
+
20
+ const HOOKS_RELATIVE_DIR = 'hooks';
21
+ const MODULE_RELATIVE_PREFIX = 'scripts/lib';
22
+ const MODULE_NAMESPACE_NAME = 'modules';
23
+ const GIT_ENV_ALLOWLIST = Object.freeze([
24
+ 'PATH',
25
+ 'HOME',
26
+ 'LANG',
27
+ 'LC_ALL',
28
+ 'TMPDIR',
29
+ 'TZ',
30
+ ]);
31
+
32
+ /**
33
+ * @typedef {{
34
+ * kind: string,
35
+ * hook: string,
36
+ * line: number,
37
+ * contract: string,
38
+ * message: string,
39
+ * }} Finding
40
+ */
41
+
42
+ /**
43
+ * @typedef {{
44
+ * hook: string,
45
+ * line: number,
46
+ * namespace: string,
47
+ * binding: string | null,
48
+ * specifier: string,
49
+ * requires: string[],
50
+ * uses: string[],
51
+ * }} GuardContract
52
+ */
53
+
54
+ /**
55
+ * Recursively collect hook modules in deterministic path order.
56
+ *
57
+ * Symlinked hook entries are returned separately and are never followed. A
58
+ * symlink is an operator-controlled path escape at exactly the boundary this
59
+ * validator is meant to census, so silently treating it as "not a hook" would
60
+ * make the check incomplete.
61
+ *
62
+ * @param {string} directory
63
+ * @returns {{files: string[], symlinks: string[]}}
64
+ */
65
+ function collectHookFiles(directory) {
66
+ if (!existsSync(directory)) return { files: [], symlinks: [] };
67
+ const entries = readdirSync(directory, { withFileTypes: true });
68
+ /** @type {string[]} */
69
+ const files = [];
70
+ /** @type {string[]} */
71
+ const symlinks = [];
72
+ for (const entry of entries) {
73
+ const fullPath = path.join(directory, entry.name);
74
+ if (entry.isSymbolicLink()) {
75
+ symlinks.push(fullPath);
76
+ } else if (entry.isDirectory()) {
77
+ const nested = collectHookFiles(fullPath);
78
+ files.push(...nested.files);
79
+ symlinks.push(...nested.symlinks);
80
+ } else if (entry.isFile() && path.extname(entry.name) === '.mjs') {
81
+ files.push(fullPath);
82
+ }
83
+ }
84
+ return { files: files.sort(), symlinks: symlinks.sort() };
85
+ }
86
+
87
+ /**
88
+ * Parse a source module with the same syntax family used by this repository.
89
+ *
90
+ * @param {string} source
91
+ * @param {string} filename
92
+ * @returns {import('@babel/parser').ParseResult<import('@babel/types').File>}
93
+ */
94
+ function parseModule(source, filename) {
95
+ return parse(source, {
96
+ sourceType: 'module',
97
+ sourceFilename: filename,
98
+ errorRecovery: false,
99
+ plugins: ['topLevelAwait', 'importMeta'],
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Walk Babel's AST without depending on a second AST package. Babel nodes have
105
+ * no parent links, and location/token metadata is deliberately not traversed.
106
+ *
107
+ * @param {unknown} value
108
+ * @param {(node: any, parent: any, key: string | number | null) => void} visit
109
+ * @param {any} [parent]
110
+ * @param {string | number | null} [key]
111
+ * @param {Set<object>} [seen]
112
+ */
113
+ function walk(value, visit, parent = null, key = null, seen = new Set()) {
114
+ if (!value || typeof value !== 'object') return;
115
+ if (seen.has(value)) return;
116
+ seen.add(value);
117
+ if (typeof value.type === 'string') visit(value, parent, key);
118
+
119
+ if (Array.isArray(value)) {
120
+ for (let i = 0; i < value.length; i += 1) walk(value[i], visit, parent, i, seen);
121
+ return;
122
+ }
123
+
124
+ for (const [childKey, child] of Object.entries(value)) {
125
+ if (childKey === 'loc' || childKey === 'start' || childKey === 'end' || childKey === 'extra') continue;
126
+ if (childKey === 'tokens' || childKey === 'comments' || childKey === 'errors') continue;
127
+ if (child && typeof child === 'object') walk(child, visit, value, childKey, seen);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Return a static property name, or null when the property is not a literal
133
+ * identifier/string. Computed properties are handled separately and rejected.
134
+ *
135
+ * @param {any} node
136
+ * @returns {string | null}
137
+ */
138
+ function staticName(node) {
139
+ if (!node) return null;
140
+ if (node.type === 'Identifier') return node.name;
141
+ if (node.type === 'StringLiteral') return node.value;
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * @param {any} node
147
+ * @returns {boolean}
148
+ */
149
+ function isGuardLoaderImport(node) {
150
+ const expression = node?.type === 'AwaitExpression' ? node.argument : node;
151
+ return Boolean(
152
+ expression?.type === 'CallExpression' &&
153
+ expression.callee?.type === 'Import' &&
154
+ expression.arguments.length === 1 &&
155
+ expression.arguments[0]?.type === 'StringLiteral' &&
156
+ expression.arguments[0].value === './_lib/guard-source-loader.mjs',
157
+ );
158
+ }
159
+
160
+ /**
161
+ * @param {any} property
162
+ * @returns {{key: string, local: any} | null}
163
+ */
164
+ function readImportProperty(property) {
165
+ if (!property || property.type !== 'ObjectProperty' || property.computed) return null;
166
+ const key = staticName(property.key);
167
+ if (key === null || property.value?.type !== 'Identifier') return null;
168
+ return { key, local: property.value };
169
+ }
170
+
171
+ /**
172
+ * Find the only callee provenance this validator trusts: an unaliased named
173
+ * `armGuard` binding imported from the guard source loader. Every other route is
174
+ * reported instead of being omitted from the contract census.
175
+ *
176
+ * @param {any} ast
177
+ * @returns {{validImportNodes: Set<object>, loaderImportNodes: Set<object>, armGuardLikeNames: Set<string>, foreignBindingNodes: Set<object>, invalidImportNames: Set<string>}}
178
+ */
179
+ function findArmGuardProvenance(ast) {
180
+ const validImportNodes = new Set();
181
+ const loaderImportNodes = new Set();
182
+ const invalidImportNames = new Set();
183
+ const loaderNames = new Set();
184
+ const armGuardMemberNames = new Set();
185
+ const aliasEdges = new Map();
186
+
187
+ walk(ast, (node) => {
188
+ if (node.type === 'ImportDeclaration' && node.source?.value === './_lib/guard-source-loader.mjs') {
189
+ for (const specifier of node.specifiers) {
190
+ if (specifier.type === 'ImportSpecifier') {
191
+ const imported = staticName(specifier.imported);
192
+ const local = specifier.local;
193
+ if (imported === 'armGuard' && local?.type === 'Identifier') {
194
+ loaderImportNodes.add(specifier);
195
+ if (local.name === 'armGuard') validImportNodes.add(local);
196
+ else invalidImportNames.add(local.name);
197
+ }
198
+ } else if (specifier.local?.type === 'Identifier') {
199
+ loaderNames.add(specifier.local.name);
200
+ }
201
+ }
202
+ }
203
+
204
+ if (node.type !== 'VariableDeclarator') return;
205
+ if (isGuardLoaderImport(node.init)) {
206
+ loaderImportNodes.add(node);
207
+ if (node.id?.type === 'Identifier') {
208
+ loaderNames.add(node.id.name);
209
+ } else if (node.id?.type === 'ObjectPattern') {
210
+ for (const property of node.id.properties) {
211
+ const imported = readImportProperty(property);
212
+ if (!imported) continue;
213
+ if (imported.key === 'armGuard') {
214
+ if (imported.local.name === 'armGuard') validImportNodes.add(imported.local);
215
+ else invalidImportNames.add(imported.local.name);
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ if (node.id?.type === 'Identifier' && node.init?.type === 'Identifier') {
222
+ aliasEdges.set(node.id.name, node.init.name);
223
+ }
224
+ if (
225
+ node.id?.type === 'Identifier' &&
226
+ isMemberExpression(node.init) &&
227
+ node.init.object?.type === 'Identifier' &&
228
+ staticName(node.init.property) === 'armGuard'
229
+ ) {
230
+ aliasEdges.set(node.id.name, node.init.object.name);
231
+ if (loaderNames.has(node.init.object.name)) armGuardMemberNames.add(node.id.name);
232
+ }
233
+
234
+ if (
235
+ node.type === 'AssignmentExpression' &&
236
+ node.left?.type === 'Identifier' &&
237
+ node.right?.type === 'Identifier'
238
+ ) {
239
+ aliasEdges.set(node.left.name, node.right.name);
240
+ }
241
+ if (
242
+ node.type === 'AssignmentExpression' &&
243
+ node.left?.type === 'Identifier' &&
244
+ isMemberExpression(node.right) &&
245
+ node.right.object?.type === 'Identifier' &&
246
+ staticName(node.right.property) === 'armGuard'
247
+ ) {
248
+ aliasEdges.set(node.left.name, node.right.object.name);
249
+ if (loaderNames.has(node.right.object.name)) armGuardMemberNames.add(node.left.name);
250
+ }
251
+ });
252
+
253
+ const armGuardLikeNames = new Set(['armGuard', ...invalidImportNames, ...armGuardMemberNames]);
254
+ for (const [local, source] of aliasEdges) {
255
+ if (source === 'armGuard' || armGuardLikeNames.has(source)) armGuardLikeNames.add(local);
256
+ }
257
+ let changed = true;
258
+ while (changed) {
259
+ changed = false;
260
+ for (const [local, source] of aliasEdges) {
261
+ if (!armGuardLikeNames.has(source) || armGuardLikeNames.has(local)) continue;
262
+ armGuardLikeNames.add(local);
263
+ changed = true;
264
+ }
265
+ }
266
+
267
+ const foreignBindingNodes = new Set();
268
+ const recordForeign = (binding) => {
269
+ if (binding?.type === 'Identifier' && binding.name === 'armGuard' && !validImportNodes.has(binding)) {
270
+ foreignBindingNodes.add(binding);
271
+ }
272
+ };
273
+ walk(ast, (node) => {
274
+ if (node.type === 'VariableDeclarator') {
275
+ const bindings = new Set();
276
+ collectBindingIdentifiers(node.id, bindings);
277
+ for (const binding of bindings) recordForeign(binding);
278
+ }
279
+ if (
280
+ node.type === 'FunctionDeclaration' ||
281
+ node.type === 'FunctionExpression' ||
282
+ node.type === 'ArrowFunctionExpression' ||
283
+ node.type === 'ObjectMethod' ||
284
+ node.type === 'ClassMethod' ||
285
+ node.type === 'ClassDeclaration' ||
286
+ node.type === 'ClassExpression'
287
+ ) {
288
+ recordForeign(node.id);
289
+ for (const parameter of node.params || []) {
290
+ const bindings = new Set();
291
+ collectBindingIdentifiers(parameter, bindings);
292
+ for (const binding of bindings) recordForeign(binding);
293
+ }
294
+ }
295
+ if (node.type === 'CatchClause') {
296
+ const bindings = new Set();
297
+ collectBindingIdentifiers(node.param, bindings);
298
+ for (const binding of bindings) recordForeign(binding);
299
+ }
300
+ if (
301
+ (node.type === 'ImportSpecifier' || node.type === 'ImportDefaultSpecifier' || node.type === 'ImportNamespaceSpecifier') &&
302
+ node.local?.type === 'Identifier'
303
+ ) {
304
+ recordForeign(node.local);
305
+ }
306
+ });
307
+
308
+ return { validImportNodes, loaderImportNodes, armGuardLikeNames, foreignBindingNodes, invalidImportNames };
309
+ }
310
+
311
+ /**
312
+ * @param {any} ast
313
+ * @returns {{validCalls: any[], invalidCalls: any[]}}
314
+ */
315
+ function findArmGuardCalls(ast) {
316
+ const provenance = findArmGuardProvenance(ast);
317
+ const validCalls = [];
318
+ const invalidCalls = [];
319
+ const seenInvalid = new Set();
320
+ const hasValidImport = provenance.validImportNodes.size > 0;
321
+ const validDirectCall = (node) =>
322
+ node?.type === 'CallExpression' &&
323
+ node.callee?.type === 'Identifier' &&
324
+ node.callee.name === 'armGuard' &&
325
+ hasValidImport &&
326
+ provenance.foreignBindingNodes.size === 0;
327
+ const reportInvalid = (node) => {
328
+ if (!seenInvalid.has(node)) {
329
+ seenInvalid.add(node);
330
+ invalidCalls.push(node);
331
+ }
332
+ };
333
+
334
+ walk(ast, (node, parent, key) => {
335
+ if (node.type === 'CallExpression') {
336
+ if (validDirectCall(node)) {
337
+ validCalls.push(node);
338
+ return;
339
+ }
340
+
341
+ const callee = node.callee;
342
+ if (
343
+ callee?.type === 'Identifier' &&
344
+ (callee.name === 'armGuard' || provenance.armGuardLikeNames.has(callee.name))
345
+ ) {
346
+ reportInvalid(node);
347
+ return;
348
+ }
349
+ if (
350
+ isMemberExpression(callee) &&
351
+ ((callee.object?.type === 'Identifier' && provenance.armGuardLikeNames.has(callee.object.name)) ||
352
+ staticName(callee.property) === 'armGuard')
353
+ ) {
354
+ reportInvalid(node);
355
+ }
356
+ }
357
+
358
+ if (node.type !== 'Identifier' || !provenance.armGuardLikeNames.has(node.name)) return;
359
+ if (provenance.validImportNodes.has(node)) return;
360
+ if (isBindingIdentifier(node, parent, key, provenance.validImportNodes)) return;
361
+
362
+ if (
363
+ (parent?.type === 'CallExpression' || parent?.type === 'OptionalCallExpression') &&
364
+ key === 'callee'
365
+ ) {
366
+ if (!validDirectCall(parent)) reportInvalid(parent);
367
+ return;
368
+ }
369
+ reportInvalid(node);
370
+ });
371
+
372
+ if (provenance.loaderImportNodes.size > 0 && validCalls.length === 0 && invalidCalls.length === 0) {
373
+ for (const importNode of provenance.loaderImportNodes) reportInvalid(importNode);
374
+ }
375
+
376
+ return { validCalls, invalidCalls };
377
+ }
378
+
379
+ /**
380
+ * @param {any} node
381
+ * @returns {number}
382
+ */
383
+ function lineOf(node) {
384
+ return node?.loc?.start?.line ?? 1;
385
+ }
386
+
387
+ /**
388
+ * @param {string} pluginRoot
389
+ * @param {string} hookPath
390
+ * @param {string} contract
391
+ * @param {string} kind
392
+ * @param {string} message
393
+ * @param {any} node
394
+ * @returns {Finding}
395
+ */
396
+ function finding(pluginRoot, hookPath, contract, kind, message, node) {
397
+ return {
398
+ kind,
399
+ hook: path.relative(pluginRoot, hookPath),
400
+ line: lineOf(node),
401
+ contract,
402
+ message,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * @param {any} expression
408
+ * @param {string} expectedProperty
409
+ * @returns {boolean}
410
+ */
411
+ function isDirectModulesMember(expression, expectedProperty) {
412
+ return Boolean(
413
+ expression &&
414
+ (expression.type === 'MemberExpression' || expression.type === 'OptionalMemberExpression') &&
415
+ !expression.computed &&
416
+ expression.object?.type === 'Identifier' &&
417
+ expression.object.name === MODULE_NAMESPACE_NAME &&
418
+ staticName(expression.property) === expectedProperty,
419
+ );
420
+ }
421
+
422
+ /**
423
+ * @param {any} node
424
+ * @returns {boolean}
425
+ */
426
+ function isMemberExpression(node) {
427
+ return node?.type === 'MemberExpression' || node?.type === 'OptionalMemberExpression';
428
+ }
429
+
430
+ /**
431
+ * Get a static object property's value while rejecting duplicate names and
432
+ * non-object property forms. The caller supplies the finding callback because
433
+ * location and contract context belong to the hook, not this helper.
434
+ *
435
+ * @param {any} objectNode
436
+ * @param {(kind: string, message: string, node: any) => void} report
437
+ * @returns {Map<string, any> | null}
438
+ */
439
+ function readStaticObject(objectNode, report) {
440
+ if (!objectNode || objectNode.type !== 'ObjectExpression') {
441
+ report('dynamic-contract', 'armGuard contract must be an inline object literal', objectNode);
442
+ return null;
443
+ }
444
+
445
+ const properties = new Map();
446
+ for (const property of objectNode.properties) {
447
+ if (property.type === 'SpreadElement') {
448
+ report('dynamic-contract', 'spread properties are unsupported in armGuard contracts', property);
449
+ continue;
450
+ }
451
+ if (property.type !== 'ObjectProperty' || property.computed) {
452
+ report('dynamic-contract', 'computed or indirect properties are unsupported in armGuard contracts', property);
453
+ continue;
454
+ }
455
+ const name = staticName(property.key);
456
+ if (name === null) {
457
+ report('dynamic-contract', 'armGuard contract property names must be literal identifiers or strings', property);
458
+ continue;
459
+ }
460
+ if (properties.has(name)) {
461
+ report('dynamic-contract', `duplicate armGuard contract property: ${name}`, property);
462
+ continue;
463
+ }
464
+ properties.set(name, property.value);
465
+ }
466
+ return properties;
467
+ }
468
+
469
+ /**
470
+ * @param {any} value
471
+ * @returns {{segments: string[]} | {error: string}}
472
+ */
473
+ function readSpecifier(value) {
474
+ if (!value || value.type !== 'CallExpression' || value.callee?.type !== 'Identifier' || value.callee.name !== 'lib') {
475
+ return { error: 'specifier must be a direct lib(<literal path segments>) call' };
476
+ }
477
+ if (value.arguments.length === 0) return { error: 'specifier lib() call must contain at least one path segment' };
478
+ const segments = [];
479
+ for (const argument of value.arguments) {
480
+ if (argument.type !== 'StringLiteral' || argument.value.length === 0 || path.isAbsolute(argument.value)) {
481
+ return { error: 'specifier lib() arguments must be nonempty relative literal path segments' };
482
+ }
483
+ segments.push(argument.value);
484
+ }
485
+ return { segments };
486
+ }
487
+
488
+ /**
489
+ * @param {any} value
490
+ * @returns {{requires: string[]} | {error: string}}
491
+ */
492
+ function readRequires(value) {
493
+ if (!value || value.type !== 'ArrayExpression') {
494
+ return { error: 'requires must be a nonempty literal-string array' };
495
+ }
496
+ if (value.elements.length === 0) return { error: 'requires must not be empty' };
497
+ const requires = [];
498
+ const unique = new Set();
499
+ for (const element of value.elements) {
500
+ if (!element || element.type !== 'StringLiteral' || element.value.length === 0) {
501
+ return { error: 'requires entries must be nonempty literal strings' };
502
+ }
503
+ if (unique.has(element.value)) return { error: `requires contains duplicate export: ${element.value}` };
504
+ unique.add(element.value);
505
+ requires.push(element.value);
506
+ }
507
+ return { requires };
508
+ }
509
+
510
+ /**
511
+ * Find direct assignments of `modules.<contractName>` to a local identifier.
512
+ * The `modules` object is trusted only when it is destructured directly from the
513
+ * exact `armGuard()` call being inspected. Any other binding with that name
514
+ * makes the contract ambiguous and is therefore rejected.
515
+ *
516
+ * @param {any} ast
517
+ * @param {string} contractName
518
+ * @param {any} armGuardCall
519
+ * @returns {{aliases: string[], bindingNodes: Set<object>, modulesBindingNodes: Set<object>, shadowedModulesNodes: any[]}}
520
+ */
521
+ function findDirectBindings(ast, contractName, armGuardCall) {
522
+ const aliases = new Set();
523
+ const bindingNodes = new Set();
524
+ const modulesBindingNodes = new Set();
525
+ const allModulesBindings = new Set();
526
+
527
+ const recordModulesBinding = (binding) => {
528
+ if (binding?.type === 'Identifier' && binding.name === MODULE_NAMESPACE_NAME) {
529
+ allModulesBindings.add(binding);
530
+ }
531
+ };
532
+ walk(ast, (node) => {
533
+ if (node.type === 'VariableDeclarator') {
534
+ const bindings = new Set();
535
+ collectBindingIdentifiers(node.id, bindings);
536
+ for (const binding of bindings) recordModulesBinding(binding);
537
+
538
+ if (
539
+ node.init?.type === 'AwaitExpression' &&
540
+ node.init.argument === armGuardCall &&
541
+ node.id?.type === 'ObjectPattern'
542
+ ) {
543
+ for (const property of node.id.properties) {
544
+ const imported = readImportProperty(property);
545
+ if (imported?.key === MODULE_NAMESPACE_NAME && imported.local.name === MODULE_NAMESPACE_NAME) {
546
+ modulesBindingNodes.add(imported.local);
547
+ }
548
+ }
549
+ }
550
+ }
551
+ if (
552
+ node.type === 'FunctionDeclaration' ||
553
+ node.type === 'FunctionExpression' ||
554
+ node.type === 'ArrowFunctionExpression' ||
555
+ node.type === 'ObjectMethod' ||
556
+ node.type === 'ClassMethod' ||
557
+ node.type === 'ClassDeclaration' ||
558
+ node.type === 'ClassExpression'
559
+ ) {
560
+ recordModulesBinding(node.id);
561
+ for (const parameter of node.params || []) {
562
+ const bindings = new Set();
563
+ collectBindingIdentifiers(parameter, bindings);
564
+ for (const binding of bindings) recordModulesBinding(binding);
565
+ }
566
+ }
567
+ if (node.type === 'CatchClause') {
568
+ const bindings = new Set();
569
+ collectBindingIdentifiers(node.param, bindings);
570
+ for (const binding of bindings) recordModulesBinding(binding);
571
+ }
572
+ if (
573
+ (node.type === 'ImportSpecifier' || node.type === 'ImportDefaultSpecifier' || node.type === 'ImportNamespaceSpecifier') &&
574
+ node.local?.type === 'Identifier'
575
+ ) {
576
+ recordModulesBinding(node.local);
577
+ }
578
+ if (
579
+ node.type === 'AssignmentExpression' &&
580
+ node.left?.type === 'Identifier' &&
581
+ node.left.name === MODULE_NAMESPACE_NAME
582
+ ) {
583
+ allModulesBindings.add(node.left);
584
+ }
585
+ });
586
+
587
+ const shadowedModulesNodes = [...allModulesBindings].filter(
588
+ (binding) => !modulesBindingNodes.has(binding),
589
+ );
590
+ const modulesTrusted = modulesBindingNodes.size > 0 && shadowedModulesNodes.length === 0;
591
+ if (!modulesTrusted) {
592
+ return {
593
+ aliases: [],
594
+ bindingNodes,
595
+ modulesBindingNodes,
596
+ shadowedModulesNodes,
597
+ };
598
+ }
599
+
600
+ walk(ast, (node) => {
601
+ if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') {
602
+ if (isDirectModulesMember(node.init, contractName)) {
603
+ aliases.add(node.id.name);
604
+ bindingNodes.add(node.id);
605
+ }
606
+ }
607
+ if (
608
+ node.type === 'AssignmentExpression' &&
609
+ node.left?.type === 'Identifier' &&
610
+ isDirectModulesMember(node.right, contractName)
611
+ ) {
612
+ aliases.add(node.left.name);
613
+ bindingNodes.add(node.left);
614
+ }
615
+ });
616
+ return {
617
+ aliases: [...aliases].sort(),
618
+ bindingNodes,
619
+ modulesBindingNodes,
620
+ shadowedModulesNodes,
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Collect identifiers introduced by a binding pattern.
626
+ *
627
+ * @param {any} pattern
628
+ * @param {Set<object>} output
629
+ */
630
+ function collectBindingIdentifiers(pattern, output) {
631
+ if (!pattern) return;
632
+ if (pattern.type === 'Identifier') {
633
+ output.add(pattern);
634
+ return;
635
+ }
636
+ if (pattern.type === 'RestElement' || pattern.type === 'AssignmentPattern') {
637
+ collectBindingIdentifiers(pattern.argument || pattern.left, output);
638
+ return;
639
+ }
640
+ if (pattern.type === 'ArrayPattern') {
641
+ for (const element of pattern.elements) collectBindingIdentifiers(element, output);
642
+ return;
643
+ }
644
+ if (pattern.type === 'ObjectPattern') {
645
+ for (const property of pattern.properties) {
646
+ if (property.type === 'RestElement') {
647
+ collectBindingIdentifiers(property.argument, output);
648
+ } else if (property.type === 'ObjectProperty') {
649
+ collectBindingIdentifiers(property.value, output);
650
+ }
651
+ }
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Top-level declarations are the one non-direct binding form allowed for the
657
+ * current hook shape (`let blocker; blocker = modules.blocker`). Nested
658
+ * declarations with the same name are not safe to classify by identifier text.
659
+ *
660
+ * @param {any} ast
661
+ * @returns {Set<object>}
662
+ */
663
+ function findTopLevelBindingNodes(ast) {
664
+ const topLevelBindings = new Set();
665
+ for (const statement of ast.program.body) {
666
+ if (statement.type !== 'VariableDeclaration') continue;
667
+ for (const declaration of statement.declarations) {
668
+ collectBindingIdentifiers(declaration.id, topLevelBindings);
669
+ }
670
+ }
671
+ return topLevelBindings;
672
+ }
673
+
674
+ /**
675
+ * @param {any} node
676
+ * @param {any} parent
677
+ * @param {string | number | null} key
678
+ * @param {Set<object>} directBindingNodes
679
+ * @returns {boolean}
680
+ */
681
+ function isBindingIdentifier(node, parent, key, directBindingNodes) {
682
+ if (node.type !== 'Identifier') return false;
683
+ if (directBindingNodes.has(node)) return true;
684
+ if (parent?.type === 'VariableDeclarator' && key === 'id') return true;
685
+ if (
686
+ (parent?.type === 'FunctionDeclaration' || parent?.type === 'FunctionExpression' || parent?.type === 'ClassDeclaration' || parent?.type === 'ClassExpression') &&
687
+ (key === 'id' || key === 'params')
688
+ ) return true;
689
+ if (parent?.type === 'CatchClause' && key === 'param') return true;
690
+ if (parent?.type === 'ImportSpecifier' || parent?.type === 'ImportDefaultSpecifier' || parent?.type === 'ImportNamespaceSpecifier') return true;
691
+ if (parent?.type === 'RestElement' && key === 'argument') return true;
692
+ if (parent?.type === 'ObjectProperty' && key === 'key' && !parent.computed) return true;
693
+ if (isMemberExpression(parent) && key === 'property' && !parent.computed) return true;
694
+ if ((parent?.type === 'ObjectMethod' || parent?.type === 'ClassMethod') && key === 'key' && !parent.computed) return true;
695
+ return false;
696
+ }
697
+
698
+ /**
699
+ * Compare direct namespace-member use with the declared requires set.
700
+ *
701
+ * @param {any} ast
702
+ * @param {string[]} aliases
703
+ * @param {Set<object>} directBindingNodes
704
+ * @returns {{uses: string[], dynamicNodes: any[], indirectNodes: any[], shadowedNodes: any[]}}
705
+ */
706
+ function findNamespaceUses(ast, aliases, directBindingNodes) {
707
+ const aliasSet = new Set(aliases);
708
+ const topLevelBindingNodes = findTopLevelBindingNodes(ast);
709
+ const uses = new Set();
710
+ const dynamicNodes = [];
711
+ const indirectNodes = [];
712
+ const shadowedNodes = [];
713
+ const shadowedSet = new Set();
714
+ const addShadowed = (node) => {
715
+ if (!node || !aliasSet.has(node.name) || directBindingNodes.has(node) || topLevelBindingNodes.has(node)) return;
716
+ if (!shadowedSet.has(node)) {
717
+ shadowedSet.add(node);
718
+ shadowedNodes.push(node);
719
+ }
720
+ };
721
+
722
+ walk(ast, (node, parent, key) => {
723
+ if (node.type === 'VariableDeclarator') {
724
+ const bindings = new Set();
725
+ collectBindingIdentifiers(node.id, bindings);
726
+ for (const binding of bindings) addShadowed(binding);
727
+ }
728
+ if (
729
+ node.type === 'FunctionDeclaration' ||
730
+ node.type === 'FunctionExpression' ||
731
+ node.type === 'ArrowFunctionExpression' ||
732
+ node.type === 'ObjectMethod' ||
733
+ node.type === 'ClassMethod'
734
+ ) {
735
+ addShadowed(node.id);
736
+ for (const parameter of node.params || []) {
737
+ const bindings = new Set();
738
+ collectBindingIdentifiers(parameter, bindings);
739
+ for (const binding of bindings) addShadowed(binding);
740
+ }
741
+ }
742
+ if (node.type === 'CatchClause') {
743
+ const bindings = new Set();
744
+ collectBindingIdentifiers(node.param, bindings);
745
+ for (const binding of bindings) addShadowed(binding);
746
+ }
747
+ if (
748
+ (node.type === 'ImportSpecifier' || node.type === 'ImportDefaultSpecifier' || node.type === 'ImportNamespaceSpecifier') &&
749
+ node.local?.type === 'Identifier'
750
+ ) {
751
+ addShadowed(node.local);
752
+ }
753
+
754
+ if (isMemberExpression(node) && node.object?.type === 'Identifier' && aliasSet.has(node.object.name)) {
755
+ if (node.computed || staticName(node.property) === null) {
756
+ dynamicNodes.push(node);
757
+ } else {
758
+ uses.add(staticName(node.property));
759
+ }
760
+ return;
761
+ }
762
+
763
+ if (node.type !== 'Identifier' || !aliasSet.has(node.name)) return;
764
+ if (shadowedSet.has(node)) return;
765
+ if (isBindingIdentifier(node, parent, key, directBindingNodes)) return;
766
+ if (isMemberExpression(parent) && key === 'object' && !parent.computed) return;
767
+ indirectNodes.push(node);
768
+ });
769
+
770
+ return {
771
+ uses: [...uses].sort(),
772
+ dynamicNodes,
773
+ indirectNodes,
774
+ shadowedNodes,
775
+ };
776
+ }
777
+
778
+ /**
779
+ * Classify the statically visible value of a local binding.
780
+ *
781
+ * @param {any} expression
782
+ * @returns {{kind: 'function' | 'non-function' | 'alias' | 'unknown', name?: string}}
783
+ */
784
+ function classifyExpression(expression) {
785
+ if (!expression) return { kind: 'unknown' };
786
+ if (expression.type === 'FunctionExpression' || expression.type === 'ArrowFunctionExpression') return { kind: 'function' };
787
+ if (expression.type === 'Identifier') return { kind: 'alias', name: expression.name };
788
+ return { kind: 'non-function' };
789
+ }
790
+
791
+ /**
792
+ * Resolve named ESM exports, including local export aliases.
793
+ *
794
+ * @param {any} ast
795
+ * @returns {Map<string, 'function' | 'non-function' | 'unknown'>}
796
+ */
797
+ function resolveNamedExports(ast) {
798
+ /** @type {Map<string, {kind: 'function' | 'non-function' | 'alias' | 'unknown', name?: string}>} */
799
+ const locals = new Map();
800
+ /** @type {Map<string, string>} */
801
+ const exportedLocals = new Map();
802
+
803
+ const recordDeclaration = (declaration) => {
804
+ if (!declaration) return;
805
+ if (declaration.type === 'FunctionDeclaration' && declaration.id) {
806
+ locals.set(declaration.id.name, { kind: 'function' });
807
+ return;
808
+ }
809
+ if (declaration.type === 'ClassDeclaration' && declaration.id) {
810
+ locals.set(declaration.id.name, { kind: 'non-function' });
811
+ return;
812
+ }
813
+ if (declaration.type === 'VariableDeclaration') {
814
+ for (const declarator of declaration.declarations) {
815
+ if (declarator.id?.type === 'Identifier') {
816
+ locals.set(declarator.id.name, classifyExpression(declarator.init));
817
+ }
818
+ }
819
+ }
820
+ };
821
+
822
+ for (const statement of ast.program.body) {
823
+ if (statement.type === 'ExportNamedDeclaration') {
824
+ recordDeclaration(statement.declaration);
825
+ if (statement.source) {
826
+ for (const specifier of statement.specifiers) {
827
+ const exported = staticName(specifier.exported);
828
+ if (exported) exportedLocals.set(exported, '');
829
+ }
830
+ } else {
831
+ for (const specifier of statement.specifiers) {
832
+ const local = staticName(specifier.local);
833
+ const exported = staticName(specifier.exported);
834
+ if (local && exported) exportedLocals.set(exported, local);
835
+ }
836
+ }
837
+ } else {
838
+ recordDeclaration(statement);
839
+ }
840
+ }
841
+
842
+ const resolving = new Set();
843
+ /** @param {string} name */
844
+ const resolveLocal = (name) => {
845
+ if (resolving.has(name)) return 'unknown';
846
+ const local = locals.get(name);
847
+ if (!local) return 'unknown';
848
+ if (local.kind === 'function' || local.kind === 'non-function' || local.kind === 'unknown') return local.kind;
849
+ if (!local.name) return 'unknown';
850
+ resolving.add(name);
851
+ const result = resolveLocal(local.name);
852
+ resolving.delete(name);
853
+ return result;
854
+ };
855
+
856
+ const exports = new Map();
857
+ for (const [exported, local] of exportedLocals) {
858
+ exports.set(exported, local ? resolveLocal(local) : 'unknown');
859
+ }
860
+ // `export function foo(){}` and `export const foo = () => {}` have a
861
+ // declaration but no specifier. They are still named exports.
862
+ for (const statement of ast.program.body) {
863
+ if (statement.type !== 'ExportNamedDeclaration' || !statement.declaration) continue;
864
+ const declaration = statement.declaration;
865
+ if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
866
+ if (declaration.id) exports.set(declaration.id.name, resolveLocal(declaration.id.name));
867
+ } else if (declaration.type === 'VariableDeclaration') {
868
+ for (const declarator of declaration.declarations) {
869
+ if (declarator.id?.type === 'Identifier') exports.set(declarator.id.name, resolveLocal(declarator.id.name));
870
+ }
871
+ }
872
+ }
873
+ return exports;
874
+ }
875
+
876
+ /**
877
+ * Load and parse both module versions. The cache is per inspection run so a
878
+ * repeated module reference cannot observe different sources mid-check.
879
+ *
880
+ * @param {string} pluginRoot
881
+ * @param {string} modulePath
882
+ * @param {Map<string, {working: any, head: any}>} cache
883
+ * @returns {{working: any, head: any}}
884
+ */
885
+ function loadModuleVersions(pluginRoot, modulePath, cache) {
886
+ const relative = path.relative(pluginRoot, modulePath);
887
+ const cached = cache.get(relative);
888
+ if (cached) return cached;
889
+
890
+ const workingSource = readFileSync(modulePath, 'utf8');
891
+ const working = parseModule(workingSource, modulePath);
892
+ const gitPath = relative.split(path.sep).join('/');
893
+ const env = {};
894
+ for (const key of GIT_ENV_ALLOWLIST) {
895
+ if (process.env[key] !== undefined) env[key] = process.env[key];
896
+ }
897
+ const result = spawnSync('git', ['show', `HEAD:${gitPath}`], {
898
+ cwd: pluginRoot,
899
+ encoding: 'utf8',
900
+ stdio: ['ignore', 'pipe', 'pipe'],
901
+ env,
902
+ });
903
+ if (result.error) throw result.error;
904
+ if (result.status !== 0) {
905
+ throw new Error(`git show HEAD:${gitPath} failed: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
906
+ }
907
+ const head = parseModule(result.stdout, `HEAD:${gitPath}`);
908
+ const versions = { working, head };
909
+ cache.set(relative, versions);
910
+ return versions;
911
+ }
912
+
913
+ /**
914
+ * Inspect all static head-fallback contracts under recursive hooks/*.mjs files.
915
+ *
916
+ * @param {string} pluginRoot
917
+ * @returns {{ok: boolean, summary: {filesScanned: number, contracts: number, requires: number}, contracts: GuardContract[], findings: Finding[], toolError: boolean}}
918
+ */
919
+ export function inspectGuardRequiresParity(pluginRoot) {
920
+ const result = {
921
+ ok: false,
922
+ summary: { filesScanned: 0, contracts: 0, requires: 0 },
923
+ contracts: [],
924
+ findings: [],
925
+ toolError: false,
926
+ };
927
+
928
+ let hookFiles;
929
+ let symlinkedHookEntries;
930
+ try {
931
+ const collected = collectHookFiles(path.join(pluginRoot, HOOKS_RELATIVE_DIR));
932
+ hookFiles = collected.files;
933
+ symlinkedHookEntries = collected.symlinks;
934
+ } catch (error) {
935
+ result.toolError = true;
936
+ result.findings.push({
937
+ kind: 'tool-error',
938
+ hook: HOOKS_RELATIVE_DIR,
939
+ line: 1,
940
+ contract: '',
941
+ message: `cannot scan hook files: ${error instanceof Error ? error.message : String(error)}`,
942
+ });
943
+ return result;
944
+ }
945
+ result.summary.filesScanned = hookFiles.length + symlinkedHookEntries.length;
946
+ for (const symlinkPath of symlinkedHookEntries) {
947
+ result.findings.push({
948
+ kind: 'symlink-handler',
949
+ hook: path.relative(pluginRoot, symlinkPath),
950
+ line: 1,
951
+ contract: path.relative(pluginRoot, symlinkPath),
952
+ message: 'symlinked hook entries are unsupported; refusing to follow a handler outside the plugin root',
953
+ });
954
+ }
955
+
956
+ const moduleCache = new Map();
957
+ for (const hookPath of hookFiles) {
958
+ let ast;
959
+ try {
960
+ ast = parseModule(readFileSync(hookPath, 'utf8'), hookPath);
961
+ } catch (error) {
962
+ result.toolError = true;
963
+ result.findings.push({
964
+ kind: 'tool-error',
965
+ hook: path.relative(pluginRoot, hookPath),
966
+ line: 1,
967
+ contract: '',
968
+ message: `cannot parse hook: ${error instanceof Error ? error.message : String(error)}`,
969
+ });
970
+ continue;
971
+ }
972
+
973
+ const { validCalls, invalidCalls } = findArmGuardCalls(ast);
974
+ for (const invalidCall of invalidCalls) {
975
+ result.findings.push({
976
+ kind: 'invalid-armguard-provenance',
977
+ hook: path.relative(pluginRoot, hookPath),
978
+ line: lineOf(invalidCall),
979
+ contract: path.relative(pluginRoot, hookPath),
980
+ message: `armGuard call must use the unaliased named import from ./_lib/guard-source-loader.mjs; indirect, shadowed, aliased, or member calls are unsupported`,
981
+ });
982
+ }
983
+
984
+ for (const node of validCalls) {
985
+ const contractFindings = [];
986
+ const report = (kind, message, sourceNode) => {
987
+ contractFindings.push(finding(pluginRoot, hookPath, '', kind, message, sourceNode || node));
988
+ };
989
+ const specMap = readStaticObject(node.arguments[0], report);
990
+ if (!specMap) {
991
+ for (const item of contractFindings) result.findings.push(item);
992
+ continue;
993
+ }
994
+
995
+ for (const [namespace, specValue] of specMap) {
996
+ if (!specValue || specValue.type !== 'ObjectExpression') {
997
+ report('dynamic-contract', `armGuard spec for ${namespace} must be an inline object literal`, specValue || node);
998
+ continue;
999
+ }
1000
+ const specProperties = readStaticObject(specValue, (kind, message, sourceNode) => {
1001
+ report(kind, `${namespace}: ${message}`, sourceNode);
1002
+ });
1003
+ if (!specProperties) continue;
1004
+
1005
+ const headFallback = specProperties.get('headFallback');
1006
+ if (headFallback === undefined) continue;
1007
+ if (headFallback.type !== 'BooleanLiteral') {
1008
+ report('dynamic-contract', `${namespace}: headFallback must be a literal boolean`, headFallback);
1009
+ continue;
1010
+ }
1011
+ if (headFallback.value !== true) continue;
1012
+
1013
+ result.summary.contracts += 1;
1014
+ const contractStart = specValue;
1015
+ const specifierValue = specProperties.get('specifier');
1016
+ const specifier = readSpecifier(specifierValue);
1017
+ if ('error' in specifier) {
1018
+ report('dynamic-contract', `${namespace}: ${specifier.error}`, specifierValue || contractStart);
1019
+ continue;
1020
+ }
1021
+ const requiresValue = specProperties.get('requires');
1022
+ const requires = readRequires(requiresValue);
1023
+ if ('error' in requires) {
1024
+ report('dynamic-contract', `${namespace}: ${requires.error}`, requiresValue || contractStart);
1025
+ continue;
1026
+ }
1027
+ result.summary.requires += requires.requires.length;
1028
+
1029
+ const modulePath = path.resolve(pluginRoot, MODULE_RELATIVE_PREFIX, ...specifier.segments);
1030
+ const moduleRelative = path.relative(pluginRoot, modulePath);
1031
+ if (moduleRelative.startsWith('..') || path.isAbsolute(moduleRelative) || !moduleRelative.startsWith(`${MODULE_RELATIVE_PREFIX}${path.sep}`)) {
1032
+ report('dynamic-contract', `${namespace}: specifier resolves outside ${MODULE_RELATIVE_PREFIX}`, specifierValue);
1033
+ continue;
1034
+ }
1035
+ if (!existsSync(modulePath) || !statSync(modulePath).isFile()) {
1036
+ result.toolError = true;
1037
+ report('tool-error', `${namespace}: referenced module does not exist: ${moduleRelative}`, specifierValue);
1038
+ continue;
1039
+ }
1040
+
1041
+ const { aliases, bindingNodes, shadowedModulesNodes } = findDirectBindings(ast, namespace, node);
1042
+ const contract = {
1043
+ hook: path.relative(pluginRoot, hookPath),
1044
+ line: lineOf(contractStart),
1045
+ namespace,
1046
+ binding: aliases.length === 1 ? aliases[0] : aliases.length > 1 ? aliases.join(',') : null,
1047
+ specifier: moduleRelative,
1048
+ requires: [...requires.requires].sort(),
1049
+ uses: [],
1050
+ };
1051
+ result.contracts.push(contract);
1052
+
1053
+ if (aliases.length === 0) {
1054
+ report('indirect-contract', `${namespace}: no direct binding from armGuard().modules.${namespace} to a local namespace`, contractStart);
1055
+ }
1056
+ if (shadowedModulesNodes.length > 0) {
1057
+ report('shadowed-binding', `${namespace}: modules must be directly bound from this armGuard() result; unrelated or nested modules bindings are unsupported`, shadowedModulesNodes[0]);
1058
+ }
1059
+ const namespaceUses = findNamespaceUses(ast, aliases, bindingNodes);
1060
+ contract.uses = namespaceUses.uses;
1061
+ if (namespaceUses.dynamicNodes.length > 0) {
1062
+ report('dynamic-contract', `${namespace}: computed namespace-member use is unsupported`, namespaceUses.dynamicNodes[0]);
1063
+ }
1064
+ if (namespaceUses.indirectNodes.length > 0) {
1065
+ report('indirect-contract', `${namespace}: namespace binding is used indirectly; use direct ${aliases.join(' / ') || `${namespace}`}.<member> access`, namespaceUses.indirectNodes[0]);
1066
+ }
1067
+
1068
+ if (namespaceUses.shadowedNodes.length > 0) {
1069
+ report('shadowed-binding', `${namespace}: a nested binding shadows the namespace alias; lexical namespace use is unsupported`, namespaceUses.shadowedNodes[0]);
1070
+ }
1071
+
1072
+ const requiredSet = new Set(requires.requires);
1073
+ for (const required of requires.requires) {
1074
+ if (!namespaceUses.uses.includes(required)) {
1075
+ report('requires-missing-use', `${namespace}: requires ${required}, but the hook has no direct namespace use`, requiresValue);
1076
+ }
1077
+ }
1078
+ for (const used of namespaceUses.uses) {
1079
+ if (!requiredSet.has(used)) {
1080
+ report('use-missing-require', `${namespace}: direct namespace use ${used} is absent from requires`, contractStart);
1081
+ }
1082
+ }
1083
+
1084
+ let versions;
1085
+ try {
1086
+ versions = loadModuleVersions(pluginRoot, modulePath, moduleCache);
1087
+ } catch (error) {
1088
+ result.toolError = true;
1089
+ report('tool-error', `${namespace}: cannot parse/load working-tree and HEAD module ${moduleRelative}: ${error instanceof Error ? error.message : String(error)}`, specifierValue);
1090
+ continue;
1091
+ }
1092
+ const workingExports = resolveNamedExports(versions.working);
1093
+ const headExports = resolveNamedExports(versions.head);
1094
+ for (const required of requires.requires) {
1095
+ const workingKind = workingExports.get(required);
1096
+ const headKind = headExports.get(required);
1097
+ if (!workingKind) report('required-export-missing', `${namespace}: required export ${required} is absent from the working-tree module`, requiresValue);
1098
+ if (!headKind) report('required-export-missing', `${namespace}: required export ${required} is absent from HEAD module`, requiresValue);
1099
+ if (workingKind && workingKind !== 'function') report('required-export-non-function', `${namespace}: required export ${required} is not a statically callable function in the working-tree module`, requiresValue);
1100
+ if (headKind && headKind !== 'function') report('required-export-non-function', `${namespace}: required export ${required} is not a statically callable function in HEAD module`, requiresValue);
1101
+ }
1102
+ }
1103
+
1104
+ for (const item of contractFindings) {
1105
+ if (!item.contract) item.contract = path.relative(pluginRoot, hookPath);
1106
+ result.findings.push(item);
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ result.ok = !result.toolError && result.findings.length === 0;
1112
+ return result;
1113
+ }
1114
+
1115
+ /**
1116
+ * Run the human-readable validator CLI.
1117
+ *
1118
+ * @param {string} pluginRoot
1119
+ * @returns {number} 0 = pass, 1 = contract violation, 2 = parser/Git/filesystem/tool failure
1120
+ */
1121
+ export function runCheckGuardRequiresParity(pluginRoot) {
1122
+ console.log('--- Check: guard requires parity (headFallback contracts) ---');
1123
+ const inspection = inspectGuardRequiresParity(pluginRoot);
1124
+ if (inspection.ok) {
1125
+ console.log(` PASS: ${inspection.summary.contracts} headFallback contract(s) have exact requires parity (${inspection.summary.requires} required export(s))`);
1126
+ console.log('');
1127
+ console.log('Results: 1 passed, 0 failed');
1128
+ return 0;
1129
+ }
1130
+
1131
+ for (const item of inspection.findings) {
1132
+ const location = item.hook ? `${item.hook}:${item.line} — ` : '';
1133
+ console.log(` FAIL: ${location}${item.message}`);
1134
+ }
1135
+ console.log('');
1136
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
1137
+ return inspection.toolError ? 2 : 1;
1138
+ }
1139
+
1140
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
1141
+ if (isMain) {
1142
+ const pluginRoot = process.argv[2];
1143
+ if (!pluginRoot) {
1144
+ console.error('Usage: check-guard-requires-parity.mjs <plugin-root>');
1145
+ process.exit(2);
1146
+ }
1147
+ process.exit(runCheckGuardRequiresParity(path.resolve(pluginRoot)));
1148
+ }