ucn 5.4.1 → 5.5.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 (87) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +42 -16
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis-ext.js +6 -6
  38. package/core/output/analysis.js +18 -2
  39. package/core/output/brief.js +3 -0
  40. package/core/output/doctor.js +4 -0
  41. package/core/output/endpoints.js +15 -6
  42. package/core/output/find.js +11 -2
  43. package/core/output/lines.js +8 -2
  44. package/core/output/refactoring.js +121 -14
  45. package/core/output/reporting.js +31 -3
  46. package/core/output/shared.js +14 -0
  47. package/core/output-budget.js +316 -122
  48. package/core/parallel-build.js +124 -47
  49. package/core/parser.js +3 -2
  50. package/core/project.js +457 -60
  51. package/core/provenance-facts.js +156 -12
  52. package/core/provenance-overload.js +30 -3
  53. package/core/provenance.js +104 -30
  54. package/core/pytest-fixtures.js +434 -0
  55. package/core/python-fixture-flow.js +17 -3
  56. package/core/python-modules.js +173 -0
  57. package/core/reflection.js +877 -0
  58. package/core/registry.js +1 -1
  59. package/core/reporting.js +234 -52
  60. package/core/request-helpers.js +561 -0
  61. package/core/route-graph.js +1922 -0
  62. package/core/route-lists.js +2414 -0
  63. package/core/rust-macro-expansion.js +2113 -0
  64. package/core/rust-modules.js +185 -0
  65. package/core/search.js +121 -29
  66. package/core/shared.js +19 -1
  67. package/core/test-name-bindings.js +256 -0
  68. package/core/type-denotation.js +1161 -0
  69. package/core/type-references.js +3229 -0
  70. package/core/verify.js +3360 -672
  71. package/languages/adapter.js +31 -5
  72. package/languages/c-family.js +4061 -212
  73. package/languages/c-macro-invocations.js +380 -0
  74. package/languages/c-preprocessor.js +254 -0
  75. package/languages/csharp.js +1406 -49
  76. package/languages/go.js +1243 -70
  77. package/languages/index.js +728 -6
  78. package/languages/java.js +473 -61
  79. package/languages/javascript.js +1270 -197
  80. package/languages/lexical-scope.js +1280 -0
  81. package/languages/python.js +1557 -72
  82. package/languages/rust-macro-rules.js +1020 -0
  83. package/languages/rust-value-flow.js +416 -0
  84. package/languages/rust.js +2237 -115
  85. package/languages/utils.js +298 -4
  86. package/mcp/server.js +25 -11
  87. package/package.json +1 -1
@@ -0,0 +1,1416 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Token-level expansion of C/C++ function-like macro invocations (fix #362).
5
+ *
6
+ * `#define XX(uc, lc) case UV_FS_##uc: fs__##lc(req); break;` followed by
7
+ * `XX(STAT, stat)` calls `fs__stat`, but no AST node spells that name: the
8
+ * call target exists only after the preprocessor substitutes arguments and
9
+ * pastes tokens. This module performs that substitution for invocations of
10
+ * project macros, parses the substituted token stream with the language's own
11
+ * tree-sitter grammar, and reports the identifiers the expansion produced:
12
+ *
13
+ * calls - pasted (`fs__##lc`) or argument-substituted (`CALL(foo)` ->
14
+ * `foo()`) identifiers in call position, attributed to the
15
+ * invocation site with `macroExpansion` provenance
16
+ * refs - pasted identifiers in value position (X-macro tables:
17
+ * `{ #name, handle_##name }`), used as deadcode liveness
18
+ * blind - invocations whose expansion cannot be computed (budget,
19
+ * unbalanced arguments, a pasted operand that is an unexpanded
20
+ * object-like project macro); disclosed, never guessed
21
+ *
22
+ * Nothing here is persisted: macro templates are indexed per file
23
+ * (`ppParams`/`ppBody` on macro symbols) and expansions are derived from the
24
+ * current index on demand, so a header edit or an incremental rebuild never
25
+ * leaves stale expansion edges behind. Work is proportional to invocations of
26
+ * macros that can produce names (pasting or higher-order macros, or macros
27
+ * that invoke them); a project without such macros pays one symbol scan.
28
+ */
29
+
30
+ const crypto = require('crypto');
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { langTraits, getParser, safeParse, PARSE_OPTIONS } = require('../languages');
34
+ const { lexPP, splitArguments, pastePatterns } = require('../languages/c-preprocessor');
35
+ const { codeUnitCompare } = require('./shared');
36
+
37
+ const MAX_DEPTH = 64;
38
+ const MAX_TOKENS = 20000;
39
+ const MAX_VARIANTS = 4;
40
+
41
+ class BlindExpansion extends Error {
42
+ constructor(reason) {
43
+ super(reason);
44
+ this.reason = reason;
45
+ }
46
+ }
47
+
48
+ const WRAPPERS = {
49
+ statement: ['void __ucn_macro_expansion__(void) { switch (0) {\n', '\n; } }\n'],
50
+ file: ['', '\n;\n'],
51
+ initializer: ['static int __ucn_macro_expansion__[] = {\n', '\n};\n'],
52
+ };
53
+
54
+ /** Per-build memo; reset with the other definition-derived memos. */
55
+ function expansionState(index) {
56
+ if (index._macroExpansion) return index._macroExpansion;
57
+ const templates = new Map(); // name -> function-like macro defs with a template
58
+ const macroDefs = new Map(); // name -> every macro def (object- and function-like)
59
+ const defsByFile = new Map(); // file -> function-like template defs
60
+ for (const [name, defs] of index.symbols) {
61
+ for (const def of defs) {
62
+ if (def.type !== 'macro') continue;
63
+ if (!langTraits(index.files.get(def.file)?.language)?.textualIncludes) continue;
64
+ if (!macroDefs.has(name)) macroDefs.set(name, []);
65
+ macroDefs.get(name).push(def);
66
+ if (!def.functionLike || typeof def.ppBody !== 'string') continue;
67
+ if (!templates.has(name)) templates.set(name, []);
68
+ templates.get(name).push(def);
69
+ if (!defsByFile.has(def.file)) defsByFile.set(def.file, []);
70
+ defsByFile.get(def.file).push(def);
71
+ }
72
+ }
73
+ const bodyTokens = new Map();
74
+ const tokensOf = def => {
75
+ let tokens = bodyTokens.get(def);
76
+ if (!tokens) {
77
+ tokens = lexPP(def.ppBody);
78
+ bodyTokens.set(def, tokens);
79
+ }
80
+ return tokens;
81
+ };
82
+ const relevant = productiveMacros(index, templates, tokensOf);
83
+ const state = {
84
+ templates, macroDefs, defsByFile, relevant, tokensOf,
85
+ byFile: new Map(), summary: null, includeTokens: new Map(),
86
+ words: new Map(), visibleMemo: new Map(), persistOut: new Map(), signature: null,
87
+ };
88
+ index._macroExpansion = state;
89
+ return state;
90
+ }
91
+
92
+ /**
93
+ * Macros whose invocations can produce a project name the source does not
94
+ * spell (the invocations worth expanding). Parameter flow, per definition:
95
+ * a parameter is productive when its argument can become a paste operand,
96
+ * the callee of a call (`p(...)`, higher-order `LIST(X)`), or an argument
97
+ * at a productive position of a macro the body invokes. A literal-only
98
+ * paste that spells a project name makes the macro productive by itself.
99
+ * gtest-style assertion macros (arguments only ever in value position,
100
+ * pastes only of `__LINE__`-built locals) are not expanded at all.
101
+ */
102
+ /**
103
+ * Can a paste pattern (literal pieces, null for a parameter wildcard that may
104
+ * be empty) spell a name the project defines? Memoized per build.
105
+ */
106
+ function patternSpellsProjectName(index, pieces) {
107
+ const state = index._macroSpellable || (index._macroSpellable = { names: null, memo: new Map() });
108
+ const key = pieces.map(piece => piece === null ? '\u0000' : piece).join('\u0001');
109
+ if (state.memo.has(key)) return state.memo.get(key);
110
+ const literal = pieces.filter(piece => piece !== null);
111
+ let result = true;
112
+ if (literal.length > 0) {
113
+ if (!state.names) state.names = [...index.symbols.keys()];
114
+ const pattern = new RegExp('^' + pieces.map(piece => piece === null ? '[A-Za-z0-9_$]*'
115
+ : piece.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('') + '$');
116
+ const longest = literal.reduce((a, b) => (b.length > a.length ? b : a));
117
+ result = state.names.some(candidate => candidate.includes(longest) && pattern.test(candidate));
118
+ }
119
+ state.memo.set(key, result);
120
+ return result;
121
+ }
122
+
123
+ function productiveMacros(index, templates, tokensOf) {
124
+ // Can a paste of these operands spell a project name? Parameter operands
125
+ // are wildcards (possibly empty: an empty argument is a placemarker);
126
+ // a chain of literal operands only is checked by name directly below.
127
+ const pasteCanSpell = (operands, paramIndex) => {
128
+ if (operands.some(op => op.k !== 'id' && op.k !== 'num')) return true;
129
+ return patternSpellsProjectName(index,
130
+ operands.map(op => op.k === 'id' && paramIndex.has(op.v) ? null : op.v));
131
+ };
132
+ // Does some definition of `callee` copy its parameter at `argIndex` into
133
+ // its output as written (not only pasted or stringized)?
134
+ const passthrough = new Map();
135
+ const passesThrough = (callee, argIndex) => {
136
+ const key = `${callee}\0${argIndex}`;
137
+ if (passthrough.has(key)) return passthrough.get(key);
138
+ let result = false;
139
+ for (const def of templates.get(callee) || []) {
140
+ const params = def.ppParams || [];
141
+ const position = def.ppVariadic && argIndex >= params.length - 1 ? params.length - 1 : argIndex;
142
+ const param = params[position];
143
+ if (param === undefined) continue;
144
+ const body = tokensOf(def);
145
+ if (body.some((token, i) => token.k === 'id' && token.v === param &&
146
+ body[i - 1]?.v !== '#' && body[i - 1]?.v !== '##' && body[i + 1]?.v !== '##')) {
147
+ result = true;
148
+ break;
149
+ }
150
+ }
151
+ passthrough.set(key, result);
152
+ return result;
153
+ };
154
+ const productive = new Map(); // name -> Set of productive param indices
155
+ const fixed = new Set(); // names with a literal paste spelling a project name
156
+ const deps = []; // [name, paramIndex, calleeName, argIndex]
157
+ const mark = (name, i) => {
158
+ if (!productive.has(name)) productive.set(name, new Set());
159
+ const set = productive.get(name);
160
+ if (set.has(i)) return false;
161
+ set.add(i);
162
+ return true;
163
+ };
164
+ const variadicOf = new Map();
165
+ for (const [name, defs] of templates) {
166
+ for (const def of defs) {
167
+ const tokens = tokensOf(def);
168
+ const params = def.ppParams || [];
169
+ if (def.ppVariadic) variadicOf.set(name, params.length - 1);
170
+ const paramIndex = new Map(params.map((param, i) => [param, i]));
171
+ for (let i = 0; i < tokens.length; i++) {
172
+ const t = tokens[i];
173
+ if (t.k === 'punct' && t.v === '##') continue;
174
+ if (tokens[i + 1]?.v === '##' && tokens[i + 1].k === 'punct') {
175
+ const operands = [t];
176
+ let j = i + 1;
177
+ while (tokens[j]?.v === '##' && tokens[j + 1]) {
178
+ operands.push(tokens[j + 1]);
179
+ j += 2;
180
+ }
181
+ const paramOperands = operands.filter(op => op.k === 'id' && paramIndex.has(op.v));
182
+ // A paste whose literal pieces no project name can
183
+ // contain (`suite##_##name##_Test` in a project with no
184
+ // `*_*_Test` symbol) produces no name worth expanding
185
+ // for (fix #385: gtest TEST(...) sites, a third of fmt's
186
+ // cold-build expansion work, produced nothing).
187
+ if (paramOperands.length > 0 && !pasteCanSpell(operands, paramIndex)) {
188
+ i = j - 1;
189
+ continue;
190
+ }
191
+ for (const op of paramOperands) mark(name, paramIndex.get(op.v));
192
+ if (paramOperands.length === 0 && index.symbols.has(operands.map(op => op.v).join(''))) {
193
+ fixed.add(name);
194
+ }
195
+ i = j - 1;
196
+ continue;
197
+ }
198
+ if (t.k !== 'id') continue;
199
+ if (paramIndex.has(t.v)) {
200
+ const next = tokens[i + 1];
201
+ if (next?.v === '(' || (next?.k === 'id' && paramIndex.has(next.v))) {
202
+ mark(name, paramIndex.get(t.v));
203
+ }
204
+ continue;
205
+ }
206
+ if (tokens[i + 1]?.v !== '(' || !templates.has(t.v)) continue;
207
+ const split = splitArguments(tokens, i + 1);
208
+ if (!split) continue;
209
+ split.args.forEach((arg, argIndex) => {
210
+ // A parameter inside a nested invocation in this argument
211
+ // (`OUTER(INNER(p))`) reaches OUTER's argument only when
212
+ // INNER passes that parameter through to its output; a
213
+ // pasted-only one (`INNER(a) = a##_Test`) does not (fix
214
+ // #385). Its own dependency on INNER is recorded when the
215
+ // scan reaches INNER.
216
+ const nestedAt = nestedInvocationSlots(arg, templates);
217
+ arg.forEach((token, k) => {
218
+ if (token.k !== 'id' || !paramIndex.has(token.v)) return;
219
+ const slot = nestedAt[k];
220
+ if (slot && !passesThrough(slot.name, slot.argIndex)) return;
221
+ deps.push([name, paramIndex.get(token.v), t.v, argIndex]);
222
+ });
223
+ });
224
+ deps.push([name, -1, t.v, -1]);
225
+ }
226
+ }
227
+ }
228
+ for (let changed = true; changed;) {
229
+ changed = false;
230
+ for (const [name, param, callee, argIndex] of deps) {
231
+ if (param < 0) {
232
+ if (fixed.has(callee) && !fixed.has(name)) {
233
+ fixed.add(name);
234
+ changed = true;
235
+ }
236
+ continue;
237
+ }
238
+ const calleeParams = productive.get(callee);
239
+ if (!calleeParams) continue;
240
+ const variadic = variadicOf.get(callee);
241
+ const position = variadic != null && argIndex > variadic ? variadic : argIndex;
242
+ if (calleeParams.has(position) && mark(name, param)) changed = true;
243
+ }
244
+ }
245
+ return new Set([...productive.keys(), ...fixed]);
246
+ }
247
+
248
+ /**
249
+ * For each token of a macro argument, the outermost template-macro
250
+ * invocation within the argument that contains it: { name, argIndex }, or
251
+ * undefined for a token at the argument's own level.
252
+ */
253
+ function nestedInvocationSlots(arg, templates) {
254
+ const slots = new Array(arg.length);
255
+ for (let i = 0; i < arg.length; i++) {
256
+ const token = arg[i];
257
+ if (token.k !== 'id' || !templates.has(token.v) || arg[i + 1]?.v !== '(') continue;
258
+ const split = splitArguments(arg, i + 1);
259
+ if (!split) continue;
260
+ let argIndex = 0;
261
+ let depth = 0;
262
+ for (let k = i + 2; k < split.close; k++) {
263
+ const inner = arg[k];
264
+ if (inner.k === 'punct' && inner.v === '(') depth++;
265
+ else if (inner.k === 'punct' && inner.v === ')') depth--;
266
+ else if (depth === 0 && inner.k === 'punct' && inner.v === ',') {
267
+ argIndex++;
268
+ continue;
269
+ }
270
+ slots[k] = { name: token.v, argIndex };
271
+ }
272
+ i = split.close;
273
+ }
274
+ return slots;
275
+ }
276
+
277
+ function defKey(def) {
278
+ return `${def.relativePath || def.file}:${def.startLine}`;
279
+ }
280
+
281
+ /** `#undef NAME` lines of a file (directive tokens, not text matching). */
282
+ function undefLines(fileState, lines) {
283
+ if (fileState.undefs) return fileState.undefs;
284
+ const undefs = new Map();
285
+ for (let row = 0; row < lines.length; row++) {
286
+ const line = lines[row];
287
+ const hash = line.indexOf('#');
288
+ if (hash < 0 || line.slice(0, hash).trim() !== '') continue;
289
+ const tokens = lexPP(line);
290
+ if (tokens[0]?.v === '#' && tokens[1]?.v === 'undef' && tokens[2]?.k === 'id') {
291
+ if (!undefs.has(tokens[2].v)) undefs.set(tokens[2].v, []);
292
+ undefs.get(tokens[2].v).push(row + 1);
293
+ }
294
+ }
295
+ fileState.undefs = undefs;
296
+ return undefs;
297
+ }
298
+
299
+ /**
300
+ * Macro definitions of `name` in effect at (file, line). Same-file
301
+ * definitions win (nearest preceding; an earlier one with no `#undef`
302
+ * between is a conditional alternative); otherwise definitions visible
303
+ * through the file's #include closure. Returns { defs, ambiguous }.
304
+ */
305
+ function definitionsInScope(index, state, site, name) {
306
+ const memoKey = `${name}\0${site.line}`;
307
+ const hit = site.lookupMemo.get(memoKey);
308
+ if (hit) return hit;
309
+ const all = state.macroDefs.get(name) || [];
310
+ let result = { defs: [], ambiguous: false };
311
+ if (all.length > 0) {
312
+ const sameFile = all.filter(def => def.file === site.file && def.startLine < site.line)
313
+ .sort((a, b) => b.startLine - a.startLine);
314
+ if (sameFile.length > 0) {
315
+ const undefs = undefLines(site.fileState, site.lines).get(name) || [];
316
+ const chosen = [sameFile[0]];
317
+ for (let i = 1; i < sameFile.length; i++) {
318
+ const later = chosen[chosen.length - 1];
319
+ if (undefs.some(line => line > sameFile[i].endLine && line < later.startLine)) break;
320
+ chosen.push(sameFile[i]);
321
+ }
322
+ result = { defs: chosen, ambiguous: false };
323
+ } else {
324
+ const { _textualIncludeClosures } = require('./callers');
325
+ const closure = _textualIncludeClosures(index, site.file).all;
326
+ const visible = all.filter(def => def.file !== site.file && closure.has(def.file))
327
+ .sort((a, b) => codeUnitCompare(defKey(a), defKey(b)));
328
+ result = { defs: visible, ambiguous: false };
329
+ }
330
+ // Identical redefinitions are one definition; different bodies are
331
+ // alternatives the preprocessor chooses between by configuration.
332
+ const distinct = new Map();
333
+ for (const def of result.defs) {
334
+ const key = def.functionLike
335
+ ? `f\0${(def.ppParams || []).join(',')}\0${def.ppVariadic ? 1 : 0}\0${def.ppBody}`
336
+ : `o\0${def.startLine}\0${def.file}`;
337
+ if (!distinct.has(key)) distinct.set(key, def);
338
+ }
339
+ const defs = [...distinct.values()];
340
+ result = {
341
+ defs: defs.slice(0, MAX_VARIANTS),
342
+ ambiguous: defs.length > 1,
343
+ truncated: defs.length > MAX_VARIANTS,
344
+ };
345
+ }
346
+ site.lookupMemo.set(memoKey, result);
347
+ return result;
348
+ }
349
+
350
+ /**
351
+ * Preprocessor macro definitions of `name` in effect at a C/C++ source site
352
+ * (fix #377): same-file definitions above the line (an intervening `#undef`
353
+ * ends one), else definitions in the file's #include closure. A call spelled
354
+ * NAME( there invokes the macro, not a same-named function. Returns
355
+ * { defs, ambiguous, reach } with reach 'file' | 'include', or null when the
356
+ * name has no macro definition or the file cannot be read.
357
+ */
358
+ function macroDefinitionsAt(index, filePath, line, name) {
359
+ const state = expansionState(index);
360
+ const all = state.macroDefs.get(name);
361
+ if (!all || all.length === 0) return null;
362
+ let lines;
363
+ try {
364
+ lines = index._getFileLines(filePath);
365
+ } catch {
366
+ return null;
367
+ }
368
+ if (!state.shadowFileStates) state.shadowFileStates = new Map();
369
+ let fileState = state.shadowFileStates.get(filePath);
370
+ if (!fileState) {
371
+ fileState = {};
372
+ state.shadowFileStates.set(filePath, fileState);
373
+ }
374
+ const site = { file: filePath, line, lines, fileState, lookupMemo: new Map() };
375
+ const result = definitionsInScope(index, state, site, name);
376
+ if (result.defs.length === 0) return null;
377
+ const sameFile = result.defs.every(def => def.file === filePath);
378
+ return { ...result, reach: sameFile ? 'file' : 'include' };
379
+ }
380
+
381
+ /** Tokens of a macro's replacement list (function- or object-like). */
382
+ function macroBodyTokens(index, def) {
383
+ if (typeof def.ppBody !== 'string') return null;
384
+ return expansionState(index).tokensOf(def);
385
+ }
386
+
387
+ /** Does an invocation with `args` fit the macro's parameter list? */
388
+ function arityFits(def, args) {
389
+ const params = def.ppParams || [];
390
+ const count = params.length === 0 && args.length === 1 && args[0].length === 0 ? 0 : args.length;
391
+ if (def.ppVariadic) return count >= params.length - 1;
392
+ return count === params.length;
393
+ }
394
+
395
+ /** Expand one invocation of `def` with argument token lists `args`. */
396
+ function substitute(index, state, site, def, args, hide, depth) {
397
+ if (depth > MAX_DEPTH) throw new BlindExpansion('expansion-depth');
398
+ const params = def.ppParams || [];
399
+ if (typeof def.ppBody !== 'string') throw new BlindExpansion('no-template');
400
+ let actual = args;
401
+ if (params.length === 0 && actual.length === 1 && actual[0].length === 0) actual = [];
402
+ if (def.ppVariadic) {
403
+ // The last parameter is the variadic one (`...` as __VA_ARGS__, or
404
+ // GNU `name...`); it takes every remaining argument with its commas.
405
+ const namedCount = params.length - 1;
406
+ if (actual.length < namedCount) throw new BlindExpansion('argument-count');
407
+ const joined = [];
408
+ actual.slice(namedCount).forEach((arg, i) => {
409
+ if (i > 0) joined.push({ v: ',', k: 'punct', o: 'arg' });
410
+ joined.push(...arg);
411
+ });
412
+ actual = [...actual.slice(0, namedCount), joined];
413
+ } else if (actual.length !== params.length) {
414
+ throw new BlindExpansion('argument-count');
415
+ }
416
+ const paramIndex = new Map(params.map((p, i) => [p, i]));
417
+ const body = state.tokensOf(def);
418
+ const expandedArgs = new Map();
419
+ const expandedArg = i => {
420
+ if (!expandedArgs.has(i)) {
421
+ expandedArgs.set(i, expandTokens(index, state, site, actual[i], hide, depth + 1));
422
+ }
423
+ return expandedArgs.get(i);
424
+ };
425
+ const out = [];
426
+ for (let i = 0; i < body.length; i++) {
427
+ const t = body[i];
428
+ if (t.k === 'punct' && t.v === '#' && body[i + 1]?.k === 'id' && paramIndex.has(body[i + 1].v)) {
429
+ const arg = actual[paramIndex.get(body[i + 1].v)];
430
+ out.push({ v: JSON.stringify(arg.map(a => a.v).join(' ')), k: 'str', o: 'str' });
431
+ i++;
432
+ continue;
433
+ }
434
+ if (t.k === 'id' && t.v === '__VA_OPT__' && def.ppVariadic && body[i + 1]?.v === '(') {
435
+ const split = splitArguments(body, i + 1);
436
+ if (!split) throw new BlindExpansion('va-opt');
437
+ const hasRest = actual[actual.length - 1].length > 0;
438
+ if (hasRest) {
439
+ const inner = body.slice(i + 2, split.close);
440
+ for (const token of inner) out.push({ ...token, o: 'body' });
441
+ }
442
+ i = split.close;
443
+ continue;
444
+ }
445
+ if (t.k === 'id' && paramIndex.has(t.v)) {
446
+ const pasted = body[i - 1]?.v === '##' || body[i + 1]?.v === '##';
447
+ const arg = pasted ? actual[paramIndex.get(t.v)] : expandedArg(paramIndex.get(t.v));
448
+ if (arg.length === 0 && pasted) out.push({ v: '', k: 'placemarker', o: 'body' });
449
+ for (const token of arg) out.push(token);
450
+ continue;
451
+ }
452
+ if (t.k === 'punct' && t.v === '##') {
453
+ out.push({ v: '##', k: 'paste-op', o: 'body' });
454
+ continue;
455
+ }
456
+ out.push({ v: t.v, k: t.k, o: 'body' });
457
+ }
458
+ // Paste pass: `##` from the replacement list (never one that arrived in
459
+ // an argument) joins its neighbours into one token.
460
+ const pasted = [];
461
+ for (let i = 0; i < out.length; i++) {
462
+ const t = out[i];
463
+ if (t.k !== 'paste-op') {
464
+ pasted.push(t);
465
+ continue;
466
+ }
467
+ const left = pasted.pop();
468
+ const right = out[i + 1];
469
+ i++;
470
+ if (!left || !right) throw new BlindExpansion('paste-operand');
471
+ if (left.k === 'placemarker') {
472
+ pasted.push(right);
473
+ continue;
474
+ }
475
+ if (right.k === 'placemarker') {
476
+ pasted.push(left);
477
+ continue;
478
+ }
479
+ if (left.opaque || right.opaque) throw new BlindExpansion('opaque-paste-operand');
480
+ const text = left.v + right.v;
481
+ const relexed = lexPP(text);
482
+ const token = relexed.length === 1 && relexed[0].v === text
483
+ ? { v: text, k: relexed[0].k, o: 'paste' }
484
+ // An invalid paste is undefined behavior; keep the spelling so
485
+ // no name is invented from it.
486
+ : { v: text, k: 'invalid', o: 'paste' };
487
+ pasted.push(token);
488
+ }
489
+ const result = pasted.filter(t => t.k !== 'placemarker');
490
+ site.budget -= result.length;
491
+ if (site.budget < 0) throw new BlindExpansion('expansion-budget');
492
+ const inner = new Set(hide);
493
+ inner.add(def.name);
494
+ return expandTokens(index, state, site, result, inner, depth + 1);
495
+ }
496
+
497
+ /** Rescan a token list, expanding invocations of project function-like macros. */
498
+ function expandTokens(index, state, site, tokens, hide, depth) {
499
+ if (depth > MAX_DEPTH) throw new BlindExpansion('expansion-depth');
500
+ const out = [];
501
+ for (let i = 0; i < tokens.length; i++) {
502
+ const t = tokens[i];
503
+ if (t.k !== 'id' || t.noexpand || !state.macroDefs.has(t.v)) {
504
+ out.push(t);
505
+ continue;
506
+ }
507
+ if (hide.has(t.v)) {
508
+ out.push({ ...t, noexpand: true });
509
+ continue;
510
+ }
511
+ const scope = definitionsInScope(index, state, site, t.v);
512
+ let def = scope.defs[0];
513
+ if (!def) {
514
+ out.push(t);
515
+ continue;
516
+ }
517
+ if (!def.functionLike) {
518
+ // An object-like macro expands to its replacement list when every
519
+ // definition in scope agrees on it; otherwise (or without a kept
520
+ // list) the token stays as spelled and may not take part in a
521
+ // paste. Tokens replacing an argument-spelled name are produced,
522
+ // not spelled at the site.
523
+ const bodies = new Set(scope.defs.map(other =>
524
+ !other.functionLike && typeof other.ppBody === 'string' ? other.ppBody : null));
525
+ if (scope.truncated || bodies.size !== 1 || bodies.has(null)) {
526
+ out.push({ ...t, opaque: true });
527
+ continue;
528
+ }
529
+ const origin = t.o === 'arg' || t.o === 'paste' ? 'paste' : t.o;
530
+ const replaced = lexPP(def.ppBody).map(token => ({ v: token.v, k: token.k, o: origin }));
531
+ site.budget -= replaced.length;
532
+ if (site.budget < 0) throw new BlindExpansion('expansion-budget');
533
+ const inner = new Set(hide);
534
+ inner.add(t.v);
535
+ out.push(...expandTokens(index, state, site, replaced, inner, depth + 1));
536
+ continue;
537
+ }
538
+ if (tokens[i + 1]?.v !== '(') {
539
+ out.push(t);
540
+ continue;
541
+ }
542
+ const split = splitArguments(tokens, i + 1);
543
+ if (!split) throw new BlindExpansion('unbalanced-arguments');
544
+ // A configuration alternative whose parameter list cannot take these
545
+ // arguments is not the definition in effect (it would not compile).
546
+ if (!arityFits(def, split.args)) {
547
+ def = scope.defs.find(other => other.functionLike && arityFits(other, split.args)) || def;
548
+ }
549
+ // Only alternatives of a macro that is actually expanded can change
550
+ // the produced names (an ambiguous object-like attribute cannot).
551
+ if (scope.ambiguous) site.ambiguous = true;
552
+ site.usedDefs.add(defKey(def));
553
+ out.push(...substitute(index, state, site, def, split.args, hide, depth + 1));
554
+ i = split.close;
555
+ }
556
+ return out;
557
+ }
558
+
559
+ function countErrors(root) {
560
+ if (!root.hasError) return 0;
561
+ let count = 0;
562
+ const stack = [root];
563
+ while (stack.length) {
564
+ const node = stack.pop();
565
+ if (node.type === 'ERROR' || node.isMissing) count++;
566
+ if (!node.hasError && node.type !== 'ERROR') continue;
567
+ for (let i = 0; i < node.childCount; i++) stack.push(node.child(i));
568
+ }
569
+ return count;
570
+ }
571
+
572
+ const DECLARATOR_PARENTS = new Set([
573
+ 'function_declarator', 'init_declarator', 'pointer_declarator',
574
+ 'array_declarator', 'parameter_declaration', 'declaration',
575
+ 'field_declaration', 'reference_declarator', 'parenthesized_declarator',
576
+ 'optional_parameter_declaration', 'variadic_declarator',
577
+ ]);
578
+
579
+ function calleeNameNode(fn) {
580
+ let node = fn;
581
+ for (let guard = 0; node && guard < 8; guard++) {
582
+ if (node.type === 'identifier') return node;
583
+ if (node.type === 'qualified_identifier' || node.type === 'template_function') {
584
+ node = node.childForFieldName('name');
585
+ continue;
586
+ }
587
+ return null;
588
+ }
589
+ return null;
590
+ }
591
+
592
+ function producesProjectName(index, tokens) {
593
+ for (let i = 0; i < tokens.length; i++) {
594
+ const t = tokens[i];
595
+ if (t.k !== 'id' || !index.symbols.has(t.v)) continue;
596
+ if (t.o === 'paste' || t.foreign) return true;
597
+ if (t.o === 'arg' && !t.srcCall && tokens[i + 1]?.v === '(') return true;
598
+ }
599
+ return false;
600
+ }
601
+
602
+ /**
603
+ * Parse the substituted token stream and classify the identifiers the
604
+ * expansion produced (not the replacement list's own literal tokens, which
605
+ * the macro-body scan already attributes to the definition).
606
+ */
607
+ function classifyExpansion(parser, tokens, preferred) {
608
+ const pieces = [];
609
+ const spans = [];
610
+ let offset = 0;
611
+ for (const t of tokens) {
612
+ if (t.k === 'placemarker') continue;
613
+ if (pieces.length) offset += 1;
614
+ spans.push({ start: offset, token: t });
615
+ pieces.push(t.v);
616
+ offset += t.v.length;
617
+ }
618
+ const text = pieces.join(' ');
619
+ let best = null;
620
+ for (const wrapper of preferred) {
621
+ const [prefix, suffix] = WRAPPERS[wrapper];
622
+ const tree = safeParse(parser, prefix + text + suffix, undefined, PARSE_OPTIONS);
623
+ const errors = countErrors(tree.rootNode);
624
+ if (!best || errors < best.errors) {
625
+ best?.tree.delete?.();
626
+ best = { tree, prefix, errors };
627
+ } else {
628
+ tree.delete?.();
629
+ }
630
+ if (errors === 0) break;
631
+ }
632
+ const tokenAt = new Map(spans.map(span => [span.start, span.token]));
633
+ const calls = [];
634
+ const refs = [];
635
+ const produced = (node) => {
636
+ const token = tokenAt.get(node.startIndex - best.prefix.length);
637
+ if (!token || token.v !== node.text) return null;
638
+ return token.o === 'paste' || token.o === 'arg' ? token : null;
639
+ };
640
+ const callees = new Set();
641
+ const stack = [best.tree.rootNode];
642
+ const found = [];
643
+ while (stack.length) {
644
+ const node = stack.pop();
645
+ if (node.type === 'call_expression') {
646
+ const nameNode = calleeNameNode(node.childForFieldName('function'));
647
+ if (nameNode) {
648
+ callees.add(nameNode.startIndex);
649
+ const token = produced(nameNode);
650
+ if (token) {
651
+ const args = node.childForFieldName('arguments');
652
+ const argCount = (args?.namedChildren || [])
653
+ .filter(child => child.type !== 'comment').length;
654
+ found.push({ kind: 'call', token, argCount, at: nameNode.startIndex });
655
+ }
656
+ }
657
+ } else if (node.type === 'identifier' && !callees.has(node.startIndex)) {
658
+ const parent = node.parent;
659
+ const declarator = parent && DECLARATOR_PARENTS.has(parent.type) &&
660
+ parent.childForFieldName('declarator')?.startIndex === node.startIndex;
661
+ const token = !declarator && produced(node);
662
+ if (token && (token.o === 'paste' || token.foreign)) {
663
+ found.push({ kind: 'ref', token, at: node.startIndex });
664
+ }
665
+ }
666
+ for (let i = node.childCount - 1; i >= 0; i--) stack.push(node.child(i));
667
+ }
668
+ for (const item of found) {
669
+ if (item.kind === 'call') calls.push(item);
670
+ else if (!callees.has(item.at)) refs.push(item);
671
+ }
672
+ best.tree.delete?.();
673
+ return { calls, refs };
674
+ }
675
+
676
+ /**
677
+ * classifyExpansion memoized per build (fix #365): the classification
678
+ * depends only on the stream's token texts and origins and the wrapper
679
+ * order, and invocations repeat (`cJSON_Add...ToObject(...)`, list
680
+ * entries). Results are stored as stream positions and rebound to the
681
+ * current tokens, whose source offsets differ per invocation.
682
+ */
683
+ function classifyExpansionMemo(state, parser, tokens, preferred) {
684
+ const kept = tokens.filter(t => t.k !== 'placemarker');
685
+ const key = preferred.join(',') + '\u0002' + kept.map(t =>
686
+ `${t.v}\u0001${t.o || ''}\u0001${t.foreign ? 1 : 0}`).join('\u0002');
687
+ if (!state.classifyMemo) state.classifyMemo = new Map();
688
+ let memo = state.classifyMemo.get(key);
689
+ if (!memo) {
690
+ const result = classifyExpansion(parser, tokens, preferred);
691
+ const position = new Map(kept.map((t, i) => [t, i]));
692
+ memo = {
693
+ calls: result.calls.map(c => ({ ...c, token: position.get(c.token) })),
694
+ refs: result.refs.map(r => ({ ...r, token: position.get(r.token) })),
695
+ };
696
+ state.classifyMemo.set(key, memo);
697
+ }
698
+ return {
699
+ calls: memo.calls.map(c => ({ ...c, token: kept[c.token] })),
700
+ refs: memo.refs.map(r => ({ ...r, token: kept[r.token] })),
701
+ };
702
+ }
703
+
704
+ function lineStarts(content) {
705
+ const starts = [0];
706
+ for (let i = 0; i < content.length; i++) {
707
+ if (content.charCodeAt(i) === 10) starts.push(i + 1);
708
+ }
709
+ return starts;
710
+ }
711
+
712
+ function positionOf(starts, offset) {
713
+ let lo = 0;
714
+ let hi = starts.length - 1;
715
+ while (lo < hi) {
716
+ const mid = (lo + hi + 1) >> 1;
717
+ if (starts[mid] <= offset) lo = mid;
718
+ else hi = mid - 1;
719
+ }
720
+ return { line: lo + 1, column: offset - starts[lo] };
721
+ }
722
+
723
+ /**
724
+ * Names of relevant (name-producing) macros a file can invoke: its own
725
+ * definitions and those of every file in its #include closure.
726
+ */
727
+ /** Identifier-shaped words of a file (a prefilter; invocations are token-checked). */
728
+ function wordsOf(state, filePath, text) {
729
+ let words = state.words.get(filePath);
730
+ if (!words) {
731
+ words = new Set(text.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []);
732
+ state.words.set(filePath, words);
733
+ }
734
+ return words;
735
+ }
736
+
737
+ /**
738
+ * The visible relevant macro names a file spells as whole identifiers: one
739
+ * alternation scan instead of collecting every identifier of the file into a
740
+ * set (fix #365; same identifier boundaries as wordsOf).
741
+ */
742
+ function namesSpelled(state, filePath, content, names) {
743
+ const cached = state.words.get(filePath);
744
+ if (cached) return new Set(names.filter(name => cached.has(name)));
745
+ if (names.length === 0) return new Set();
746
+ // A word is the part of a maximal [A-Za-z0-9_] run from its first
747
+ // non-digit on (`12ab` spells `ab`), exactly as wordsOf tokenizes.
748
+ const isWord = code => (code >= 48 && code <= 57) || (code >= 65 && code <= 90) ||
749
+ (code >= 97 && code <= 122) || code === 95;
750
+ const isDigit = code => code >= 48 && code <= 57;
751
+ const found = new Set();
752
+ for (const name of names) {
753
+ for (let at = content.indexOf(name); at >= 0; at = content.indexOf(name, at + 1)) {
754
+ const after = at + name.length;
755
+ if (after < content.length && isWord(content.charCodeAt(after))) continue;
756
+ let before = at - 1;
757
+ while (before >= 0 && isDigit(content.charCodeAt(before))) before--;
758
+ if (before >= 0 && isWord(content.charCodeAt(before))) continue;
759
+ found.add(name);
760
+ break;
761
+ }
762
+ }
763
+ return new Set(names.filter(name => found.has(name)));
764
+ }
765
+
766
+ function relevantNamesVisibleFrom(index, state, filePath) {
767
+ const memo = state.visibleMemo.get(filePath);
768
+ if (memo) return memo;
769
+ const result = relevantNamesVisibleFromUncached(index, state, filePath);
770
+ state.visibleMemo.set(filePath, result);
771
+ return result;
772
+ }
773
+
774
+ function relevantNamesVisibleFromUncached(index, state, filePath) {
775
+ if (!state.relevantFiles) {
776
+ state.relevantFiles = new Map();
777
+ for (const name of state.relevant) {
778
+ for (const def of state.templates.get(name) || []) {
779
+ if (!state.relevantFiles.has(def.file)) state.relevantFiles.set(def.file, new Set());
780
+ state.relevantFiles.get(def.file).add(name);
781
+ }
782
+ }
783
+ }
784
+ if (state.relevantFiles.size === 0) return [];
785
+ const names = new Set(state.relevantFiles.get(filePath) || []);
786
+ const { _textualIncludeClosures } = require('./callers');
787
+ for (const file of _textualIncludeClosures(index, filePath).all) {
788
+ for (const name of state.relevantFiles.get(file) || []) names.add(name);
789
+ }
790
+ return [...names].sort(codeUnitCompare);
791
+ }
792
+
793
+ /** Preprocessing tokens of a file outside directive lines. */
794
+ function codeTokens(content, lines) {
795
+ const directive = new Uint8Array(lines.length + 1);
796
+ for (let row = 0; row < lines.length; row++) {
797
+ const line = lines[row];
798
+ let k = 0;
799
+ while (k < line.length && (line[k] === ' ' || line[k] === '\t')) k++;
800
+ if (line[k] !== '#') continue;
801
+ directive[row] = 1;
802
+ while (row + 1 < lines.length && /\\\s*$/.test(lines[row])) directive[++row] = 1;
803
+ }
804
+ const starts = [0];
805
+ for (let row = 0; row < lines.length; row++) starts.push(starts[row] + lines[row].length + 1);
806
+ const tokens = [];
807
+ let row = 0;
808
+ for (const token of lexPP(content)) {
809
+ while (row + 1 < starts.length && starts[row + 1] <= token.start) row++;
810
+ if (!directive[row]) tokens.push(token);
811
+ }
812
+ return tokens;
813
+ }
814
+
815
+ function enclosingFunctionAt(fileEntry, line) {
816
+ let best = null;
817
+ for (const symbol of fileEntry?.symbols || []) {
818
+ if (symbol.type === 'macro' || symbol.startLine > line || symbol.endLine < line) continue;
819
+ if (!['function', 'method', 'constructor', 'destructor', 'static'].includes(symbol.type) &&
820
+ !symbol.isMethod) continue;
821
+ if (!best || symbol.startLine >= best.startLine) best = symbol;
822
+ }
823
+ return best ? {
824
+ name: best.name, startLine: best.startLine, endLine: best.endLine,
825
+ ...(best.className && { className: best.className }),
826
+ } : null;
827
+ }
828
+
829
+ /**
830
+ * Expand one invocation (tokens of `NAME ( args )`) in the given site
831
+ * context. Returns { calls, refs, defs } or throws BlindExpansion.
832
+ */
833
+ function expandInvocation(index, state, site, tokens, parser, preferred, trailing = []) {
834
+ const scope = definitionsInScope(index, state, site, tokens[0].v);
835
+ const functionDefs = scope.defs.filter(def => def.functionLike);
836
+ if (functionDefs.length === 0) return null;
837
+ const split = splitArguments(tokens, 1);
838
+ if (!split) throw new BlindExpansion('unbalanced-arguments');
839
+ // Alternatives whose parameter list cannot take these arguments are not
840
+ // the definition in effect; no fitting alternative is a blind site.
841
+ const fitting = functionDefs.filter(def => arityFits(def, split.args));
842
+ if (fitting.length === 0) throw new BlindExpansion('argument-count');
843
+ const variants = [];
844
+ for (const def of fitting) {
845
+ site.budget = MAX_TOKENS;
846
+ site.usedDefs.add(defKey(def));
847
+ const expanded = substitute(index, state, site, def, split.args, new Set(), 0);
848
+ // Parsing is the expensive step. Only an expansion that produced a
849
+ // project name by pasting, or moved an argument identifier into
850
+ // call position, can yield an edge the source does not spell.
851
+ // The preprocessor rescans the expansion together with the source
852
+ // that follows it: `CAT(uv_, run)()` calls uv_run with the `()`
853
+ // written after the invocation.
854
+ const stream = trailing.length > 0 ? expanded.concat(trailing) : expanded;
855
+ variants.push({ def, ...(producesProjectName(index, stream)
856
+ ? classifyExpansionMemo(state, parser, stream, preferred) : { calls: [], refs: [] }) });
857
+ }
858
+ // Top-level alternatives are all expanded; a name every alternative
859
+ // produces is unambiguous. Unexpanded alternatives (object-like, over
860
+ // the variant cap) or ambiguous nested macros leave every name open.
861
+ const ambiguous = scope.truncated || functionDefs.length !== scope.defs.length || site.ambiguous;
862
+ return { variants, ambiguous };
863
+ }
864
+
865
+ function fileExpansion(index, filePath, rawCalls) {
866
+ const state = expansionState(index);
867
+ let fileState = state.byFile.get(filePath);
868
+ if (fileState && fileState.raw === rawCalls) return fileState;
869
+ fileState = {
870
+ raw: rawCalls, calls: rawCalls, refs: [], blind: [], usedDefs: new Set(), sites: 0,
871
+ records: [], targets: [],
872
+ };
873
+ state.byFile.set(filePath, fileState);
874
+ state.summary = null;
875
+ if (state.relevant.size === 0 || !Array.isArray(rawCalls)) return fileState;
876
+ const fileEntry = index.files.get(filePath);
877
+ if (!langTraits(fileEntry?.language)?.textualIncludes) return fileState;
878
+ const reused = persistedFileExpansion(index, state, fileEntry);
879
+ if (reused) {
880
+ Object.assign(fileState, reused, {
881
+ calls: reused.records.length > 0 ? rawCalls.concat(reused.records) : rawCalls,
882
+ });
883
+ state.persistOut.set(fileEntry.relativePath, persistRecord(fileEntry, fileState));
884
+ return fileState;
885
+ }
886
+ computeFileExpansion(index, state, filePath, rawCalls, fileState, fileEntry);
887
+ state.persistOut.set(fileEntry.relativePath, persistRecord(fileEntry, fileState));
888
+ index.macroExpansionDirty = true;
889
+ return fileState;
890
+ }
891
+
892
+ /** Persisted shape of one file's expansion (index cache, fix #362). */
893
+ function persistRecord(fileEntry, fileState) {
894
+ return {
895
+ hash: fileEntry.hash,
896
+ targets: fileState.targets,
897
+ records: fileState.records,
898
+ refs: fileState.refs,
899
+ blind: fileState.blind,
900
+ usedDefs: [...fileState.usedDefs].sort(codeUnitCompare),
901
+ sites: fileState.sites,
902
+ };
903
+ }
904
+
905
+ /** Version token of an include target: indexed content hash, else size+mtime. */
906
+ function targetVersion(index, target) {
907
+ const entry = index.files.get(target);
908
+ if (entry) return `h:${entry.hash}`;
909
+ try {
910
+ const stat = fs.statSync(target);
911
+ return `s:${stat.size}:${stat.mtimeMs}`;
912
+ } catch {
913
+ return 'missing';
914
+ }
915
+ }
916
+
917
+ /**
918
+ * A persisted expansion is reused only when every input is unchanged: the
919
+ * file's content hash, each include target it read, and the project-wide
920
+ * signature of macro definitions and the include graph.
921
+ */
922
+ function persistedFileExpansion(index, state, fileEntry) {
923
+ const persisted = index._macroExpansionPersisted;
924
+ if (!persisted || persisted.signature !== expansionSignature(index, state)) return null;
925
+ const entry = persisted.byFile?.[fileEntry.relativePath];
926
+ if (!entry || entry.hash !== fileEntry.hash) return null;
927
+ for (const [rel, version] of entry.targets || []) {
928
+ if (targetVersion(index, path.join(index.root, rel)) !== version) return null;
929
+ }
930
+ return {
931
+ records: entry.records || [],
932
+ refs: entry.refs || [],
933
+ blind: entry.blind || [],
934
+ usedDefs: new Set(entry.usedDefs || []),
935
+ sites: entry.sites || 0,
936
+ targets: entry.targets || [],
937
+ };
938
+ }
939
+
940
+ /** Hash of every C/C++ macro definition and the C/C++ include graph. */
941
+ function expansionSignature(index, state) {
942
+ if (state.signature) return state.signature;
943
+ const hash = crypto.createHash('md5');
944
+ for (const name of [...state.macroDefs.keys()].sort(codeUnitCompare)) {
945
+ for (const def of state.macroDefs.get(name)) {
946
+ hash.update(`${name}\0${def.relativePath}\0${def.startLine}\0${def.endLine}\0` +
947
+ `${def.functionLike ? 1 : 0}\0${(def.ppParams || []).join(',')}\0` +
948
+ `${def.ppVariadic ? 1 : 0}\0${def.ppBody ?? ''}\n`);
949
+ }
950
+ }
951
+ const files = [...index.files.values()]
952
+ .filter(entry => langTraits(entry.language)?.textualIncludes)
953
+ .map(entry => entry.path).sort(codeUnitCompare);
954
+ for (const file of files) {
955
+ const edges = [...(index.importGraph?.get(file) || [])].sort(codeUnitCompare)
956
+ .map(target => path.relative(index.root, target));
957
+ hash.update(`${path.relative(index.root, file)}>${edges.join(',')}\n`);
958
+ }
959
+ state.signature = hash.digest('hex');
960
+ return state.signature;
961
+ }
962
+
963
+ /** Index-cache payload: every file expansion known this session. */
964
+ function persistableMacroExpansion(index) {
965
+ const state = index._macroExpansion;
966
+ const persisted = index._macroExpansionPersisted;
967
+ if (!state) return persisted || null;
968
+ const byFile = {};
969
+ // Carry forward persisted entries this session did not touch; their
970
+ // own validity checks run when they are next used.
971
+ if (persisted && persisted.signature === expansionSignature(index, state)) {
972
+ for (const [rel, entry] of Object.entries(persisted.byFile || {})) byFile[rel] = entry;
973
+ }
974
+ for (const [rel, entry] of state.persistOut) byFile[rel] = entry;
975
+ return { signature: expansionSignature(index, state), byFile };
976
+ }
977
+
978
+ function computeFileExpansion(index, state, filePath, rawCalls, fileState, fileEntry) {
979
+ // Invocation recognition is the preprocessor's own: a macro name token
980
+ // followed by `(` outside directive lines. Call records are not enough:
981
+ // statement-position invocations (`switch (e) { LIST(X) default: ... }`)
982
+ // are recovered as declarations or blanked by macro recovery.
983
+ const visibleRelevant = relevantNamesVisibleFrom(index, state, filePath);
984
+ // Quoted includes whose text may invoke a macro this file can see but
985
+ // the included file cannot (`#define X ...` + `#include "list.def"`, or
986
+ // a list header included after the header defining its macro).
987
+ const includeSites = visibleRelevant.length === 0 ? [] : (fileEntry.importDetails || [])
988
+ .filter(detail => detail.type === 'include' && Number.isInteger(detail.line));
989
+ if (visibleRelevant.length === 0 && includeSites.length === 0) return fileState;
990
+
991
+ let content;
992
+ try {
993
+ content = index._readFile(filePath);
994
+ } catch {
995
+ return fileState;
996
+ }
997
+ // Own words are needed once; only include targets are revisited.
998
+ const namesHere = namesSpelled(state, filePath, content, visibleRelevant);
999
+ if (namesHere.size === 0 && includeSites.length === 0) return fileState;
1000
+ const lines = content.split('\n');
1001
+ const starts = lineStarts(content);
1002
+ const parser = getParser(fileEntry.language);
1003
+ const records = [];
1004
+ // An argument-substituted call the parser already recorded at its own
1005
+ // spelling (`X(CLOSE, uv__fs_close(fd))`) is not a second call.
1006
+ let seenKeys = null; // built on first use: most files emit nothing (fix #365)
1007
+ const seen = {
1008
+ has: key => (seenKeys ||= new Set(rawCalls.map(call =>
1009
+ `${call.name}\0${call.line}\0${call.column}`))).has(key),
1010
+ add: key => (seenKeys ||= new Set(rawCalls.map(call =>
1011
+ `${call.name}\0${call.line}\0${call.column}`))).add(key),
1012
+ };
1013
+ const makeSite = line => ({
1014
+ file: filePath, line, lines, fileState, lookupMemo: new Map(),
1015
+ usedDefs: fileState.usedDefs, budget: MAX_TOKENS, ambiguous: false,
1016
+ });
1017
+ const emit = (result, invocationName, anchor, enclosingFunction) => {
1018
+ const byName = new Map();
1019
+ for (const variant of result.variants) {
1020
+ for (const call of variant.calls) {
1021
+ const token = call.token;
1022
+ const position = token.o === 'arg' && Number.isInteger(token.pos)
1023
+ ? positionOf(starts, token.pos) : anchor;
1024
+ const key = `${token.v}\0${position.line}\0${position.column}`;
1025
+ if (!byName.has(key)) {
1026
+ byName.set(key, { call, token, position, defs: new Set(), count: 0 });
1027
+ }
1028
+ const entry = byName.get(key);
1029
+ if (!entry.defs.has(variant.def)) entry.count++;
1030
+ entry.defs.add(variant.def);
1031
+ }
1032
+ for (const ref of variant.refs) fileState.refs.push(ref.token.v);
1033
+ }
1034
+ for (const [key, entry] of byName) {
1035
+ if (seen.has(key)) continue;
1036
+ // A macro name the expansion could not expand further is an
1037
+ // unresolved invocation, not a call of a function.
1038
+ if (!entry.token.noexpand && state.macroDefs.has(entry.token.v) &&
1039
+ (index.symbols.get(entry.token.v) || []).every(def => def.type === 'macro')) continue;
1040
+ seen.add(key);
1041
+ const def = [...entry.defs][0];
1042
+ const ambiguous = result.ambiguous || entry.count !== result.variants.length;
1043
+ records.push({
1044
+ name: entry.token.v,
1045
+ line: entry.position.line,
1046
+ column: entry.position.column,
1047
+ isMethod: false,
1048
+ argCount: entry.call.argCount,
1049
+ ...(enclosingFunction && { enclosingFunction }),
1050
+ macroExpansion: {
1051
+ macro: invocationName,
1052
+ definition: defKey(def),
1053
+ origin: entry.token.o === 'arg' ? 'argument' : 'paste',
1054
+ ...(ambiguous && { ambiguous: true }),
1055
+ },
1056
+ });
1057
+ }
1058
+ };
1059
+ if (namesHere.size > 0) {
1060
+ const tokens = codeTokens(content, lines);
1061
+ for (let i = 0; i < tokens.length; i++) {
1062
+ const t = tokens[i];
1063
+ if (t.k !== 'id' || !namesHere.has(t.v) || tokens[i + 1]?.v !== '(') continue;
1064
+ // A macro invoked inside another invocation's arguments is
1065
+ // expanded with that invocation (argument pre-expansion).
1066
+ const split = splitArguments(tokens, i + 1);
1067
+ const anchor = positionOf(starts, t.start);
1068
+ if (!split) {
1069
+ fileState.blind.push({ line: anchor.line, macro: t.v, reason: 'unbalanced-arguments' });
1070
+ continue;
1071
+ }
1072
+ const invocation = [];
1073
+ for (let j = i; j <= split.close; j++) {
1074
+ const token = tokens[j];
1075
+ invocation.push({
1076
+ v: token.v, k: token.k, o: j === i ? 'site' : 'arg', pos: token.start,
1077
+ // Already spelled as a call where it is written: the
1078
+ // parser recorded that call at this position.
1079
+ ...(token.k === 'id' && tokens[j + 1]?.v === '(' && { srcCall: true }),
1080
+ });
1081
+ }
1082
+ const site = makeSite(anchor.line);
1083
+ const enclosingFunction = enclosingFunctionAt(fileEntry, anchor.line);
1084
+ try {
1085
+ const after = splitArguments(tokens, split.close + 1);
1086
+ const trailing = after
1087
+ ? tokens.slice(split.close + 1, after.close + 1).map(token =>
1088
+ ({ v: token.v, k: token.k, o: 'trail' }))
1089
+ : [];
1090
+ const result = expandInvocation(index, state, site, invocation, parser,
1091
+ enclosingFunction ? ['statement', 'initializer', 'file']
1092
+ : ['file', 'initializer', 'statement'], trailing);
1093
+ if (!result) continue;
1094
+ fileState.sites++;
1095
+ emit(result, t.v, anchor, enclosingFunction);
1096
+ i = split.close;
1097
+ } catch (error) {
1098
+ if (!(error instanceof BlindExpansion)) throw error;
1099
+ fileState.blind.push({ line: anchor.line, macro: t.v, reason: error.reason });
1100
+ i = split.close;
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ // Includer-defined macros: `#define X(n) {#n, handle_##n},` then
1106
+ // `#include "list.def"` expands the included file's `X(...)` lines with
1107
+ // the includer's definition (the X-macro table pattern).
1108
+ for (const detail of includeSites) {
1109
+ const resolvedRel = fileEntry.moduleResolved?.[detail.module];
1110
+ const target = resolvedRel ? path.join(index.root, resolvedRel)
1111
+ : path.resolve(path.dirname(filePath), detail.module);
1112
+ if (!target.startsWith(index.root + path.sep) || target === filePath) continue;
1113
+ let text;
1114
+ try {
1115
+ text = index.files.has(target) ? index._readFile(target) : fs.readFileSync(target, 'utf-8');
1116
+ } catch {
1117
+ continue;
1118
+ }
1119
+ fileState.targets.push([path.relative(index.root, target), targetVersion(index, target)]);
1120
+ const targetWords = wordsOf(state, target, text);
1121
+ const candidates = visibleRelevant.filter(name => targetWords.has(name));
1122
+ if (candidates.length === 0) continue;
1123
+ const ownNames = new Set(relevantNamesVisibleFrom(index, state, target));
1124
+ const names = new Set(candidates.filter(name => !ownNames.has(name)));
1125
+ if (names.size === 0) continue;
1126
+ let tokens = state.includeTokens.get(target);
1127
+ if (!tokens) {
1128
+ tokens = codeTokens(text, text.split('\n'));
1129
+ state.includeTokens.set(target, tokens);
1130
+ }
1131
+ const enclosingFunction = enclosingFunctionAt(fileEntry, detail.line);
1132
+ for (let i = 0; i < tokens.length; i++) {
1133
+ const t = tokens[i];
1134
+ if (t.k !== 'id' || !names.has(t.v) || tokens[i + 1]?.v !== '(') continue;
1135
+ const split = splitArguments(tokens, i + 1);
1136
+ const site = makeSite(detail.line);
1137
+ if (!split) {
1138
+ fileState.blind.push({ line: detail.line, macro: t.v, reason: 'unbalanced-arguments' });
1139
+ continue;
1140
+ }
1141
+ // Tokens of an unindexed included file (`list.def`) are seen by no
1142
+ // other scan, so identifiers it passes in value position count
1143
+ // as produced references too.
1144
+ const foreign = !index.files.has(target);
1145
+ const invocation = [];
1146
+ for (let j = i; j <= split.close; j++) {
1147
+ const token = tokens[j];
1148
+ invocation.push(j === i ? { ...token, o: 'site' } : {
1149
+ ...token, o: 'arg',
1150
+ ...(foreign && { foreign: true }),
1151
+ ...(!foreign && token.k === 'id' && tokens[j + 1]?.v === '(' && { srcCall: true }),
1152
+ });
1153
+ }
1154
+ try {
1155
+ const result = expandInvocation(index, state, site, invocation, parser,
1156
+ enclosingFunction ? ['statement', 'initializer', 'file']
1157
+ : ['initializer', 'file', 'statement']);
1158
+ if (result) {
1159
+ fileState.sites++;
1160
+ emit(result, t.v, { line: detail.line, column: 0 }, enclosingFunction);
1161
+ }
1162
+ } catch (error) {
1163
+ if (!(error instanceof BlindExpansion)) throw error;
1164
+ fileState.blind.push({ line: detail.line, macro: t.v, reason: error.reason });
1165
+ }
1166
+ i = split.close;
1167
+ }
1168
+ }
1169
+ if (records.length > 0) {
1170
+ records.sort((a, b) => a.line - b.line || a.column - b.column ||
1171
+ codeUnitCompare(a.name, b.name));
1172
+ fileState.records = records;
1173
+ fileState.calls = rawCalls.concat(records);
1174
+ }
1175
+ fileState.refs = [...new Set(fileState.refs)].sort(codeUnitCompare);
1176
+ return fileState;
1177
+ }
1178
+
1179
+ /**
1180
+ * Paste chains of a function-like macro: each `a ## b ## c` run that
1181
+ * involves a parameter, as operands `{ param }` / `{ literal }`.
1182
+ */
1183
+ function pasteChains(params, body) {
1184
+ const tokens = lexPP(body);
1185
+ const paramSet = new Set(params);
1186
+ const chains = [];
1187
+ for (let i = 0; i < tokens.length; i++) {
1188
+ if (tokens[i + 1]?.v !== '##') continue;
1189
+ const operands = [tokens[i]];
1190
+ let j = i + 1;
1191
+ while (tokens[j]?.v === '##' && tokens[j + 1]) {
1192
+ operands.push(tokens[j + 1]);
1193
+ j += 2;
1194
+ }
1195
+ i = j - 1;
1196
+ if (operands.some(op => op.k !== 'id' && op.k !== 'num')) continue;
1197
+ if (!operands.some(op => op.k === 'id' && paramSet.has(op.v))) continue;
1198
+ chains.push(operands.map(op => (op.k === 'id' && paramSet.has(op.v)
1199
+ ? { param: op.v } : { literal: op.v })));
1200
+ }
1201
+ return chains;
1202
+ }
1203
+
1204
+ /**
1205
+ * Arguments of the invocation whose `(` is at `open`, read from the raw
1206
+ * text: [argText] with whitespace removed, or null when the text holds a
1207
+ * literal or a comment there (the caller then lexes the file) or the list
1208
+ * does not close within a bound.
1209
+ */
1210
+ function rawInvocationArguments(content, open) {
1211
+ const args = [];
1212
+ let depth = 0;
1213
+ let current = '';
1214
+ for (let k = open + 1; k < content.length && k - open < 4096; k++) {
1215
+ const ch = content[k];
1216
+ if (ch === '"' || ch === "'" || (ch === '/' && (content[k + 1] === '/' || content[k + 1] === '*'))) return null;
1217
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
1218
+ else if (ch === ')' || ch === ']' || ch === '}') {
1219
+ if (depth === 0) {
1220
+ if (ch !== ')') return null;
1221
+ args.push(current);
1222
+ return args;
1223
+ }
1224
+ depth--;
1225
+ } else if (ch === ',' && depth === 0) {
1226
+ args.push(current);
1227
+ current = '';
1228
+ continue;
1229
+ }
1230
+ if (!/\s/.test(ch)) current += ch;
1231
+ }
1232
+ return null;
1233
+ }
1234
+
1235
+ /** May an invocation in `content` of a macro in `byName` paste `name`? */
1236
+ function mayPasteName(content, byName, name) {
1237
+ for (const [macroName, macros] of byName) {
1238
+ for (let at = content.indexOf(macroName); at >= 0; at = content.indexOf(macroName, at + 1)) {
1239
+ if (at > 0 && /[A-Za-z0-9_]/.test(content[at - 1])) continue;
1240
+ let k = at + macroName.length;
1241
+ if (/[A-Za-z0-9_]/.test(content[k] || '')) continue;
1242
+ while (k < content.length && (content[k] === ' ' || content[k] === '\t')) k++;
1243
+ if (content[k] !== '(') continue;
1244
+ const args = rawInvocationArguments(content, k);
1245
+ if (!args) return true;
1246
+ for (const macro of macros) {
1247
+ const params = macro.symbol.ppParams;
1248
+ const argText = param => {
1249
+ const index = params.indexOf(param);
1250
+ return index >= 0 && args[index] ? args[index] : '';
1251
+ };
1252
+ if (macro.chains.some(chain => chain.map(op => op.literal ?? argText(op.param)).join('') === name)) {
1253
+ return true;
1254
+ }
1255
+ }
1256
+ }
1257
+ }
1258
+ return false;
1259
+ }
1260
+
1261
+ /**
1262
+ * fix #396: where token pasting can spell `name` without writing it. Returns
1263
+ * { macros: [{ symbol, spelling }], sites: [{ file, line, macro }] }: the
1264
+ * project function-like macros whose replacement list pastes a parameter
1265
+ * into a token that can be `name` (`struct sdshdr##T`), and their
1266
+ * invocations (outside directive lines) whose arguments make it exactly
1267
+ * `name` (`SDS_HDR_VAR(8, s)`). Arguments next to `##` are pasted as
1268
+ * written (C11 6.10.3.3), so no expansion is needed. Memoized per operation.
1269
+ */
1270
+ function pastedNameSites(index, name) {
1271
+ const memo = index._opMemo?.('pastedNameSites', () => new Map());
1272
+ if (memo?.has(name)) return memo.get(name);
1273
+ const { matchesPastePattern } = require('../languages/c-preprocessor');
1274
+ const macros = [];
1275
+ for (const defs of index.symbols.values()) {
1276
+ for (const symbol of defs) {
1277
+ if (symbol.type !== 'macro' || !Array.isArray(symbol.ppParams) ||
1278
+ typeof symbol.ppBody !== 'string' || !symbol.ppBody.includes('##')) continue;
1279
+ const chains = pasteChains(symbol.ppParams, symbol.ppBody).filter(chain =>
1280
+ matchesPastePattern(chain.map(op => op.literal ?? null), name));
1281
+ if (chains.length === 0) continue;
1282
+ macros.push({
1283
+ symbol, chains,
1284
+ spelling: chains[0].map(op => op.literal ?? op.param).join(' ## '),
1285
+ });
1286
+ }
1287
+ }
1288
+ macros.sort((a, b) => codeUnitCompare(a.symbol.file, b.symbol.file) ||
1289
+ a.symbol.startLine - b.symbol.startLine);
1290
+ const sites = [];
1291
+ const byName = new Map();
1292
+ for (const macro of macros) {
1293
+ if (!byName.has(macro.symbol.name)) byName.set(macro.symbol.name, []);
1294
+ byName.get(macro.symbol.name).push(macro);
1295
+ }
1296
+ if (byName.size > 0) {
1297
+ const files = [...index.files.keys()].sort(codeUnitCompare);
1298
+ for (const file of files) {
1299
+ const entry = index.files.get(file);
1300
+ if (!langTraits(entry?.language)?.textualIncludes) continue;
1301
+ let content;
1302
+ try {
1303
+ content = index._readFile(file);
1304
+ } catch {
1305
+ continue;
1306
+ }
1307
+ if (![...byName.keys()].some(macroName => content.includes(macroName))) continue;
1308
+ // Lex the file only when an invocation there may paste `name`:
1309
+ // most files invoke a generic pasting macro with other arguments.
1310
+ if (!mayPasteName(content, byName, name)) continue;
1311
+ const lines = content.split('\n');
1312
+ const starts = lineStarts(content);
1313
+ const tokens = codeTokens(content, lines);
1314
+ for (let i = 0; i < tokens.length; i++) {
1315
+ const candidates = byName.get(tokens[i].v);
1316
+ if (!candidates || tokens[i + 1]?.v !== '(') continue;
1317
+ const split = splitArguments(tokens, i + 1);
1318
+ if (!split) continue;
1319
+ const line = positionOf(starts, tokens[i].start).line;
1320
+ for (const macro of candidates) {
1321
+ const params = macro.symbol.ppParams;
1322
+ const argText = param => {
1323
+ const at = params.indexOf(param);
1324
+ return at >= 0 && split.args[at] ? split.args[at].map(token => token.v).join('') : '';
1325
+ };
1326
+ if (macro.chains.some(chain => chain.map(op => op.literal ?? argText(op.param)).join('') === name)) {
1327
+ sites.push({ file, line, macro: macro.symbol.name });
1328
+ break;
1329
+ }
1330
+ }
1331
+ i = split.close;
1332
+ }
1333
+ }
1334
+ }
1335
+ const result = { macros, sites };
1336
+ memo?.set(name, result);
1337
+ return result;
1338
+ }
1339
+
1340
+ /** Calls of a file including the call records its macro invocations expand to. */
1341
+ function withMacroExpansion(index, filePath, rawCalls, options = {}) {
1342
+ if (!Array.isArray(rawCalls) || !index?.symbols) return rawCalls;
1343
+ // Rust macro_rules! expansions (fix #374) derive their calls on first use.
1344
+ if (index.files.get(filePath)?.rustMacroExpansion?.callsPending) {
1345
+ return require('./rust-macro-expansion').rustExpandedCalls(index, filePath, rawCalls, options);
1346
+ }
1347
+ if (!langTraits(index.files.get(filePath)?.language)?.textualIncludes) return rawCalls;
1348
+ return fileExpansion(index, filePath, rawCalls).calls;
1349
+ }
1350
+
1351
+ /**
1352
+ * Project-wide inventory for deadcode and health: expanded invocation sites,
1353
+ * names produced in value position, blind invocations, and the paste
1354
+ * patterns whose produced names cannot be enumerated (a pasting macro with
1355
+ * a blind invocation or with no expandable invocation at all).
1356
+ */
1357
+ function macroExpansionSummary(index) {
1358
+ const state = expansionState(index);
1359
+ if (state.summary) return state.summary;
1360
+ const summary = {
1361
+ sites: 0, calls: 0, refNames: new Set(),
1362
+ blind: { count: 0, fileCount: 0, files: [], sample: [] },
1363
+ patterns: [],
1364
+ };
1365
+ if (state.relevant.size === 0) {
1366
+ state.summary = summary;
1367
+ return summary;
1368
+ }
1369
+ const { getCachedCalls } = require('./callers');
1370
+ const usedDefs = new Set();
1371
+ const blindDefs = new Set();
1372
+ const files = [...index.files.keys()].sort(codeUnitCompare);
1373
+ for (const filePath of files) {
1374
+ const fileEntry = index.files.get(filePath);
1375
+ if (!langTraits(fileEntry?.language)?.textualIncludes) continue;
1376
+ getCachedCalls(index, filePath);
1377
+ const fileState = state.byFile.get(filePath);
1378
+ if (!fileState) continue;
1379
+ summary.sites += fileState.sites;
1380
+ summary.calls += fileState.calls.length - fileState.raw.length;
1381
+ for (const name of fileState.refs) summary.refNames.add(name);
1382
+ for (const key of fileState.usedDefs) usedDefs.add(key);
1383
+ if (fileState.blind.length > 0) {
1384
+ summary.blind.count += fileState.blind.length;
1385
+ summary.blind.fileCount++;
1386
+ if (summary.blind.files.length < 10) summary.blind.files.push(fileEntry.relativePath);
1387
+ for (const site of fileState.blind) {
1388
+ blindDefs.add(site.macro);
1389
+ if (summary.blind.sample.length < 10) {
1390
+ summary.blind.sample.push({ file: fileEntry.relativePath, ...site });
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ for (const [name, defs] of state.templates) {
1396
+ for (const def of defs) {
1397
+ // A pattern no project name matches withholds nothing.
1398
+ const patterns = pastePatterns({ params: def.ppParams || [], body: def.ppBody })
1399
+ .filter(pattern => patternSpellsProjectName(index, pattern));
1400
+ if (patterns.length === 0) continue;
1401
+ const unexpanded = !usedDefs.has(defKey(def));
1402
+ if (!unexpanded && !blindDefs.has(name)) continue;
1403
+ for (const pattern of patterns) {
1404
+ summary.patterns.push({
1405
+ pattern, macro: name, file: def.relativePath, line: def.startLine,
1406
+ reason: unexpanded ? 'no-expanded-invocation' : 'blind-invocation',
1407
+ });
1408
+ }
1409
+ }
1410
+ }
1411
+ state.summary = summary;
1412
+ return summary;
1413
+ }
1414
+
1415
+ module.exports = { withMacroExpansion, macroExpansionSummary, expansionState, persistableMacroExpansion,
1416
+ macroDefinitionsAt, macroBodyTokens, codeTokens, pastedNameSites };