staysfixed 0.3.0 → 0.4.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 (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,1199 @@
1
+ /**
2
+ * What IS this project?
3
+ *
4
+ * Everything else in version 2 answers "what changed". This file answers the question that
5
+ * comes before it, and it answers it WITHOUT ASKING ANYBODY. An agent told to install this
6
+ * tool into a stranger's repository should never have to type "is this a website or a
7
+ * desktop app?" into a chat window. The repository already says. It says it in its
8
+ * package.json, in its lockfile, in the shape of its folders, in the config files its
9
+ * framework leaves lying around, in the built artifacts sitting in `out/`, in an `.xcodeproj`,
10
+ * in a `gradlew`, in a Dockerfile, in the name of its test runner. All of that is free to
11
+ * read and none of it needs a person.
12
+ *
13
+ * THE ANSWER IS USUALLY MORE THAN ONE THING, and that is the part every tool like this gets
14
+ * wrong. A repository is not "a Node project". Terminal Deck is one repository that produces
15
+ * a desktop app, an iPhone app, an Android app, a small web client and a relay server — five
16
+ * products, five toolchains, one shared `src/`. A tool that picks the single best-matching
17
+ * label and moves on will check the desktop app, report a clean run, and say nothing at all
18
+ * about the phone. So this file returns a LIST of products, each with its own evidence, its
19
+ * own confidence, its own adapter and its own reason. Several at once is the normal case.
20
+ *
21
+ * WHAT IT NEVER DOES. It never runs anything — no `npm install`, no build, no dev server, no
22
+ * `node -e`. It only reads. That matters twice over: reading is safe to do inside somebody
23
+ * else's repository while they have it open in an editor, and it is safe to do inside a
24
+ * repository nobody has audited. It also never writes. `init.js` writes; this file looks.
25
+ *
26
+ * HOW SURE IT IS, SAID OUT LOUD. Every product carries `confidence` and, more usefully,
27
+ * `evidence` — the actual files that made it say so. A wrong guess with its evidence attached
28
+ * is a thirty-second fix for an agent. A wrong guess with no evidence is an argument.
29
+ */
30
+
31
+ import fs from 'node:fs';
32
+ import fsp from 'node:fs/promises';
33
+ import path from 'node:path';
34
+
35
+ /** @typedef {import('./types.js').Surface} Surface */
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // The kinds of thing a repository can produce
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Every kind of product this tool has a name for, what it means in plain English, which
43
+ * observation surface it belongs to, and which adapter would drive it.
44
+ *
45
+ * `adapter` names the adapter that WOULD drive this kind of product. Whether that adapter is
46
+ * actually in this copy of the tool is a separate question, answered at run time by looking
47
+ * for the file — see {@link adaptersHere}. Six adapters are being written in parallel, and a
48
+ * table that hard-coded which ones exist would start lying the day one of them landed: it
49
+ * would tell somebody their Android app cannot be checked while the Android adapter sat right
50
+ * there. `null` means no adapter is even planned, which is a different and permanent answer.
51
+ *
52
+ * @type {Readonly<Record<string, {name: string, surface: Surface, adapter: string|null, what: string}>>}
53
+ */
54
+ export const PRODUCT_KINDS = Object.freeze({
55
+ cli: { name: 'a command-line tool', surface: 'cli', adapter: 'process', what: 'Something you type at a terminal. Watched by running it and reading what it printed, what it exited with and what it wrote.' },
56
+ library: { name: 'a library other code imports', surface: 'library', adapter: 'process', what: 'Code other projects import. Watched by importing it and comparing what it exports and what those exports do.' },
57
+ server: { name: 'a server or an API', surface: 'server', adapter: 'http', what: 'Something that answers requests. Watched by booting it on a spare port and asking it for every route read out of its own source.' },
58
+ web: { name: 'a website or web app', surface: 'web', adapter: 'web', what: 'Something people open in a browser. Watched by opening it in a throwaway browser and reading what the screen says each control is and does.' },
59
+ electron: { name: 'a desktop app built with Electron', surface: 'electron', adapter: 'electron', what: 'A desktop app. Watched by opening the built app on its own, reading its window, its menus and every private channel it registers.' },
60
+ ios: { name: 'an iPhone or iPad app', surface: 'ios', adapter: 'ios', what: 'An Apple app. Driven on the simulator; a real device in your hand can never be compared side by side.' },
61
+ android: { name: 'an Android app', surface: 'android', adapter: 'android', what: 'An Android app. Driven on an emulator against the stored record.' },
62
+ desktopNative: { name: 'a native desktop app', surface: 'windows', adapter: 'windows', what: 'A desktop app that is not Electron — Swift, WinUI, Tauri, Qt. Only readable from the operating system it runs on.' },
63
+ container: { name: 'a containerised service', surface: 'server', adapter: 'http', what: 'A service that ships as a container. Watched the same way as any server, once there is a command that starts it.' },
64
+ other: { name: 'a product in a language this tool cannot drive yet', surface: 'cli', adapter: null, what: 'Recognised, named, and honestly not drivable here. It is listed so a clean run is never mistaken for full coverage.' },
65
+ });
66
+
67
+ /**
68
+ * Which adapters are actually in this copy of the tool, found by looking for the file.
69
+ *
70
+ * Cheap, and deliberately not an import: importing every adapter to find out whether it is
71
+ * there costs a second and can throw. The folder is the register, and it updates itself the
72
+ * moment somebody adds one.
73
+ *
74
+ * @returns {Set<string>}
75
+ */
76
+ export function adaptersHere() {
77
+ /** @type {Set<string>} */
78
+ const here = new Set();
79
+ try {
80
+ const dir = path.join(path.dirname(new URL(import.meta.url).pathname), 'adapters');
81
+ for (const name of fs.readdirSync(dir)) {
82
+ if (!name.endsWith('.js')) continue;
83
+ const id = name.slice(0, -3);
84
+ // Two files in there are not adapters: the interface every adapter is written against,
85
+ // and the helpers two of them share.
86
+ if (id === 'contract' || id === 'isolate' || id.endsWith('-driver')) continue;
87
+ here.add(id);
88
+ }
89
+ } catch {
90
+ // A copy of the tool whose own folder cannot be read tells us nothing, and claiming no
91
+ // adapter exists would be a worse answer than claiming the usual ones do.
92
+ return new Set(['process', 'source', 'http', 'web', 'electron']);
93
+ }
94
+ return here;
95
+ }
96
+
97
+ /** Folders never worth walking into. Walking `node_modules` is how a detector takes a minute. */
98
+ const SKIP_DIRS = new Set([
99
+ 'node_modules', '.git', '.hg', '.svn', 'dist', 'out', 'build', 'release', 'coverage',
100
+ '.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', '.parcel-cache', 'vendor', 'Pods',
101
+ '.venv', 'venv', '__pycache__', 'target', '.gradle', '.idea', '.vscode', 'DerivedData',
102
+ '.staysfixed', 'tmp', 'temp', '.yarn', '.pnpm-store', 'Carthage',
103
+ ]);
104
+
105
+ /** Folders that are worth looking INSIDE for a second product, even without a package.json. */
106
+ const PLATFORM_FOLDERS = [
107
+ 'ios', 'android', 'apple', 'mobile', 'app', 'apps', 'desktop', 'electron', 'main',
108
+ 'web', 'site', 'www', 'client', 'frontend', 'pwa', 'server', 'api', 'backend',
109
+ 'relay', 'service', 'services', 'packages', 'native', 'src-tauri', 'cmd',
110
+ ];
111
+
112
+ /** How many files the artifact and test sweeps will look at before giving up and saying so. */
113
+ const MOST_FILES = 20_000;
114
+
115
+ /**
116
+ * Folders that are never a product of their own: either the contract channel already read
117
+ * them as part of the root product, or they hold work about the project rather than the
118
+ * project. Flagging one of these as "code nobody is checking" would be noise, and noise is
119
+ * how an honest warning gets ignored.
120
+ */
121
+ const ALREADY_COVERED = new Set([
122
+ 'src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages',
123
+ 'scripts', 'tools', 'test', 'tests', '__tests__', 'spec', 'e2e', 'docs', 'doc',
124
+ 'examples', 'example', 'fixtures', 'types', 'typings', 'config', 'public', 'static',
125
+ 'assets', 'styles', 'migrations', 'benchmarks',
126
+ ]);
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // The shapes this file hands back
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * One thing that was actually found on disk, and what it means.
134
+ *
135
+ * Kept as data rather than folded into a sentence because an agent correcting a wrong guess
136
+ * wants the file, and a person reading the summary wants the sentence, and building the
137
+ * sentence from the file means the two can never disagree.
138
+ *
139
+ * @typedef {object} Clue
140
+ * @property {string} where Path relative to the project root, or a key inside package.json.
141
+ * @property {string} means Plain English: what finding this tells us.
142
+ */
143
+
144
+ /**
145
+ * One product this repository makes.
146
+ *
147
+ * @typedef {object} Product
148
+ * @property {string} kind A key of PRODUCT_KINDS.
149
+ * @property {string} name Plain English, specific to this one: 'the desktop app'.
150
+ * @property {Surface} surface
151
+ * @property {string|null} adapter Which adapter would drive it, or null when none can yet.
152
+ * @property {number} confidence 0..1. How sure, and it is allowed to be unsure.
153
+ * @property {string} why One plain sentence naming what made us say so.
154
+ * @property {string} where Folder this product lives in, relative to the root. '.' for the root.
155
+ * @property {Clue[]} evidence Every file that contributed. The whole audit trail.
156
+ * @property {{found: boolean, where: string|null, how: string}} built
157
+ * Whether a built artifact is sitting there ready to be opened.
158
+ * @property {string[]} blockers Plain sentences: what stands between this and being checked.
159
+ * @property {Record<string, any>} [suggest] The config slice init would write for it.
160
+ */
161
+
162
+ /**
163
+ * Everything read off a repository without running any of it.
164
+ *
165
+ * @typedef {object} ProjectShape
166
+ * @property {string} root
167
+ * @property {string} name
168
+ * @property {string|null} version
169
+ * @property {boolean} isGitRepo
170
+ * @property {Product[]} products Ranked, most certain first. Several is normal.
171
+ * @property {string} summary One plain sentence naming what this repository makes.
172
+ * @property {{name: string, lockfile: string|null, why: string}} packageManager
173
+ * @property {string[]} workspaces Workspace globs, when it is a monorepo.
174
+ * @property {{name: string, root: string}[]} members Sub-packages actually found.
175
+ * @property {string[]} languages Languages seen, most-used first.
176
+ * @property {{runner: string|null, command: string|null, files: number, folders: string[], why: string}} tests
177
+ * @property {{dev: string|null, start: string|null, build: string|null, test: string|null, typecheck: string|null, package: string|null}} scripts
178
+ * @property {{ipc: number, route: number, export: number, command: number, env: number, unnamed: number, filesRead: number, read: boolean, why: string}} doors
179
+ * @property {{name: string, method: string, file: string}[]} routes Every route, by name. Capped.
180
+ * @property {{url: string, file: string, needs: string[]}[]} pages
181
+ * @property {{dockerfile: string|null, compose: string|null}} containers
182
+ * @property {Clue[]} evidence Everything found, including clues no product claimed.
183
+ * @property {string[]} unsure Things it could not work out, in plain English.
184
+ * @property {number} durationMs
185
+ */
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // The front door
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Work out what a project is, by reading it.
193
+ *
194
+ * @param {object} [options]
195
+ * @param {string} [options.root] Folder to look at. Defaults to the current one.
196
+ * @param {boolean} [options.readCode] Read the source for routes, IPC channels and exports.
197
+ * On by default: it is the only way to see a door
198
+ * nobody linked to. Turn it off for a fast answer on a
199
+ * very large repository.
200
+ * @param {boolean} [options.deep] Look inside sub-folders for more products. On by default,
201
+ * and it is what finds the phone app in a desktop repo.
202
+ * @returns {Promise<ProjectShape>}
203
+ */
204
+ export async function detectProject(options = {}) {
205
+ const started = Date.now();
206
+ const root = path.resolve(options.root ?? process.cwd());
207
+ const readCode = options.readCode !== false;
208
+ const deep = options.deep !== false;
209
+
210
+ /** @type {Clue[]} */
211
+ const evidence = [];
212
+ /** @type {string[]} */
213
+ const unsure = [];
214
+
215
+ const pkg = await readJson(path.join(root, 'package.json'));
216
+ if (pkg) evidence.push({ where: 'package.json', means: `This is an npm package called ${String(pkg.name ?? 'something with no name')}.` });
217
+
218
+ const listing = await listOnce(root);
219
+ const manager = packageManagerOf(listing, pkg);
220
+ if (manager.lockfile) evidence.push({ where: manager.lockfile, means: `Dependencies are installed with ${manager.name}.` });
221
+
222
+ const workspaces = workspaceGlobsOf(pkg, listing, root);
223
+ const members = deep ? await findMembers(root, workspaces, listing) : [];
224
+ for (const member of members) evidence.push({ where: member.root + '/package.json', means: `A second package inside this repository, called ${member.name}.` });
225
+
226
+ // The source read, once, for two answers — how many doors there are, and what the routes
227
+ // are called. Reading Terminal Deck's 1,416 files twice because two functions each wanted
228
+ // their own copy cost a second and a half of the two this whole detection takes.
229
+ const reading = readCode ? await readTheSource(root) : { doors: notRead(), routes: [] };
230
+ const doors = reading.doors;
231
+ const routes = reading.routes;
232
+ const pages = readCode ? await readThePages(root) : [];
233
+ if (doors.read && doors.route + doors.ipc > 0) {
234
+ evidence.push({ where: 'the source', means: `${doors.route} route${doors.route === 1 ? '' : 's'} and ${doors.ipc} private channel${doors.ipc === 1 ? '' : 's'} are written in the code.` });
235
+ }
236
+ if (pages.length > 0) evidence.push({ where: 'the page folders', means: `${pages.length} page address${pages.length === 1 ? '' : 'es'} come from the names of folders, the way Next.js and its cousins do it.` });
237
+
238
+ const tests = await findTests(root, pkg, listing);
239
+ if (tests.runner) evidence.push({ where: tests.folders[0] ?? 'package.json', means: `Its own tests are written with ${tests.runner}, and there are ${tests.files} of them. Those are journeys this tool can walk instead of inventing its own.` });
240
+
241
+ const containers = {
242
+ dockerfile: listing.files.find((f) => f === 'Dockerfile' || f.startsWith('Dockerfile.')) ?? null,
243
+ compose: listing.files.find((f) => /^(docker-)?compose\.ya?ml$/.test(f)) ?? null,
244
+ };
245
+ if (containers.dockerfile) evidence.push({ where: containers.dockerfile, means: 'It ships as a container, so there is a known way to start it and to put its data back.' });
246
+
247
+ // Every place a product might live: the root, each workspace member, and each platform
248
+ // folder that is plainly a product of its own even without a package.json — `ios/` holding
249
+ // an Xcode project is the case that matters, and it has no package.json anywhere near it.
250
+ /** @type {{root: string, pkg: any}[]} */
251
+ const places = [{ root: '.', pkg }];
252
+ for (const member of members) places.push({ root: member.root, pkg: member.pkg });
253
+ if (deep) {
254
+ for (const folder of listing.dirs) {
255
+ if (SKIP_DIRS.has(folder)) continue;
256
+ if (!PLATFORM_FOLDERS.includes(folder)) continue;
257
+ if (places.some((p) => p.root === folder)) continue;
258
+ places.push({ root: folder, pkg: await readJson(path.join(root, folder, 'package.json')) });
259
+ }
260
+ }
261
+
262
+ const available = adaptersHere();
263
+ /** @type {Product[]} */
264
+ const products = [];
265
+ for (const place of places) {
266
+ const where = path.join(root, place.root);
267
+ const local = place.root === '.' ? listing : await listOnce(where);
268
+ products.push(...(await productsIn({
269
+ root, where: place.root, listing: local, pkg: place.pkg,
270
+ // Doors and pages were read from the root, so they only describe the root. A
271
+ // sub-package gets credited with them only when it IS the root.
272
+ doors: place.root === '.' ? doors : notRead(),
273
+ pages: place.root === '.' ? pages : [],
274
+ containers: place.root === '.' ? containers : { dockerfile: null, compose: null },
275
+ scripts: place.pkg?.scripts ?? {},
276
+ available,
277
+ })));
278
+ }
279
+
280
+ const merged = mergeProducts(products);
281
+ if (merged.length === 0) {
282
+ unsure.push('Nothing here looks like a product this tool knows how to watch. If it is one, say so in the settings: put a "kind" under the adapter that fits, and everything else follows from that.');
283
+ }
284
+ const guessy = merged.filter((p) => p.confidence < 0.5).map((p) => p.name);
285
+ if (guessy.length > 0) unsure.push(`These were guessed from weak evidence and are worth a second look: ${guessy.join(', ')}.`);
286
+ if (!doors.read) unsure.push('The source was not read, so no routes, exported names or private channels were counted.');
287
+
288
+ // A folder full of code that no product claimed is the most dangerous thing this file can
289
+ // find, because everything else about the run will look complete. Terminal Deck's `relay/`
290
+ // is exactly it: real source, no package.json, no framework, nothing to match on. Naming
291
+ // the folder is honest and cheap; guessing what it is would be neither.
292
+ if (deep) {
293
+ const claimed = new Set(merged.map((p) => p.where));
294
+ for (const folder of listing.dirs) {
295
+ if (SKIP_DIRS.has(folder) || folder.startsWith('.') || claimed.has(folder)) continue;
296
+ if (members.some((m) => m.root === folder)) continue;
297
+ // The root product's own source is not an unclaimed folder. These are the folders the
298
+ // contract channel already read, plus the ones that are never a product on their own.
299
+ if (ALREADY_COVERED.has(folder)) continue;
300
+ // And neither is a folder the root product simply imports from. `components/` in a
301
+ // Next.js site holds a hundred files and is not a second product; flagging it would be
302
+ // noise, and noise is how a real warning like `relay/` gets skimmed past. So only a
303
+ // folder NAMED like a product, or one carrying its own container, is worth saying.
304
+ const own = PLATFORM_FOLDERS.includes(folder) || fs.existsSync(path.join(root, folder, 'Dockerfile'));
305
+ if (!own) continue;
306
+ const codeFiles = await countMatching(path.join(root, folder), /\.[cm]?[jt]sx?$/);
307
+ // Three is the line. Terminal Deck's relay is three files and it is a real running
308
+ // service; one or two files is a helper somebody left lying about.
309
+ if (codeFiles >= 3) {
310
+ unsure.push(`${folder}/ holds ${codeFiles} source files and nothing here could work out what it produces, so nothing in it is being checked. If it is a product of its own, say what starts it in the settings.`);
311
+ }
312
+ }
313
+ }
314
+
315
+ for (const product of merged) evidence.push(...product.evidence);
316
+
317
+ return {
318
+ root,
319
+ name: String(pkg?.name ?? path.basename(root)),
320
+ version: pkg?.version ? String(pkg.version) : null,
321
+ isGitRepo: fs.existsSync(path.join(root, '.git')),
322
+ products: merged,
323
+ summary: summarise(merged, path.basename(root)),
324
+ packageManager: manager,
325
+ workspaces,
326
+ members: members.map((m) => ({ name: m.name, root: m.root })),
327
+ languages: await languagesIn(root),
328
+ tests,
329
+ scripts: scriptsOf(pkg?.scripts ?? {}),
330
+ doors,
331
+ routes,
332
+ pages,
333
+ containers,
334
+ evidence: dedupeClues(evidence),
335
+ unsure,
336
+ durationMs: Date.now() - started,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * The same thing, said out loud, short enough to paste into a message to a person.
342
+ *
343
+ * @param {ProjectShape} shape
344
+ * @returns {string[]}
345
+ */
346
+ export function describeShape(shape) {
347
+ /** @type {string[]} */
348
+ const lines = [];
349
+ lines.push(shape.summary);
350
+ lines.push('');
351
+ for (const product of shape.products) {
352
+ const sure = product.confidence >= 0.8 ? '' : product.confidence >= 0.5 ? ' (fairly sure)' : ' (a guess)';
353
+ lines.push(`${product.name}${sure} — ${product.why}`);
354
+ if (product.built.found) lines.push(` built and ready: ${product.built.where}`);
355
+ for (const blocker of product.blockers) lines.push(` in the way: ${blocker}`);
356
+ }
357
+ if (shape.products.length > 0) lines.push('');
358
+ if (shape.tests.runner) {
359
+ const how = shape.tests.command ? `, run by \`${short(shape.tests.command)}\`` : '';
360
+ lines.push(`Its own tests: ${shape.tests.files} file${shape.tests.files === 1 ? '' : 's'} written with ${shape.tests.runner}${how}. Those are journeys this tool can borrow instead of inventing its own.`);
361
+ } else {
362
+ lines.push('No test suite was found, so every journey has to come from the code or from a recording.');
363
+ }
364
+ if (shape.doors.read) {
365
+ const many = (/** @type {number} */ n, /** @type {string} */ one, /** @type {string} */ lots) => `${n} ${n === 1 ? one : lots}`;
366
+ lines.push(`Read out of the code without running any of it: ${many(shape.doors.route, 'route', 'routes')}, ${many(shape.doors.ipc, 'private channel', 'private channels')}, ${many(shape.doors.export, 'exported name', 'exported names')}, ${many(shape.doors.command, 'command', 'commands')}.`);
367
+ }
368
+ for (const doubt of shape.unsure) lines.push(doubt);
369
+ return lines;
370
+ }
371
+
372
+ /**
373
+ * The single most important product, when something has to pick one — the front page of a
374
+ * report, the default when a command takes one name. It is the most certain, and ties break
375
+ * towards the one a person would name first.
376
+ *
377
+ * @param {ProjectShape} shape
378
+ * @returns {Product|null}
379
+ */
380
+ export function mainProduct(shape) {
381
+ return shape.products[0] ?? null;
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Working out the products in one folder
386
+ // ---------------------------------------------------------------------------
387
+
388
+ /**
389
+ * @param {object} input
390
+ * @param {string} input.root
391
+ * @param {string} input.where Relative folder, '.' for the project root.
392
+ * @param {{files: string[], dirs: string[]}} input.listing
393
+ * @param {any} input.pkg
394
+ * @param {ProjectShape['doors']} input.doors
395
+ * @param {ProjectShape['pages']} input.pages
396
+ * @param {{dockerfile: string|null, compose: string|null}} input.containers
397
+ * @param {Record<string, string>} input.scripts
398
+ * @param {Set<string>} input.available Adapters actually present in this copy of the tool.
399
+ * @returns {Promise<Product[]>}
400
+ */
401
+ async function productsIn(input) {
402
+ const { root, where, listing, pkg, doors, pages, containers, scripts, available } = input;
403
+ const dir = path.join(root, where);
404
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
405
+ const has = (/** @type {string} */ name) => name in deps;
406
+ const file = (/** @type {string} */ name) => listing.files.includes(name);
407
+ const anyFile = (/** @type {RegExp} */ re) => listing.files.find((f) => re.test(f)) ?? null;
408
+ const folder = (/** @type {string} */ name) => listing.dirs.includes(name);
409
+ const at = (/** @type {string} */ rel) => (where === '.' ? rel : path.join(where, rel));
410
+
411
+ /** @type {Product[]} */
412
+ const found = [];
413
+
414
+ /**
415
+ * @param {string} kind
416
+ * @param {object} spec
417
+ * @param {string} spec.name
418
+ * @param {number} spec.confidence
419
+ * @param {string} spec.why
420
+ * @param {Clue[]} spec.evidence
421
+ * @param {{found: boolean, where: string|null, how: string}} [spec.built]
422
+ * @param {string[]} [spec.blockers]
423
+ * @param {Record<string, any>} [spec.suggest]
424
+ */
425
+ const add = (kind, spec) => {
426
+ const meta = PRODUCT_KINDS[kind];
427
+ found.push({
428
+ kind,
429
+ name: spec.name,
430
+ surface: meta.surface,
431
+ adapter: meta.adapter && available.has(meta.adapter) ? meta.adapter : null,
432
+ confidence: spec.confidence,
433
+ why: spec.why,
434
+ where,
435
+ evidence: spec.evidence,
436
+ built: spec.built ?? { found: false, where: null, how: 'nothing to build — it runs from source' },
437
+ blockers: spec.blockers ?? [],
438
+ suggest: spec.suggest,
439
+ });
440
+ };
441
+
442
+ // ── Electron ──────────────────────────────────────────────────────────────
443
+ const builderFile = anyFile(/^electron-builder\.(ya?ml|json|js|cjs|ts)$/) ?? anyFile(/^forge\.config\.(js|cjs|mjs|ts)$/);
444
+ const electronish = has('electron') || Boolean(builderFile) || Boolean(pkg?.build?.appId);
445
+ if (electronish) {
446
+ const app = await findBuiltApp(dir);
447
+ /** @type {Clue[]} */
448
+ const clues = [];
449
+ if (has('electron')) clues.push({ where: at('package.json'), means: 'It depends on Electron, which is how desktop apps are built out of web code.' });
450
+ if (builderFile) clues.push({ where: at(builderFile), means: 'There is a packaging config, so this repository produces an installable desktop app.' });
451
+ if (pkg?.build?.appId) clues.push({ where: at('package.json'), means: `It has an application id (${String(pkg.build.appId)}), which only a packaged desktop app has.` });
452
+ if (app.where) clues.push({ where: path.relative(root, app.where), means: 'A built desktop app is sitting here already, so it can be opened and read straight away.' });
453
+ add('electron', {
454
+ name: 'the desktop app',
455
+ confidence: app.where ? 1 : 0.85,
456
+ why: app.where ? `It depends on Electron and a built app is already sitting at ${path.relative(root, app.where)}.` : 'It depends on Electron, so it produces a desktop app — but no built copy was found, so there is nothing to open yet.',
457
+ evidence: clues,
458
+ built: { found: Boolean(app.where), where: app.where ? path.relative(root, app.where) : null, how: app.how },
459
+ blockers: app.where ? [] : ['The app has not been built. A desktop app can only be checked once there is a built copy to open — build it the way you normally do, then point the settings at the result.'],
460
+ suggest: app.where ? { binary: path.relative(root, app.where) } : {},
461
+ });
462
+ }
463
+
464
+ // ── iOS ───────────────────────────────────────────────────────────────────
465
+ const xcode = listing.dirs.find((d) => d.endsWith('.xcodeproj')) ?? listing.dirs.find((d) => d.endsWith('.xcworkspace')) ?? null;
466
+ const swiftPackage = file('Package.swift');
467
+ const podfile = file('Podfile');
468
+ const xcodegen = file('project.yml') && listing.dirs.some((d) => /^[A-Z]/.test(d));
469
+ // React Native and Expo are one codebase that becomes two apps. Both are reported, because
470
+ // reporting one would leave the other silently unchecked.
471
+ const reactNative = has('react-native') || has('expo');
472
+ if (xcode || (swiftPackage && (podfile || folder('Sources'))) || (podfile && !xcode) || (xcodegen && podfile) || reactNative) {
473
+ /** @type {Clue[]} */
474
+ const clues = [];
475
+ if (xcode) clues.push({ where: at(xcode), means: 'An Xcode project, which is how an Apple app is built.' });
476
+ if (swiftPackage) clues.push({ where: at('Package.swift'), means: 'Swift source organised as a package.' });
477
+ if (podfile) clues.push({ where: at('Podfile'), means: 'CocoaPods dependencies, which are used by iOS apps.' });
478
+ if (reactNative) clues.push({ where: at('package.json'), means: 'It depends on React Native, so one codebase becomes both an iPhone app and an Android app.' });
479
+ const ipa = await findFirst(dir, /\.(ipa|app)$/, ['build', 'DerivedData', 'Products']);
480
+ add('ios', {
481
+ name: 'the iPhone app',
482
+ confidence: xcode ? 1 : reactNative ? 0.8 : 0.6,
483
+ why: xcode ? `There is an Xcode project at ${at(xcode)}.` : reactNative ? 'It depends on React Native, which builds an iPhone app.' : 'There is Swift and iOS tooling here, though no Xcode project was found in the usual place.',
484
+ evidence: clues,
485
+ built: { found: Boolean(ipa), where: ipa ? path.relative(root, ipa) : null, how: ipa ? 'a built app was found' : 'nothing built was found' },
486
+ blockers: available.has('ios')
487
+ ? ['It runs on the simulator. Two builds on a real phone in your hand can never be compared side by side, on any machine.']
488
+ : ['Nothing in this copy of the tool can drive an iPhone app yet. When it can, it will run on the simulator; two builds on a real phone in your hand can never be compared side by side.'],
489
+ });
490
+ }
491
+
492
+ // ── Android ───────────────────────────────────────────────────────────────
493
+ const gradle = anyFile(/^build\.gradle(\.kts)?$/);
494
+ const gradlew = file('gradlew');
495
+ const manifest = folder('app') && fs.existsSync(path.join(dir, 'app', 'src', 'main', 'AndroidManifest.xml'));
496
+ if ((gradle && gradlew) || manifest || reactNative) {
497
+ /** @type {Clue[]} */
498
+ const clues = [];
499
+ if (gradle) clues.push({ where: at(gradle), means: 'A Gradle build, which is how an Android app is built.' });
500
+ if (manifest) clues.push({ where: at('app/src/main/AndroidManifest.xml'), means: 'An Android manifest, which only an Android app has.' });
501
+ if (reactNative) clues.push({ where: at('package.json'), means: 'It depends on React Native, so the same codebase also becomes an Android app.' });
502
+ const apk = await findFirst(dir, /\.(apk|aab)$/, ['build', 'app']);
503
+ add('android', {
504
+ name: 'the Android app',
505
+ confidence: manifest ? 1 : gradle && gradlew ? 0.8 : 0.7,
506
+ why: manifest ? 'There is an Android manifest and a Gradle build here.' : gradle && gradlew ? 'There is a Gradle build with a wrapper script, which is the shape of an Android project.' : 'It depends on React Native, which builds an Android app.',
507
+ evidence: clues,
508
+ built: { found: Boolean(apk), where: apk ? path.relative(root, apk) : null, how: apk ? 'a built package was found' : 'nothing built was found' },
509
+ suggest: apk ? { apk: path.relative(root, apk) } : {},
510
+ blockers: available.has('android')
511
+ ? ['It runs on an emulator. Whether two emulator snapshots restore identically is unproven, so a run says which mode it used.']
512
+ : ['Nothing in this copy of the tool can drive an Android app yet. When it can, it will run on an emulator against the stored record.'],
513
+ });
514
+ }
515
+
516
+ // ── Native desktop that is not Electron ───────────────────────────────────
517
+ if (folder('src-tauri') || (file('Cargo.toml') && folder('src-tauri'))) {
518
+ add('desktopNative', {
519
+ name: 'the Tauri desktop app',
520
+ confidence: 0.9,
521
+ why: 'There is a src-tauri folder, which is how a Tauri desktop app is built.',
522
+ evidence: [{ where: at('src-tauri'), means: 'Tauri wraps a web front end in a native window, so the window is not an Electron one.' }],
523
+ blockers: available.has('windows')
524
+ ? ['A native window can only be read from the operating system it runs on, so this needs a machine running that system — a reachable SSH host counts.']
525
+ : ['A native desktop window can only be read from the operating system it runs on, and nothing in this copy of the tool drives one yet.'],
526
+ });
527
+ }
528
+
529
+ // ── Web ───────────────────────────────────────────────────────────────────
530
+ // Two tiers, because they are two different strengths of evidence. The first list only
531
+ // ever builds websites, so finding one settles the question. The second is a rendering
532
+ // library that turns up inside desktop apps and phone apps just as often, so on its own it
533
+ // is a hint and the confidence says so.
534
+ const onlyBuildsWebsites = ['next', 'nuxt', 'astro', '@remix-run/react', '@sveltejs/kit', 'gatsby', '11ty', '@11ty/eleventy'].find(has) ?? null;
535
+ const webFramework = onlyBuildsWebsites ?? (['vue', 'svelte', 'solid-js', 'preact', 'react'].find(has) ?? null);
536
+ const bundler = ['vite', 'webpack', 'parcel', 'esbuild', 'rollup', '@rsbuild/core'].find(has) ?? null;
537
+ const indexHtml = file('index.html') || fs.existsSync(path.join(dir, 'public', 'index.html'));
538
+ const hostConfig = anyFile(/^(vercel|netlify|firebase|now|wrangler)\.(json|toml)$/);
539
+ const routeFolders = folder('pages') || (folder('app') && (fs.existsSync(path.join(dir, 'app', 'page.tsx')) || fs.existsSync(path.join(dir, 'app', 'page.jsx')) || fs.existsSync(path.join(dir, 'app', 'layout.tsx'))));
540
+ const webish = Boolean(webFramework) || indexHtml || Boolean(hostConfig) || routeFolders || pages.length > 0;
541
+ if (webish) {
542
+ /** @type {Clue[]} */
543
+ const clues = [];
544
+ if (webFramework) clues.push({ where: at('package.json'), means: `It depends on ${webFramework}, which builds web pages.` });
545
+ if (bundler) clues.push({ where: at('package.json'), means: `It is bundled with ${bundler}, so there is a build step and usually a dev server.` });
546
+ if (indexHtml) clues.push({ where: at(file('index.html') ? 'index.html' : 'public/index.html'), means: 'There is a page to open.' });
547
+ if (hostConfig) clues.push({ where: at(hostConfig), means: 'It is configured for a hosting service, so it is deployed as a site.' });
548
+ if (pages.length > 0) clues.push({ where: 'the page folders', means: `${pages.length} page address${pages.length === 1 ? '' : 'es'} were read out of folder names.` });
549
+
550
+ // An Electron repository almost always has web code in it — that IS the window. Calling
551
+ // the window a second, separate website is how one product gets checked twice and the
552
+ // report doubles in size for nothing. So it only counts as a website of its own when it
553
+ // has its own pages, its own host config, or its own package.
554
+ const isTheElectronWindow = electronish && where === '.' && !hostConfig && pages.length === 0;
555
+ if (!isTheElectronWindow) {
556
+ const dev = scripts.dev ?? scripts.start ?? scripts.serve ?? null;
557
+ const start = dev ? inFolder(npmRun(scripts, dev), where) : null;
558
+ // A site made of plain .html files has no framework and no dev server, and every one
559
+ // of those files is a page somebody can open. Listing them is what turns "there is a
560
+ // website here" into journeys that can actually be walked.
561
+ const flat = listing.files.filter((f) => f.endsWith('.html')).map((f) => (f === 'index.html' ? '/' : `/${f}`));
562
+ if (flat.length > 1) clues.push({ where: at('*.html'), means: `${flat.length} pages are plain HTML files sitting in this folder.` });
563
+ add('web', {
564
+ name: where === '.' ? 'the website' : `the website in ${where}/`,
565
+ confidence: onlyBuildsWebsites || ((webFramework || indexHtml) && (bundler || hostConfig || pages.length > 0)) ? 0.95 : 0.6,
566
+ why: [
567
+ webFramework ? `It uses ${webFramework}` : 'There is a page here',
568
+ pages.length > 0 ? ` and ${pages.length} page address${pages.length === 1 ? '' : 'es'} were read out of the folder names` : '',
569
+ flat.length > 1 && pages.length === 0 ? ` and ${flat.length} more are plain HTML files` : '',
570
+ hostConfig ? `, it is set up to deploy to ${hostConfig.split('.')[0]}` : '',
571
+ start ? `, and \`${start}\` starts it` : '',
572
+ ].join('') + '.',
573
+ evidence: clues,
574
+ blockers: start
575
+ ? []
576
+ : ['There is no command that starts it, so each build cannot be booted on its own. Without that, both halves of a comparison would read the same running copy and prove nothing. A static site only needs a static file server — anything that serves this folder on the PORT it is given will do.'],
577
+ suggest: {
578
+ ...(start ? { start } : {}),
579
+ ...(flat.length > 0 && pages.length === 0 ? { screens: flat.map((url) => ({ name: url === '/' ? 'the front page' : url, url })) } : {}),
580
+ },
581
+ });
582
+ }
583
+ }
584
+
585
+ // ── Server ────────────────────────────────────────────────────────────────
586
+ const serverFramework = ['express', 'fastify', 'hono', 'koa', '@hapi/hapi', '@nestjs/core', 'polka', 'restify'].find(has) ?? null;
587
+ const serverish = Boolean(serverFramework) || doors.route > 0 || Boolean(containers.dockerfile && scripts.start);
588
+ if (serverish) {
589
+ /** @type {Clue[]} */
590
+ const clues = [];
591
+ if (serverFramework) clues.push({ where: at('package.json'), means: `It depends on ${serverFramework}, which serves requests.` });
592
+ if (doors.route > 0) clues.push({ where: 'the source', means: `${doors.route} route${doors.route === 1 ? '' : 's'} are declared in the code.` });
593
+ if (containers.dockerfile) clues.push({ where: containers.dockerfile, means: 'It ships as a container, so there is a known way to start it.' });
594
+ // Next.js and its cousins are a website first. Their API routes are real and worth
595
+ // checking, but calling the whole thing "a server" as well as "a website" would report
596
+ // one product twice.
597
+ const alreadyAWebsite = found.some((p) => p.kind === 'web') && !serverFramework;
598
+ if (!alreadyAWebsite) {
599
+ add('server', {
600
+ name: where === '.' ? 'the server' : `the server in ${where}/`,
601
+ confidence: serverFramework ? 0.9 : doors.route > 3 ? 0.6 : 0.4,
602
+ why: serverFramework ? `It uses ${serverFramework} and ${doors.route} route${doors.route === 1 ? '' : 's'} are written in the code.` : `${doors.route} route${doors.route === 1 ? '' : 's'} are written in the code, though no web framework is installed.`,
603
+ evidence: clues,
604
+ blockers: scripts.start ? [] : ['There is no command that starts it. The routes can be listed from the source without one, but none of them can be walked.'],
605
+ suggest: scripts.start ? { start: inFolder(npmRun(scripts, scripts.start), where) } : {},
606
+ });
607
+ }
608
+ }
609
+
610
+ // ── Command-line tool ─────────────────────────────────────────────────────
611
+ const bins = pkg?.bin ? (typeof pkg.bin === 'string' ? [String(pkg.name ?? 'the command')] : Object.keys(pkg.bin)) : [];
612
+ if (bins.length > 0) {
613
+ add('cli', {
614
+ name: bins.length === 1 ? `the \`${bins[0]}\` command` : `${bins.length} command-line tools`,
615
+ confidence: 1,
616
+ why: `package.json installs ${bins.length === 1 ? `a command called \`${bins[0]}\`` : `${bins.length} commands: ${bins.join(', ')}`}.`,
617
+ evidence: [{ where: at('package.json'), means: 'The "bin" field is what makes a package installable as a command you can type.' }],
618
+ // `--help` and nothing else, deliberately. A command in package.json could deploy, could
619
+ // publish, could wipe a database — running one because it was there would be the tool
620
+ // causing the very kind of damage it exists to catch. Asking a command to describe
621
+ // itself is the one thing every command-line tool does safely.
622
+ suggest: { commands: bins.map((name) => ({ name: `${name} --help`, run: `${runnerFor(pkg, name)} --help`, describe: `ask ${name} to print its help, and compare every word of it` })) },
623
+ });
624
+ }
625
+
626
+ // ── Library ───────────────────────────────────────────────────────────────
627
+ // A package can be both. `staysfixed` itself installs a command AND publishes an entry
628
+ // other code imports, and checking only the command would leave every exported name
629
+ // unwatched — which is the whole `library` surface, gone quietly.
630
+ const publishes = Boolean(pkg?.exports || pkg?.main || pkg?.module) && pkg?.private !== true;
631
+ const declaresAnEntry = Boolean(pkg?.exports);
632
+ if (publishes && (bins.length === 0 || declaresAnEntry) && !electronish && !webish) {
633
+ const entry = entryPointOf(pkg);
634
+ add('library', {
635
+ name: 'the library other code imports',
636
+ confidence: declaresAnEntry ? 0.85 : 0.5,
637
+ why: declaresAnEntry ? 'It publishes an "exports" map, which is what other code imports.' : 'It has a main entry point and is not marked private, so other code can import it.',
638
+ evidence: [{ where: at('package.json'), means: `Other projects import it, entering at ${entry}.` }],
639
+ suggest: { imports: [{ name: 'the package entry', module: entry }] },
640
+ });
641
+ }
642
+
643
+ // ── Something real that nothing here can drive ────────────────────────────
644
+ const otherLanguage = file('Cargo.toml') ? 'Rust' : file('go.mod') ? 'Go' : file('pubspec.yaml') ? 'Flutter' : file('pyproject.toml') || file('requirements.txt') ? 'Python' : file('Gemfile') ? 'Ruby' : file('composer.json') ? 'PHP' : null;
645
+ if (otherLanguage && found.length === 0) {
646
+ add('other', {
647
+ name: `a ${otherLanguage} project`,
648
+ confidence: 0.8,
649
+ why: `${otherLanguage} project files are here, and no adapter in this tool can drive ${otherLanguage} yet.`,
650
+ evidence: [{ where: at(file('Cargo.toml') ? 'Cargo.toml' : file('go.mod') ? 'go.mod' : file('pubspec.yaml') ? 'pubspec.yaml' : file('Gemfile') ? 'Gemfile' : file('composer.json') ? 'composer.json' : 'pyproject.toml'), means: `This is how a ${otherLanguage} project declares itself.` }],
651
+ blockers: [`Nothing drives ${otherLanguage} directly. If it produces a command you can type, list it under "process" in the settings and the whole command-line half of this tool works on it today.`],
652
+ });
653
+ }
654
+
655
+ return found;
656
+ }
657
+
658
+ // ---------------------------------------------------------------------------
659
+ // Reading the repository
660
+ // ---------------------------------------------------------------------------
661
+
662
+ /**
663
+ * One directory listing, split into files and folders, read once and passed around.
664
+ * @param {string} dir
665
+ * @returns {Promise<{files: string[], dirs: string[]}>}
666
+ */
667
+ async function listOnce(dir) {
668
+ /** @type {import('node:fs').Dirent[]} */
669
+ let entries;
670
+ try {
671
+ entries = await fsp.readdir(dir, { withFileTypes: true });
672
+ } catch {
673
+ return { files: [], dirs: [] };
674
+ }
675
+ /** @type {string[]} */
676
+ const files = [];
677
+ /** @type {string[]} */
678
+ const dirs = [];
679
+ for (const entry of entries) {
680
+ if (entry.isDirectory()) dirs.push(entry.name);
681
+ else if (entry.isFile() || entry.isSymbolicLink()) files.push(entry.name);
682
+ }
683
+ files.sort();
684
+ dirs.sort();
685
+ return { files, dirs };
686
+ }
687
+
688
+ /**
689
+ * @param {string} file
690
+ * @returns {Promise<any|null>}
691
+ */
692
+ async function readJson(file) {
693
+ try {
694
+ return JSON.parse(await fsp.readFile(file, 'utf8'));
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Which package manager installs this project, from the lockfile that is actually there.
702
+ * The lockfile is the truth: a `packageManager` field says what somebody INTENDED, and a
703
+ * lockfile says what happened.
704
+ *
705
+ * @param {{files: string[], dirs: string[]}} listing
706
+ * @param {any} pkg
707
+ * @returns {{name: string, lockfile: string|null, why: string}}
708
+ */
709
+ function packageManagerOf(listing, pkg) {
710
+ /** @type {[string, string][]} */
711
+ const known = [
712
+ ['pnpm-lock.yaml', 'pnpm'],
713
+ ['yarn.lock', 'yarn'],
714
+ ['bun.lockb', 'bun'],
715
+ ['bun.lock', 'bun'],
716
+ ['package-lock.json', 'npm'],
717
+ ['npm-shrinkwrap.json', 'npm'],
718
+ ];
719
+ for (const [lockfile, name] of known) {
720
+ if (listing.files.includes(lockfile)) return { name, lockfile, why: `${lockfile} is here, so this project is installed with ${name}.` };
721
+ }
722
+ const declared = typeof pkg?.packageManager === 'string' ? String(pkg.packageManager).split('@')[0] : null;
723
+ if (declared) return { name: declared, lockfile: null, why: `package.json asks for ${declared}, though no lockfile was found.` };
724
+ return { name: 'npm', lockfile: null, why: 'No lockfile was found, so npm is assumed.' };
725
+ }
726
+
727
+ /**
728
+ * Workspace globs, from either of the two places they live.
729
+ * @param {any} pkg
730
+ * @param {{files: string[], dirs: string[]}} listing
731
+ * @param {string} root
732
+ * @returns {string[]}
733
+ */
734
+ function workspaceGlobsOf(pkg, listing, root) {
735
+ /** @type {string[]} */
736
+ const globs = [];
737
+ const declared = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
738
+ if (Array.isArray(declared)) globs.push(...declared.map(String));
739
+ if (listing.files.includes('pnpm-workspace.yaml')) {
740
+ try {
741
+ const text = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
742
+ for (const line of text.split('\n')) {
743
+ const found = /^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/.exec(line);
744
+ if (found) globs.push(found[1].trim());
745
+ }
746
+ } catch {
747
+ // An unreadable workspace file is not worth an error; the folder scan finds the
748
+ // members anyway, and finding them twice is harmless.
749
+ }
750
+ }
751
+ return [...new Set(globs)].filter(Boolean);
752
+ }
753
+
754
+ /**
755
+ * The sub-packages that really exist, from the workspace globs and from a plain look at the
756
+ * usual folders. Only the one level of glob everybody actually uses is expanded — `packages/*`
757
+ * and `apps/*` — because implementing a glob engine to find a folder that is right there
758
+ * would be a lot of code to answer a question `readdir` answers.
759
+ *
760
+ * @param {string} root
761
+ * @param {string[]} globs
762
+ * @param {{files: string[], dirs: string[]}} listing
763
+ * @returns {Promise<{name: string, root: string, pkg: any}[]>}
764
+ */
765
+ async function findMembers(root, globs, listing) {
766
+ /** @type {Set<string>} */
767
+ const folders = new Set();
768
+ for (const glob of globs) {
769
+ if (glob.endsWith('/*') || glob.endsWith('/**')) {
770
+ const parent = glob.replace(/\/\*+$/, '');
771
+ const inner = await listOnce(path.join(root, parent));
772
+ for (const dir of inner.dirs) folders.add(path.join(parent, dir));
773
+ } else if (!glob.includes('*')) {
774
+ folders.add(glob);
775
+ }
776
+ }
777
+ // A repository can hold a second package without ever declaring a workspace — Terminal
778
+ // Deck's web client is exactly that. One level of readdir finds it, and missing it would
779
+ // mean reporting a repository as one product when it makes two.
780
+ for (const dir of listing.dirs) {
781
+ if (SKIP_DIRS.has(dir) || dir.startsWith('.')) continue;
782
+ folders.add(dir);
783
+ }
784
+
785
+ /** @type {{name: string, root: string, pkg: any}[]} */
786
+ const members = [];
787
+ for (const folder of [...folders].sort()) {
788
+ const pkg = await readJson(path.join(root, folder, 'package.json'));
789
+ if (!pkg) continue;
790
+ members.push({ name: String(pkg.name ?? folder), root: folder, pkg });
791
+ }
792
+ return members;
793
+ }
794
+
795
+ /**
796
+ * Every door in the source, counted AND named, using the same reader the contract channel
797
+ * uses so the number here and the number in a check can never disagree.
798
+ *
799
+ * @param {string} root
800
+ * @returns {Promise<{doors: ProjectShape['doors'], routes: ProjectShape['routes']}>}
801
+ */
802
+ async function readTheSource(root) {
803
+ try {
804
+ const { readContract, readFileRoutes, readPackageCommands } = await import('./adapters/source.js');
805
+ const reading = await readContract({ root });
806
+ const fileRoutes = await readFileRoutes(root);
807
+ const commands = await readPackageCommands(root);
808
+ const doors = [...reading.doors, ...fileRoutes, ...commands];
809
+ /** @type {Record<string, number>} */
810
+ const counts = {};
811
+ for (const door of doors) counts[door.kind] = (counts[door.kind] ?? 0) + 1;
812
+
813
+ // The route names, capped: a settings file listing five thousand routes helps nobody, and
814
+ // the count above is the honest total either way.
815
+ /** @type {Map<string, {name: string, method: string, file: string}>} */
816
+ const routes = new Map();
817
+ for (const door of doors) {
818
+ if (door.kind !== 'route') continue;
819
+ const method = door.detail === 'MOUNT' || door.detail === 'ANY' ? 'GET' : door.detail;
820
+ const key = `${method} ${door.name}`;
821
+ if (!routes.has(key)) routes.set(key, { name: door.name, method, file: door.file });
822
+ if (routes.size >= 200) break;
823
+ }
824
+
825
+ return {
826
+ doors: {
827
+ ipc: counts.ipc ?? 0,
828
+ route: counts.route ?? 0,
829
+ export: counts.export ?? 0,
830
+ command: counts.command ?? 0,
831
+ env: counts.env ?? 0,
832
+ unnamed: reading.report.unnamed,
833
+ filesRead: reading.report.filesRead,
834
+ read: true,
835
+ why: reading.report.filesRead === 1
836
+ ? '1 source file was read without running it.'
837
+ : `${reading.report.filesRead} source files were read without running any of them.`,
838
+ },
839
+ routes: [...routes.values()],
840
+ };
841
+ } catch (error) {
842
+ return {
843
+ doors: { ...notRead(), why: `The source could not be read: ${error instanceof Error ? error.message : String(error)}` },
844
+ routes: [],
845
+ };
846
+ }
847
+ }
848
+
849
+ /** @returns {ProjectShape['doors']} */
850
+ function notRead() {
851
+ return { ipc: 0, route: 0, export: 0, command: 0, env: 0, unnamed: 0, filesRead: 0, read: false, why: 'The source was not read.' };
852
+ }
853
+
854
+ /**
855
+ * The page addresses a framework builds out of folder names, which no amount of reading
856
+ * calls will ever find.
857
+ *
858
+ * @param {string} root
859
+ * @returns {Promise<ProjectShape['pages']>}
860
+ */
861
+ async function readThePages(root) {
862
+ try {
863
+ const { readPageRoutes } = await import('./adapters/web.js');
864
+ return await readPageRoutes(root);
865
+ } catch {
866
+ return [];
867
+ }
868
+ }
869
+
870
+ /**
871
+ * The project's own test suite: what runs it, how many there are, and where they live.
872
+ *
873
+ * This is the most valuable thing in the whole detection, and it is worth saying why. A test
874
+ * suite is a set of journeys somebody already wrote, already keeps working, and already
875
+ * trusts. Borrowing them is free. Inventing journeys is not.
876
+ *
877
+ * @param {string} root
878
+ * @param {any} pkg
879
+ * @param {{files: string[], dirs: string[]}} listing
880
+ * @returns {Promise<ProjectShape['tests']>}
881
+ */
882
+ async function findTests(root, pkg, listing) {
883
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
884
+ const command = typeof pkg?.scripts?.test === 'string' ? String(pkg.scripts.test) : null;
885
+
886
+ /** @type {string|null} */
887
+ let runner = null;
888
+ for (const [name, label] of /** @type {[string, string][]} */ ([
889
+ ['vitest', 'Vitest'], ['jest', 'Jest'], ['mocha', 'Mocha'], ['ava', 'AVA'],
890
+ ['@playwright/test', 'Playwright Test'], ['cypress', 'Cypress'], ['tap', 'tap'], ['uvu', 'uvu'], ['@japa/runner', 'Japa'],
891
+ ])) {
892
+ if (name in deps) { runner = label; break; }
893
+ }
894
+ // Node's own runner is often buried inside a wrapper script rather than typed plainly, so
895
+ // the flag is what is looked for, not the shape of the line around it.
896
+ if (!runner && command && /(^|\s|')--test(\s|'|$)|node:test/.test(command)) runner = "Node's own test runner";
897
+ if (!runner && command && command.trim() !== '' && !/^echo\b/.test(command)) runner = 'a command in package.json';
898
+
899
+ const folders = listing.dirs.filter((d) => ['test', 'tests', '__tests__', 'spec', 'e2e', 'cypress'].includes(d));
900
+ const files = await countMatching(root, /\.(test|spec)\.[cm]?[jt]sx?$/);
901
+ return {
902
+ runner,
903
+ command,
904
+ files,
905
+ folders,
906
+ why: runner
907
+ ? `${files} test file${files === 1 ? '' : 's'} were found, run with ${runner}. Each one is a journey this tool can walk under instrumentation instead of inventing its own.`
908
+ : 'No test runner was found, so every journey has to come from reading the code or from a recording.',
909
+ };
910
+ }
911
+
912
+ /**
913
+ * How many files in this project match a pattern. Capped, and the cap is real: a repository
914
+ * with a hundred thousand files is not worth two seconds to count its tests.
915
+ *
916
+ * @param {string} root
917
+ * @param {RegExp} pattern
918
+ * @returns {Promise<number>}
919
+ */
920
+ async function countMatching(root, pattern) {
921
+ let count = 0;
922
+ let seen = 0;
923
+ /** @param {string} dir */
924
+ const walk = async (dir) => {
925
+ if (seen > MOST_FILES) return;
926
+ /** @type {import('node:fs').Dirent[]} */
927
+ let entries;
928
+ try {
929
+ entries = await fsp.readdir(dir, { withFileTypes: true });
930
+ } catch {
931
+ return;
932
+ }
933
+ for (const entry of entries) {
934
+ if (seen > MOST_FILES) return;
935
+ if (entry.name.startsWith('.')) continue;
936
+ if (entry.isSymbolicLink()) continue;
937
+ if (entry.isDirectory()) {
938
+ if (!SKIP_DIRS.has(entry.name)) await walk(path.join(dir, entry.name));
939
+ continue;
940
+ }
941
+ seen++;
942
+ if (pattern.test(entry.name)) count++;
943
+ }
944
+ };
945
+ await walk(root);
946
+ return count;
947
+ }
948
+
949
+ /**
950
+ * The first file under here matching a pattern, looking only in the folders named — because
951
+ * a built artifact is always in one of four places, and searching a whole repository for a
952
+ * `.apk` is how a detector takes twenty seconds.
953
+ *
954
+ * @param {string} dir
955
+ * @param {RegExp} pattern
956
+ * @param {string[]} folders
957
+ * @returns {Promise<string|null>}
958
+ */
959
+ async function findFirst(dir, pattern, folders) {
960
+ /** @type {string[]} */
961
+ const queue = folders.map((f) => path.join(dir, f));
962
+ let looked = 0;
963
+ while (queue.length > 0 && looked < 400) {
964
+ const here = /** @type {string} */ (queue.shift());
965
+ /** @type {import('node:fs').Dirent[]} */
966
+ let entries;
967
+ try {
968
+ entries = await fsp.readdir(here, { withFileTypes: true });
969
+ } catch {
970
+ continue;
971
+ }
972
+ looked++;
973
+ for (const entry of entries) {
974
+ const full = path.join(here, entry.name);
975
+ if (pattern.test(entry.name)) return full;
976
+ if (entry.isDirectory() && !entry.name.startsWith('.') && !SKIP_DIRS.has(entry.name)) queue.push(full);
977
+ }
978
+ }
979
+ return null;
980
+ }
981
+
982
+ /**
983
+ * A built desktop app, in the folders packagers actually use.
984
+ *
985
+ * Deliberately its own function rather than a call into the Electron adapter: detection has
986
+ * to work on a project the Electron adapter would refuse, and it must never depend on an
987
+ * adapter being loadable to say what a repository is.
988
+ *
989
+ * @param {string} dir
990
+ * @returns {Promise<{where: string|null, how: string}>}
991
+ */
992
+ async function findBuiltApp(dir) {
993
+ const suffix = process.platform === 'darwin' ? '.app' : process.platform === 'win32' ? '.exe' : '.AppImage';
994
+ for (const folder of ['out', 'dist', 'release', 'build']) {
995
+ const here = path.join(dir, folder);
996
+ const listing = await listOnce(here);
997
+ for (const name of [...listing.dirs, ...listing.files]) {
998
+ if (name.endsWith(suffix)) return { where: path.join(here, name), how: `it is built and sitting in ${folder}/` };
999
+ }
1000
+ // electron-builder puts the app one level down, in a folder named after the platform.
1001
+ for (const inner of listing.dirs) {
1002
+ const deeper = await listOnce(path.join(here, inner));
1003
+ for (const name of [...deeper.dirs, ...deeper.files]) {
1004
+ if (name.endsWith(suffix)) return { where: path.join(here, inner, name), how: `it is built and sitting in ${folder}/${inner}/` };
1005
+ }
1006
+ }
1007
+ }
1008
+ return { where: null, how: 'no built app was found in out/, dist/, release/ or build/' };
1009
+ }
1010
+
1011
+ /**
1012
+ * Which languages this repository is written in, most-used first, by counting extensions.
1013
+ *
1014
+ * Three files of a language before it is named at all: one stray `.py` script in a
1015
+ * JavaScript repository is not "a Python project", and a list that says it is makes every
1016
+ * other line on the report less believable.
1017
+ *
1018
+ * @param {string} root
1019
+ * @returns {Promise<string[]>}
1020
+ */
1021
+ async function languagesIn(root) {
1022
+ /** @type {Record<string, string>} */
1023
+ const byExtension = {
1024
+ '.ts': 'TypeScript', '.tsx': 'TypeScript', '.js': 'JavaScript', '.jsx': 'JavaScript',
1025
+ '.mjs': 'JavaScript', '.cjs': 'JavaScript', '.swift': 'Swift', '.kt': 'Kotlin',
1026
+ '.java': 'Java', '.go': 'Go', '.rs': 'Rust', '.py': 'Python', '.rb': 'Ruby',
1027
+ '.php': 'PHP', '.cs': 'C#', '.dart': 'Dart', '.m': 'Objective-C', '.mm': 'Objective-C',
1028
+ };
1029
+ /** @type {Record<string, number>} */
1030
+ const counts = {};
1031
+ let seen = 0;
1032
+ /** @param {string} dir */
1033
+ const walk = async (dir) => {
1034
+ if (seen > MOST_FILES) return;
1035
+ /** @type {import('node:fs').Dirent[]} */
1036
+ let entries;
1037
+ try {
1038
+ entries = await fsp.readdir(dir, { withFileTypes: true });
1039
+ } catch {
1040
+ return;
1041
+ }
1042
+ for (const entry of entries) {
1043
+ if (seen > MOST_FILES) return;
1044
+ if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
1045
+ if (entry.isDirectory()) {
1046
+ if (!SKIP_DIRS.has(entry.name)) await walk(path.join(dir, entry.name));
1047
+ continue;
1048
+ }
1049
+ seen++;
1050
+ const language = byExtension[path.extname(entry.name)];
1051
+ if (language) counts[language] = (counts[language] ?? 0) + 1;
1052
+ }
1053
+ };
1054
+ await walk(root);
1055
+ return Object.entries(counts)
1056
+ .sort((a, b) => b[1] - a[1])
1057
+ .filter(([, n]) => n >= 3)
1058
+ .map(([language]) => language);
1059
+ }
1060
+
1061
+ // ---------------------------------------------------------------------------
1062
+ // Tidying up
1063
+ // ---------------------------------------------------------------------------
1064
+
1065
+ /**
1066
+ * The scripts that mean something to this tool, picked out of the pile.
1067
+ * @param {Record<string, string>} scripts
1068
+ * @returns {ProjectShape['scripts']}
1069
+ */
1070
+ function scriptsOf(scripts) {
1071
+ /** @param {string[]} names */
1072
+ const first = (names) => names.map((n) => (typeof scripts[n] === 'string' ? `npm run ${n}` : null)).find(Boolean) ?? null;
1073
+ return {
1074
+ dev: first(['dev', 'develop', 'serve', 'watch']),
1075
+ start: first(['start', 'serve', 'dev']),
1076
+ build: first(['build', 'compile', 'bundle']),
1077
+ test: first(['test']),
1078
+ typecheck: first(['typecheck', 'types', 'tsc', 'check-types']),
1079
+ package: first(['package', 'dist', 'pack', 'make']),
1080
+ };
1081
+ }
1082
+
1083
+ /**
1084
+ * `npm run dev` when the thing named is a script, and the command itself when it is not.
1085
+ * @param {Record<string, string>} scripts
1086
+ * @param {string} candidate
1087
+ * @returns {string}
1088
+ */
1089
+ function npmRun(scripts, candidate) {
1090
+ if (candidate.startsWith('npm run ') || candidate.startsWith('npm ')) return candidate;
1091
+ const name = Object.keys(scripts).find((key) => scripts[key] === candidate);
1092
+ return name ? `npm run ${name}` : candidate;
1093
+ }
1094
+
1095
+ /**
1096
+ * A command that runs in a sub-folder, written so it works from the project root.
1097
+ *
1098
+ * No adapter takes a "which folder" setting — they all run the command from a scratch copy of
1099
+ * the whole project — so the folder has to be part of the command itself. Putting it in a
1100
+ * setting nobody reads would look right in the file and silently start the wrong thing.
1101
+ *
1102
+ * @param {string} command
1103
+ * @param {string} where
1104
+ * @returns {string}
1105
+ */
1106
+ function inFolder(command, where) {
1107
+ return where === '.' ? command : `cd ${where} && ${command}`;
1108
+ }
1109
+
1110
+ /**
1111
+ * How you would actually run one of this package's commands, without installing it globally.
1112
+ * @param {any} pkg
1113
+ * @param {string} name
1114
+ * @returns {string}
1115
+ */
1116
+ function runnerFor(pkg, name) {
1117
+ const bin = typeof pkg?.bin === 'string' ? pkg.bin : pkg?.bin?.[name];
1118
+ return typeof bin === 'string' ? `node ${bin}` : `npx ${name}`;
1119
+ }
1120
+
1121
+ /**
1122
+ * One product per kind per folder, keeping the most confident of any duplicates, ranked so
1123
+ * the thing a person would name first comes first.
1124
+ *
1125
+ * @param {Product[]} products
1126
+ * @returns {Product[]}
1127
+ */
1128
+ function mergeProducts(products) {
1129
+ /** @type {Map<string, Product>} */
1130
+ const best = new Map();
1131
+ for (const product of products) {
1132
+ const key = `${product.kind}:${product.where}`;
1133
+ const already = best.get(key);
1134
+ if (!already || product.confidence > already.confidence) best.set(key, product);
1135
+ }
1136
+ /** @type {Record<string, number>} */
1137
+ const order = { electron: 0, ios: 1, android: 2, web: 3, server: 4, cli: 5, library: 6, desktopNative: 7, container: 8, other: 9 };
1138
+ return [...best.values()].sort((a, b) => {
1139
+ const byConfidence = Math.round(b.confidence * 10) - Math.round(a.confidence * 10);
1140
+ if (byConfidence !== 0) return byConfidence;
1141
+ return (order[a.kind] ?? 99) - (order[b.kind] ?? 99);
1142
+ });
1143
+ }
1144
+
1145
+ /**
1146
+ * One sentence naming everything this repository makes.
1147
+ * @param {Product[]} products
1148
+ * @param {string} fallbackName
1149
+ * @returns {string}
1150
+ */
1151
+ function summarise(products, fallbackName) {
1152
+ if (products.length === 0) return `Nothing in ${fallbackName} looks like a product this tool knows how to watch yet.`;
1153
+ const names = products.map((p) => p.name);
1154
+ const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
1155
+ if (products.length === 1) return `This repository makes one thing: ${list}.`;
1156
+ return `This repository makes ${products.length} things at once: ${list}. A change in shared code can break any of them, so all of them are worth checking.`;
1157
+ }
1158
+
1159
+ /**
1160
+ * Where `import` would actually enter this package.
1161
+ * @param {any} pkg
1162
+ * @returns {string}
1163
+ */
1164
+ function entryPointOf(pkg) {
1165
+ const dot = pkg?.exports?.['.'] ?? pkg?.exports;
1166
+ if (typeof dot === 'string') return dot;
1167
+ if (dot && typeof dot === 'object') {
1168
+ for (const key of ['import', 'default', 'require', 'node']) {
1169
+ if (typeof dot[key] === 'string') return dot[key];
1170
+ }
1171
+ }
1172
+ if (typeof pkg?.module === 'string') return pkg.module;
1173
+ if (typeof pkg?.main === 'string') return pkg.main;
1174
+ return '.';
1175
+ }
1176
+
1177
+ /**
1178
+ * A command short enough to read. The whole of it stays in the data; only the sentence is cut.
1179
+ * @param {string} text
1180
+ * @returns {string}
1181
+ */
1182
+ function short(text) {
1183
+ const flat = text.replace(/\s+/g, ' ').trim();
1184
+ return flat.length <= 70 ? flat : `${flat.slice(0, 67)}...`;
1185
+ }
1186
+
1187
+ /**
1188
+ * @param {Clue[]} clues
1189
+ * @returns {Clue[]}
1190
+ */
1191
+ function dedupeClues(clues) {
1192
+ /** @type {Map<string, Clue>} */
1193
+ const seen = new Map();
1194
+ for (const clue of clues) {
1195
+ const key = `${clue.where}|${clue.means}`;
1196
+ if (!seen.has(key)) seen.set(key, clue);
1197
+ }
1198
+ return [...seen.values()];
1199
+ }