@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.
- package/known-external-services.json +1192 -0
- package/package.json +12 -5
- package/src/app-url.ts +175 -0
- package/src/attach.ts +113 -0
- package/src/browser-proxy-cli.ts +2 -1
- package/src/changeset.ts +416 -0
- package/src/cli.ts +589 -10
- package/src/covers.ts +724 -0
- package/src/fixture-env.ts +207 -0
- package/src/host-cli.ts +2 -1
- package/src/host-worker.ts +4 -3
- package/src/host.ts +26 -4
- package/src/index.ts +97 -1
- package/src/init.ts +1142 -0
- package/src/inject-map.ts +70 -0
- package/src/managed-infra-cli.ts +208 -0
- package/src/pack-facts.ts +136 -0
- package/src/pglite-backing.ts +125 -0
- package/src/pglite-host.mjs +147 -0
- package/src/prerequisites.ts +15 -58
- package/src/project-inspect.ts +683 -0
- package/src/redirect-proxy.ts +109 -85
- package/src/reflect.ts +443 -0
- package/src/resource-holder.ts +11 -0
- package/src/resources.ts +160 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +667 -106
- package/src/schema.ts +67 -0
- package/src/serve.ts +102 -0
- package/src/tail.ts +203 -0
|
@@ -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
|
+
}
|