@volter/twin-world 0.1.0 → 0.1.2

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.
@@ -0,0 +1,683 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, relative, resolve } from 'node:path';
3
+ import { normalizePypiName, overlayPypiTwins, overlaySdkTwins } from './pack-facts.ts';
4
+
5
+ export type ProjectInspection = {
6
+ root: string;
7
+ runtimes: string[];
8
+ manifests: string[];
9
+ vendorSdks: Array<{ vendor: string; packages: string[]; twin: string }>;
10
+ registryDestinations: Array<ProjectNpmRegistry & {
11
+ classification: 'npm-registry' | 'unknown';
12
+ twin?: '@volter/twin-npm-registry';
13
+ }>;
14
+ envNames: string[];
15
+ entrypoints: string[];
16
+ coverage: string[];
17
+ decisions: string[];
18
+ };
19
+
20
+ /** npm package → vendor/twin mapping — the shared vendor↔SDK knowledge used by both
21
+ * `inspect-project` (adoption discovery) and `covers` (room-setup coverage proof).
22
+ * The canonical entry for each pack is the real client package that pack's own tests
23
+ * prove the twin against (see each `packages/twin/<pack>/package.json` devDependencies);
24
+ * the extra aliases are the vendor's other official/first-party clients of the same
25
+ * API surface. */
26
+ export const SDK_TWINS: Record<string, { vendor: string; twin: string }> = {
27
+ // (empty — every entry has moved onto its pack descriptor. See the note below.)
28
+ };
29
+ // TWIN-PACK-CONTRACT migration, COMPLETE for this table: every pack declares its own sdk names
30
+ // (`adoption.sdks` on its descriptor, compiled into the committed pack-facts artifact), and the
31
+ // literal above is now empty — the map is populated entirely by the overlay below. It is kept as
32
+ // the overlay's target, not as a home: the exported object identity is what every use site already
33
+ // reads, so nothing downstream changed. Do NOT re-add an entry here; a fact declared in both homes
34
+ // throws at module init.
35
+ overlaySdkTwins(SDK_TWINS);
36
+
37
+ /** PyPI distribution name (PEP 503) → twin. Built ENTIRELY from descriptors' `adoption.pypi`
38
+ * (no hand table): the Python half of the same map SDK_TWINS is for npm. A Python dependency
39
+ * detected here surfaces under the vendor's key, so covers/inspect treat both halves alike. */
40
+ export const PYPI_TWINS: Record<string, { vendor: string; twin: string }> = {};
41
+ overlayPypiTwins(PYPI_TWINS);
42
+
43
+ /** Python dependency names declared by a project — requirements*.txt (one requirement per line;
44
+ * `-r` includes followed one level; comments, options and URLs skipped) and pyproject.toml
45
+ * (`[project] dependencies`, every `[project.optional-dependencies.*]` list, and Poetry's
46
+ * `[tool.poetry.dependencies]` / `[tool.poetry.group.*.dependencies]` tables) — across the
47
+ * root and every manifest dir. Names are PEP 503 normalized; extras and version specs dropped. */
48
+ export function projectPythonDependencies(input = process.cwd()): string[] {
49
+ const root = resolve(input);
50
+ const out = new Set<string>();
51
+ const reqName = (line: string): string | null => {
52
+ const s = line.replace(/\s+#.*$/, '').trim();
53
+ if (!s || s.startsWith('#') || s.startsWith('-') || s.includes('://') || s.startsWith('.') || s.startsWith('/')) return null;
54
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(s);
55
+ return m ? normalizePypiName(m[1]!) : null;
56
+ };
57
+ const readRequirements = (path: string, depth: number): void => {
58
+ if (depth > 1 || !existsSync(path)) return;
59
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
60
+ const t = line.trim();
61
+ const inc = /^-r\s+(\S+)/.exec(t) ?? /^--requirement\s+(\S+)/.exec(t);
62
+ if (inc) { readRequirements(resolve(path, '..', inc[1]!), depth + 1); continue; }
63
+ const n = reqName(t);
64
+ if (n) out.add(n);
65
+ }
66
+ };
67
+ const readPyproject = (path: string): void => {
68
+ if (!existsSync(path)) return;
69
+ const text = readFileSync(path, 'utf8');
70
+ // TOML arrays of requirement strings under the known keys — a targeted read, not a parser.
71
+ // The KEY is checked, not just the section: under `[project]` only `dependencies` holds
72
+ // requirements, while `keywords` and `classifiers` are prose arrays that live in the same
73
+ // table. Accepting every key there read Airflow's `keywords = ["dag", "workflow", …]` as
74
+ // dependency names — harmless while PYPI_TWINS was empty, a FALSE VENDOR DETECTION the moment
75
+ // descriptors claim real distribution names (a repo whose keywords say "openai" does not
76
+ // thereby call OpenAI). Under `[project.optional-dependencies]` and `[dependency-groups]`
77
+ // every key IS a requirement list (the key is the extra/group name), so any key counts there.
78
+ for (const m of text.matchAll(/^[ \t]*([A-Za-z0-9_-]+)\s*=\s*\[([\s\S]*?)\]/gm)) {
79
+ const head = text.slice(0, m.index).split('\n').reverse().find((l) => /^\s*\[/.test(l)) ?? '';
80
+ const section = /^\s*\[(project|project\.optional-dependencies|dependency-groups)\]/.exec(head)?.[1];
81
+ if (section === undefined) continue;
82
+ if (section === 'project' && m[1] !== 'dependencies') continue;
83
+ for (const q of m[2]!.matchAll(/["']([^"']+)["']/g)) { const n = reqName(q[1]!); if (n) out.add(n); }
84
+ }
85
+ // Poetry tables: keys are the package names.
86
+ for (const m of text.matchAll(/^[ \t]*\[tool\.poetry(?:\.group\.[A-Za-z0-9_-]+)?\.dependencies\][ \t]*\r?\n([\s\S]*?)(?=^[ \t]*\[|$(?![\s\S]))/gm)) {
87
+ for (const line of m[1]!.split('\n')) {
88
+ const k = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/.exec(line);
89
+ if (k && k[1]!.toLowerCase() !== 'python') out.add(normalizePypiName(k[1]!));
90
+ }
91
+ }
92
+ };
93
+ for (const dir of [root, ...projectManifestDirs(root).filter((d) => d !== root)]) {
94
+ for (const f of filesAt(dir)) if (/^requirements[^/]*\.txt$/.test(f)) readRequirements(resolve(dir, f), 0);
95
+ readPyproject(resolve(dir, 'pyproject.toml'));
96
+ }
97
+ return [...out].sort();
98
+ }
99
+
100
+ function escapeRegExp(text: string): string {
101
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
102
+ }
103
+
104
+ /** Directories (any depth up to `depth`) below `dir`, skipping hidden dirs and node_modules. */
105
+ function walkDirs(dir: string, depth: number): string[] {
106
+ if (depth <= 0 || !existsSync(dir)) return [];
107
+ const found: string[] = [];
108
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
109
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
110
+ const child = resolve(dir, entry.name);
111
+ found.push(child, ...walkDirs(child, depth - 1));
112
+ }
113
+ return found;
114
+ }
115
+
116
+ // Expand one workspace glob into the member dirs that actually hold a package.json.
117
+ // Supports literal dirs, `*` in any segment (one level: "apps/*", two: "packages/*" + "/*"),
118
+ // and a terminal `**` (all nested dirs). Line comment on purpose: glob examples contain
119
+ // the block-comment terminator.
120
+ function expandWorkspaceGlob(root: string, glob: string): string[] {
121
+ const segments = glob.replace(/\/+$/, '').split('/');
122
+ let current: string[] = [root];
123
+ for (const segment of segments) {
124
+ const next: string[] = [];
125
+ if (segment === '**') {
126
+ for (const dir of current) next.push(dir, ...walkDirs(dir, 4));
127
+ current = next;
128
+ break; // `**` is terminal: it already covers every nested dir
129
+ }
130
+ for (const dir of current) {
131
+ if (segment.includes('*')) {
132
+ if (!existsSync(dir)) continue;
133
+ const matcher = new RegExp(`^${segment.split('*').map(escapeRegExp).join('.*')}$`);
134
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
135
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && matcher.test(entry.name)) {
136
+ next.push(resolve(dir, entry.name));
137
+ }
138
+ }
139
+ } else if (existsSync(resolve(dir, segment))) {
140
+ next.push(resolve(dir, segment));
141
+ }
142
+ }
143
+ current = next;
144
+ }
145
+ return current.filter((dir) => dir !== root && existsSync(resolve(dir, 'package.json')));
146
+ }
147
+
148
+ /** Workspace member dirs of a JS monorepo: pnpm-workspace.yaml packages plus
149
+ * package.json "workspaces" (npm/yarn/bun form), glob patterns expanded via
150
+ * expandWorkspaceGlob above. A repo's vendor SDKs usually live in members, not
151
+ * the root manifest — the ponder blind-adoption test found inspect-project
152
+ * blind to exactly this. */
153
+ export function workspaceDirs(root: string): string[] {
154
+ const globs: string[] = [];
155
+ const workspaceYaml = resolve(root, 'pnpm-workspace.yaml');
156
+ if (existsSync(workspaceYaml)) {
157
+ for (const line of readFileSync(workspaceYaml, 'utf8').split(/\r?\n/)) {
158
+ const match = line.match(/^\s*-\s*["']?([^"'#]+?)["']?\s*$/);
159
+ if (match) globs.push(match[1]!.trim());
160
+ }
161
+ }
162
+ const pkg = json(resolve(root, 'package.json'));
163
+ const declared = Array.isArray(pkg?.workspaces) ? pkg.workspaces : Array.isArray(pkg?.workspaces?.packages) ? pkg.workspaces.packages : [];
164
+ globs.push(...declared.filter((entry: unknown): entry is string => typeof entry === 'string'));
165
+ const dirs = new Set<string>();
166
+ for (const glob of globs) {
167
+ if (glob.includes('!')) continue;
168
+ for (const dir of expandWorkspaceGlob(root, glob)) dirs.add(dir);
169
+ }
170
+ return [...dirs].sort();
171
+ }
172
+
173
+ const NESTED_ROOT_SKIP = new Set(['node_modules', 'dist', 'build', 'out', 'vendor', 'target', 'coverage']);
174
+ const NESTED_ROOT_CAP = 200;
175
+
176
+ /** Nested JS project roots: subdirs (depth ≤ 2) holding a package.json or
177
+ * pnpm-workspace.yaml that the GIT root's own workspace declarations never reach.
178
+ * The 2026-08 battery scan (evals/battery) found the detector blind to exactly this
179
+ * shape — a Go/Rails/Python repo with its JS app in `web/` or `frontend/` (unkey's
180
+ * stripe lives in web/apps/dashboard, reachable only via web/'s OWN workspace file).
181
+ * Each nested root contributes itself plus ITS workspace members. Same class as the
182
+ * ponder workspace fix above, one level out. */
183
+ export function nestedProjectRoots(root: string): string[] {
184
+ const found = new Set<string>();
185
+ const candidates: string[] = [];
186
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
187
+ if (!entry.isDirectory() || entry.name.startsWith('.') || NESTED_ROOT_SKIP.has(entry.name)) continue;
188
+ const child = resolve(root, entry.name);
189
+ candidates.push(child);
190
+ try {
191
+ for (const nested of readdirSync(child, { withFileTypes: true })) {
192
+ if (!nested.isDirectory() || nested.name.startsWith('.') || NESTED_ROOT_SKIP.has(nested.name)) continue;
193
+ candidates.push(resolve(child, nested.name));
194
+ }
195
+ } catch { /* unreadable dir */ }
196
+ }
197
+ for (const dir of candidates) {
198
+ if (found.size >= NESTED_ROOT_CAP) break;
199
+ if (!existsSync(resolve(dir, 'package.json')) && !existsSync(resolve(dir, 'pnpm-workspace.yaml'))) continue;
200
+ found.add(dir);
201
+ for (const member of workspaceDirs(dir)) if (found.size < NESTED_ROOT_CAP) found.add(member);
202
+ }
203
+ return [...found].sort();
204
+ }
205
+
206
+ /** Every dir whose package.json should count toward the project's dependency /
207
+ * env-name census: the root, its declared workspace members, and nested project
208
+ * roots (plus THEIR members). Single source of truth for the walkers below. */
209
+ export function projectManifestDirs(root: string): string[] {
210
+ return [...new Set([root, ...workspaceDirs(root), ...nestedProjectRoots(root)])].sort();
211
+ }
212
+
213
+ function json(path: string): Record<string, any> | undefined {
214
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return undefined; }
215
+ }
216
+
217
+ function filesAt(root: string): string[] {
218
+ if (!existsSync(root)) return [];
219
+ return readdirSync(root, { withFileTypes: true })
220
+ .filter((entry) => entry.isFile())
221
+ .map((entry) => entry.name);
222
+ }
223
+
224
+ /** Every dependency name declared anywhere in the repo — root manifest plus all
225
+ * workspace members, across dependencies/devDependencies/peerDependencies — plus
226
+ * the python manifest candidates. Exported raw for `covers`, which must also see
227
+ * the external-service-shaped deps that DON'T map to any twin. */
228
+ export function projectDependencies(input = process.cwd()): string[] {
229
+ const root = resolve(input);
230
+ const dependencies = new Set<string>();
231
+ // A dep that IS one of the repo's own workspace members resolves LOCALLY (the workspace
232
+ // protocol wins under every package manager), so it is never an external-vendor signal —
233
+ // whatever scope it carries. Without this, one mapped package in a vendor's scope turns the
234
+ // vendor's OWN monorepo self-references into "unmapped siblings of a mapped scope": the
235
+ // 2026-08-31 census counted 110 of posthog's `@posthog/*` workspace packages as unknown
236
+ // external services the day `@posthog/api-client` entered a pack's adoption.
237
+ const memberNames = new Set<string>();
238
+ const pkg = json(resolve(root, 'package.json'));
239
+ for (const dir of projectManifestDirs(root)) {
240
+ const manifest = dir === root ? pkg : json(resolve(dir, 'package.json'));
241
+ if (typeof manifest?.name === 'string' && manifest.name.length > 0) memberNames.add(manifest.name);
242
+ for (const group of ['dependencies', 'devDependencies', 'peerDependencies']) {
243
+ for (const name of Object.keys(manifest?.[group] ?? {})) dependencies.add(name);
244
+ }
245
+ }
246
+ for (const name of memberNames) dependencies.delete(name);
247
+ // The Python half: every declared PyPI dependency that a descriptor maps (PYPI_TWINS)
248
+ // surfaces under the vendor's key — the same key the npm half yields — so a mixed repo
249
+ // counts a vendor once. Unmapped Python names are not external-service-shaped by
250
+ // themselves and stay out (the env-name and URL detectors still see a vendor either way).
251
+ for (const name of projectPythonDependencies(root)) {
252
+ const hit = PYPI_TWINS[name];
253
+ if (hit) dependencies.add(`pypi:${name}`);
254
+ }
255
+ return [...dependencies].sort();
256
+ }
257
+
258
+ /** Env var names assigned in committed .env* files (root + workspace members) —
259
+ * the secondary vendor signal: a repo may hold a vendor key without declaring
260
+ * the vendor's SDK (raw fetch / CLI use). */
261
+ export function projectEnvNames(input = process.cwd()): string[] {
262
+ const root = resolve(input);
263
+ const envNames = new Set<string>();
264
+ for (const dir of projectManifestDirs(root)) {
265
+ for (const name of filesAt(dir).filter((candidate) => candidate === '.env' || candidate.startsWith('.env.'))) {
266
+ const text = readFileSync(resolve(dir, name), 'utf8');
267
+ for (const line of text.split(/\r?\n/)) {
268
+ const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
269
+ if (match) envNames.add(match[1]!);
270
+ }
271
+ }
272
+ }
273
+ return [...envNames].sort();
274
+ }
275
+
276
+ export type ProjectNpmRegistry = { url: string; source: string };
277
+
278
+ const BUNFIG_MAX_BYTES = 256 * 1024;
279
+ const BUNFIG_MAX_LINE_LENGTH = 4 * 1024;
280
+
281
+ function publicRegistryUrl(configured: string): string {
282
+ const parsed = new URL(configured);
283
+ // inspect-project is a public diagnostic surface, not a credential transport. Registry host and
284
+ // path are sufficient for classification; userinfo, query tokens, and fragments must never be
285
+ // copied from config files into its structured report or formatted stdout.
286
+ parsed.username = '';
287
+ parsed.password = '';
288
+ parsed.search = '';
289
+ parsed.hash = '';
290
+ return parsed.toString();
291
+ }
292
+
293
+ type InspectedEnv = { values: Map<string, string>; sources: Map<string, string> };
294
+
295
+ function inspectedEnv(dir: string, label: string): InspectedEnv {
296
+ const values = new Map<string, string>();
297
+ const sources = new Map<string, string>();
298
+ for (const name of filesAt(dir).filter((candidate) => candidate === '.env' || candidate.startsWith('.env.')).sort()) {
299
+ for (const [index, line] of readFileSync(resolve(dir, name), 'utf8').split(/\r?\n/).entries()) {
300
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
301
+ if (!match) continue;
302
+ let value = match[2]!;
303
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
304
+ value = value.slice(1, -1);
305
+ } else {
306
+ value = value.replace(/\s+#.*$/, '').trim();
307
+ }
308
+ if (value.length > 2_048) continue;
309
+ values.set(match[1]!, value);
310
+ sources.set(match[1]!, `${label === '.' ? '' : `${label}/`}${name}:${index + 1}`);
311
+ }
312
+ }
313
+ return { values, sources };
314
+ }
315
+
316
+ /** Resolve only ordinary npm-style `${NAME}` references from inspected, committed env files.
317
+ * Eight rounds bound cycles/expansion; shell operators and unresolved variables stay unresolved
318
+ * instead of being guessed or evaluated. */
319
+ function resolveInspectedEnv(value: string, env: Map<string, string>): string | null {
320
+ let resolved = value;
321
+ for (let depth = 0; depth < 8 && /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(resolved); depth++) {
322
+ let missing = false;
323
+ resolved = resolved.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_whole, name: string) => {
324
+ const replacement = env.get(name);
325
+ if (replacement === undefined) { missing = true; return ''; }
326
+ return replacement;
327
+ });
328
+ if (missing || resolved.length > 4_096) return null;
329
+ }
330
+ return resolved.includes('${') ? null : resolved;
331
+ }
332
+
333
+ function resolveBunfigEnv(value: string, env: Map<string, string>): string | null {
334
+ let resolved = resolveInspectedEnv(value, env);
335
+ if (resolved === null) return null;
336
+ // Bun's documented bunfig syntax also accepts bare $NAME references. Keep interpolation
337
+ // deliberately narrower than a shell: no operators, commands, or process environment reads.
338
+ for (let depth = 0; depth < 8 && /\$[A-Za-z_][A-Za-z0-9_]*/.test(resolved); depth++) {
339
+ let missing = false;
340
+ resolved = resolved.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_whole, name: string) => {
341
+ const replacement = env.get(name);
342
+ if (replacement === undefined) { missing = true; return ''; }
343
+ return replacement;
344
+ });
345
+ if (missing || resolved.length > 4_096) return null;
346
+ }
347
+ return /\$[A-Za-z_][A-Za-z0-9_]*/.test(resolved) ? null : resolved;
348
+ }
349
+
350
+ function bunfigInstallRegistries(path: string, sourcePrefix: string, env: Map<string, string>): ProjectNpmRegistry[] {
351
+ if (!existsSync(path)) return [];
352
+ const stat = statSync(path);
353
+ if (!stat.isFile() || stat.size > BUNFIG_MAX_BYTES) return [];
354
+ const text = readFileSync(path, 'utf8');
355
+ const lines = text.split(/\r?\n/);
356
+ if (lines.some((line) => line.length > BUNFIG_MAX_LINE_LENGTH)) return [];
357
+ let parsed: unknown;
358
+ try { parsed = Bun.TOML.parse(text); } catch { return []; }
359
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return [];
360
+ const install = (parsed as Record<string, unknown>).install;
361
+ if (!install || typeof install !== 'object' || Array.isArray(install)) return [];
362
+ const registry = (install as Record<string, unknown>).registry;
363
+ const scopes = (install as Record<string, unknown>).scopes;
364
+ const candidates: Array<{ raw: string; linePattern: RegExp }> = [];
365
+ const registryUrl = typeof registry === 'string'
366
+ ? registry
367
+ : registry && typeof registry === 'object' && !Array.isArray(registry)
368
+ ? (registry as Record<string, unknown>).url
369
+ : undefined;
370
+ if (typeof registryUrl === 'string') {
371
+ candidates.push({ raw: registryUrl, linePattern: /^\s*(?:install\.)?registry\s*=/ });
372
+ }
373
+ if (scopes && typeof scopes === 'object' && !Array.isArray(scopes)) {
374
+ for (const [scope, value] of Object.entries(scopes)) {
375
+ const url = typeof value === 'string'
376
+ ? value
377
+ : value && typeof value === 'object' && !Array.isArray(value)
378
+ ? (value as Record<string, unknown>).url
379
+ : undefined;
380
+ if (typeof url === 'string') {
381
+ const escapedScope = scope.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
382
+ candidates.push({ raw: url, linePattern: new RegExp(`^\\s*["']?${escapedScope}["']?\\s*=`) });
383
+ }
384
+ }
385
+ }
386
+ const found: ProjectNpmRegistry[] = [];
387
+ for (const candidate of candidates) {
388
+ const configured = resolveBunfigEnv(candidate.raw, env);
389
+ if (configured === null) continue;
390
+ const index = lines.findIndex((line) => candidate.linePattern.test(line));
391
+ try {
392
+ found.push({ url: publicRegistryUrl(configured), source: `${sourcePrefix}bunfig.toml:${index >= 0 ? index + 1 : 1}` });
393
+ } catch { /* Bun owns malformed registry diagnostics. */ }
394
+ }
395
+ return found;
396
+ }
397
+
398
+ /** Explicit npm registry destinations in root/workspace `.npmrc`, `bunfig.toml`, and publishConfig.
399
+ * npm/Bun are executables rather than SDK dependencies, so adoption must inspect their actual
400
+ * destination configuration: an NPM_TOKEN alone does not prove that traffic targets one of the
401
+ * two official hosts the npm-registry injector owns. */
402
+ export function projectNpmRegistries(input = process.cwd()): ProjectNpmRegistry[] {
403
+ const root = resolve(input);
404
+ const found = new Map<string, ProjectNpmRegistry>();
405
+ const rootEnv = inspectedEnv(root, '.');
406
+ for (const dir of projectManifestDirs(root)) {
407
+ const label = relative(root, dir) || '.';
408
+ const localEnv = dir === root ? rootEnv : inspectedEnv(dir, label);
409
+ const env = new Map([...rootEnv.values, ...localEnv.values]);
410
+ for (const envName of ['NPM_CONFIG_REGISTRY', 'BUN_CONFIG_REGISTRY']) {
411
+ const configuredByEnv = env.get(envName);
412
+ if (configuredByEnv === undefined) continue;
413
+ const resolvedRegistry = resolveInspectedEnv(configuredByEnv, env);
414
+ if (resolvedRegistry !== null) {
415
+ try {
416
+ const url = publicRegistryUrl(resolvedRegistry);
417
+ const source = `${localEnv.sources.get(envName) ?? rootEnv.sources.get(envName) ?? '.env'} ${envName}`;
418
+ found.set(`${source}\0${url}`, { url, source });
419
+ } catch { /* npm/Bun own invalid registry diagnostics */ }
420
+ }
421
+ }
422
+ const npmrc = resolve(dir, '.npmrc');
423
+ if (existsSync(npmrc)) {
424
+ for (const [index, line] of readFileSync(npmrc, 'utf8').split(/\r?\n/).entries()) {
425
+ const match = line.match(/^\s*(?:@[^:]+:)?registry\s*=\s*([^#;\s]+)\s*$/i);
426
+ if (!match) continue;
427
+ const configured = resolveInspectedEnv(match[1]!, env);
428
+ if (configured === null) continue;
429
+ try {
430
+ const url = publicRegistryUrl(configured);
431
+ const source = `${label === '.' ? '' : `${label}/`}.npmrc:${index + 1}`;
432
+ found.set(`${source}\0${url}`, { url, source });
433
+ } catch { /* malformed config is npm's own startup error, not a vendor destination */ }
434
+ }
435
+ }
436
+ for (const bunfig of bunfigInstallRegistries(resolve(dir, 'bunfig.toml'), label === '.' ? '' : `${label}/`, env)) {
437
+ found.set(`${bunfig.source}\0${bunfig.url}`, bunfig);
438
+ }
439
+ const manifest = json(resolve(dir, 'package.json'));
440
+ const configured = manifest?.publishConfig?.registry;
441
+ if (typeof configured === 'string') {
442
+ const resolvedRegistry = resolveInspectedEnv(configured, env);
443
+ if (resolvedRegistry === null) continue;
444
+ try {
445
+ const url = publicRegistryUrl(resolvedRegistry);
446
+ const source = `${label === '.' ? '' : `${label}/`}package.json#publishConfig.registry`;
447
+ found.set(`${source}\0${url}`, { url, source });
448
+ } catch { /* npm owns invalid publishConfig diagnostics */ }
449
+ }
450
+ }
451
+ return [...found.values()].sort((a, b) => a.source.localeCompare(b.source) || a.url.localeCompare(b.url));
452
+ }
453
+
454
+ export type ProjectFetchUrl = {
455
+ /** Absolute URL passed directly as the first argument to fetch. */
456
+ url: string;
457
+ /** Repo-relative source location (`path:line`). */
458
+ source: string;
459
+ };
460
+
461
+ const SOURCE_EXTENSIONS = new Set([
462
+ '.astro', '.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.svelte', '.ts', '.tsx', '.vue',
463
+ ]);
464
+ const SOURCE_DIR_IGNORE = new Set([
465
+ '.git', '.next', '.storybook', '.turbo', '.volter', '__fixtures__', '__mocks__', '__tests__',
466
+ 'build', 'coverage', 'dist', 'fixtures', 'mocks', 'node_modules', 'storybook',
467
+ 'storybook-static', 'target', 'test', 'tests', 'vendor',
468
+ ]);
469
+
470
+ function sourceExtension(name: string): string {
471
+ const index = name.lastIndexOf('.');
472
+ return index < 0 ? '' : name.slice(index).toLowerCase();
473
+ }
474
+
475
+ /** Whether `index` is executable source rather than a comment or string. This deliberately small
476
+ * lexer only answers that one question; it is not a JavaScript parser. It keeps comment examples
477
+ * and strings containing `fetch("https://...")` from becoming false vendor evidence. */
478
+ function isCodeAt(text: string, index: number): boolean {
479
+ let quote: "'" | '"' | '`' | null = null;
480
+ let lineComment = false;
481
+ let blockComment = false;
482
+ for (let cursor = 0; cursor < index; cursor += 1) {
483
+ const char = text[cursor]!;
484
+ const next = text[cursor + 1];
485
+ if (lineComment) {
486
+ if (char === '\n') lineComment = false;
487
+ continue;
488
+ }
489
+ if (blockComment) {
490
+ if (char === '*' && next === '/') { blockComment = false; cursor += 1; }
491
+ continue;
492
+ }
493
+ if (quote !== null) {
494
+ if (char === '\\') { cursor += 1; continue; }
495
+ // JavaScript single/double-quoted strings cannot cross an unescaped newline. Resetting here
496
+ // also keeps ordinary JSX prose (`We're ...`) from hiding every fetch later in a TSX file.
497
+ if (char === '\n' && quote !== '`') { quote = null; continue; }
498
+ if (char === quote) quote = null;
499
+ continue;
500
+ }
501
+ if (char === '/' && next === '/') { lineComment = true; cursor += 1; continue; }
502
+ if (char === '/' && next === '*') { blockComment = true; cursor += 1; continue; }
503
+ if (char === "'" || char === '"' || char === '`') quote = char;
504
+ }
505
+ return quote === null && !lineComment && !blockComment;
506
+ }
507
+
508
+ /** Production JS/TS source files, deterministically ordered. Shared by every source-level
509
+ * discovery signal so raw-fetch detection and env-read boot-risk detection exclude the same
510
+ * generated, test, fixture, mock, story, and dependency trees. */
511
+ function projectSourceFiles(input: string): string[] {
512
+ const root = resolve(input);
513
+ if (!existsSync(root)) return [];
514
+ const files: string[] = [];
515
+ const walk = (dir: string): void => {
516
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
517
+ if (SOURCE_DIR_IGNORE.has(entry.name)) continue;
518
+ const path = resolve(dir, entry.name);
519
+ if (entry.isDirectory()) {
520
+ if (!entry.name.startsWith('.')) walk(path);
521
+ continue;
522
+ }
523
+ if (!entry.isFile() || !SOURCE_EXTENSIONS.has(sourceExtension(entry.name))) continue;
524
+ if (/\.(?:spec|stories|test)\.[^.]+$/i.test(entry.name)) continue;
525
+ // Generated/minified bundles can be enormous and are already excluded by directory; keep a
526
+ // final size guard so one checked-in bundle cannot make discovery unbounded.
527
+ if (statSync(path).size > 1_000_000) continue;
528
+ files.push(path);
529
+ }
530
+ };
531
+ walk(root);
532
+ return files;
533
+ }
534
+
535
+ /** Literal HTTP(S) destinations passed directly to the platform fetch API in production source.
536
+ *
537
+ * This is the third vendor signal beside dependencies and env names: hand-written REST clients
538
+ * often declare no SDK, and an unconventional credential name can hide the same vendor from the
539
+ * env heuristic. Only direct `fetch("https://...")`, `window.fetch`, and `globalThis.fetch` calls
540
+ * count. Dynamic destinations remain intentionally unclaimed; guessing from arbitrary URL strings
541
+ * would turn docs, links, and webhook examples into false external-service dependencies. */
542
+ export function projectFetchUrls(input = process.cwd()): ProjectFetchUrl[] {
543
+ const root = resolve(input);
544
+ const found: ProjectFetchUrl[] = [];
545
+ const seen = new Set<string>();
546
+ // A leading identifier/dot would make this a custom method (`client.fetch`), not platform fetch.
547
+ const literalFetch = /(?<![\w$.])(?:(?:globalThis|window)\.)?fetch\s*\(\s*(['"`])(https?:\/\/[^'"`\s]+)\1/g;
548
+ for (const path of projectSourceFiles(root)) {
549
+ const text = readFileSync(path, 'utf8');
550
+ for (const match of text.matchAll(literalFetch)) {
551
+ if (match.index === undefined || !isCodeAt(text, match.index)) continue;
552
+ const url = match[2]!;
553
+ try { new URL(url); } catch { continue; }
554
+ const line = text.slice(0, match.index).split(/\r?\n/).length;
555
+ const source = `${relative(root, path)}:${line}`;
556
+ const key = `${source}\0${url}`;
557
+ if (seen.has(key)) continue;
558
+ seen.add(key);
559
+ found.push({ url, source });
560
+ }
561
+ }
562
+ return found;
563
+ }
564
+
565
+ /** Env names read directly by production JS/TS source, with repo-relative `path:line` evidence.
566
+ * This is intentionally evidence, not validator inference: seeing an empty example value read by
567
+ * the app is enough to rank it as a boot risk, but not enough to claim whether the app permits an
568
+ * empty string. Supports the standard runtime forms without guessing through arbitrary wrappers. */
569
+ export function projectEnvReads(input = process.cwd()): Map<string, string[]> {
570
+ const root = resolve(input);
571
+ const reads = new Map<string, string[]>();
572
+ const patterns = [
573
+ /\b(?:process|Bun)\.env\.([A-Z][A-Z0-9_]*)\b/g,
574
+ /\b(?:process|Bun)\.env\[\s*['"]([A-Z][A-Z0-9_]*)['"]\s*\]/g,
575
+ /\bimport\.meta\.env\.([A-Z][A-Z0-9_]*)\b/g,
576
+ /\bDeno\.env\.get\(\s*['"]([A-Z][A-Z0-9_]*)['"]\s*\)/g,
577
+ ];
578
+ for (const path of projectSourceFiles(root)) {
579
+ const text = readFileSync(path, 'utf8');
580
+ const matches: Array<{ index: number; name: string }> = [];
581
+ for (const pattern of patterns) {
582
+ for (const match of text.matchAll(pattern)) {
583
+ if (match.index === undefined || !isCodeAt(text, match.index)) continue;
584
+ const after = text.slice(match.index + match[0].length);
585
+ const before = text.slice(0, match.index);
586
+ // Assignment/delete sites do not prove the app consumes the value. `??=`/`||=` remain
587
+ // evidence because they read before writing; only a plain assignment is write-only.
588
+ if (/^\s*=(?!=)/.test(after) || /\bdelete\s*$/.test(before)) continue;
589
+ matches.push({ index: match.index, name: match[1]! });
590
+ }
591
+ }
592
+ for (const match of matches.sort((a, b) => a.index - b.index || a.name.localeCompare(b.name))) {
593
+ const line = text.slice(0, match.index).split(/\r?\n/).length;
594
+ const source = `${relative(root, path)}:${line}`;
595
+ const current = reads.get(match.name) ?? [];
596
+ if (!current.includes(source)) reads.set(match.name, [...current, source]);
597
+ }
598
+ }
599
+ return reads;
600
+ }
601
+
602
+ export function inspectProject(input = process.cwd()): ProjectInspection {
603
+ const root = resolve(input);
604
+ if (!existsSync(root)) throw new Error(`inspect-project: path does not exist: ${root}`);
605
+ const files = filesAt(root);
606
+ const manifests = ['package.json', 'pyproject.toml', 'requirements.txt', 'uv.lock', 'bun.lock', 'package-lock.json', 'pnpm-lock.yaml']
607
+ .filter((name) => files.includes(name));
608
+ const runtimes = new Set<string>();
609
+ if (manifests.some((name) => ['package.json', 'bun.lock', 'package-lock.json', 'pnpm-lock.yaml'].includes(name))) runtimes.add('javascript/typescript');
610
+ if (manifests.some((name) => ['pyproject.toml', 'requirements.txt', 'uv.lock'].includes(name))) runtimes.add('python');
611
+
612
+ const pkg = json(resolve(root, 'package.json'));
613
+ const dependencies = new Set(projectDependencies(root));
614
+ const grouped = new Map<string, { vendor: string; packages: string[]; twin: string }>();
615
+ for (const dependency of dependencies) {
616
+ const match = SDK_TWINS[dependency];
617
+ if (!match) continue;
618
+ const current = grouped.get(match.vendor) ?? { ...match, packages: [] };
619
+ current.packages.push(dependency);
620
+ grouped.set(match.vendor, current);
621
+ }
622
+
623
+ const envNames = projectEnvNames(root);
624
+ const officialNpmHosts = new Set(['registry.npmjs.org', 'npm.pkg.github.com']);
625
+ const registryDestinations = projectNpmRegistries(root).map((registry) => {
626
+ const classification = officialNpmHosts.has(new URL(registry.url).hostname.toLowerCase())
627
+ ? 'npm-registry' as const
628
+ : 'unknown' as const;
629
+ return {
630
+ ...registry,
631
+ classification,
632
+ ...(classification === 'npm-registry' ? { twin: '@volter/twin-npm-registry' as const } : {}),
633
+ };
634
+ });
635
+ const entrypoints = ['src/index.ts', 'src/index.js', 'src/app.ts', 'src/app.js', 'app.ts', 'app.js', 'app.mjs', 'main.py']
636
+ .filter((name) => existsSync(resolve(root, name)));
637
+ const coverage: string[] = [];
638
+ if (dependencies.has('c8') || dependencies.has('nyc') || pkg?.scripts?.coverage?.includes('coverage')) coverage.push('istanbul/v8');
639
+ if (manifests.includes('pyproject.toml') || manifests.includes('requirements.txt')) {
640
+ const text = manifests.map((name) => readFileSync(resolve(root, name), 'utf8')).join('\n').toLowerCase();
641
+ if (text.includes('coverage') || text.includes('pytest-cov')) coverage.push('coverage.py');
642
+ }
643
+ const hasKnownVendor = grouped.size > 0 || registryDestinations.some((registry) => registry.classification === 'npm-registry');
644
+ const unknownRegistries = registryDestinations.filter((registry) => registry.classification === 'unknown');
645
+ const decisions = [
646
+ ...(hasKnownVendor ? [] : ['Identify vendor APIs that should be replaced by twins.']),
647
+ ...unknownRegistries.map((registry) => `Resolve uncovered npm registry destination ${new URL(registry.url).hostname} (${registry.source}).`),
648
+ ...(entrypoints.length ? [] : ['Choose the application command the world should run or test.']),
649
+ 'Choose representative user behavior and assertions.',
650
+ ...(coverage.length ? [] : ['Choose the application native coverage command, if coverage acquisition is a goal.']),
651
+ ];
652
+ return {
653
+ root,
654
+ runtimes: [...runtimes].sort(),
655
+ manifests,
656
+ vendorSdks: [...grouped.values()].map((item) => ({ ...item, packages: item.packages.sort() })).sort((a, b) => a.vendor.localeCompare(b.vendor)),
657
+ registryDestinations,
658
+ envNames,
659
+ entrypoints,
660
+ coverage,
661
+ decisions,
662
+ };
663
+ }
664
+
665
+ export function formatProjectInspection(report: ProjectInspection): string {
666
+ const lines = [`Project: ${basename(report.root)} (${report.root})`];
667
+ lines.push(`Runtime: ${report.runtimes.join(', ') || 'not detected'}`);
668
+ lines.push(`Manifests: ${report.manifests.join(', ') || 'none detected'}`);
669
+ lines.push('Vendor SDKs:');
670
+ if (!report.vendorSdks.length) lines.push(' none detected');
671
+ for (const sdk of report.vendorSdks) lines.push(` ${sdk.vendor}: ${sdk.packages.join(', ')} -> ${sdk.twin}`);
672
+ lines.push('Registry destinations:');
673
+ if (!report.registryDestinations.length) lines.push(' none detected');
674
+ for (const registry of report.registryDestinations) {
675
+ lines.push(` ${registry.url} (${registry.source}) -> ${registry.twin ?? 'unknown/uncovered'}`);
676
+ }
677
+ lines.push(`Environment names: ${report.envNames.join(', ') || 'none detected'}`);
678
+ lines.push(`Likely entrypoints: ${report.entrypoints.join(', ') || 'none detected'}`);
679
+ lines.push(`Native coverage: ${report.coverage.join(', ') || 'not detected'}`);
680
+ lines.push('Decisions left to the adopter:');
681
+ for (const decision of report.decisions) lines.push(` - ${decision}`);
682
+ return `${lines.join('\n')}\n`;
683
+ }