@specverse/engines 6.32.0 → 6.33.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/dist/ai/analyse-runner.d.ts.map +1 -1
- package/dist/ai/analyse-runner.js +8 -1
- package/dist/ai/analyse-runner.js.map +1 -1
- package/dist/ai/behaviours-runner.d.ts.map +1 -1
- package/dist/ai/behaviours-runner.js +35 -7
- package/dist/ai/behaviours-runner.js.map +1 -1
- package/dist/ai/create-runner.d.ts.map +1 -1
- package/dist/ai/create-runner.js +6 -0
- package/dist/ai/create-runner.js.map +1 -1
- package/dist/ai/deployment-emitter.d.ts +3 -0
- package/dist/ai/deployment-emitter.d.ts.map +1 -1
- package/dist/ai/deployment-emitter.js +145 -0
- package/dist/ai/deployment-emitter.js.map +1 -1
- package/dist/ai/manifest-emitter.d.ts +35 -10
- package/dist/ai/manifest-emitter.d.ts.map +1 -1
- package/dist/ai/manifest-emitter.js +140 -54
- package/dist/ai/manifest-emitter.js.map +1 -1
- package/dist/ai/skeleton-emitter.d.ts +1 -1
- package/dist/ai/skeleton-emitter.d.ts.map +1 -1
- package/dist/ai/skeleton-emitter.js +152 -14
- package/dist/ai/skeleton-emitter.js.map +1 -1
- package/dist/analyse-prepass/imports-graph.d.ts +407 -0
- package/dist/analyse-prepass/imports-graph.d.ts.map +1 -0
- package/dist/analyse-prepass/imports-graph.js +1200 -0
- package/dist/analyse-prepass/imports-graph.js.map +1 -0
- package/dist/analyse-prepass/index.d.ts +33 -0
- package/dist/analyse-prepass/index.d.ts.map +1 -1
- package/dist/analyse-prepass/index.js +35 -0
- package/dist/analyse-prepass/index.js.map +1 -1
- package/dist/inference/logical/generators/view-generator.d.ts +10 -0
- package/dist/inference/logical/generators/view-generator.d.ts.map +1 -1
- package/dist/inference/logical/generators/view-generator.js +20 -0
- package/dist/inference/logical/generators/view-generator.js.map +1 -1
- package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +66 -4
- package/dist/parser/unified-parser.d.ts.map +1 -1
- package/dist/parser/unified-parser.js +103 -0
- package/dist/parser/unified-parser.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +73 -148
- package/dist/realize/index.js.map +1 -1
- package/dist/realize/per-action-emitter.d.ts +235 -0
- package/dist/realize/per-action-emitter.d.ts.map +1 -0
- package/dist/realize/per-action-emitter.js +229 -0
- package/dist/realize/per-action-emitter.js.map +1 -0
- package/dist/realize/per-action-llm-emit.d.ts +87 -0
- package/dist/realize/per-action-llm-emit.d.ts.map +1 -0
- package/dist/realize/per-action-llm-emit.js +427 -0
- package/dist/realize/per-action-llm-emit.js.map +1 -0
- package/dist/realize/per-action-runner.d.ts +127 -0
- package/dist/realize/per-action-runner.d.ts.map +1 -0
- package/dist/realize/per-action-runner.js +269 -0
- package/dist/realize/per-action-runner.js.map +1 -0
- package/dist/realize/structural-validator.d.ts +71 -0
- package/dist/realize/structural-validator.d.ts.map +1 -0
- package/dist/realize/structural-validator.js +167 -0
- package/dist/realize/structural-validator.js.map +1 -0
- package/libs/instance-factories/orms/templates/prisma/__tests__/schema-generator.test.ts +416 -0
- package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +182 -5
- package/package.json +3 -3
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-component imports graph (engines 6.32.6+).
|
|
3
|
+
*
|
|
4
|
+
* Phase A of Dom Williams's "shared library / reference vs implicit
|
|
5
|
+
* include" feedback (2026-05-07). When the analysed source is a
|
|
6
|
+
* monorepo with shared/ libraries (idle_meta has shared/services/,
|
|
7
|
+
* shared/ui/, shared/data/, shared/schemas/), the prepass already
|
|
8
|
+
* decomposes the source into one component per top-level dir — but
|
|
9
|
+
* never models the dependency direction. `IdleApiService` ends up
|
|
10
|
+
* with action steps like `[call] AuthService.login` without any
|
|
11
|
+
* spec-level binding to the `Services` component that owns AuthService.
|
|
12
|
+
*
|
|
13
|
+
* This module walks each component's TS/JS files for `import { X } from
|
|
14
|
+
* '...'` statements, resolves the import path relative to the source
|
|
15
|
+
* tree, and matches the resolved path against the other components'
|
|
16
|
+
* sourceDirs. Result: a map of consumer → (target component → set of
|
|
17
|
+
* imported symbol names). The skeleton-emitter consumes that map to
|
|
18
|
+
* emit `import:` blocks per consumer component.
|
|
19
|
+
*
|
|
20
|
+
* Detection is regex-based — same approach as the express-routes /
|
|
21
|
+
* zod-schemas adapters. Good enough for ES module + CommonJS source;
|
|
22
|
+
* misses dynamic `await import()` and `require(variable)` (rare in
|
|
23
|
+
* spec-able codebases). Type-only imports are included; the realize
|
|
24
|
+
* engine will ignore unused selects.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Extract all import statements from a TS/JS source file. Handles:
|
|
28
|
+
* - `import { A, B as C } from 'mod'` — named
|
|
29
|
+
* - `import D from 'mod'` — default
|
|
30
|
+
* - `import * as M from 'mod'` — namespace
|
|
31
|
+
* - `import 'mod'` — side-effect (returns names: [])
|
|
32
|
+
* - `import type { A } from 'mod'` — type-only
|
|
33
|
+
* - `const { A } = require('mod')` — CommonJS destructuring
|
|
34
|
+
*
|
|
35
|
+
* Returns an empty array on empty input. Skips comments and string
|
|
36
|
+
* literals (basic heuristic — strips block comments and line comments).
|
|
37
|
+
*/
|
|
38
|
+
export function parseImports(source) {
|
|
39
|
+
if (!source)
|
|
40
|
+
return [];
|
|
41
|
+
// Strip comments — naive but adequate; we don't need exact lex
|
|
42
|
+
const cleaned = source
|
|
43
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
44
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
45
|
+
const out = [];
|
|
46
|
+
// ES module imports: `import [type] (...) from 'mod'`
|
|
47
|
+
// Cases:
|
|
48
|
+
// import { A, B as C } from 'mod'
|
|
49
|
+
// import D from 'mod'
|
|
50
|
+
// import D, { A } from 'mod'
|
|
51
|
+
// import * as M from 'mod'
|
|
52
|
+
// import 'mod' (side-effect)
|
|
53
|
+
// import type { A } from 'mod'
|
|
54
|
+
const esRe = /import\s+(type\s+)?(?:([\w$]+)(?:\s*,\s*\{([^}]*)\})?|(\{[^}]*\})|(\*\s+as\s+[\w$]+))?\s*(?:from\s+)?['"]([^'"]+)['"]/g;
|
|
55
|
+
let m;
|
|
56
|
+
while ((m = esRe.exec(cleaned)) !== null) {
|
|
57
|
+
const typeOnly = !!m[1];
|
|
58
|
+
const defaultName = m[2];
|
|
59
|
+
const namedAfterDefault = m[3];
|
|
60
|
+
const namedOnly = m[4];
|
|
61
|
+
const namespaceForm = m[5];
|
|
62
|
+
const moduleName = m[6];
|
|
63
|
+
const names = [];
|
|
64
|
+
let namespaceAlias;
|
|
65
|
+
if (defaultName)
|
|
66
|
+
names.push(defaultName);
|
|
67
|
+
if (namedAfterDefault)
|
|
68
|
+
names.push(...parseNamedSpecifiers(namedAfterDefault));
|
|
69
|
+
if (namedOnly)
|
|
70
|
+
names.push(...parseNamedSpecifiers(namedOnly.slice(1, -1)));
|
|
71
|
+
if (namespaceForm) {
|
|
72
|
+
const aliasMatch = namespaceForm.match(/\*\s+as\s+([\w$]+)/);
|
|
73
|
+
if (aliasMatch)
|
|
74
|
+
namespaceAlias = aliasMatch[1];
|
|
75
|
+
}
|
|
76
|
+
out.push({
|
|
77
|
+
source: moduleName,
|
|
78
|
+
names: dedup(names),
|
|
79
|
+
...(namespaceAlias ? { namespaceAlias } : {}),
|
|
80
|
+
typeOnly,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// CommonJS destructuring: `const { A, B } = require('mod')`
|
|
84
|
+
const cjsDestructuringRe = /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
85
|
+
while ((m = cjsDestructuringRe.exec(cleaned)) !== null) {
|
|
86
|
+
const names = parseNamedSpecifiers(m[1]);
|
|
87
|
+
out.push({ source: m[2], names: dedup(names), typeOnly: false });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/** Parse `A, B as C, D` into ['A', 'B', 'D'] (uses imported name, not local alias). */
|
|
92
|
+
function parseNamedSpecifiers(raw) {
|
|
93
|
+
return raw
|
|
94
|
+
.split(',')
|
|
95
|
+
.map((s) => s.trim())
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.map((s) => s.split(/\s+as\s+/)[0].trim())
|
|
98
|
+
.filter((s) => /^[\w$]+$/.test(s));
|
|
99
|
+
}
|
|
100
|
+
function dedup(arr) {
|
|
101
|
+
return [...new Set(arr)];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a relative import path against the file that contains the
|
|
105
|
+
* import. Returns a normalised path (no `..` / `.` segments, no leading
|
|
106
|
+
* `./`). Non-relative specifiers (`react`, `@scope/pkg`) return null
|
|
107
|
+
* unless they match an alias in `aliases` (Engines 6.32.7+).
|
|
108
|
+
*
|
|
109
|
+
* The result is the path as it would appear in the source tree, NOT
|
|
110
|
+
* the absolute disk path. Caller compares it against component
|
|
111
|
+
* sourceDirs (which are also tree-relative) to see if the import
|
|
112
|
+
* crosses a component boundary.
|
|
113
|
+
*
|
|
114
|
+
* @param aliases Optional map of alias resolutions from a tsconfig
|
|
115
|
+
* `paths` block. Keys are alias patterns (e.g. `@shared/ui`,
|
|
116
|
+
* `@/*`); values are pre-resolved tree-paths. The resolver
|
|
117
|
+
* substitutes a longest-prefix match before falling back
|
|
118
|
+
* to relative-only resolution.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveImportPath(fromFile, importSpec, aliases = {}) {
|
|
121
|
+
// 1. Try alias resolution first — longest-prefix match wins.
|
|
122
|
+
const aliasKeys = Object.keys(aliases).sort((a, b) => b.length - a.length);
|
|
123
|
+
for (const pattern of aliasKeys) {
|
|
124
|
+
if (pattern.endsWith('/*')) {
|
|
125
|
+
const stem = pattern.slice(0, -2);
|
|
126
|
+
if (importSpec.startsWith(stem + '/') || importSpec === stem) {
|
|
127
|
+
const tail = importSpec.slice(stem.length).replace(/^\//, '');
|
|
128
|
+
const target = aliases[pattern].replace(/\/\*$/, '');
|
|
129
|
+
return tail ? `${target}/${tail}` : target;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (importSpec === pattern) {
|
|
133
|
+
return aliases[pattern];
|
|
134
|
+
}
|
|
135
|
+
else if (importSpec.startsWith(pattern + '/')) {
|
|
136
|
+
const tail = importSpec.slice(pattern.length + 1);
|
|
137
|
+
return `${aliases[pattern]}/${tail}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// 2. Relative resolution — `./X` or `../../X`.
|
|
141
|
+
// Engines 6.32.10+: preserve leading `/` so absolute paths
|
|
142
|
+
// (`/tmp/run/input/...`) round-trip cleanly. Earlier the empty-segment
|
|
143
|
+
// filter dropped the leading slash, breaking startsWith() matches
|
|
144
|
+
// against component sourceDirs (which keep their absolute prefix).
|
|
145
|
+
if (!importSpec.startsWith('.'))
|
|
146
|
+
return null;
|
|
147
|
+
const fromDir = fromFile.replace(/\/[^/]*$/, '');
|
|
148
|
+
const isAbsolute = fromFile.startsWith('/');
|
|
149
|
+
const segments = (fromDir + '/' + importSpec).split('/');
|
|
150
|
+
const stack = [];
|
|
151
|
+
for (const seg of segments) {
|
|
152
|
+
if (seg === '' || seg === '.')
|
|
153
|
+
continue;
|
|
154
|
+
if (seg === '..') {
|
|
155
|
+
stack.pop();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
stack.push(seg);
|
|
159
|
+
}
|
|
160
|
+
return (isAbsolute ? '/' : '') + stack.join('/');
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Engines 6.32.7+ — parse the `compilerOptions.paths` block from a
|
|
164
|
+
* tsconfig.json and return an absolute alias map.
|
|
165
|
+
*
|
|
166
|
+
* tsconfig paths are relative to the tsconfig's directory (or to
|
|
167
|
+
* `compilerOptions.baseUrl` if set). This function resolves them to
|
|
168
|
+
* tree-relative absolute paths so the import resolver can match them
|
|
169
|
+
* against component sourceDirs without further normalisation.
|
|
170
|
+
*
|
|
171
|
+
* Returns an empty object when the tsconfig has no paths block, or
|
|
172
|
+
* when parsing fails (best-effort — broken tsconfigs shouldn't break
|
|
173
|
+
* the analyse pipeline).
|
|
174
|
+
*
|
|
175
|
+
* Note: paths entries like `[ "../../shared/ui/src/index.ts" ]` — we
|
|
176
|
+
* strip the trailing `index.ts` segment so the alias maps to the
|
|
177
|
+
* directory level (which is what the import resolver needs to match
|
|
178
|
+
* against component sourceDirs).
|
|
179
|
+
*/
|
|
180
|
+
export function parseTsconfigPaths(tsconfigSource, tsconfigDir) {
|
|
181
|
+
let parsed;
|
|
182
|
+
try {
|
|
183
|
+
parsed = JSON.parse(stripJsonComments(tsconfigSource));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
const co = parsed?.compilerOptions ?? {};
|
|
189
|
+
const paths = co.paths ?? {};
|
|
190
|
+
const baseUrl = co.baseUrl ?? '.';
|
|
191
|
+
const baseDir = resolveJoin(tsconfigDir, baseUrl);
|
|
192
|
+
const out = {};
|
|
193
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
194
|
+
if (!Array.isArray(targets) || targets.length === 0)
|
|
195
|
+
continue;
|
|
196
|
+
const target = targets[0];
|
|
197
|
+
let resolved = resolveJoin(baseDir, target.replace(/\/\*$/, ''));
|
|
198
|
+
// Strip trailing index file so the alias points at the directory.
|
|
199
|
+
resolved = resolved.replace(/\/(index|main)\.(ts|tsx|js|jsx|mjs|cjs)$/, '');
|
|
200
|
+
out[pattern] = resolved;
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* String-aware comment stripper for JSON-with-comments (tsconfig.json
|
|
206
|
+
* is the canonical case). Walks character by character, tracking
|
|
207
|
+
* whether we're inside a string literal — comment delimiters inside
|
|
208
|
+
* strings (e.g. paths like `"@lib/*"`) are preserved.
|
|
209
|
+
*
|
|
210
|
+
* Engines 6.32.7+. A previous regex-based approach choked on string
|
|
211
|
+
* values containing wildcards because comment-style delimiters could
|
|
212
|
+
* appear inside legitimate JSON keys like `"@lib/*"`. Walking
|
|
213
|
+
* character by character with string awareness is the only safe way.
|
|
214
|
+
*/
|
|
215
|
+
function stripJsonComments(src) {
|
|
216
|
+
let out = '';
|
|
217
|
+
let i = 0;
|
|
218
|
+
let inString = false;
|
|
219
|
+
while (i < src.length) {
|
|
220
|
+
const c = src[i];
|
|
221
|
+
if (inString) {
|
|
222
|
+
out += c;
|
|
223
|
+
if (c === '\\') {
|
|
224
|
+
out += src[i + 1] ?? '';
|
|
225
|
+
i += 2;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (c === '"')
|
|
229
|
+
inString = false;
|
|
230
|
+
i++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (c === '"') {
|
|
234
|
+
inString = true;
|
|
235
|
+
out += c;
|
|
236
|
+
i++;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
240
|
+
// Line comment — skip to end of line (preserve the newline so
|
|
241
|
+
// downstream line counting still works for error messages).
|
|
242
|
+
const nl = src.indexOf('\n', i);
|
|
243
|
+
if (nl < 0)
|
|
244
|
+
return out;
|
|
245
|
+
i = nl;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
249
|
+
const end = src.indexOf('*/', i + 2);
|
|
250
|
+
if (end < 0)
|
|
251
|
+
return out;
|
|
252
|
+
i = end + 2;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
out += c;
|
|
256
|
+
i++;
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
function resolveJoin(base, rel) {
|
|
261
|
+
const stack = base.split('/').filter((s) => s !== '' && s !== '.');
|
|
262
|
+
for (const seg of rel.split('/')) {
|
|
263
|
+
if (seg === '' || seg === '.')
|
|
264
|
+
continue;
|
|
265
|
+
if (seg === '..') {
|
|
266
|
+
stack.pop();
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
stack.push(seg);
|
|
270
|
+
}
|
|
271
|
+
return (base.startsWith('/') ? '/' : '') + stack.join('/');
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Match a resolved import path against component sourceDirs and return
|
|
275
|
+
* the owning component's suggestedName. Picks the LONGEST matching
|
|
276
|
+
* sourceDir prefix (handles the case where one component's dir is
|
|
277
|
+
* nested inside another's). Returns null if no component owns the path.
|
|
278
|
+
*/
|
|
279
|
+
export function findOwningComponent(resolvedPath, components) {
|
|
280
|
+
let best = null;
|
|
281
|
+
for (const comp of components) {
|
|
282
|
+
const sd = comp.structural?.sourceDir;
|
|
283
|
+
if (!sd)
|
|
284
|
+
continue;
|
|
285
|
+
if (resolvedPath === sd || resolvedPath.startsWith(sd + '/') || resolvedPath.startsWith(sd)) {
|
|
286
|
+
if (!best || sd.length > best.len) {
|
|
287
|
+
best = { name: comp.suggestedName, len: sd.length };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return best?.name ?? null;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Build the per-consumer imports graph. Each component's files are
|
|
295
|
+
* walked, their imports parsed, and resolved against the source tree;
|
|
296
|
+
* imports that cross into another component's sourceDir are aggregated.
|
|
297
|
+
*/
|
|
298
|
+
export async function buildImportsByComponent(opts) {
|
|
299
|
+
const result = {};
|
|
300
|
+
const components = opts.facts.suggestedComponents ?? [];
|
|
301
|
+
if (components.length === 0)
|
|
302
|
+
return result;
|
|
303
|
+
const fileExt = opts.extensionFilter ?? defaultExtFilter;
|
|
304
|
+
const filesByComponent = new Map();
|
|
305
|
+
for (const comp of components) {
|
|
306
|
+
filesByComponent.set(comp.suggestedName, []);
|
|
307
|
+
}
|
|
308
|
+
// Collect all files known to the prepass — candidateMethods + views +
|
|
309
|
+
// entities + routes is sufficient coverage; we don't re-walk the FS.
|
|
310
|
+
const allFiles = new Set();
|
|
311
|
+
for (const cm of opts.facts.candidateMethods ?? [])
|
|
312
|
+
allFiles.add(cm.filePath);
|
|
313
|
+
for (const v of opts.facts.views ?? [])
|
|
314
|
+
allFiles.add(v.filePath);
|
|
315
|
+
for (const e of opts.facts.entities ?? []) {
|
|
316
|
+
if (e.filePath)
|
|
317
|
+
allFiles.add(e.filePath);
|
|
318
|
+
}
|
|
319
|
+
for (const r of opts.facts.routes ?? []) {
|
|
320
|
+
if (r.filePath)
|
|
321
|
+
allFiles.add(r.filePath);
|
|
322
|
+
}
|
|
323
|
+
for (const file of allFiles) {
|
|
324
|
+
if (!fileExt(file))
|
|
325
|
+
continue;
|
|
326
|
+
const owner = findOwningComponent(file, components);
|
|
327
|
+
if (owner)
|
|
328
|
+
filesByComponent.get(owner).push(file);
|
|
329
|
+
}
|
|
330
|
+
// Engines 6.32.7+ — load each component's tsconfig.json paths block
|
|
331
|
+
// upfront so workspace-style aliases (`@shared/ui`, `@/*`) resolve to
|
|
332
|
+
// tree-paths the resolver can match. Most monorepos use these
|
|
333
|
+
// patterns; without alias resolution idle-meta-style codebases
|
|
334
|
+
// produce zero detected cross-component imports.
|
|
335
|
+
//
|
|
336
|
+
// Engines 6.32.8+ — also load each component's package.json `name`
|
|
337
|
+
// field. Workspace imports like `import { X } from '@idle-games/ui'`
|
|
338
|
+
// resolve via npm/yarn workspaces, not tsconfig paths. We build a
|
|
339
|
+
// shared-alias bag keyed by package name → owning component sourceDir
|
|
340
|
+
// and merge it with each component's tsconfig aliases.
|
|
341
|
+
const packageAliases = {};
|
|
342
|
+
for (const comp of components) {
|
|
343
|
+
const sd = comp.structural?.sourceDir;
|
|
344
|
+
if (!sd)
|
|
345
|
+
continue;
|
|
346
|
+
try {
|
|
347
|
+
const pkgJson = await opts.readFile(`${sd}/package.json`);
|
|
348
|
+
if (pkgJson) {
|
|
349
|
+
const parsed = JSON.parse(pkgJson);
|
|
350
|
+
if (parsed?.name) {
|
|
351
|
+
// Conventional source-root for TS packages — most monorepos
|
|
352
|
+
// ship from `src/` or `lib/`. Probe a couple of obvious dirs;
|
|
353
|
+
// if neither exists, fall through to the package root.
|
|
354
|
+
packageAliases[parsed.name] = sd;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch { /* missing or malformed package.json — skip */ }
|
|
359
|
+
}
|
|
360
|
+
const aliasesByComponent = new Map();
|
|
361
|
+
for (const comp of components) {
|
|
362
|
+
const sd = comp.structural?.sourceDir;
|
|
363
|
+
if (!sd)
|
|
364
|
+
continue;
|
|
365
|
+
let tsAliases = {};
|
|
366
|
+
try {
|
|
367
|
+
const tsconfig = await opts.readFile(`${sd}/tsconfig.json`);
|
|
368
|
+
if (tsconfig)
|
|
369
|
+
tsAliases = parseTsconfigPaths(tsconfig, sd);
|
|
370
|
+
}
|
|
371
|
+
catch { /* component without a tsconfig */ }
|
|
372
|
+
// tsconfig aliases override workspace package names when both
|
|
373
|
+
// patterns match (the local tsconfig is the closer authority).
|
|
374
|
+
aliasesByComponent.set(comp.suggestedName, { ...packageAliases, ...tsAliases });
|
|
375
|
+
}
|
|
376
|
+
for (const consumer of components) {
|
|
377
|
+
const files = filesByComponent.get(consumer.suggestedName) ?? [];
|
|
378
|
+
if (files.length === 0)
|
|
379
|
+
continue;
|
|
380
|
+
const aliases = aliasesByComponent.get(consumer.suggestedName) ?? {};
|
|
381
|
+
// Build with Sets for O(1) dedup, then convert to sorted arrays.
|
|
382
|
+
const perTarget = new Map();
|
|
383
|
+
for (const file of files) {
|
|
384
|
+
let source;
|
|
385
|
+
try {
|
|
386
|
+
source = await opts.readFile(file);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
continue; // unreadable files don't block the others
|
|
390
|
+
}
|
|
391
|
+
const imports = parseImports(source);
|
|
392
|
+
for (const imp of imports) {
|
|
393
|
+
const resolved = resolveImportPath(file, imp.source, aliases);
|
|
394
|
+
if (!resolved)
|
|
395
|
+
continue;
|
|
396
|
+
const target = findOwningComponent(resolved, components);
|
|
397
|
+
if (!target || target === consumer.suggestedName)
|
|
398
|
+
continue;
|
|
399
|
+
if (!perTarget.has(target))
|
|
400
|
+
perTarget.set(target, new Set());
|
|
401
|
+
const bucket = perTarget.get(target);
|
|
402
|
+
for (const name of imp.names)
|
|
403
|
+
bucket.add(name);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (perTarget.size > 0) {
|
|
407
|
+
const targetMap = {};
|
|
408
|
+
for (const [target, names] of perTarget) {
|
|
409
|
+
targetMap[target] = [...names].sort();
|
|
410
|
+
}
|
|
411
|
+
result[consumer.suggestedName] = targetMap;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
function defaultExtFilter(path) {
|
|
417
|
+
return /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(path);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Convenience: build the imports graph from a StructuralPrepass backend.
|
|
421
|
+
* The backend's `fileSourceText` provides the file reader; this is the
|
|
422
|
+
* same surface the adapters use to read files.
|
|
423
|
+
*/
|
|
424
|
+
export async function buildImportsByComponentFromBackend(facts, backend) {
|
|
425
|
+
return buildImportsByComponent({
|
|
426
|
+
facts,
|
|
427
|
+
readFile: (path) => backend.fileSourceText(path),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* V2 (Component Dependencies V2 — 2026-05-08) — extract dotted operation
|
|
432
|
+
* references from a consumer source file. Walks property-access chains
|
|
433
|
+
* rooted at the import-bound symbol names supplied in `bindings` and
|
|
434
|
+
* returns each chain's tail (`charges.create`, `Calculator.calculate`,
|
|
435
|
+
* etc.). The leading symbol is stripped because it names a JS-side
|
|
436
|
+
* binding, not a SpecVerse-side `select:` entry — the dotted op is what
|
|
437
|
+
* matters for V2's `select: [Charge.create]` grammar extension.
|
|
438
|
+
*
|
|
439
|
+
* Heuristic: regex over `<symbol>(.<segment>)+` where each segment is a
|
|
440
|
+
* camelCase or PascalCase identifier. Skips comments via the same naive
|
|
441
|
+
* stripper as `parseImports`. Two-segment chains (`s.charges`) are
|
|
442
|
+
* dropped — those are property accesses, not operation calls. Three-
|
|
443
|
+
* segment-or-more chains (`stripe.charges.create`) keep the trailing two
|
|
444
|
+
* segments (`charges.create`) as the dotted op. Single bound-symbol
|
|
445
|
+
* call sites (`calculator(...)`) emit nothing — there's no operation
|
|
446
|
+
* to name.
|
|
447
|
+
*
|
|
448
|
+
* The recogniser runs per-file and returns a Set so the caller can
|
|
449
|
+
* dedup across files. Empty input → empty Set.
|
|
450
|
+
*/
|
|
451
|
+
export function detectDottedOps(source, bindings) {
|
|
452
|
+
const out = new Set();
|
|
453
|
+
if (!source)
|
|
454
|
+
return out;
|
|
455
|
+
const cleaned = source
|
|
456
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
457
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
458
|
+
for (const sym of bindings) {
|
|
459
|
+
if (!sym || !/^[A-Za-z_$][\w$]*$/.test(sym))
|
|
460
|
+
continue;
|
|
461
|
+
// Match sym followed by ≥1 dotted segment. Anchor on a non-word char
|
|
462
|
+
// (or start-of-string) to avoid spuriously matching tail of a longer
|
|
463
|
+
// identifier (`barCharges` should not match a binding `Charges`).
|
|
464
|
+
const re = new RegExp(`(?:^|[^\\w$.])${escapeRegex(sym)}((?:\\.[A-Za-z_$][\\w$]*)+)`, 'g');
|
|
465
|
+
let m;
|
|
466
|
+
while ((m = re.exec(cleaned)) !== null) {
|
|
467
|
+
const tail = m[1].replace(/^\./, ''); // strip leading dot
|
|
468
|
+
const segments = tail.split('.');
|
|
469
|
+
if (segments.length === 0)
|
|
470
|
+
continue;
|
|
471
|
+
if (segments.length === 1) {
|
|
472
|
+
// Single tail segment — `stripe.create` becomes `create` which
|
|
473
|
+
// is too coarse to be useful. Drop unless it looks like a method
|
|
474
|
+
// call (followed by `(` after optional whitespace).
|
|
475
|
+
const after = cleaned.slice(m.index + m[0].length).match(/^\s*\(/);
|
|
476
|
+
if (!after)
|
|
477
|
+
continue;
|
|
478
|
+
// Even with `(`, drop bare tails — they could be local methods.
|
|
479
|
+
// V2 callers want `Service.op` granularity; bare `op` is the
|
|
480
|
+
// entity-level visibility lane handled by `select: [Service]`.
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
// Two-or-more tail segments: keep the FINAL two as the dotted op.
|
|
484
|
+
// For `stripe.charges.create` → `charges.create`. For
|
|
485
|
+
// `calculator.calculate` (sym=calculator, tail=calculate) → already
|
|
486
|
+
// handled above. For `service.module.method.deep` → `method.deep`
|
|
487
|
+
// captures the immediate enclosing service + operation.
|
|
488
|
+
const trimmed = segments.slice(-2).join('.');
|
|
489
|
+
// Filter out clearly-non-op tails (numeric, all-caps constants).
|
|
490
|
+
if (!/^[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*$/.test(trimmed))
|
|
491
|
+
continue;
|
|
492
|
+
out.add(trimmed);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
function escapeRegex(s) {
|
|
498
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* V2 — build both the bare-names imports graph (existing) AND the V2
|
|
502
|
+
* per-target metadata index (version + dotted ops). The bare-names half
|
|
503
|
+
* is identical to `buildImportsByComponent`; the metadata half walks
|
|
504
|
+
* each target component's package.json (for `version`) and re-scans the
|
|
505
|
+
* consumer's source files for dotted-op patterns rooted at symbols
|
|
506
|
+
* bound by imports from that target.
|
|
507
|
+
*
|
|
508
|
+
* Backward-compat: callers that don't need V2 metadata keep using
|
|
509
|
+
* `buildImportsByComponent` directly. The V2-aware skeleton emitter
|
|
510
|
+
* can switch to this two-output variant when emitting `version:` and
|
|
511
|
+
* dotted-form `select:` entries.
|
|
512
|
+
*/
|
|
513
|
+
export async function buildImportsByComponentWithMetadata(opts) {
|
|
514
|
+
const components = opts.facts.suggestedComponents ?? [];
|
|
515
|
+
const result = {
|
|
516
|
+
imports: {},
|
|
517
|
+
metadata: {},
|
|
518
|
+
};
|
|
519
|
+
if (components.length === 0)
|
|
520
|
+
return result;
|
|
521
|
+
// Per-target version: read each component's package.json (if any) and
|
|
522
|
+
// record its `version` field. Same fileReader path as the package-name
|
|
523
|
+
// alias loader uses below.
|
|
524
|
+
const versionByTarget = {};
|
|
525
|
+
for (const comp of components) {
|
|
526
|
+
const sd = comp.structural?.sourceDir;
|
|
527
|
+
if (!sd)
|
|
528
|
+
continue;
|
|
529
|
+
try {
|
|
530
|
+
const pkgJson = await opts.readFile(`${sd}/package.json`);
|
|
531
|
+
if (pkgJson) {
|
|
532
|
+
const parsed = JSON.parse(pkgJson);
|
|
533
|
+
if (parsed?.version && typeof parsed.version === 'string') {
|
|
534
|
+
versionByTarget[comp.suggestedName] = parsed.version;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
catch { /* missing or malformed package.json — skip */ }
|
|
539
|
+
}
|
|
540
|
+
// Reuse the existing bare-names build for symbol aggregation.
|
|
541
|
+
result.imports = await buildImportsByComponent(opts);
|
|
542
|
+
// For each consumer × target, scan the consumer's source files for
|
|
543
|
+
// dotted-op patterns rooted at the imported symbols and record them.
|
|
544
|
+
const fileExt = opts.extensionFilter ?? defaultExtFilter;
|
|
545
|
+
const filesByComponent = new Map();
|
|
546
|
+
for (const comp of components)
|
|
547
|
+
filesByComponent.set(comp.suggestedName, []);
|
|
548
|
+
const allFiles = new Set();
|
|
549
|
+
for (const cm of opts.facts.candidateMethods ?? [])
|
|
550
|
+
allFiles.add(cm.filePath);
|
|
551
|
+
for (const v of opts.facts.views ?? [])
|
|
552
|
+
allFiles.add(v.filePath);
|
|
553
|
+
for (const e of opts.facts.entities ?? []) {
|
|
554
|
+
if (e.filePath)
|
|
555
|
+
allFiles.add(e.filePath);
|
|
556
|
+
}
|
|
557
|
+
for (const r of opts.facts.routes ?? []) {
|
|
558
|
+
if (r.filePath)
|
|
559
|
+
allFiles.add(r.filePath);
|
|
560
|
+
}
|
|
561
|
+
for (const file of allFiles) {
|
|
562
|
+
if (!fileExt(file))
|
|
563
|
+
continue;
|
|
564
|
+
const owner = findOwningComponent(file, components);
|
|
565
|
+
if (owner)
|
|
566
|
+
filesByComponent.get(owner).push(file);
|
|
567
|
+
}
|
|
568
|
+
for (const [consumerName, targetMap] of Object.entries(result.imports)) {
|
|
569
|
+
const targetMetaMap = {};
|
|
570
|
+
const consumerFiles = filesByComponent.get(consumerName) ?? [];
|
|
571
|
+
for (const [targetName, names] of Object.entries(targetMap)) {
|
|
572
|
+
const meta = {};
|
|
573
|
+
if (versionByTarget[targetName]) {
|
|
574
|
+
meta.version = versionByTarget[targetName];
|
|
575
|
+
}
|
|
576
|
+
// Scan consumer files for dotted-op references rooted at any of
|
|
577
|
+
// the bound names from this target. Collect across all files.
|
|
578
|
+
const ops = new Set();
|
|
579
|
+
for (const file of consumerFiles) {
|
|
580
|
+
let source = '';
|
|
581
|
+
try {
|
|
582
|
+
source = await opts.readFile(file);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const fileOps = detectDottedOps(source, names);
|
|
588
|
+
for (const op of fileOps)
|
|
589
|
+
ops.add(op);
|
|
590
|
+
}
|
|
591
|
+
if (ops.size > 0) {
|
|
592
|
+
meta.dottedOps = [...ops].sort();
|
|
593
|
+
}
|
|
594
|
+
if (meta.version || meta.dottedOps) {
|
|
595
|
+
targetMetaMap[targetName] = meta;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (Object.keys(targetMetaMap).length > 0) {
|
|
599
|
+
result.metadata[consumerName] = targetMetaMap;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Convert an arbitrary identifier-shaped string to a deployment-instance
|
|
606
|
+
* name (lowerCamelCase). `gitnexus` → `gitnexus`; `@stripe/stripe-js` →
|
|
607
|
+
* `stripeStripeJs`; `Bun.spawn` → `bunSpawn`.
|
|
608
|
+
*/
|
|
609
|
+
function toInstanceKey(raw) {
|
|
610
|
+
if (!raw)
|
|
611
|
+
return 'instance';
|
|
612
|
+
const cleaned = raw
|
|
613
|
+
.replace(/^@/, '')
|
|
614
|
+
.replace(/[^A-Za-z0-9]+/g, ' ')
|
|
615
|
+
.trim();
|
|
616
|
+
if (!cleaned)
|
|
617
|
+
return 'instance';
|
|
618
|
+
const parts = cleaned.split(/\s+/);
|
|
619
|
+
const first = parts[0].charAt(0).toLowerCase() + parts[0].slice(1);
|
|
620
|
+
const rest = parts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1));
|
|
621
|
+
return [first, ...rest].join('');
|
|
622
|
+
}
|
|
623
|
+
/** Convert a shell-friendly binary name (kebab-case) into a capability suffix. */
|
|
624
|
+
function binaryToCapabilitySuffix(binary) {
|
|
625
|
+
return binary.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Stripped + cleaned version of a consumer source for detection passes.
|
|
629
|
+
* Same comment-stripping rule as `parseImports` so `// fetch('foo')` in
|
|
630
|
+
* a comment doesn't trigger a false positive.
|
|
631
|
+
*/
|
|
632
|
+
function stripCommentsForDetection(source) {
|
|
633
|
+
return source
|
|
634
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
635
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Detect subprocess invocations:
|
|
639
|
+
* - `spawnSync('git', [...])`
|
|
640
|
+
* - `execSync('gitnexus analyze', ...)`
|
|
641
|
+
* - `child_process.spawn('ffmpeg', ...)`
|
|
642
|
+
* - `Bun.spawn(['<binary>', ...])`
|
|
643
|
+
*
|
|
644
|
+
* Heuristics:
|
|
645
|
+
* 1. First positional argument is a string literal — treat as the
|
|
646
|
+
* binary name (`spawnSync('git', [...])` → binary `git`).
|
|
647
|
+
* 2. `Bun.spawn([...])` — first array element string literal is the
|
|
648
|
+
* binary (`Bun.spawn(['ffmpeg', ...])` → `ffmpeg`).
|
|
649
|
+
* 3. `execSync('foo bar baz')` — command string is shell-style; we
|
|
650
|
+
* take the first whitespace-delimited token as the binary
|
|
651
|
+
* (`execSync('git status')` → `git`).
|
|
652
|
+
* 4. Variable arguments (`spawnSync(cmd, args)`) — skipped, can't
|
|
653
|
+
* reliably name the binary.
|
|
654
|
+
*
|
|
655
|
+
* Multiple invocations of the same binary in the same source produce
|
|
656
|
+
* one hint (deduped by binary name).
|
|
657
|
+
*
|
|
658
|
+
* Each hint emits:
|
|
659
|
+
* - `import:` entry with `from: <binary>` (kebab-cased)
|
|
660
|
+
* - deployment instance under `infrastructure:`, `advertises:
|
|
661
|
+
* ["executable.<binary>"]`, `config: { binary: <binary> }`
|
|
662
|
+
*/
|
|
663
|
+
export function detectSubprocessCalls(source, sourceFile) {
|
|
664
|
+
if (!source)
|
|
665
|
+
return [];
|
|
666
|
+
const cleaned = stripCommentsForDetection(source);
|
|
667
|
+
const seen = new Map();
|
|
668
|
+
// Pattern A: spawnSync / execSync / spawn / exec — first arg is a
|
|
669
|
+
// string literal binary name OR a shell command string.
|
|
670
|
+
// We accept the function called as a bare identifier OR as a method
|
|
671
|
+
// on `child_process` / `cp` / similar (`childProcess.spawn(...)`).
|
|
672
|
+
const fnRe = /(?:^|[^.\w$])(?:[a-zA-Z_$][\w$]*\.)?(spawn(?:Sync)?|exec(?:Sync|File|FileSync)?)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
673
|
+
let m;
|
|
674
|
+
while ((m = fnRe.exec(cleaned)) !== null) {
|
|
675
|
+
const fn = m[1];
|
|
676
|
+
const argRaw = m[2];
|
|
677
|
+
let binary;
|
|
678
|
+
if (fn.startsWith('exec') && !fn.startsWith('execFile')) {
|
|
679
|
+
// `exec('git status')` — shell command string. Take the first token.
|
|
680
|
+
const firstToken = argRaw.trim().split(/\s+/)[0];
|
|
681
|
+
// Strip path prefix (`/usr/bin/git` → `git`).
|
|
682
|
+
binary = firstToken.split('/').pop();
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
// `spawn('git', [...])` / `execFile('git', [...])` — first arg is
|
|
686
|
+
// the binary directly.
|
|
687
|
+
binary = argRaw.split('/').pop();
|
|
688
|
+
}
|
|
689
|
+
if (!binary || !/^[a-zA-Z][\w.\-]*$/.test(binary))
|
|
690
|
+
continue;
|
|
691
|
+
if (seen.has(binary))
|
|
692
|
+
continue;
|
|
693
|
+
seen.set(binary, makeSubprocessHint(binary, sourceFile));
|
|
694
|
+
}
|
|
695
|
+
// Pattern B: Bun.spawn(['ffmpeg', ...]) — bun-specific, array form.
|
|
696
|
+
const bunRe = /Bun\.spawn\s*\(\s*\[\s*['"]([^'"]+)['"]/g;
|
|
697
|
+
while ((m = bunRe.exec(cleaned)) !== null) {
|
|
698
|
+
const binary = m[1].split('/').pop();
|
|
699
|
+
if (!binary || !/^[a-zA-Z][\w.\-]*$/.test(binary))
|
|
700
|
+
continue;
|
|
701
|
+
if (seen.has(binary))
|
|
702
|
+
continue;
|
|
703
|
+
seen.set(binary, makeSubprocessHint(binary, sourceFile));
|
|
704
|
+
}
|
|
705
|
+
return [...seen.values()];
|
|
706
|
+
}
|
|
707
|
+
function makeSubprocessHint(binary, sourceFile) {
|
|
708
|
+
const capability = `executable.${binaryToCapabilitySuffix(binary)}`;
|
|
709
|
+
return {
|
|
710
|
+
from: binary,
|
|
711
|
+
pattern: 'subprocess',
|
|
712
|
+
...(sourceFile ? { sourceFile } : {}),
|
|
713
|
+
deploymentHint: {
|
|
714
|
+
category: 'infrastructure',
|
|
715
|
+
instanceName: toInstanceKey(binary),
|
|
716
|
+
advertises: [capability],
|
|
717
|
+
config: { binary },
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Detect HTTP/RPC service-client construction:
|
|
723
|
+
* - `axios.create({ baseURL: '...' })` — REST client
|
|
724
|
+
* - `axios.create({ baseURL: process.env.X })` — env-driven REST client
|
|
725
|
+
* - `fetch(BASE_URL + ...)` / `fetch(\`${BASE}/foo\`)` — bare fetch
|
|
726
|
+
* - `new GreeterClient('host:port', ...)` — gRPC stub instantiation
|
|
727
|
+
* - `new Client({ url: ... })` — generic MCP / RPC client
|
|
728
|
+
*
|
|
729
|
+
* Heuristics:
|
|
730
|
+
* 1. axios.create({ baseURL }) — extracts the baseURL string when literal,
|
|
731
|
+
* or the env-var name when the value is `process.env.<X>`.
|
|
732
|
+
* 2. Bare `fetch(<expr>)` — only when called against an env-var-shaped
|
|
733
|
+
* identifier (`process.env.X` or an UPPER_CASE constant the consumer
|
|
734
|
+
* owns); otherwise too noisy. The env-var becomes the hint's
|
|
735
|
+
* `endpoint.env`.
|
|
736
|
+
* 3. gRPC stubs are tagged by the `*Client` suffix on a constructor
|
|
737
|
+
* whose first arg is a hostname-shaped string ('host:port').
|
|
738
|
+
*
|
|
739
|
+
* Returns an empty array when no patterns match. Multiple distinct
|
|
740
|
+
* baseURLs in one file produce multiple hints.
|
|
741
|
+
*
|
|
742
|
+
* Each hint emits:
|
|
743
|
+
* - `import:` entry with `from: <baseURL-host or env-var>`
|
|
744
|
+
* - deployment instance under `services:`, `advertises:
|
|
745
|
+
* ["api.rest"]` (or `api.grpc` / `api.rpc`), `config:
|
|
746
|
+
* { endpoint: { env: <ENV_NAME> | url: <URL> } }`
|
|
747
|
+
*/
|
|
748
|
+
export function detectServiceClients(source, sourceFile) {
|
|
749
|
+
if (!source)
|
|
750
|
+
return [];
|
|
751
|
+
const cleaned = stripCommentsForDetection(source);
|
|
752
|
+
const out = [];
|
|
753
|
+
const seen = new Set();
|
|
754
|
+
// axios.create({ baseURL: ... }) — both literal and env-var.
|
|
755
|
+
// Match the call up to the closing brace of the options object; we
|
|
756
|
+
// don't need a full parser, just a non-greedy scan.
|
|
757
|
+
const axiosRe = /(?:axios|httpClient|http)\s*\.\s*create\s*\(\s*\{([^}]*)\}/g;
|
|
758
|
+
let m;
|
|
759
|
+
while ((m = axiosRe.exec(cleaned)) !== null) {
|
|
760
|
+
const optsBody = m[1];
|
|
761
|
+
const literalMatch = optsBody.match(/baseURL\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
762
|
+
const envMatch = optsBody.match(/baseURL\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/);
|
|
763
|
+
if (literalMatch) {
|
|
764
|
+
const url = literalMatch[1];
|
|
765
|
+
const host = extractHost(url);
|
|
766
|
+
if (!host)
|
|
767
|
+
continue;
|
|
768
|
+
const key = `axios:${host}`;
|
|
769
|
+
if (seen.has(key))
|
|
770
|
+
continue;
|
|
771
|
+
seen.add(key);
|
|
772
|
+
out.push(makeServiceClientHint({
|
|
773
|
+
from: host,
|
|
774
|
+
protocol: 'rest',
|
|
775
|
+
endpointUrl: url,
|
|
776
|
+
sourceFile,
|
|
777
|
+
}));
|
|
778
|
+
}
|
|
779
|
+
else if (envMatch) {
|
|
780
|
+
const env = envMatch[1];
|
|
781
|
+
const key = `axios-env:${env}`;
|
|
782
|
+
if (seen.has(key))
|
|
783
|
+
continue;
|
|
784
|
+
seen.add(key);
|
|
785
|
+
out.push(makeServiceClientHint({
|
|
786
|
+
from: envToProviderName(env),
|
|
787
|
+
protocol: 'rest',
|
|
788
|
+
endpointEnv: env,
|
|
789
|
+
sourceFile,
|
|
790
|
+
}));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// Bare fetch(process.env.X + ...) or fetch(`${process.env.X}/...`).
|
|
794
|
+
// Restricted form to avoid false positives against in-tree fetch calls.
|
|
795
|
+
const fetchEnvRe = /\bfetch\s*\(\s*(?:`[^`]*\$\{)?process\.env\.([A-Z][A-Z0-9_]*)/g;
|
|
796
|
+
while ((m = fetchEnvRe.exec(cleaned)) !== null) {
|
|
797
|
+
const env = m[1];
|
|
798
|
+
const key = `fetch-env:${env}`;
|
|
799
|
+
if (seen.has(key))
|
|
800
|
+
continue;
|
|
801
|
+
seen.add(key);
|
|
802
|
+
out.push(makeServiceClientHint({
|
|
803
|
+
from: envToProviderName(env),
|
|
804
|
+
protocol: 'rest',
|
|
805
|
+
endpointEnv: env,
|
|
806
|
+
sourceFile,
|
|
807
|
+
}));
|
|
808
|
+
}
|
|
809
|
+
// gRPC stub: `new <Pascal>Client('host:port', ...)`. Looks for the
|
|
810
|
+
// canonical grpc-js / grpc-web convention.
|
|
811
|
+
const grpcRe = /new\s+([A-Z][\w$]*Client)\s*\(\s*['"]([\w.\-]+:\d+)['"]/g;
|
|
812
|
+
while ((m = grpcRe.exec(cleaned)) !== null) {
|
|
813
|
+
const stub = m[1];
|
|
814
|
+
const target = m[2];
|
|
815
|
+
const key = `grpc:${stub}`;
|
|
816
|
+
if (seen.has(key))
|
|
817
|
+
continue;
|
|
818
|
+
seen.add(key);
|
|
819
|
+
const providerName = stub.replace(/Client$/, '');
|
|
820
|
+
out.push(makeServiceClientHint({
|
|
821
|
+
from: providerName,
|
|
822
|
+
protocol: 'grpc',
|
|
823
|
+
endpointUrl: target,
|
|
824
|
+
sourceFile,
|
|
825
|
+
}));
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
function makeServiceClientHint(opts) {
|
|
830
|
+
const advertise = `api.${opts.protocol}`;
|
|
831
|
+
const endpoint = {};
|
|
832
|
+
if (opts.endpointEnv)
|
|
833
|
+
endpoint.env = opts.endpointEnv;
|
|
834
|
+
if (opts.endpointUrl)
|
|
835
|
+
endpoint.url = opts.endpointUrl;
|
|
836
|
+
return {
|
|
837
|
+
from: opts.from,
|
|
838
|
+
pattern: 'service-client',
|
|
839
|
+
...(opts.sourceFile ? { sourceFile: opts.sourceFile } : {}),
|
|
840
|
+
deploymentHint: {
|
|
841
|
+
category: 'services',
|
|
842
|
+
instanceName: toInstanceKey(opts.from),
|
|
843
|
+
advertises: [advertise],
|
|
844
|
+
config: { endpoint },
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Best-effort hostname extraction. Drops protocol + path; returns the
|
|
850
|
+
* host portion (which may include a port). Returns null for clearly-
|
|
851
|
+
* malformed URLs. The host doubles as a `from:` value for the import.
|
|
852
|
+
*/
|
|
853
|
+
function extractHost(url) {
|
|
854
|
+
// Drop protocol.
|
|
855
|
+
const protoStrip = url.replace(/^[a-z][a-zA-Z0-9+.-]*:\/\//, '');
|
|
856
|
+
// First path/query segment.
|
|
857
|
+
const host = protoStrip.split(/[/?#]/)[0];
|
|
858
|
+
if (!host || /\$\{|\$/.test(host))
|
|
859
|
+
return null; // unresolved interpolation
|
|
860
|
+
return host;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Map `XXX_API_URL` / `STRIPE_KEY` style env-var names to a provider
|
|
864
|
+
* name suitable for `from:`. Drops the trailing _URL / _KEY / _BASE_URL
|
|
865
|
+
* suffix and lowercases. Falls back to lowercased env-var when no
|
|
866
|
+
* suffix matches.
|
|
867
|
+
*/
|
|
868
|
+
function envToProviderName(env) {
|
|
869
|
+
const stripped = env.replace(/_(API_URL|BASE_URL|URL|KEY|TOKEN|SECRET|HOST|ENDPOINT)$/i, '');
|
|
870
|
+
return stripped.toLowerCase().replace(/_/g, '-');
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Detect async messaging crossings:
|
|
874
|
+
* - `rabbit.subscribe('queue', handler)` / `rabbitMq.publish(...)`
|
|
875
|
+
* - `kafka.consume(...)` / `kafka.subscribe(...)` / `kafkaProducer.send(...)`
|
|
876
|
+
* - `eventBus.publish(...)` / `eventBus.subscribe(...)` (in-memory or
|
|
877
|
+
* cross-component bus crossings)
|
|
878
|
+
* - `nats.subscribe(...)` / `nats.publish(...)`
|
|
879
|
+
* - `redis.xadd(...)` (streams) / `redis.publish(...)` (pub-sub)
|
|
880
|
+
*
|
|
881
|
+
* Heuristic:
|
|
882
|
+
* - Match `<broker-handle>.<verb>(...)` where verb ∈ {subscribe,
|
|
883
|
+
* consume, publish, send, xadd}.
|
|
884
|
+
* - Broker name comes from the receiver identifier (`rabbitMq`,
|
|
885
|
+
* `kafka`, `eventBus`).
|
|
886
|
+
* - First positional arg, if a string literal, is recorded as a
|
|
887
|
+
* `queues[]` / `topics[]` entry on the deployment hint.
|
|
888
|
+
*
|
|
889
|
+
* Each hint emits:
|
|
890
|
+
* - `import:` entry with `from: <broker>` (lowercased)
|
|
891
|
+
* - deployment instance under `communications:`, `advertises:
|
|
892
|
+
* ["messaging.<broker>"]`, `config: { broker, queues: [...] }`
|
|
893
|
+
*/
|
|
894
|
+
export function detectMessagingCrossings(source, sourceFile) {
|
|
895
|
+
if (!source)
|
|
896
|
+
return [];
|
|
897
|
+
const cleaned = stripCommentsForDetection(source);
|
|
898
|
+
const byBroker = new Map();
|
|
899
|
+
// Receiver names we recognise as brokers. Conservative list to keep
|
|
900
|
+
// false-positive rate low; arbitrary `*.publish()` would over-match.
|
|
901
|
+
const BROKER_RE = /\b(rabbit|rabbitmq|rabbitMq|kafka|kafkaProducer|kafkaConsumer|nats|redis|eventBus|amqp|sns|sqs|sqsClient|snsClient)\b/;
|
|
902
|
+
const verbsRe = /\b(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(/;
|
|
903
|
+
// Combined: `<broker>.<verb>('queueOrTopic', ...)` — first arg literal.
|
|
904
|
+
const callRe = /\b([a-zA-Z_$][\w$]*)\s*\.\s*(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
905
|
+
let m;
|
|
906
|
+
while ((m = callRe.exec(cleaned)) !== null) {
|
|
907
|
+
const receiver = m[1];
|
|
908
|
+
const queue = m[3];
|
|
909
|
+
if (!BROKER_RE.test(receiver))
|
|
910
|
+
continue;
|
|
911
|
+
const broker = receiverToBrokerName(receiver);
|
|
912
|
+
if (!byBroker.has(broker)) {
|
|
913
|
+
byBroker.set(broker, { queues: new Set(), broker });
|
|
914
|
+
}
|
|
915
|
+
byBroker.get(broker).queues.add(queue);
|
|
916
|
+
}
|
|
917
|
+
// Also catch `<broker>.<verb>(<non-string-arg>)` so the broker still
|
|
918
|
+
// shows up as a hint, just with no queues recorded.
|
|
919
|
+
const argLessRe = /\b([a-zA-Z_$][\w$]*)\s*\.\s*(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(/g;
|
|
920
|
+
while ((m = argLessRe.exec(cleaned)) !== null) {
|
|
921
|
+
const receiver = m[1];
|
|
922
|
+
if (!BROKER_RE.test(receiver))
|
|
923
|
+
continue;
|
|
924
|
+
if (!verbsRe.test(m[0]))
|
|
925
|
+
continue;
|
|
926
|
+
const broker = receiverToBrokerName(receiver);
|
|
927
|
+
if (!byBroker.has(broker)) {
|
|
928
|
+
byBroker.set(broker, { queues: new Set(), broker });
|
|
929
|
+
}
|
|
930
|
+
// No queue to add.
|
|
931
|
+
}
|
|
932
|
+
const out = [];
|
|
933
|
+
for (const { broker, queues } of byBroker.values()) {
|
|
934
|
+
out.push(makeMessagingHint(broker, [...queues].sort(), sourceFile));
|
|
935
|
+
}
|
|
936
|
+
return out;
|
|
937
|
+
}
|
|
938
|
+
/** Normalise a JS-side broker handle to a canonical broker name. */
|
|
939
|
+
function receiverToBrokerName(receiver) {
|
|
940
|
+
const lower = receiver.toLowerCase();
|
|
941
|
+
if (lower.startsWith('rabbit'))
|
|
942
|
+
return 'rabbitmq';
|
|
943
|
+
if (lower.startsWith('kafka'))
|
|
944
|
+
return 'kafka';
|
|
945
|
+
if (lower.startsWith('nats'))
|
|
946
|
+
return 'nats';
|
|
947
|
+
if (lower.startsWith('redis'))
|
|
948
|
+
return 'redis';
|
|
949
|
+
if (lower.startsWith('amqp'))
|
|
950
|
+
return 'amqp';
|
|
951
|
+
if (lower.includes('sqs'))
|
|
952
|
+
return 'sqs';
|
|
953
|
+
if (lower.includes('sns'))
|
|
954
|
+
return 'sns';
|
|
955
|
+
if (lower === 'eventbus')
|
|
956
|
+
return 'eventbus';
|
|
957
|
+
return lower;
|
|
958
|
+
}
|
|
959
|
+
function makeMessagingHint(broker, queues, sourceFile) {
|
|
960
|
+
const config = { broker };
|
|
961
|
+
if (queues.length > 0)
|
|
962
|
+
config.queues = queues;
|
|
963
|
+
// Capability tag: messaging.<broker>. The `<topic-domain>` form in the
|
|
964
|
+
// proposal would require domain inference from queue names — not done
|
|
965
|
+
// here; spec author tunes if needed.
|
|
966
|
+
const advertise = `messaging.${broker}`;
|
|
967
|
+
return {
|
|
968
|
+
from: broker,
|
|
969
|
+
pattern: 'messaging',
|
|
970
|
+
...(sourceFile ? { sourceFile } : {}),
|
|
971
|
+
deploymentHint: {
|
|
972
|
+
category: 'communications',
|
|
973
|
+
instanceName: toInstanceKey(broker),
|
|
974
|
+
advertises: [advertise],
|
|
975
|
+
config,
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Detect known managed-SaaS SDK initialisations:
|
|
981
|
+
* - `new Stripe(apiKey, ...)` → from: `@stripe/stripe-js`, advertises:
|
|
982
|
+
* `managed.payments`
|
|
983
|
+
* - `new OpenAI({ apiKey })` → from: `openai`, advertises: `managed.ai`
|
|
984
|
+
* - `new Anthropic({ apiKey })` → from: `@anthropic-ai/sdk`, advertises:
|
|
985
|
+
* `managed.ai`
|
|
986
|
+
* - `new SESClient({ region })` / AWS SDK init → from: `@aws-sdk/<x>`
|
|
987
|
+
* - `Twilio(sid, token)` → from: `twilio`, advertises: `managed.sms`
|
|
988
|
+
* - `new SendGridClient({ apiKey })` / `sgMail.setApiKey(...)` →
|
|
989
|
+
* from: `@sendgrid/mail`, advertises: `managed.email`
|
|
990
|
+
*
|
|
991
|
+
* The lookup table is INTENTIONALLY conservative — only well-known SDKs
|
|
992
|
+
* with stable name signatures. For unknown providers, the spec author
|
|
993
|
+
* adds the `import:` block manually.
|
|
994
|
+
*
|
|
995
|
+
* Authentication env-var inference:
|
|
996
|
+
* - When the constructor is called with `process.env.X`, that's the
|
|
997
|
+
* authEnv.
|
|
998
|
+
* - Otherwise we leave `authEnv` unset; the deployment-instance config
|
|
999
|
+
* just carries `provider:` and the spec author fills the env wiring.
|
|
1000
|
+
*
|
|
1001
|
+
* Each hint emits:
|
|
1002
|
+
* - `import:` entry with `from: <scoped-package-name>` and version
|
|
1003
|
+
* when the consumer's package.json names it as a dependency.
|
|
1004
|
+
* - deployment instance under `infrastructure:`, `advertises:
|
|
1005
|
+
* ["managed.<domain>"]`, `config: { provider: <name>, authEnv: <ENV> }`
|
|
1006
|
+
*/
|
|
1007
|
+
export function detectManagedSdkInit(source, sourceFile) {
|
|
1008
|
+
if (!source)
|
|
1009
|
+
return [];
|
|
1010
|
+
const cleaned = stripCommentsForDetection(source);
|
|
1011
|
+
const out = [];
|
|
1012
|
+
const seen = new Set();
|
|
1013
|
+
// Known-SDK lookup. Keys are constructor identifiers (`Stripe`,
|
|
1014
|
+
// `OpenAI`, etc.); values describe the canonical scoped package name
|
|
1015
|
+
// and capability tag.
|
|
1016
|
+
const SDK_TABLE = {
|
|
1017
|
+
Stripe: { pkg: '@stripe/stripe-js', provider: 'stripe', advertise: 'managed.payments' },
|
|
1018
|
+
OpenAI: { pkg: 'openai', provider: 'openai', advertise: 'managed.ai' },
|
|
1019
|
+
Anthropic: { pkg: '@anthropic-ai/sdk', provider: 'anthropic', advertise: 'managed.ai' },
|
|
1020
|
+
Twilio: { pkg: 'twilio', provider: 'twilio', advertise: 'managed.sms' },
|
|
1021
|
+
SendGridClient: { pkg: '@sendgrid/client', provider: 'sendgrid', advertise: 'managed.email' },
|
|
1022
|
+
SESClient: { pkg: '@aws-sdk/client-ses', provider: 'aws-ses', advertise: 'managed.email' },
|
|
1023
|
+
SNSClient: { pkg: '@aws-sdk/client-sns', provider: 'aws-sns', advertise: 'managed.sns' },
|
|
1024
|
+
SQSClient: { pkg: '@aws-sdk/client-sqs', provider: 'aws-sqs', advertise: 'managed.queue' },
|
|
1025
|
+
S3Client: { pkg: '@aws-sdk/client-s3', provider: 'aws-s3', advertise: 'managed.storage' },
|
|
1026
|
+
DynamoDBClient: { pkg: '@aws-sdk/client-dynamodb', provider: 'aws-dynamodb', advertise: 'managed.database' },
|
|
1027
|
+
PrismaClient: { pkg: '@prisma/client', provider: 'prisma', advertise: 'managed.database' },
|
|
1028
|
+
};
|
|
1029
|
+
// Match `new <Ctor>(...)` for each known constructor. Capture the
|
|
1030
|
+
// first-arg form so we can pull out `process.env.X` if present.
|
|
1031
|
+
for (const [ctor, info] of Object.entries(SDK_TABLE)) {
|
|
1032
|
+
const re = new RegExp(`new\\s+${escapeRegex(ctor)}\\s*\\(([^)]*)\\)`, 'g');
|
|
1033
|
+
let m;
|
|
1034
|
+
while ((m = re.exec(cleaned)) !== null) {
|
|
1035
|
+
const args = m[1] ?? '';
|
|
1036
|
+
const envMatch = args.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
|
|
1037
|
+
const key = `${info.pkg}:${envMatch ? envMatch[1] : 'noenv'}`;
|
|
1038
|
+
if (seen.has(key))
|
|
1039
|
+
continue;
|
|
1040
|
+
seen.add(key);
|
|
1041
|
+
const config = { provider: info.provider };
|
|
1042
|
+
if (envMatch)
|
|
1043
|
+
config.authEnv = envMatch[1];
|
|
1044
|
+
out.push({
|
|
1045
|
+
from: info.pkg,
|
|
1046
|
+
pattern: 'managed-sdk',
|
|
1047
|
+
...(sourceFile ? { sourceFile } : {}),
|
|
1048
|
+
deploymentHint: {
|
|
1049
|
+
category: 'infrastructure',
|
|
1050
|
+
instanceName: toInstanceKey(info.provider),
|
|
1051
|
+
advertises: [info.advertise],
|
|
1052
|
+
config,
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// Also catch the bare-call shape Twilio uses (`Twilio(sid, token)`).
|
|
1058
|
+
const twilioRe = /(?:^|[^.\w$])Twilio\s*\(\s*([^)]*)\)/g;
|
|
1059
|
+
let tm;
|
|
1060
|
+
while ((tm = twilioRe.exec(cleaned)) !== null) {
|
|
1061
|
+
const args = tm[1] ?? '';
|
|
1062
|
+
const envMatch = args.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
|
|
1063
|
+
const key = `twilio:${envMatch ? envMatch[1] : 'noenv'}`;
|
|
1064
|
+
if (seen.has(key))
|
|
1065
|
+
continue;
|
|
1066
|
+
seen.add(key);
|
|
1067
|
+
const config = { provider: 'twilio' };
|
|
1068
|
+
if (envMatch)
|
|
1069
|
+
config.authEnv = envMatch[1];
|
|
1070
|
+
out.push({
|
|
1071
|
+
from: 'twilio',
|
|
1072
|
+
pattern: 'managed-sdk',
|
|
1073
|
+
...(sourceFile ? { sourceFile } : {}),
|
|
1074
|
+
deploymentHint: {
|
|
1075
|
+
category: 'infrastructure',
|
|
1076
|
+
instanceName: toInstanceKey('twilio'),
|
|
1077
|
+
advertises: ['managed.sms'],
|
|
1078
|
+
config,
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
return out;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Run all four V2 Phase 2 detections against a single source file and
|
|
1086
|
+
* return a merged hint list. Convenience wrapper used by the prepass
|
|
1087
|
+
* orchestrator.
|
|
1088
|
+
*/
|
|
1089
|
+
export function detectNonLibraryDeps(source, sourceFile) {
|
|
1090
|
+
return [
|
|
1091
|
+
...detectSubprocessCalls(source, sourceFile),
|
|
1092
|
+
...detectServiceClients(source, sourceFile),
|
|
1093
|
+
...detectMessagingCrossings(source, sourceFile),
|
|
1094
|
+
...detectManagedSdkInit(source, sourceFile),
|
|
1095
|
+
];
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Build the per-component non-library imports index. Walks every
|
|
1099
|
+
* consumer component's source files, runs the four detection passes,
|
|
1100
|
+
* deduplicates by (from, pattern) within a component, and unions the
|
|
1101
|
+
* select / queues fields. File walking reuses the same file-discovery
|
|
1102
|
+
* logic as `buildImportsByComponent` so detections are scoped to the
|
|
1103
|
+
* same surface as in-tree imports.
|
|
1104
|
+
*/
|
|
1105
|
+
export async function buildNonLibraryImportsByComponent(opts) {
|
|
1106
|
+
const result = {};
|
|
1107
|
+
const components = opts.facts.suggestedComponents ?? [];
|
|
1108
|
+
if (components.length === 0)
|
|
1109
|
+
return result;
|
|
1110
|
+
const fileExt = opts.extensionFilter ?? defaultExtFilter;
|
|
1111
|
+
const filesByComponent = new Map();
|
|
1112
|
+
for (const comp of components)
|
|
1113
|
+
filesByComponent.set(comp.suggestedName, []);
|
|
1114
|
+
const allFiles = new Set();
|
|
1115
|
+
for (const cm of opts.facts.candidateMethods ?? [])
|
|
1116
|
+
allFiles.add(cm.filePath);
|
|
1117
|
+
for (const v of opts.facts.views ?? [])
|
|
1118
|
+
allFiles.add(v.filePath);
|
|
1119
|
+
for (const e of opts.facts.entities ?? []) {
|
|
1120
|
+
if (e.filePath)
|
|
1121
|
+
allFiles.add(e.filePath);
|
|
1122
|
+
}
|
|
1123
|
+
for (const r of opts.facts.routes ?? []) {
|
|
1124
|
+
if (r.filePath)
|
|
1125
|
+
allFiles.add(r.filePath);
|
|
1126
|
+
}
|
|
1127
|
+
for (const file of allFiles) {
|
|
1128
|
+
if (!fileExt(file))
|
|
1129
|
+
continue;
|
|
1130
|
+
const owner = findOwningComponent(file, components);
|
|
1131
|
+
if (owner)
|
|
1132
|
+
filesByComponent.get(owner).push(file);
|
|
1133
|
+
}
|
|
1134
|
+
for (const consumer of components) {
|
|
1135
|
+
const files = filesByComponent.get(consumer.suggestedName) ?? [];
|
|
1136
|
+
if (files.length === 0)
|
|
1137
|
+
continue;
|
|
1138
|
+
// Merge per (from, pattern) tuple so multiple subprocess calls or
|
|
1139
|
+
// multiple managed-SDK constructors from the same provider collapse.
|
|
1140
|
+
const merged = new Map();
|
|
1141
|
+
for (const file of files) {
|
|
1142
|
+
let source = '';
|
|
1143
|
+
try {
|
|
1144
|
+
source = await opts.readFile(file);
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const hints = detectNonLibraryDeps(source, file);
|
|
1150
|
+
for (const hint of hints) {
|
|
1151
|
+
const key = `${hint.pattern}:${hint.from}`;
|
|
1152
|
+
const existing = merged.get(key);
|
|
1153
|
+
if (!existing) {
|
|
1154
|
+
merged.set(key, hint);
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
// Union select arrays.
|
|
1158
|
+
if (hint.select) {
|
|
1159
|
+
const sel = new Set([...(existing.select ?? []), ...hint.select]);
|
|
1160
|
+
existing.select = [...sel].sort();
|
|
1161
|
+
}
|
|
1162
|
+
// Union queues.
|
|
1163
|
+
const exQ = existing.deploymentHint.config.queues;
|
|
1164
|
+
const newQ = hint.deploymentHint.config.queues;
|
|
1165
|
+
if (newQ && newQ.length > 0) {
|
|
1166
|
+
const unioned = new Set([...(exQ ?? []), ...newQ]);
|
|
1167
|
+
existing.deploymentHint.config.queues = [...unioned].sort();
|
|
1168
|
+
}
|
|
1169
|
+
// Prefer an existing authEnv; otherwise inherit a newly-detected one.
|
|
1170
|
+
const exAuth = existing.deploymentHint.config.authEnv;
|
|
1171
|
+
const newAuth = hint.deploymentHint.config.authEnv;
|
|
1172
|
+
if (!exAuth && newAuth) {
|
|
1173
|
+
existing.deploymentHint.config.authEnv = newAuth;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (merged.size > 0) {
|
|
1178
|
+
// Stable order: sort by pattern then `from:`.
|
|
1179
|
+
const arr = [...merged.values()].sort((a, b) => {
|
|
1180
|
+
if (a.pattern !== b.pattern)
|
|
1181
|
+
return a.pattern.localeCompare(b.pattern);
|
|
1182
|
+
return a.from.localeCompare(b.from);
|
|
1183
|
+
});
|
|
1184
|
+
result[consumer.suggestedName] = arr;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return result;
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Convenience: run the V2 Phase 2 non-library walker against a
|
|
1191
|
+
* StructuralPrepass backend (analogous to
|
|
1192
|
+
* `buildImportsByComponentFromBackend`).
|
|
1193
|
+
*/
|
|
1194
|
+
export async function buildNonLibraryImportsByComponentFromBackend(facts, backend) {
|
|
1195
|
+
return buildNonLibraryImportsByComponent({
|
|
1196
|
+
facts,
|
|
1197
|
+
readFile: (path) => backend.fileSourceText(path),
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
//# sourceMappingURL=imports-graph.js.map
|