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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- 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
|
+
}
|