@volter/world-runtime 2.0.0

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