@guilz-dev/belay 0.9.1 → 0.9.3

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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/codex/runtime-entry.d.ts +3 -0
  3. package/dist/adapters/codex/runtime-entry.js +27 -4
  4. package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
  5. package/dist/adapters/cursor/cwd-resolution.js +58 -0
  6. package/dist/adapters/cursor/hooks.d.ts +5 -3
  7. package/dist/adapters/cursor/hooks.js +29 -20
  8. package/dist/adapters/cursor/runtime-entry.d.ts +1 -0
  9. package/dist/adapters/cursor/runtime-entry.js +107 -6
  10. package/dist/adapters/shared/gate-runtime.js +26 -3
  11. package/dist/adapters/shared/repo-root.js +20 -1
  12. package/dist/bundle/claude-runtime.mjs +1225 -287
  13. package/dist/bundle/codex-runtime.mjs +1247 -291
  14. package/dist/bundle/cursor-runtime.mjs +4320 -3154
  15. package/dist/cli.js +33 -3
  16. package/dist/commands/doctor.js +38 -9
  17. package/dist/commands/health-snapshot.d.ts +3 -0
  18. package/dist/commands/health-snapshot.js +56 -0
  19. package/dist/commands/report.js +14 -0
  20. package/dist/commands/status.js +15 -0
  21. package/dist/commands/where.d.ts +4 -0
  22. package/dist/commands/where.js +52 -0
  23. package/dist/core/approval-repo-lookup.d.ts +16 -0
  24. package/dist/core/approval-repo-lookup.js +48 -0
  25. package/dist/core/audit-io.d.ts +1 -1
  26. package/dist/core/audit-io.js +1 -1
  27. package/dist/core/audit-legacy-archive.d.ts +1 -0
  28. package/dist/core/audit-legacy-archive.js +5 -0
  29. package/dist/core/audit-query.d.ts +1 -0
  30. package/dist/core/audit-query.js +7 -0
  31. package/dist/core/audit-serialize.d.ts +3 -0
  32. package/dist/core/audit-serialize.js +39 -3
  33. package/dist/core/audit-summary.d.ts +9 -0
  34. package/dist/core/audit-summary.js +58 -1
  35. package/dist/core/audit-types.d.ts +4 -0
  36. package/dist/core/effect-ir/shell-lower.js +283 -27
  37. package/dist/core/replay-scrub.d.ts +1 -0
  38. package/dist/core/replay-scrub.js +22 -3
  39. package/dist/core/shell-tokenizer.d.ts +28 -0
  40. package/dist/core/shell-tokenizer.js +111 -29
  41. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  42. package/dist/core/verdict/docker-compose-run.js +136 -0
  43. package/dist/core/verdict/launcher-resolve.js +66 -25
  44. package/dist/core/verdict/makefile-expand.d.ts +4 -0
  45. package/dist/core/verdict/makefile-expand.js +151 -0
  46. package/dist/core/verdict/parser.d.ts +4 -0
  47. package/dist/core/verdict/parser.js +25 -30
  48. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  49. package/dist/core/verdict/recursive-invocation.js +224 -0
  50. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  51. package/dist/corpus/benign-probe-cores.js +2 -0
  52. package/dist/defaults.js +16 -0
  53. package/dist/installer/scope-config.d.ts +2 -2
  54. package/dist/installer.d.ts +10 -1
  55. package/dist/installer.js +43 -2
  56. package/dist/types.d.ts +34 -1
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +5 -2
  60. package/skills/belay/SKILL.md +5 -0
  61. package/skills/belay/belay-report.md +4 -1
@@ -67,61 +67,143 @@ export function isRedirectOperator(token) {
67
67
  export function isFdDuplication(token) {
68
68
  return FD_DUPLICATION_PATTERN.test(token);
69
69
  }
70
- export function tokenizeShell(input) {
70
+ export function lexShell(input) {
71
71
  const tokens = [];
72
- let buffer = '';
72
+ let value = '';
73
+ let wordStart = null;
74
+ let parts = [];
73
75
  let quote = null;
74
- let escaping = false;
75
- const flush = () => {
76
- if (buffer.length > 0) {
77
- tokens.push(buffer);
78
- buffer = '';
76
+ let quoteStart = -1;
77
+ let quoteHadContent = false;
78
+ let complete = true;
79
+ const startWord = (index) => {
80
+ wordStart ??= index;
81
+ };
82
+ const append = (decoded, start, end, mode, hasExpansion) => {
83
+ startWord(start);
84
+ value += decoded;
85
+ const previous = parts.at(-1);
86
+ if (previous &&
87
+ previous.quote === mode &&
88
+ previous.hasExpansion === hasExpansion &&
89
+ previous.end === start) {
90
+ previous.value += decoded;
91
+ previous.raw += input.slice(start, end);
92
+ previous.end = end;
93
+ return;
79
94
  }
95
+ parts.push({
96
+ value: decoded,
97
+ raw: input.slice(start, end),
98
+ start,
99
+ end,
100
+ quote: mode,
101
+ hasExpansion,
102
+ });
103
+ };
104
+ const flushWord = (end) => {
105
+ if (wordStart === null)
106
+ return;
107
+ tokens.push({
108
+ kind: 'word',
109
+ value,
110
+ raw: input.slice(wordStart, end),
111
+ start: wordStart,
112
+ end,
113
+ parts,
114
+ });
115
+ value = '';
116
+ wordStart = null;
117
+ parts = [];
118
+ };
119
+ const pushOperator = (token, start, end) => {
120
+ tokens.push({ kind: 'operator', value: token, raw: input.slice(start, end), start, end });
80
121
  };
81
122
  for (let index = 0; index < input.length; index += 1) {
82
- const char = input[index];
83
- if (escaping) {
84
- buffer += char;
85
- escaping = false;
86
- continue;
87
- }
88
- if (char === '\\') {
89
- escaping = true;
123
+ const char = input[index] ?? '';
124
+ if (quote === 'single') {
125
+ if (char === "'") {
126
+ if (!quoteHadContent)
127
+ append('', quoteStart, index + 1, 'single', false);
128
+ quote = null;
129
+ }
130
+ else {
131
+ append(char, index, index + 1, 'single', false);
132
+ quoteHadContent = true;
133
+ }
90
134
  continue;
91
135
  }
92
- if (quote) {
93
- if (char === quote) {
136
+ if (quote === 'double') {
137
+ if (char === '"') {
138
+ if (!quoteHadContent)
139
+ append('', quoteStart, index + 1, 'double', false);
94
140
  quote = null;
141
+ continue;
95
142
  }
96
- else {
97
- buffer += char;
143
+ if (char === '\\') {
144
+ const next = input[index + 1];
145
+ if (next === undefined) {
146
+ append('\\', index, index + 1, 'double', false);
147
+ complete = false;
148
+ continue;
149
+ }
150
+ if (next === '$' || next === '`' || next === '"' || next === '\\' || next === '\n') {
151
+ append(next === '\n' ? '' : next, index, index + 2, 'double', false);
152
+ quoteHadContent = true;
153
+ index += 1;
154
+ continue;
155
+ }
156
+ append('\\', index, index + 1, 'double', false);
157
+ quoteHadContent = true;
158
+ continue;
98
159
  }
160
+ append(char, index, index + 1, 'double', char === '$' || char === '`');
161
+ quoteHadContent = true;
99
162
  continue;
100
163
  }
101
- if (char === '"' || char === "'") {
102
- quote = char;
164
+ if (char === "'" || char === '"') {
165
+ startWord(index);
166
+ quote = char === "'" ? 'single' : 'double';
167
+ quoteStart = index;
168
+ quoteHadContent = false;
169
+ continue;
170
+ }
171
+ if (char === '\\') {
172
+ const next = input[index + 1];
173
+ if (next === undefined) {
174
+ append('\\', index, index + 1, 'unquoted', false);
175
+ complete = false;
176
+ continue;
177
+ }
178
+ append(next, index, index + 2, 'unquoted', false);
179
+ index += 1;
103
180
  continue;
104
181
  }
105
182
  const operator = readShellOperator(input, index);
106
183
  if (operator) {
107
- flush();
108
- tokens.push(operator.token);
184
+ flushWord(index);
185
+ pushOperator(operator.token, index, index + operator.length);
109
186
  index += operator.length - 1;
110
187
  continue;
111
188
  }
112
189
  if (char === '\n' || char === '\r') {
113
- flush();
114
- tokens.push(';');
190
+ flushWord(index);
191
+ pushOperator(';', index, index + 1);
115
192
  continue;
116
193
  }
117
194
  if (/\s/.test(char)) {
118
- flush();
195
+ flushWord(index);
119
196
  continue;
120
197
  }
121
- buffer += char;
198
+ append(char, index, index + 1, 'unquoted', char === '$' || char === '`');
122
199
  }
123
- flush();
124
- return tokens;
200
+ if (quote !== null)
201
+ complete = false;
202
+ flushWord(input.length);
203
+ return { tokens, complete };
204
+ }
205
+ export function tokenizeShell(input) {
206
+ return lexShell(input).tokens.map((token) => token.value);
125
207
  }
126
208
  export function normalizeShellCommand(command, repoRoot, normalizeToken) {
127
209
  const tokens = tokenizeShell(command);
@@ -0,0 +1,18 @@
1
+ import type { ShellToken } from '../shell-tokenizer.js';
2
+ export type DockerComposeRunInvocation = {
3
+ kind: 'recursive';
4
+ service: string;
5
+ interpreter: string;
6
+ script: string;
7
+ } | {
8
+ kind: 'dynamic';
9
+ service: string;
10
+ signal: string;
11
+ } | {
12
+ kind: 'none';
13
+ } | {
14
+ kind: 'indeterminate';
15
+ signal: 'shell.compose_argv_indeterminate';
16
+ };
17
+ export declare function decodeDockerComposeRun(tokens: readonly ShellToken[]): DockerComposeRunInvocation;
18
+ export declare function decodeDockerComposeRunValues(values: readonly string[]): DockerComposeRunInvocation;
@@ -0,0 +1,136 @@
1
+ import path from 'node:path';
2
+ import { decodeRecursiveInvocation, shellTokensFromValues } from './recursive-invocation.js';
3
+ const COMPOSE_GLOBAL_OPTIONS = new Map([
4
+ ['--all-resources', 0],
5
+ ['--ansi', 1],
6
+ ['--compatibility', 0],
7
+ ['--dry-run', 0],
8
+ ['--env-file', 1],
9
+ ['-f', 1],
10
+ ['--file', 1],
11
+ ['--parallel', 1],
12
+ ['--profile', 1],
13
+ ['--progress', 1],
14
+ ['--project-directory', 1],
15
+ ['-p', 1],
16
+ ['--project-name', 1],
17
+ ]);
18
+ const COMPOSE_RUN_OPTIONS = new Map([
19
+ ['--build', 0],
20
+ ['--cap-add', 1],
21
+ ['--cap-drop', 1],
22
+ ['-d', 0],
23
+ ['--detach', 0],
24
+ ['--entrypoint', 1],
25
+ ['-e', 1],
26
+ ['--env', 1],
27
+ ['--env-from-file', 1],
28
+ ['-i', 0],
29
+ ['--interactive', 0],
30
+ ['-l', 1],
31
+ ['--label', 1],
32
+ ['--name', 1],
33
+ ['--no-deps', 0],
34
+ ['-T', 0],
35
+ ['--no-tty', 0],
36
+ ['-p', 1],
37
+ ['--publish', 1],
38
+ ['--pull', 1],
39
+ ['-q', 0],
40
+ ['--quiet', 0],
41
+ ['--quiet-build', 0],
42
+ ['--quiet-pull', 0],
43
+ ['--remove-orphans', 0],
44
+ ['--rm', 0],
45
+ ['-P', 0],
46
+ ['--service-ports', 0],
47
+ ['--use-aliases', 0],
48
+ ['-u', 1],
49
+ ['--user', 1],
50
+ ['-v', 1],
51
+ ['--volume', 1],
52
+ ['-w', 1],
53
+ ['--workdir', 1],
54
+ ]);
55
+ function parseOptions(words, start, options) {
56
+ let index = start;
57
+ while (index < words.length) {
58
+ const value = words[index]?.value ?? '';
59
+ if (value === '--')
60
+ return { kind: 'ok', index: index + 1 };
61
+ if (!value.startsWith('-') || value === '-')
62
+ return { kind: 'ok', index };
63
+ const equalsIndex = value.indexOf('=');
64
+ const name = equalsIndex === -1 ? value : value.slice(0, equalsIndex);
65
+ const arity = options.get(name);
66
+ if (arity === undefined)
67
+ return { kind: 'indeterminate' };
68
+ if (equalsIndex !== -1) {
69
+ if (!value.startsWith('--') || arity !== 1 || equalsIndex === value.length - 1) {
70
+ return { kind: 'indeterminate' };
71
+ }
72
+ index += 1;
73
+ continue;
74
+ }
75
+ if (arity === 1) {
76
+ if (!words[index + 1])
77
+ return { kind: 'indeterminate' };
78
+ index += 2;
79
+ continue;
80
+ }
81
+ index += 1;
82
+ }
83
+ return { kind: 'ok', index };
84
+ }
85
+ export function decodeDockerComposeRun(tokens) {
86
+ if (tokens.some((token) => token.kind === 'operator'))
87
+ return { kind: 'none' };
88
+ const words = tokens.filter((token) => token.kind === 'word');
89
+ const head = path.basename(words[0]?.value ?? '');
90
+ let index;
91
+ if (head === 'docker-compose') {
92
+ index = 1;
93
+ }
94
+ else if (head === 'docker' && words[1]?.value === 'compose') {
95
+ index = 2;
96
+ }
97
+ else {
98
+ return { kind: 'none' };
99
+ }
100
+ const globalOptions = parseOptions(words, index, COMPOSE_GLOBAL_OPTIONS);
101
+ if (globalOptions.kind === 'indeterminate') {
102
+ return { kind: 'indeterminate', signal: 'shell.compose_argv_indeterminate' };
103
+ }
104
+ index = globalOptions.index;
105
+ if (words[index]?.value !== 'run')
106
+ return { kind: 'none' };
107
+ const runOptions = parseOptions(words, index + 1, COMPOSE_RUN_OPTIONS);
108
+ if (runOptions.kind === 'indeterminate') {
109
+ return { kind: 'indeterminate', signal: 'shell.compose_argv_indeterminate' };
110
+ }
111
+ index = runOptions.index;
112
+ const service = words[index]?.value;
113
+ if (!service)
114
+ return { kind: 'indeterminate', signal: 'shell.compose_argv_indeterminate' };
115
+ const command = words.slice(index + 1);
116
+ if (command.length === 0)
117
+ return { kind: 'none' };
118
+ const recursive = decodeRecursiveInvocation(command);
119
+ if (recursive.kind === 'static') {
120
+ return {
121
+ kind: 'recursive',
122
+ service,
123
+ interpreter: recursive.interpreter,
124
+ script: recursive.script,
125
+ };
126
+ }
127
+ if (recursive.kind === 'dynamic')
128
+ return { kind: 'dynamic', service, signal: recursive.signal };
129
+ if (recursive.kind === 'indeterminate') {
130
+ return { kind: 'indeterminate', signal: 'shell.compose_argv_indeterminate' };
131
+ }
132
+ return { kind: 'none' };
133
+ }
134
+ export function decodeDockerComposeRunValues(values) {
135
+ return decodeDockerComposeRun(shellTokensFromValues(values, { detectExpansion: false }));
136
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { expandMakeExpression, normalizeMakeRecipeLine, parseMakefileVariables, } from './makefile-expand.js';
3
4
  const MAX_RESOLVE_DEPTH = 8;
4
5
  const PNPM_BUILTIN_COMMANDS = new Set([
5
6
  'add',
@@ -145,10 +146,9 @@ function resolveNpmRecipe(cwd, repoRoot, scriptName, extraArgs) {
145
146
  reason: 'npm_script_resolved',
146
147
  };
147
148
  }
148
- function parseMakefileRecipes(makefilePath) {
149
+ function parseMakefileRecipeContent(content) {
149
150
  const targets = new Map();
150
151
  try {
151
- const content = readFileSync(makefilePath, 'utf8');
152
152
  const lines = content.split('\n');
153
153
  let currentTarget = null;
154
154
  let recipeLines = [];
@@ -206,7 +206,7 @@ function parseMakefileRecipes(makefilePath) {
206
206
  }
207
207
  return targets;
208
208
  }
209
- function resolveMakeRecipe(cwd, repoRoot, target) {
209
+ function resolveMakeRecipe(cwd, repoRoot, target, cliVars = {}) {
210
210
  const candidates = ['Makefile', 'makefile', 'GNUmakefile'];
211
211
  let makefilePath = null;
212
212
  let searchDir = path.resolve(cwd);
@@ -227,57 +227,75 @@ function resolveMakeRecipe(cwd, repoRoot, target) {
227
227
  if (!makefilePath) {
228
228
  return { recipes: [], opaque: true, reason: 'unknown_local_effect' };
229
229
  }
230
- const targets = parseMakefileRecipes(makefilePath);
230
+ const makefileContent = readFileSync(makefilePath, 'utf8');
231
+ const makefileVars = parseMakefileVariables(makefileContent);
232
+ const targets = parseMakefileRecipeContent(makefileContent);
231
233
  if (!targets.has(target)) {
232
234
  return { recipes: [], opaque: true, reason: 'make_target_undefined' };
233
235
  }
234
236
  const recipeLines = [];
235
237
  const visiting = new Set();
236
238
  const visited = new Set();
237
- let opaquePrerequisites = false;
239
+ let hasDynamicPrerequisite = false;
240
+ let hasUndefinedPrerequisite = false;
241
+ let hasDependencyCycle = false;
238
242
  const collect = (name) => {
239
243
  if (visited.has(name)) {
240
- return true;
244
+ return;
241
245
  }
242
246
  if (visiting.has(name)) {
243
- return false;
247
+ hasDependencyCycle = true;
248
+ return;
244
249
  }
245
250
  const entry = targets.get(name);
246
251
  if (!entry) {
247
- return true;
252
+ if (!existsSync(path.resolve(path.dirname(makefilePath), name))) {
253
+ hasUndefinedPrerequisite = true;
254
+ }
255
+ return;
248
256
  }
249
257
  visiting.add(name);
250
- opaquePrerequisites ||= entry.opaquePrerequisites;
258
+ hasDynamicPrerequisite ||= entry.opaquePrerequisites;
251
259
  for (const prerequisite of entry.prerequisites) {
252
- if (targets.has(prerequisite) && !collect(prerequisite)) {
253
- return false;
254
- }
260
+ collect(prerequisite);
255
261
  }
256
262
  recipeLines.push(...entry.recipes);
257
263
  visiting.delete(name);
258
264
  visited.add(name);
259
- return true;
260
265
  };
261
- if (!collect(target)) {
262
- return { recipes: recipeLines, opaque: true, reason: 'make_dependency_cycle' };
263
- }
266
+ collect(target);
267
+ const expandedRecipes = [];
264
268
  for (const line of recipeLines) {
265
- if (/\$\(/.test(line) || /\$\{/.test(line)) {
269
+ const normalized = normalizeMakeRecipeLine(line);
270
+ const expanded = expandMakeExpression(normalized, cliVars, makefileVars);
271
+ if (expanded === null) {
266
272
  return { recipes: recipeLines, opaque: true, reason: 'make_recipe_dynamic' };
267
273
  }
274
+ expandedRecipes.push(expanded);
275
+ }
276
+ for (const line of expandedRecipes) {
277
+ if (/\$\(/.test(line) || /\$\{/.test(line)) {
278
+ return { recipes: expandedRecipes, opaque: true, reason: 'make_recipe_dynamic' };
279
+ }
280
+ }
281
+ if (hasDependencyCycle) {
282
+ return { recipes: expandedRecipes, opaque: true, reason: 'make_dependency_cycle' };
268
283
  }
269
- if (opaquePrerequisites) {
270
- return { recipes: recipeLines, opaque: true, reason: 'make_prerequisite_dynamic' };
284
+ if (hasDynamicPrerequisite) {
285
+ return { recipes: expandedRecipes, opaque: true, reason: 'make_prerequisite_dynamic' };
271
286
  }
272
- return { recipes: recipeLines, opaque: false, reason: 'make_recipe_resolved' };
287
+ if (hasUndefinedPrerequisite) {
288
+ return { recipes: expandedRecipes, opaque: true, reason: 'make_prerequisite_undefined' };
289
+ }
290
+ return { recipes: expandedRecipes, opaque: false, reason: 'make_recipe_resolved' };
273
291
  }
274
292
  export function resolveLauncherRecipe(params) {
275
- if (params.depth >= MAX_RESOLVE_DEPTH) {
276
- return { recipes: [], opaque: true, reason: 'launcher_depth_exceeded' };
277
- }
278
293
  const tokens = params.tokens;
279
294
  const scriptName = npmScriptName(tokens);
280
295
  if (scriptName) {
296
+ if (params.depth >= MAX_RESOLVE_DEPTH) {
297
+ return { recipes: [], opaque: true, reason: 'launcher_depth_exceeded' };
298
+ }
281
299
  const resolution = resolveNpmRecipe(params.cwd, params.repoRoot, scriptName, forwardedArgs(tokens));
282
300
  if (tokens[0] === 'pnpm' &&
283
301
  tokens[1] &&
@@ -291,8 +309,31 @@ export function resolveLauncherRecipe(params) {
291
309
  }
292
310
  return resolution;
293
311
  }
294
- if (tokens[0] === 'make' && tokens[1] && !tokens[1].startsWith('-')) {
295
- return resolveMakeRecipe(params.cwd, params.repoRoot, tokens[1]);
312
+ if (tokens[0] === 'make') {
313
+ if (params.depth >= MAX_RESOLVE_DEPTH) {
314
+ return { recipes: [], opaque: true, reason: 'launcher_depth_exceeded' };
315
+ }
316
+ if (tokens.includes('-n') || tokens.includes('--dry-run')) {
317
+ return null;
318
+ }
319
+ let target = null;
320
+ const cliVars = {};
321
+ for (const token of tokens.slice(1)) {
322
+ if (token.startsWith('-')) {
323
+ continue;
324
+ }
325
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(token);
326
+ if (assignment) {
327
+ cliVars[assignment[1] ?? ''] = assignment[2] ?? '';
328
+ continue;
329
+ }
330
+ if (!target) {
331
+ target = token;
332
+ }
333
+ }
334
+ if (target) {
335
+ return resolveMakeRecipe(params.cwd, params.repoRoot, target, cliVars);
336
+ }
296
337
  }
297
338
  if (tokens[0] === 'pnpm' && tokens[1] === 'exec' && tokens[2]) {
298
339
  return {
@@ -0,0 +1,4 @@
1
+ export declare function parseMakefileVariables(content: string): Map<string, string>;
2
+ export declare function parsePhonyTargets(content: string): Set<string>;
3
+ export declare function normalizeMakeRecipeLine(line: string): string;
4
+ export declare function expandMakeExpression(expression: string, cliVars: Readonly<Record<string, string>>, makefileVars: ReadonlyMap<string, string>): string | null;
@@ -0,0 +1,151 @@
1
+ const MAX_EXPAND_DEPTH = 16;
2
+ export function parseMakefileVariables(content) {
3
+ const variables = new Map();
4
+ for (const line of content.split('\n')) {
5
+ const trimmed = line.trim();
6
+ if (!trimmed || trimmed.startsWith('#')) {
7
+ continue;
8
+ }
9
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*[:?]?=\s*(.+)$/.exec(trimmed);
10
+ if (!match) {
11
+ continue;
12
+ }
13
+ variables.set(match[1] ?? '', (match[2] ?? '').trim());
14
+ }
15
+ return variables;
16
+ }
17
+ export function parsePhonyTargets(content) {
18
+ const phony = new Set();
19
+ for (const line of content.split('\n')) {
20
+ const trimmed = line.trim();
21
+ const match = /^\.PHONY:\s*(.+)$/.exec(trimmed);
22
+ if (!match) {
23
+ continue;
24
+ }
25
+ for (const token of (match[1] ?? '').split(/\s+/)) {
26
+ if (token) {
27
+ phony.add(token);
28
+ }
29
+ }
30
+ }
31
+ return phony;
32
+ }
33
+ export function normalizeMakeRecipeLine(line) {
34
+ let normalized = line.trim();
35
+ while (normalized.startsWith('@') || normalized.startsWith('-') || normalized.startsWith('+')) {
36
+ normalized = normalized.slice(1).trimStart();
37
+ }
38
+ return normalized;
39
+ }
40
+ export function expandMakeExpression(expression, cliVars, makefileVars) {
41
+ if (/\$\(\s*shell\b/i.test(expression) || expression.includes('$$')) {
42
+ return null;
43
+ }
44
+ try {
45
+ const expanded = expandMakeValue(expression, cliVars, makefileVars, 0);
46
+ if (expanded === null || /\$\(/.test(expanded) || /\$\{/.test(expanded)) {
47
+ return null;
48
+ }
49
+ return expanded;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function expandMakeValue(expression, cliVars, makefileVars, depth) {
56
+ if (depth > MAX_EXPAND_DEPTH) {
57
+ return null;
58
+ }
59
+ let value = expression.trim();
60
+ let changed = true;
61
+ let iterations = 0;
62
+ while (changed && iterations < MAX_EXPAND_DEPTH) {
63
+ changed = false;
64
+ iterations += 1;
65
+ const orMatch = value.match(/\$\(\s*or\s+([^()]*(?:\([^)]*\)[^()]*)*)\)/);
66
+ if (orMatch) {
67
+ const [fullMatch, inner] = orMatch;
68
+ const parts = splitMakeFunctionArgs(inner ?? '');
69
+ let selected = null;
70
+ for (const part of parts) {
71
+ const expanded = expandMakeValue(part.trim(), cliVars, makefileVars, depth + 1);
72
+ if (expanded !== null && expanded.trim() !== '') {
73
+ selected = expanded;
74
+ break;
75
+ }
76
+ }
77
+ if (selected === null) {
78
+ const fallback = parts.at(-1)?.trim();
79
+ selected =
80
+ fallback === undefined ? '' : expandMakeValue(fallback, cliVars, makefileVars, depth + 1);
81
+ }
82
+ if (selected === null) {
83
+ return null;
84
+ }
85
+ value = value.replace(fullMatch, selected);
86
+ changed = true;
87
+ continue;
88
+ }
89
+ const varMatch = value.match(/\$\(([A-Za-z_][A-Za-z0-9_]*)\)/);
90
+ if (varMatch) {
91
+ const [fullMatch, name] = varMatch;
92
+ const resolved = resolveMakeVariable(name ?? '', cliVars, makefileVars, depth + 1);
93
+ if (resolved === null) {
94
+ return null;
95
+ }
96
+ value = value.replace(fullMatch, resolved);
97
+ changed = true;
98
+ continue;
99
+ }
100
+ const bracedMatch = value.match(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/);
101
+ if (bracedMatch) {
102
+ const [fullMatch, name] = bracedMatch;
103
+ const resolved = name === 'PWD' ? '.' : resolveMakeVariable(name ?? '', cliVars, makefileVars, depth + 1);
104
+ if (resolved === null) {
105
+ return null;
106
+ }
107
+ value = value.replace(fullMatch, resolved);
108
+ changed = true;
109
+ continue;
110
+ }
111
+ break;
112
+ }
113
+ return value;
114
+ }
115
+ function resolveMakeVariable(name, cliVars, makefileVars, depth) {
116
+ if (Object.hasOwn(cliVars, name)) {
117
+ return cliVars[name] ?? '';
118
+ }
119
+ const definition = makefileVars.get(name);
120
+ if (definition === undefined) {
121
+ return null;
122
+ }
123
+ return expandMakeValue(definition, cliVars, makefileVars, depth);
124
+ }
125
+ function splitMakeFunctionArgs(input) {
126
+ const parts = [];
127
+ let current = '';
128
+ let depth = 0;
129
+ for (const char of input) {
130
+ if (char === '(') {
131
+ depth += 1;
132
+ current += char;
133
+ continue;
134
+ }
135
+ if (char === ')') {
136
+ depth -= 1;
137
+ current += char;
138
+ continue;
139
+ }
140
+ if (char === ',' && depth === 0) {
141
+ parts.push(current.trim());
142
+ current = '';
143
+ continue;
144
+ }
145
+ current += char;
146
+ }
147
+ if (current.trim()) {
148
+ parts.push(current.trim());
149
+ }
150
+ return parts;
151
+ }
@@ -1,3 +1,5 @@
1
+ import { type ShellToken } from '../shell-tokenizer.js';
2
+ import { type RecursiveInvocation } from './recursive-invocation.js';
1
3
  import type { VerdictOpacity } from './types.js';
2
4
  export interface ParsedSegment {
3
5
  tokens: string[];
@@ -16,6 +18,8 @@ export declare function peelTransparentWrappers(tokens: string[]): {
16
18
  export declare function isVariableIndirectHead(head: string): boolean;
17
19
  export declare function extractEvalBody(tokens: string[]): string | null;
18
20
  export declare function extractRecursiveScript(tokens: string[]): string | null;
21
+ export declare function decodeRecursiveInvocationTokens(tokens: readonly ShellToken[]): RecursiveInvocation;
22
+ export declare function extractDockerComposeRunScript(tokens: string[]): string | null;
19
23
  /**
20
24
  * True when a recursive script is evaluated from a command argument rather
21
25
  * than expanded from a static launcher recipe. Callers use this semantic fact