staysfixed 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/v2/detect.js CHANGED
@@ -31,6 +31,7 @@
31
31
  import fs from 'node:fs';
32
32
  import fsp from 'node:fs/promises';
33
33
  import path from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
34
35
 
35
36
  /** @typedef {import('./types.js').Surface} Surface */
36
37
 
@@ -76,8 +77,11 @@ export const PRODUCT_KINDS = Object.freeze({
76
77
  export function adaptersHere() {
77
78
  /** @type {Set<string>} */
78
79
  const here = new Set();
80
+ // `fileURLToPath` rather than reading `.pathname` off the URL: on Windows a module URL's
81
+ // pathname is `/C:/...`, which is not a path any filesystem call accepts, so the listing
82
+ // below threw and every adapter went missing on the one platform nobody tests on.
83
+ const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'adapters');
79
84
  try {
80
- const dir = path.join(path.dirname(new URL(import.meta.url).pathname), 'adapters');
81
85
  for (const name of fs.readdirSync(dir)) {
82
86
  if (!name.endsWith('.js')) continue;
83
87
  const id = name.slice(0, -3);
@@ -86,14 +90,38 @@ export function adaptersHere() {
86
90
  if (id === 'contract' || id === 'isolate' || id.endsWith('-driver')) continue;
87
91
  here.add(id);
88
92
  }
93
+ return here;
89
94
  } 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']);
95
+ // Fall through. A listing can be refused where a single file can still be asked about.
96
+ }
97
+ // Second angle, and it is still a reading rather than a claim: ask about each adapter this
98
+ // tool has a name for, one file at a time. A hard-coded answer here would be the thing this
99
+ // whole file exists to avoid — it would go on saying "there is no iOS adapter" on the day
100
+ // one landed, and a stranger would be told their iPhone app cannot be checked by a copy of
101
+ // the tool that could check it.
102
+ for (const id of everyAdapterNamed()) {
103
+ try {
104
+ if (fs.existsSync(path.join(dir, `${id}.js`))) here.add(id);
105
+ } catch {
106
+ // Nothing readable about this one either. It stays out, which is the honest direction.
107
+ }
93
108
  }
94
109
  return here;
95
110
  }
96
111
 
112
+ /**
113
+ * Every adapter this tool has a name for, taken from the product table rather than typed out
114
+ * a second time, so the two can never disagree.
115
+ *
116
+ * @returns {string[]}
117
+ */
118
+ function everyAdapterNamed() {
119
+ /** @type {Set<string>} */
120
+ const names = new Set(['source']);
121
+ for (const kind of Object.values(PRODUCT_KINDS)) if (kind.adapter) names.add(kind.adapter);
122
+ return [...names];
123
+ }
124
+
97
125
  /** Folders never worth walking into. Walking `node_modules` is how a detector takes a minute. */
98
126
  const SKIP_DIRS = new Set([
99
127
  'node_modules', '.git', '.hg', '.svn', 'dist', 'out', 'build', 'release', 'coverage',
@@ -157,6 +185,10 @@ const ALREADY_COVERED = new Set([
157
185
  * Whether a built artifact is sitting there ready to be opened.
158
186
  * @property {string[]} blockers Plain sentences: what stands between this and being checked.
159
187
  * @property {Record<string, any>} [suggest] The config slice init would write for it.
188
+ * @property {Router} [router] For a web app: how it decides which screen to show, and so
189
+ * whether a screen has an address at all or only a click.
190
+ * @property {string} [startNote] Why the start command is the one it is, so the settings can
191
+ * say it beside the line itself.
160
192
  */
161
193
 
162
194
  /**
@@ -177,6 +209,18 @@ const ALREADY_COVERED = new Set([
177
209
  * @property {{dev: string|null, start: string|null, build: string|null, test: string|null, typecheck: string|null, package: string|null}} scripts
178
210
  * @property {{ipc: number, route: number, export: number, command: number, env: number, unnamed: number, filesRead: number, read: boolean, why: string}} doors
179
211
  * @property {{name: string, method: string, file: string}[]} routes Every route, by name. Capped.
212
+ * @property {{name: string, answers: boolean, file: string}[]} channels
213
+ * Every private channel between a desktop app's two halves,
214
+ * by name. Capped. `answers` is true for the ones that hand
215
+ * a value back, which are the only ones worth asking.
216
+ * @property {string[]} envNames Every setting the code reads out of the environment, by
217
+ * name. Capped. Asking for one that does not exist is how a
218
+ * set-up list gets a line nobody can ever tick off.
219
+ * @property {string[]} sourceFolders The folders the contract channel should read, worked out
220
+ * from where the products actually are.
221
+ * @property {{bytes: number, files: number, capped: boolean, biggest: {folder: string, bytes: number}[], freeBytes: number|null, tooBig: boolean, why: string}} bulk
222
+ * What copying this project would cost, and whether there is
223
+ * room. Three adapters copy it before running anything.
180
224
  * @property {{url: string, file: string, needs: string[]}[]} pages
181
225
  * @property {{dockerfile: string|null, compose: string|null}} containers
182
226
  * @property {Clue[]} evidence Everything found, including clues no product claimed.
@@ -226,9 +270,11 @@ export async function detectProject(options = {}) {
226
270
  // The source read, once, for two answers — how many doors there are, and what the routes
227
271
  // are called. Reading Terminal Deck's 1,416 files twice because two functions each wanted
228
272
  // 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: [] };
273
+ const reading = readCode ? await readTheSource(root) : { doors: notRead(), routes: [], channels: [], envNames: [] };
230
274
  const doors = reading.doors;
231
275
  const routes = reading.routes;
276
+ const channels = reading.channels;
277
+ const envNames = reading.envNames;
232
278
  const pages = readCode ? await readThePages(root) : [];
233
279
  if (doors.read && doors.route + doors.ipc > 0) {
234
280
  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.` });
@@ -277,6 +323,12 @@ export async function detectProject(options = {}) {
277
323
  })));
278
324
  }
279
325
 
326
+ // Products no manifest advertises: a command-line program built into a folder nobody
327
+ // commits, and a server that is three files and a socket. Both are real, both are shipped,
328
+ // and both were invisible to everything above, which reads what a project SAYS about itself.
329
+ if (deep) products.push(...(await findCommandPrograms({ root, listing, scripts: pkg?.scripts ?? {}, available, claimed: products.map((p) => p.where) })));
330
+ if (deep) products.push(...(await findServersInCode({ root, listing, products, available })));
331
+
280
332
  const merged = mergeProducts(products);
281
333
  if (merged.length === 0) {
282
334
  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.');
@@ -327,8 +379,8 @@ export async function detectProject(options = {}) {
327
379
  languages: await languagesIn(root),
328
380
  tests,
329
381
  scripts: scriptsOf(pkg?.scripts ?? {}),
330
- doors,
331
- routes,
382
+ ...(await theSourceAgain({ root, readCode, merged, listing, first: { doors, routes, channels, envNames } })),
383
+ bulk: await measureBulk(root),
332
384
  pages,
333
385
  containers,
334
386
  evidence: dedupeClues(evidence),
@@ -421,6 +473,8 @@ async function productsIn(input) {
421
473
  * @param {{found: boolean, where: string|null, how: string}} [spec.built]
422
474
  * @param {string[]} [spec.blockers]
423
475
  * @param {Record<string, any>} [spec.suggest]
476
+ * @param {Router} [spec.router]
477
+ * @param {string} [spec.startNote]
424
478
  */
425
479
  const add = (kind, spec) => {
426
480
  const meta = PRODUCT_KINDS[kind];
@@ -436,6 +490,8 @@ async function productsIn(input) {
436
490
  built: spec.built ?? { found: false, where: null, how: 'nothing to build — it runs from source' },
437
491
  blockers: spec.blockers ?? [],
438
492
  suggest: spec.suggest,
493
+ router: spec.router,
494
+ startNote: spec.startNote,
439
495
  });
440
496
  };
441
497
 
@@ -448,7 +504,11 @@ async function productsIn(input) {
448
504
  const clues = [];
449
505
  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
506
  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.` });
507
+ // The application id lives in package.json in one project and in the packaging config in
508
+ // the next, and Terminal Deck is the second kind — reading only the first said 'no id
509
+ // here' about a repository whose id is on line one of electron-builder.yml.
510
+ const appId = typeof pkg?.build?.appId === 'string' ? String(pkg.build.appId) : appIdInConfig(dir, builderFile);
511
+ if (appId) clues.push({ where: at(typeof pkg?.build?.appId === 'string' ? 'package.json' : String(builderFile)), means: `It has an application id (${appId}), which only a packaged desktop app has.` });
452
512
  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
513
  add('electron', {
454
514
  name: 'the desktop app',
@@ -457,7 +517,15 @@ async function productsIn(input) {
457
517
  evidence: clues,
458
518
  built: { found: Boolean(app.where), where: app.where ? path.relative(root, app.where) : null, how: app.how },
459
519
  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) } : {},
520
+ // The application id goes in beside the binary when the manifest carries one. The
521
+ // adapter uses it to find the window it opened rather than any other window of the same
522
+ // app that was already on screen, and it is written in package.json already — asking
523
+ // somebody for a value that is sitting in their own manifest is the exact shape of
524
+ // question this command exists to stop asking.
525
+ suggest: {
526
+ ...(app.where ? { binary: path.relative(root, app.where) } : {}),
527
+ ...(appId ? { appId } : {}),
528
+ },
461
529
  });
462
530
  }
463
531
 
@@ -465,24 +533,43 @@ async function productsIn(input) {
465
533
  const xcode = listing.dirs.find((d) => d.endsWith('.xcodeproj')) ?? listing.dirs.find((d) => d.endsWith('.xcworkspace')) ?? null;
466
534
  const swiftPackage = file('Package.swift');
467
535
  const podfile = file('Podfile');
468
- const xcodegen = file('project.yml') && listing.dirs.some((d) => /^[A-Z]/.test(d));
536
+ // An `.xcodeproj` is very often NOT committed. It is the one file in an Xcode repository no
537
+ // two people can edit at once — it carries a random identity per file and settles conflicts
538
+ // by corrupting itself — so a great many projects generate it from an XcodeGen spec and keep
539
+ // only the spec in git. Terminal Deck does exactly that, and the effect was that the iPhone
540
+ // app existed on the machine it was last built on and vanished from every fresh clone: three
541
+ // products found where there are five, and no word said about the missing two.
542
+ const xcodegen = isXcodeGenSpec(dir, listing);
469
543
  // React Native and Expo are one codebase that becomes two apps. Both are reported, because
470
544
  // reporting one would leave the other silently unchecked.
471
545
  const reactNative = has('react-native') || has('expo');
472
- if (xcode || (swiftPackage && (podfile || folder('Sources'))) || (podfile && !xcode) || (xcodegen && podfile) || reactNative) {
546
+ if (xcode || (swiftPackage && (podfile || folder('Sources'))) || (podfile && !xcode) || xcodegen || reactNative) {
473
547
  /** @type {Clue[]} */
474
548
  const clues = [];
475
549
  if (xcode) clues.push({ where: at(xcode), means: 'An Xcode project, which is how an Apple app is built.' });
476
550
  if (swiftPackage) clues.push({ where: at('Package.swift'), means: 'Swift source organised as a package.' });
477
551
  if (podfile) clues.push({ where: at('Podfile'), means: 'CocoaPods dependencies, which are used by iOS apps.' });
552
+ if (xcodegen) clues.push({ where: at('project.yml'), means: 'An XcodeGen spec. The Xcode project itself is generated from this and is usually not committed, which is why looking only for an .xcodeproj misses the app entirely on a fresh clone.' });
478
553
  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
554
  const ipa = await findFirst(dir, /\.(ipa|app)$/, ['build', 'DerivedData', 'Products']);
480
555
  add('ios', {
481
556
  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.',
557
+ confidence: xcode ? 1 : xcodegen ? 0.95 : reactNative ? 0.8 : 0.6,
558
+ why: xcode ? `There is an Xcode project at ${at(xcode)}.` : xcodegen ? `${at('project.yml')} is an XcodeGen spec, so the Xcode project is generated from it rather than committed.` : 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
559
  evidence: clues,
485
560
  built: { found: Boolean(ipa), where: ipa ? path.relative(root, ipa) : null, how: ipa ? 'a built app was found' : 'nothing built was found' },
561
+ // Only a folder ending .app is worth naming: the simulator installs a bundle, and an
562
+ // .ipa is a signed archive for a real phone, which is the one thing that can never be
563
+ // driven two builds at a time anyway.
564
+ suggest: {
565
+ ...(ipa && ipa.endsWith('.app') ? { app: path.relative(root, ipa) } : {}),
566
+ // The scheme, which is the one word `xcodebuild` cannot be run without. It is written
567
+ // in the project name — in the XcodeGen spec where there is one, in the .xcodeproj's
568
+ // own name otherwise — so handing somebody a command with a blank in it, for a value
569
+ // sitting in their own repository, would be exactly the kind of asking this command
570
+ // exists to stop.
571
+ ...(schemeName(dir, listing, xcode) ? { scheme: schemeName(dir, listing, xcode) } : {}),
572
+ },
486
573
  blockers: available.has('ios')
487
574
  ? ['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
575
  : ['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.'],
@@ -553,30 +640,55 @@ async function productsIn(input) {
553
640
  // has its own pages, its own host config, or its own package.
554
641
  const isTheElectronWindow = electronish && where === '.' && !hostConfig && pages.length === 0;
555
642
  if (!isTheElectronWindow) {
556
- const dev = scripts.dev ?? scripts.start ?? scripts.serve ?? null;
557
- const start = dev ? inFolder(npmRun(scripts, dev), where) : null;
643
+ // How to boot it. Build-then-serve wherever there is a way to, and the development
644
+ // server only when there is not see {@link startCommandFor} for why that order and
645
+ // not the other one.
646
+ const booting = startCommandFor({ scripts, has, dir, listing });
647
+ const start = booting.command ? inFolder(booting.command, where) : null;
558
648
  // A site made of plain .html files has no framework and no dev server, and every one
559
649
  // of those files is a page somebody can open. Listing them is what turns "there is a
560
650
  // website here" into journeys that can actually be walked.
561
651
  const flat = listing.files.filter((f) => f.endsWith('.html')).map((f) => (f === 'index.html' ? '/' : `/${f}`));
562
652
  if (flat.length > 1) clues.push({ where: at('*.html'), means: `${flat.length} pages are plain HTML files sitting in this folder.` });
653
+
654
+ // Where the screens come from. Folder names answer for a framework that builds its
655
+ // addresses out of them; for everything else the router is read, and where there is no
656
+ // router the screens are read off the control that switches between them. Reading the
657
+ // folder names and stopping there is what reported a four-screen app as a one-page one.
658
+ const reading = pages.length > 0
659
+ ? { screens: /** @type {Screen[]} */ ([]), needValues: /** @type {{url: string, names: string[]}[]} */ ([]), router: /** @type {Router} */ ({ kind: 'files', where: 'the page folders', why: `${pages.length} addresses are built out of folder names, the way Next.js and its cousins do it, and every one of them is opened.` }) }
660
+ : fromHere(await readScreens(dir, has), where);
661
+ /** @type {Screen[]} */
662
+ const screens = flat.length > 1
663
+ ? flat.map((url) => ({ name: url === '/' ? 'the front page' : url, url }))
664
+ : reading.screens;
665
+ if (reading.router.kind === 'tabs' || reading.router.kind === 'hash' || reading.router.kind === 'declared') {
666
+ clues.push({ where: at(reading.router.where ?? 'the source'), means: reading.router.why });
667
+ }
668
+
563
669
  add('web', {
564
670
  name: where === '.' ? 'the website' : `the website in ${where}/`,
565
671
  confidence: onlyBuildsWebsites || ((webFramework || indexHtml) && (bundler || hostConfig || pages.length > 0)) ? 0.95 : 0.6,
566
672
  why: [
567
673
  webFramework ? `It uses ${webFramework}` : 'There is a page here',
568
674
  pages.length > 0 ? ` and ${pages.length} page address${pages.length === 1 ? '' : 'es'} were read out of the folder names` : '',
675
+ pages.length === 0 && screens.length > 0 ? ` and ${screens.length} screen${screens.length === 1 ? '' : 's'} were read out of ${reading.router.kind === 'tabs' ? 'the strip of tabs that switches between them' : reading.router.kind === 'files' ? 'the folder names' : 'its router'}` : '',
569
676
  flat.length > 1 && pages.length === 0 ? ` and ${flat.length} more are plain HTML files` : '',
570
677
  hostConfig ? `, it is set up to deploy to ${hostConfig.split('.')[0]}` : '',
571
678
  start ? `, and \`${start}\` starts it` : '',
572
679
  ].join('') + '.',
573
680
  evidence: clues,
681
+ router: pages.length > 0 || flat.length > 1
682
+ ? { kind: 'files', where: null, why: 'Every page here is a file with an address of its own, so each one is opened directly.' }
683
+ : reading.router,
684
+ startNote: booting.why,
574
685
  blockers: start
575
686
  ? []
576
687
  : ['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
688
  suggest: {
578
689
  ...(start ? { start } : {}),
579
- ...(flat.length > 0 && pages.length === 0 ? { screens: flat.map((url) => ({ name: url === '/' ? 'the front page' : url, url })) } : {}),
690
+ ...(screens.length > 0 ? { screens } : {}),
691
+ ...(reading.needValues.length > 0 ? { screensNeedingValues: reading.needValues } : {}),
580
692
  },
581
693
  });
582
694
  }
@@ -602,7 +714,14 @@ async function productsIn(input) {
602
714
  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
715
  evidence: clues,
604
716
  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) } : {},
717
+ suggest: {
718
+ ...(scripts.start ? { start: inFolder(npmRun(scripts, scripts.start), where) } : {}),
719
+ // Whether this server keeps anything. Both builds have to see the same rows, so a
720
+ // server with a database needs a command that puts the data back — and a server
721
+ // with NO database needs no such command, and must not be asked for one. Asking is
722
+ // how a set-up list grows a line nobody can ever tick off.
723
+ stateless: !keepsData(deps, containers),
724
+ },
606
725
  });
607
726
  }
608
727
  }
@@ -797,12 +916,15 @@ async function findMembers(root, globs, listing) {
797
916
  * uses so the number here and the number in a check can never disagree.
798
917
  *
799
918
  * @param {string} root
800
- * @returns {Promise<{doors: ProjectShape['doors'], routes: ProjectShape['routes']}>}
919
+ * @param {string[]} [folders] Which folders to read. Left out, the reader uses its own usual
920
+ * list, which is right for a repository that makes one thing and
921
+ * misses whole products in one that makes several.
922
+ * @returns {Promise<{doors: ProjectShape['doors'], routes: ProjectShape['routes'], channels: ProjectShape['channels'], envNames: ProjectShape['envNames']}>}
801
923
  */
802
- async function readTheSource(root) {
924
+ async function readTheSource(root, folders) {
803
925
  try {
804
926
  const { readContract, readFileRoutes, readPackageCommands } = await import('./adapters/source.js');
805
- const reading = await readContract({ root });
927
+ const reading = await readContract({ root, folders });
806
928
  const fileRoutes = await readFileRoutes(root);
807
929
  const commands = await readPackageCommands(root);
808
930
  const doors = [...reading.doors, ...fileRoutes.doors, ...commands];
@@ -822,7 +944,33 @@ async function readTheSource(root) {
822
944
  if (routes.size >= 200) break;
823
945
  }
824
946
 
947
+ // The private channels, by name, and whether each one hands a value back. Kept because
948
+ // the count alone cannot answer the only question worth asking about them — which ones
949
+ // are safe to knock on — and because a settings file that lists them saves somebody
950
+ // reading four hundred and fifty registrations by hand.
951
+ /** @type {Map<string, {name: string, answers: boolean, file: string}>} */
952
+ const channels = new Map();
953
+ for (const door of doors) {
954
+ if (door.kind !== 'ipc' || !door.named || door.inTest) continue;
955
+ if (channels.has(door.name)) continue;
956
+ channels.set(door.name, { name: door.name, answers: door.detail.startsWith('answers'), file: door.file });
957
+ if (channels.size >= 800) break;
958
+ }
959
+
960
+ // Every setting the code reads out of the environment. This is what stops the set-up list
961
+ // asking for a variable that does not exist: a name nobody can find is a line nobody can
962
+ // ever tick off, and it makes every other line on the list less believable.
963
+ /** @type {Set<string>} */
964
+ const envNames = new Set();
965
+ for (const door of doors) {
966
+ if (door.kind !== 'env' || !door.named) continue;
967
+ envNames.add(door.name);
968
+ if (envNames.size >= 400) break;
969
+ }
970
+
825
971
  return {
972
+ channels: [...channels.values()],
973
+ envNames: [...envNames].sort(),
826
974
  doors: {
827
975
  ipc: counts.ipc ?? 0,
828
976
  route: counts.route ?? 0,
@@ -842,6 +990,8 @@ async function readTheSource(root) {
842
990
  return {
843
991
  doors: { ...notRead(), why: `The source could not be read: ${error instanceof Error ? error.message : String(error)}` },
844
992
  routes: [],
993
+ channels: [],
994
+ envNames: [],
845
995
  };
846
996
  }
847
997
  }
@@ -1197,3 +1347,1246 @@ function dedupeClues(clues) {
1197
1347
  }
1198
1348
  return [...seen.values()];
1199
1349
  }
1350
+
1351
+ // ---------------------------------------------------------------------------
1352
+ // Reading what the code IS, rather than what package.json advertises
1353
+ // ---------------------------------------------------------------------------
1354
+
1355
+ /**
1356
+ * Where a build puts what it made. Looked in for products that exist only after a build —
1357
+ * the ones no manifest at the top of the repository mentions.
1358
+ */
1359
+ const BUILD_OUTPUT_DIRS = ['out', 'dist', 'build', 'lib', 'release', 'target', 'bin', '.output'];
1360
+
1361
+ /**
1362
+ * Folders whose command-line-looking files are machinery rather than a product: the scripts
1363
+ * that release the thing, the tests that check it, the samples that show it off. Every one of
1364
+ * these normally holds a file with a shebang on it, and calling those a product would fill the
1365
+ * report with things nobody ships.
1366
+ */
1367
+ const NOT_A_SHIPPED_PROGRAM = new Set([
1368
+ 'scripts', 'script', 'tools', 'tooling', 'build', 'ci', 'test', 'tests', '__tests__', 'spec',
1369
+ 'e2e', 'fixtures', 'examples', 'example', 'demo', 'demos', 'docs', 'doc', 'benchmarks', 'bench',
1370
+ 'migrations', 'seeds', 'infra', 'deploy', 'types', 'typings', 'assets', 'public', 'static',
1371
+ ]);
1372
+
1373
+ /**
1374
+ * How much of one file is worth reading to work out what kind of thing it is.
1375
+ *
1376
+ * Two megabytes, and the number was chosen by measuring rather than by feel. A single-page
1377
+ * app that keeps its whole client in one file is normal — the phone client this was tested
1378
+ * against is 315KB in one file, and it holds the entire list of screens. At the 400KB this
1379
+ * started at, an app half again as big would have had its router go unread, and the answer
1380
+ * would have been "this is one page" with nothing anywhere saying a file had been skipped.
1381
+ * That is the exact shape of silence this whole tool exists to remove, so the ceiling is
1382
+ * generous AND anything above it is named out loud.
1383
+ */
1384
+ const MOST_BYTES_PER_FILE = 2_000_000;
1385
+
1386
+ /**
1387
+ * Read a file, but only if it is small enough to be worth reading.
1388
+ *
1389
+ * @param {string} file
1390
+ * @param {number} [limit]
1391
+ * @returns {Promise<string|null>}
1392
+ */
1393
+ async function readTextIfSmall(file, limit = MOST_BYTES_PER_FILE) {
1394
+ try {
1395
+ const info = await fsp.stat(file);
1396
+ if (!info.isFile() || info.size > limit) return null;
1397
+ return await fsp.readFile(file, 'utf8');
1398
+ } catch {
1399
+ return null;
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * Every source file under one folder, bounded, with its text.
1405
+ *
1406
+ * Bounded twice over — a file count and a byte ceiling per file — because this runs inside
1407
+ * somebody else's repository, and a detector that takes twenty seconds is a detector people
1408
+ * turn off.
1409
+ *
1410
+ * @param {string} dir
1411
+ * @param {object} [opts]
1412
+ * @param {RegExp} [opts.match] Which filenames are worth opening.
1413
+ * @param {number} [opts.most] How many files to open before stopping.
1414
+ * @param {number} [opts.depth] How deep to go.
1415
+ * @returns {Promise<{files: {file: string, rel: string, text: string}[], tooBig: string[]}>}
1416
+ * `tooBig` names anything skipped for size. Every reading built on this has to be able to
1417
+ * say what it did not open, because a reader that quietly skips a file and then reports
1418
+ * what it found is indistinguishable from one that found nothing.
1419
+ */
1420
+ async function readSome(dir, opts = {}) {
1421
+ const match = opts.match ?? /\.([cm]?[jt]sx?|svelte|vue)$/;
1422
+ const most = opts.most ?? 300;
1423
+ const maxDepth = opts.depth ?? 4;
1424
+ /** @type {{file: string, rel: string, text: string}[]} */
1425
+ const out = [];
1426
+ /** @type {string[]} */
1427
+ const tooBig = [];
1428
+ /**
1429
+ * @param {string} here
1430
+ * @param {number} depth
1431
+ * @returns {Promise<void>}
1432
+ */
1433
+ const walk = async (here, depth) => {
1434
+ if (out.length >= most || depth > maxDepth) return;
1435
+ /** @type {import('node:fs').Dirent[]} */
1436
+ let entries;
1437
+ try {
1438
+ entries = await fsp.readdir(here, { withFileTypes: true });
1439
+ } catch {
1440
+ return;
1441
+ }
1442
+ for (const entry of entries) {
1443
+ if (out.length >= most) return;
1444
+ if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
1445
+ const full = path.join(here, entry.name);
1446
+ if (entry.isDirectory()) {
1447
+ if (!SKIP_DIRS.has(entry.name)) await walk(full, depth + 1);
1448
+ continue;
1449
+ }
1450
+ if (!match.test(entry.name)) continue;
1451
+ const text = await readTextIfSmall(full);
1452
+ if (text === null) {
1453
+ tooBig.push(path.relative(dir, full));
1454
+ continue;
1455
+ }
1456
+ out.push({ file: full, rel: path.relative(dir, full), text });
1457
+ }
1458
+ };
1459
+ await walk(dir, 0);
1460
+ return { files: out, tooBig };
1461
+ }
1462
+
1463
+ /**
1464
+ * The first bytes of a file, for the one question a shebang answers.
1465
+ *
1466
+ * @param {string} file
1467
+ * @param {number} bytes
1468
+ * @returns {Promise<string>}
1469
+ */
1470
+ async function readHead(file, bytes) {
1471
+ /** @type {import('node:fs/promises').FileHandle|null} */
1472
+ let handle = null;
1473
+ try {
1474
+ handle = await fsp.open(file, 'r');
1475
+ const buffer = Buffer.alloc(bytes);
1476
+ const read = await handle.read(buffer, 0, bytes, 0);
1477
+ return buffer.subarray(0, read.bytesRead).toString('utf8');
1478
+ } catch {
1479
+ return '';
1480
+ } finally {
1481
+ if (handle) await handle.close().catch(() => {});
1482
+ }
1483
+ }
1484
+
1485
+ /**
1486
+ * Which script builds the thing in this folder, and where it lands.
1487
+ *
1488
+ * Matched on the folder's own word — `src/headless` against `build:headless` against
1489
+ * `vite.headless.config.ts` — because that is how every repository that builds a second
1490
+ * program out of one tree actually names things. The output folder is read out of the build
1491
+ * config rather than guessed, so what the settings say is where the file will be.
1492
+ *
1493
+ * @param {string} root
1494
+ * @param {string} where
1495
+ * @param {{files: string[], dirs: string[]}} listing
1496
+ * @param {Record<string, string>} scripts
1497
+ * @returns {{command: string|null, outDir: string|null}}
1498
+ */
1499
+ function buildFor(root, where, listing, scripts) {
1500
+ const word = path.basename(where).toLowerCase();
1501
+ /** @type {string|null} */
1502
+ let command = null;
1503
+ for (const [name, line] of Object.entries(scripts)) {
1504
+ if (typeof line !== 'string') continue;
1505
+ const lower = name.toLowerCase();
1506
+ if (lower.includes(word) && /build|bundle|compile|dist|pack/.test(lower)) {
1507
+ command = `npm run ${name}`;
1508
+ break;
1509
+ }
1510
+ }
1511
+ if (!command) {
1512
+ for (const [name, line] of Object.entries(scripts)) {
1513
+ if (typeof line !== 'string') continue;
1514
+ const lower = line.toLowerCase();
1515
+ if (lower.includes(word) && /build|bundle|compile|esbuild|tsc|vite|rollup|webpack/.test(lower)) {
1516
+ command = `npm run ${name}`;
1517
+ break;
1518
+ }
1519
+ }
1520
+ }
1521
+
1522
+ /** @type {string|null} */
1523
+ let outDir = null;
1524
+ for (const file of listing.files) {
1525
+ if (!file.toLowerCase().includes(word)) continue;
1526
+ if (!/\.(js|cjs|mjs|ts|mts|json|yml|yaml)$/.test(file)) continue;
1527
+ /** @type {string} */
1528
+ let text = '';
1529
+ try {
1530
+ const info = fs.statSync(path.join(root, file));
1531
+ if (info.size > MOST_BYTES_PER_FILE) continue;
1532
+ text = fs.readFileSync(path.join(root, file), 'utf8');
1533
+ } catch {
1534
+ continue;
1535
+ }
1536
+ const hit = /out(?:Dir|dir|put|putDir|putDirectory|File|file)\s*[:=]\s*['"]([^'"]+)['"]/.exec(text);
1537
+ if (hit) {
1538
+ const named = hit[1].replace(/\/+$/, '');
1539
+ // Some builds name the file rather than the folder — `outfile: 'dist/bundle.js'`. The
1540
+ // folder is what is wanted either way, and a folder with a .js on the end of it would
1541
+ // read like a mistake in the settings.
1542
+ outDir = /\.[a-z]{1,4}$/i.test(named) ? path.posix.dirname(named) : named;
1543
+ break;
1544
+ }
1545
+ }
1546
+ return { command, outDir };
1547
+ }
1548
+
1549
+ /**
1550
+ * The command-line programs this repository makes, found by what the code IS.
1551
+ *
1552
+ * THIS IS THE ANSWER TO A PRODUCT GOING MISSING. Terminal Deck ships a headless host: a real
1553
+ * command-line program with its own help, its own `status`, `devices`, `pair`, `folders` and
1554
+ * `stop`, which runs agent sessions on a server with no window. Nothing in the repository's
1555
+ * own `package.json` mentions it — no `bin`, no entry point — because it is built by a config
1556
+ * of its own into an `out/` folder nobody commits, and the manifest naming its commands is
1557
+ * written BY that build. A detector that reads only the manifest at the top of the repository
1558
+ * reports four products out of five, says nothing at all about the fifth, and the clean run
1559
+ * that follows looks complete.
1560
+ *
1561
+ * So three readings, strongest first.
1562
+ *
1563
+ * 1. A MANIFEST A BUILD WROTE. A `package.json` inside a build output folder with a `bin`
1564
+ * map in it. Exact: it names each command and the file that runs it. It also draws the
1565
+ * line the build drew — Terminal Deck's build emits a third program, a public demo host,
1566
+ * and deliberately keeps it out of `bin`. Reading `bin` inherits that decision; scanning
1567
+ * for shebangs would have proposed running it.
1568
+ * 2. A BUILT FILE THAT ANNOUNCES ITSELF. A JavaScript file in a build output folder whose
1569
+ * first line is a shebang. That is what makes a file something an operating system will
1570
+ * run, and nothing else puts one there.
1571
+ * 3. SOURCE THAT READS ITS OWN ARGUMENTS. A folder holding a file that reads the command
1572
+ * line and a file that mentions a help flag. That is a command-line program before
1573
+ * anybody has built it, and saying so is what turns silence into "build it first".
1574
+ *
1575
+ * A shipped program and a release script look identical from a distance — both are a file
1576
+ * with a shebang on it. They are told apart by where they live: {@link NOT_A_SHIPPED_PROGRAM}.
1577
+ *
1578
+ * @param {object} input
1579
+ * @param {string} input.root
1580
+ * @param {{files: string[], dirs: string[]}} input.listing
1581
+ * @param {Record<string, string>} input.scripts
1582
+ * @param {Set<string>} input.available
1583
+ * @param {string[]} input.claimed Folders another product already spoke for. A phone app folder
1584
+ * holds build scripts with shebangs on them, and calling one
1585
+ * of those a second product would report the same tree twice.
1586
+ * @returns {Promise<Product[]>}
1587
+ */
1588
+ async function findCommandPrograms(input) {
1589
+ const { root, listing, scripts, available, claimed } = input;
1590
+ const spokenFor = (/** @type {string} */ where) =>
1591
+ claimed.some((taken) => taken !== "." && (where === taken || where.startsWith(`${taken}/`)));
1592
+ /** @type {Product[]} */
1593
+ const found = [];
1594
+
1595
+ // ── 1 and 2: what a build left behind ─────────────────────────────────────
1596
+ //
1597
+ // "Built" is decided by the repository's own ignore list rather than by the folder's name.
1598
+ // `build/` in one repository is where a bundler writes and in the next it is committed
1599
+ // artwork scripts; the difference is whether git is told to leave it alone. Reading the
1600
+ // ignore list is exact, free, and it is the project's own answer rather than this file's
1601
+ // opinion — without it, a folder of committed release scripts gets reported as a product.
1602
+ const ignored = await readIgnoreList(root);
1603
+ /** @type {string[]} */
1604
+ const outputFolders = [];
1605
+ for (const name of BUILD_OUTPUT_DIRS) {
1606
+ if (!listing.dirs.includes(name)) continue;
1607
+ const inner = await listOnce(path.join(root, name));
1608
+ /** @type {string[]} */
1609
+ const here = [name, ...inner.dirs.filter((deeper) => !deeper.startsWith('.')).map((deeper) => path.posix.join(name, deeper))];
1610
+ if (ignored.has(name)) {
1611
+ outputFolders.push(...here);
1612
+ continue;
1613
+ }
1614
+ // Not ignored, so the shebang reading would be guessing. A manifest with a `bin` map in it
1615
+ // is not a guess, so those folders still go in and the shebang reading skips them.
1616
+ for (const folder of here) {
1617
+ if (fs.existsSync(path.join(root, folder, 'package.json'))) outputFolders.push(folder);
1618
+ }
1619
+ }
1620
+
1621
+ for (const where of outputFolders) {
1622
+ const dir = path.join(root, where);
1623
+ const manifest = await readJson(path.join(dir, 'package.json'));
1624
+ /** @type {{command: string, file: string}[]} */
1625
+ const commands = [];
1626
+ /** @type {Clue[]} */
1627
+ const clues = [];
1628
+
1629
+ if (manifest?.bin) {
1630
+ const bins = typeof manifest.bin === 'string'
1631
+ ? { [String(manifest.name ?? path.basename(where))]: manifest.bin }
1632
+ : manifest.bin;
1633
+ for (const [command, file] of Object.entries(bins)) {
1634
+ if (typeof file !== 'string') continue;
1635
+ const full = path.join(dir, file);
1636
+ if (!fs.existsSync(full)) continue;
1637
+ commands.push({ command, file: path.relative(root, full) });
1638
+ }
1639
+ if (commands.length > 0) {
1640
+ clues.push({
1641
+ where: path.posix.join(where, 'package.json'),
1642
+ means: `A package written by a build, installing ${commands.length === 1 ? 'a command' : `${commands.length} commands`}: ${commands.map((c) => c.command).join(', ')}. Nothing at the top of this repository mentions any of them.`,
1643
+ });
1644
+ }
1645
+ }
1646
+
1647
+ if (commands.length < 1 && ignored.has(where.split('/')[0])) {
1648
+ const inner = await listOnce(dir);
1649
+ for (const name of inner.files) {
1650
+ if (!/\.[cm]?js$/.test(name)) continue;
1651
+ const head = await readHead(path.join(dir, name), 64);
1652
+ if (!head.startsWith('#!')) continue;
1653
+ commands.push({ command: name.replace(/\.[cm]?js$/, ''), file: path.posix.join(where, name) });
1654
+ }
1655
+ if (commands.length > 0) {
1656
+ clues.push({
1657
+ where: commands[0].file,
1658
+ means: `${commands.length === 1 ? 'A built file starts' : `${commands.length} built files start`} with a shebang, which is the thing that makes a file runnable as a command.`,
1659
+ });
1660
+ }
1661
+ }
1662
+
1663
+ if (commands.length === 0) continue;
1664
+ found.push({
1665
+ kind: 'cli',
1666
+ name: commands.length === 1 ? `the \`${commands[0].command}\` command` : `the command-line program in ${where}/`,
1667
+ surface: 'cli',
1668
+ adapter: available.has('process') ? 'process' : null,
1669
+ confidence: 1,
1670
+ why: `${where}/ holds a built command-line program that nothing in this repository's own package.json names: ${commands.map((c) => `\`${c.command}\``).join(', ')}.`,
1671
+ where,
1672
+ evidence: clues,
1673
+ built: { found: true, where, how: `it is built and sitting in ${where}/` },
1674
+ blockers: [],
1675
+ // `--help` and nothing else, exactly as for a command named in package.json. A command
1676
+ // this tool found for itself has even less business being run: nobody wrote it down, so
1677
+ // nobody has said it is safe.
1678
+ suggest: {
1679
+ commands: commands.map((one) => ({
1680
+ name: `${one.command} --help`,
1681
+ run: `node ${one.file} --help`,
1682
+ describe: `ask ${one.command} to print its help, and compare every word of it`,
1683
+ })),
1684
+ },
1685
+ });
1686
+ }
1687
+
1688
+ // ── 3: source that reads its own arguments ────────────────────────────────
1689
+ const holdsOtherFolders = new Set(['src', 'source', 'lib', 'packages', 'apps', 'app']);
1690
+ /** @type {string[]} */
1691
+ const sourceFolders = [];
1692
+ for (const top of ['src', 'source', 'lib', 'packages', 'apps', '.']) {
1693
+ if (top !== '.' && !listing.dirs.includes(top)) continue;
1694
+ const inner = top === '.' ? listing : await listOnce(path.join(root, top));
1695
+ for (const name of inner.dirs) {
1696
+ if (SKIP_DIRS.has(name) || name.startsWith('.') || NOT_A_SHIPPED_PROGRAM.has(name)) continue;
1697
+ // A folder that only holds other folders is not itself the program. Descending into it
1698
+ // is what finds the program; naming it as well reports the same one twice, under a name
1699
+ // that tells nobody anything.
1700
+ if (top === '.' && holdsOtherFolders.has(name)) continue;
1701
+ sourceFolders.push(top === '.' ? name : path.posix.join(top, name));
1702
+ }
1703
+ }
1704
+
1705
+ for (const where of sourceFolders) {
1706
+ if (spokenFor(where)) continue;
1707
+ const dir = path.join(root, where);
1708
+ const files = (await readSome(dir, { most: 60, depth: 1 })).files.filter((f) => !/\.(test|spec)\.[cm]?[jt]sx?$/.test(f.rel));
1709
+ if (files.length === 0) continue;
1710
+ // The file that reads the command line has to be one somebody would run — the entry
1711
+ // point, by the name every project gives it. Any file at all reading `process.argv` is
1712
+ // far too weak: a test helper does it, a build script does it, and each one of those
1713
+ // would arrive as a product this repository does not make.
1714
+ const readsArguments = files.find((f) => /^(cli|main|bin|index|program|command|cmd|run|app)\.[cm]?[jt]sx?$/.test(path.basename(f.rel)) && /process\.argv|Deno\.args|Bun\.argv/.test(f.text));
1715
+ const printsHelp = files.find((f) => /(['"`])(-h|--help|help)\1|Usage:|usage:/.test(f.text));
1716
+ if (!readsArguments || !printsHelp) continue;
1717
+
1718
+ const build = buildFor(root, where, listing, scripts);
1719
+ // A built copy of this same program was already found above. The built one is the exact
1720
+ // answer and this one is the guess, so the guess stays quiet.
1721
+ if (build.outDir && found.some((p) => p.where === build.outDir || p.where.startsWith(`${build.outDir}/`))) continue;
1722
+
1723
+ found.push({
1724
+ kind: 'cli',
1725
+ name: `the command-line program in ${where}/`,
1726
+ surface: 'cli',
1727
+ adapter: available.has('process') ? 'process' : null,
1728
+ confidence: 0.8,
1729
+ why: `${path.posix.join(where, readsArguments.rel)} reads the command line and ${path.posix.join(where, printsHelp.rel)} carries a help text, which is a command-line program whatever package.json says.`,
1730
+ where,
1731
+ evidence: [
1732
+ { where: path.posix.join(where, readsArguments.rel), means: 'This file reads the arguments it was started with, which only a program somebody types does.' },
1733
+ { where: path.posix.join(where, printsHelp.rel), means: 'This file holds the help text, so the program describes itself when asked — the one thing every command-line tool can safely be asked to do.' },
1734
+ ],
1735
+ built: { found: false, where: null, how: build.outDir ? `it builds into ${build.outDir}/, and there is nothing there yet` : 'nothing built was found' },
1736
+ blockers: [
1737
+ build.command
1738
+ ? `It has not been built yet. Run \`${build.command}\`${build.outDir ? `, which puts it in ${build.outDir}/,` : ''} and then \`staysfixed init --force\`: the commands are filled in exactly from what the build wrote. Nothing has been edited by hand at that point, so nothing is lost.`
1739
+ : 'It has not been built, and nothing in package.json says how. Name the command that builds it and the command that runs the result under "process" in the settings.',
1740
+ ],
1741
+ suggest: { buildWith: build.command, outDir: build.outDir },
1742
+ });
1743
+ }
1744
+
1745
+ return found;
1746
+ }
1747
+
1748
+ /**
1749
+ * Does a folder hold something that listens on a port?
1750
+ *
1751
+ * The third product a manifest never mentions. Terminal Deck's relay is three TypeScript
1752
+ * files, no package.json, no framework and no script — and it is the one machine every phone
1753
+ * depends on. Everything before this reported it as "a folder nothing could work out", which
1754
+ * is honest and useless. What the code says plainly is that it opens a socket and reads the
1755
+ * port out of the environment, and that is a server whatever else is missing.
1756
+ *
1757
+ * @param {string} dir
1758
+ * @returns {Promise<{yes: boolean, file: string|null, readsPort: boolean, why: string}>}
1759
+ */
1760
+ async function looksLikeAServer(dir) {
1761
+ const { files } = await readSome(dir, { most: 60, depth: 3 });
1762
+ for (const one of files) {
1763
+ if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(one.rel)) continue;
1764
+ const listens = /\.listen\s*\(|createServer\s*\(|Deno\.serve\s*\(|Bun\.serve\s*\(|serve\s*\(\s*\{[^}]*port/.test(one.text);
1765
+ if (!listens) continue;
1766
+ const readsPort = /process\.env\.PORT|Deno\.env\.get\(\s*['"]PORT|env\.PORT/.test(one.text);
1767
+ return {
1768
+ yes: true,
1769
+ file: one.rel,
1770
+ readsPort,
1771
+ why: readsPort
1772
+ ? `${one.rel} opens a socket and takes its port out of the environment, which is a server that can be booted on a spare port.`
1773
+ : `${one.rel} opens a socket, which is a server — though it does not read a PORT out of the environment, so the port it uses has to be given to it another way.`,
1774
+ };
1775
+ }
1776
+ return { yes: false, file: null, readsPort: false, why: 'Nothing here opens a socket.' };
1777
+ }
1778
+
1779
+ // ---------------------------------------------------------------------------
1780
+ // Screens: read the router, not the folder names
1781
+ // ---------------------------------------------------------------------------
1782
+
1783
+ /**
1784
+ * How this web app decides what to show, and what that means for reaching a screen.
1785
+ *
1786
+ * @typedef {object} Router
1787
+ * @property {'files'|'declared'|'hash'|'tabs'|'single'} kind
1788
+ * @property {string|null} where The file the answer came out of.
1789
+ * @property {string} why One plain sentence, safe to put in front of a person.
1790
+ */
1791
+
1792
+ /**
1793
+ * One screen worth walking, and how to get to it.
1794
+ *
1795
+ * @typedef {object} Screen
1796
+ * @property {string} name
1797
+ * @property {string} url
1798
+ * @property {Record<string, string>[]} [steps]
1799
+ * @property {string} [describe]
1800
+ */
1801
+
1802
+ /**
1803
+ * Every route a router declares, plus the screens a router does not declare at all.
1804
+ *
1805
+ * THE FAILURE THIS EXISTS TO STOP. A single-page app is one HTML file, so reading folder
1806
+ * names finds one screen and reports it as the whole product. Terminal Deck's phone client is
1807
+ * exactly that: one `index.html`, and four screens a person moves between all day. It was
1808
+ * checked for months of pretend coverage — one page walked, three unwatched, and a clean run
1809
+ * every time. Where the app declares a router, the router is read. Where it does not, the
1810
+ * screens are read out of the strip of tabs that switches between them — and the fact that
1811
+ * they are reached by CLICKING rather than by an address is said out loud, because a made-up
1812
+ * `#address` that silently lands on the same page is worse than nothing: it turns three
1813
+ * unchecked screens into three identical checks that agree with each other forever.
1814
+ *
1815
+ * Four readings, and every one names the file it came from:
1816
+ *
1817
+ * 1. DECLARED ROUTES — `path: '/x'` in a route table, `<Route path="/x">`, the shape every
1818
+ * router library from React Router to Vue Router to Angular writes.
1819
+ * 2. HASH ROUTES — a `#name` compared against the address bar. A real address, reachable by
1820
+ * opening it, and only reported when the code actually reads `location.hash`.
1821
+ * 3. TABS — an object literal pairing a screen name with the label on the control that
1822
+ * switches to it. Not an address: a click, and it is written as one.
1823
+ * 4. NOTHING FOUND — one page, said plainly, so the coverage ledger can say so too.
1824
+ *
1825
+ * @param {string} dir
1826
+ * @param {(name: string) => boolean} [has] Is this package a dependency? A project that
1827
+ * installs a router library is a project whose route tables mean what they say, and that
1828
+ * fact lives in package.json rather than in the file the table is written in.
1829
+ * @returns {Promise<{screens: Screen[], router: Router, needValues: {url: string, names: string[]}[]}>}
1830
+ */
1831
+ async function readScreens(dir, has = () => false) {
1832
+ const { files, tooBig } = await readSome(dir, { most: 200, depth: 5 });
1833
+ const source = files.filter((f) => !/\.(test|spec)\.[cm]?[jt]sx?$/.test(f.rel));
1834
+
1835
+ // ── 1: routes a router library declares ───────────────────────────────────
1836
+ /** @type {Map<string, Screen>} */
1837
+ const declared = new Map();
1838
+ /** @type {string|null} */
1839
+ let declaredIn = null;
1840
+ const routerInstalled = ['react-router', 'react-router-dom', 'vue-router', '@tanstack/react-router', '@tanstack/router', 'wouter', 'svelte-spa-router', 'svelte-routing', '@reach/router', '@angular/router', 'vue-router-next'].some(has);
1841
+ const looksLikeARouter = /react-router|vue-router|@tanstack\/(react-)?router|wouter|svelte-spa-router|@angular\/router|createBrowserRouter|createHashRouter|createMemoryRouter|createRouter|createWebHistory|useRoutes|RouterModule|\b[Rr]outes\s*[:=]\s*\[|defineRoutes/;
1842
+ for (const one of source) {
1843
+ /** @type {string[]} */
1844
+ const paths = [];
1845
+ // A JSX route element says what it is on its own. A bare `path:` does not — it turns up in
1846
+ // build configs, in file helpers, in anything — so it only counts inside a file that names
1847
+ // a router. Two path-shaped strings in an unrelated file were enough to invent a list of
1848
+ // screens and to hide the fact that the app had no addresses at all.
1849
+ for (const hit of one.text.matchAll(/<Route\b[^>]*\bpath\s*=\s*["'{`]([^"'`}]+)/g)) paths.push(hit[1]);
1850
+ if (routerInstalled || looksLikeARouter.test(one.text)) {
1851
+ for (const hit of one.text.matchAll(/\bpath\s*:\s*['"]([^'"]*)['"]/g)) paths.push(hit[1]);
1852
+ for (const hit of one.text.matchAll(/^\s*['"](\/[^'"]*)['"]\s*:\s*[A-Za-z_$]/gm)) paths.push(hit[1]);
1853
+ }
1854
+ for (const raw of paths) {
1855
+ const url = tidyRoute(raw);
1856
+ if (!url) continue;
1857
+ if (declared.has(url)) continue;
1858
+ declared.set(url, { name: url === '/' ? 'the front page' : url, url });
1859
+ declaredIn = declaredIn ?? one.rel;
1860
+ }
1861
+ if (declared.size >= 60) break;
1862
+ }
1863
+ if (declared.size >= 2) {
1864
+ // An address with a changing part in it — /reports/:id — cannot be opened until somebody
1865
+ // says which report. Writing it into the settings as it stands would open a page that
1866
+ // does not exist and report a difference nobody caused, so it is held back and named
1867
+ // instead: the settings say which addresses are waiting on a value, and the set-up list
1868
+ // asks for one. Dropping them silently would be the worse half of the same choice.
1869
+ /** @type {{url: string, names: string[]}[]} */
1870
+ const needValues = [];
1871
+ /** @type {Screen[]} */
1872
+ const openable = [];
1873
+ for (const screen of declared.values()) {
1874
+ const names = [...screen.url.matchAll(/:([A-Za-z_$][\w$]*)|\[\.{0,3}([^\]]+)\]|\{([^}]+)\}/g)].map((hit) => hit[1] ?? hit[2] ?? hit[3]);
1875
+ if (names.length > 0) needValues.push({ url: screen.url, names });
1876
+ else openable.push(screen);
1877
+ }
1878
+ return {
1879
+ screens: openable,
1880
+ needValues,
1881
+ router: {
1882
+ kind: 'declared',
1883
+ where: declaredIn,
1884
+ why: `${declared.size} addresses are declared in a router, starting in ${declaredIn}. Each one is opened directly${needValues.length > 0 ? (needValues.length === 1 ? ', except one that has a changing part in it and is waiting on a real value' : `, except ${needValues.length} that have a changing part in them and are waiting on a real value`) : ''}.`,
1885
+ },
1886
+ };
1887
+ }
1888
+
1889
+ // ── 2: hash routes ────────────────────────────────────────────────────────
1890
+ const readsHash = source.find((f) => /location\.hash|hashchange|useHashLocation|createWebHashHistory|HashRouter/.test(f.text));
1891
+ if (readsHash) {
1892
+ /** @type {Map<string, Screen>} */
1893
+ const hashes = new Map();
1894
+ for (const one of source) {
1895
+ for (const hit of one.text.matchAll(/['"`]#([A-Za-z][\w-]{0,40})['"`]/g)) {
1896
+ const url = `/#${hit[1]}`;
1897
+ if (!hashes.has(url)) hashes.set(url, { name: `the ${hit[1]} screen`, url });
1898
+ }
1899
+ if (hashes.size >= 40) break;
1900
+ }
1901
+ if (hashes.size >= 2) {
1902
+ return {
1903
+ screens: [{ name: 'the front page', url: '/' }, ...hashes.values()],
1904
+ needValues: [],
1905
+ router: {
1906
+ kind: 'hash',
1907
+ where: readsHash.rel,
1908
+ why: `${readsHash.rel} switches screens on the part of the address after the #, so each screen has an address of its own and is opened directly.`,
1909
+ },
1910
+ };
1911
+ }
1912
+ }
1913
+
1914
+ // ── 3: a strip of tabs ────────────────────────────────────────────────────
1915
+ /** @type {Map<string, Screen>} */
1916
+ const tabs = new Map();
1917
+ /** @type {string|null} */
1918
+ let tabsIn = null;
1919
+ const pair = /\{[^{}]*?\b(?:screen|view|tab|page|panel|route)\s*:\s*['"]([A-Za-z][\w-]{0,40})['"][^{}]*?\b(?:label|title|text|name|caption)\s*:\s*['"]([^'"]{1,40})['"][^{}]*?\}/g;
1920
+ const flipped = /\{[^{}]*?\b(?:label|title|text|caption)\s*:\s*['"]([^'"]{1,40})['"][^{}]*?\b(?:screen|view|tab|page|panel|route)\s*:\s*['"]([A-Za-z][\w-]{0,40})['"][^{}]*?\}/g;
1921
+ for (const one of source) {
1922
+ for (const hit of one.text.matchAll(pair)) {
1923
+ if (!tabs.has(hit[1])) tabs.set(hit[1], tabScreen(hit[1], hit[2]));
1924
+ tabsIn = tabsIn ?? one.rel;
1925
+ }
1926
+ for (const hit of one.text.matchAll(flipped)) {
1927
+ if (!tabs.has(hit[2])) tabs.set(hit[2], tabScreen(hit[2], hit[1]));
1928
+ tabsIn = tabsIn ?? one.rel;
1929
+ }
1930
+ if (tabs.size >= 20) break;
1931
+ }
1932
+ if (tabs.size >= 2) {
1933
+ return {
1934
+ screens: [{ name: 'the page as it opens', url: '/' }, ...tabs.values()],
1935
+ needValues: [],
1936
+ router: {
1937
+ kind: 'tabs',
1938
+ where: tabsIn,
1939
+ why: `The address never changes: ${tabsIn} switches between ${tabs.size} screens in code, and each one is reached by clicking the control that says its name. That is why they are written as clicks and not as addresses — opening a made-up address would land on the same screen every time and report it as checked. The list below is the page as it opens, and then those ${tabs.size}.`,
1940
+ },
1941
+ };
1942
+ }
1943
+
1944
+ return {
1945
+ screens: [],
1946
+ needValues: [],
1947
+ router: {
1948
+ kind: 'single',
1949
+ where: null,
1950
+ why: tooBig.length > 0
1951
+ ? `Nothing that was read declares an address for a second screen, so this is read as one page — but ${plainly(tooBig.slice(0, 3))}${tooBig.length > 3 ? ' and others' : ''} ${tooBig.length === 1 ? 'was' : 'were'} too big to open, and a router in there would not have been seen. Name the screens under "web.screens" rather than trusting this.`
1952
+ : 'Nothing here declares an address for a second screen, so this is read as one page. If it has more, name them under "web.screens" with the clicks that reach them.',
1953
+ },
1954
+ };
1955
+ }
1956
+
1957
+ /**
1958
+ * One screen reached by clicking the tab that names it.
1959
+ *
1960
+ * The click is written as Playwright's text selector rather than a CSS one, because the name
1961
+ * on the control is the only thing known here — and it is the right thing to aim at anyway,
1962
+ * since it is what a person reads and what a screen reader says.
1963
+ *
1964
+ * @param {string} id
1965
+ * @param {string} label The words actually on the control, taken from the same object the
1966
+ * screen's own name came out of. Reconstructing a label from the id
1967
+ * would guess at capitalisation and spacing and be wrong about both.
1968
+ * @returns {Screen}
1969
+ */
1970
+ function tabScreen(id, label) {
1971
+ return {
1972
+ name: `the ${id.replace(/[-_]/g, ' ')} screen`,
1973
+ url: '/',
1974
+ steps: [{ click: `text=${label}` }],
1975
+ describe: `open the front page, click ${label}, and read what that screen says every control is and does`,
1976
+ };
1977
+ }
1978
+
1979
+ /**
1980
+ * A route as an address somebody can open, or nothing at all.
1981
+ *
1982
+ * Route tables are full of strings that are not addresses — a `path` in a build config, a
1983
+ * relative fragment, a catch-all. Anything that is not plainly an address is dropped rather
1984
+ * than opened, because an address that 404s reports a difference nobody caused.
1985
+ *
1986
+ * @param {string} raw
1987
+ * @returns {string|null}
1988
+ */
1989
+ function tidyRoute(raw) {
1990
+ const text = String(raw).trim();
1991
+ if (text === '' || text === '*' || text === '**') return null;
1992
+ if (!text.startsWith('/')) return null;
1993
+ if (/[\\<>{}()\s]/.test(text)) return null;
1994
+ if (/\.(js|ts|tsx|jsx|css|json|png|svg|html)$/.test(text)) return null;
1995
+ if (text.includes('*')) return null;
1996
+ if (text.length > 120) return null;
1997
+ return text;
1998
+ }
1999
+
2000
+ // ---------------------------------------------------------------------------
2001
+ // Starting a web app without hanging
2002
+ // ---------------------------------------------------------------------------
2003
+
2004
+ /**
2005
+ * The command that boots this web app for a check, and why it is that one.
2006
+ *
2007
+ * THE FAULT THIS FIXES. The first answer here was "whatever `npm run dev` is", and a dev
2008
+ * server never exits. That is fine for the browser check, which waits for the port and then
2009
+ * walks the page — and it is a hang anywhere the same command is treated as something that
2010
+ * finishes. Worse, a dev server is not the thing anybody ships: it serves unbundled source
2011
+ * with a live-reload socket wired into every page, which is a second thing changing under the
2012
+ * comparison for reasons that have nothing to do with the change.
2013
+ *
2014
+ * So the order is: build it and then serve what was built; and only if there is no way to do
2015
+ * that, the dev server — with the reason written down, because the run has to say which of
2016
+ * the two it used.
2017
+ *
2018
+ * @param {object} input
2019
+ * @param {Record<string, string>} input.scripts
2020
+ * @param {(name: string) => boolean} input.has Is this package a dependency?
2021
+ * @param {string} input.dir The folder this product lives in, absolute.
2022
+ * @param {{files: string[], dirs: string[]}} input.listing
2023
+ * @returns {{command: string|null, kind: 'build-and-serve'|'build-and-start'|'dev'|'static'|'none', why: string}}
2024
+ */
2025
+ function startCommandFor(input) {
2026
+ const { scripts, has, dir, listing } = input;
2027
+ const script = (/** @type {string} */ name) => (typeof scripts[name] === 'string' ? `npm run ${name}` : null);
2028
+ const build = script('build');
2029
+
2030
+ // A framework that serves its own build, and reads PORT while doing it. `next start`,
2031
+ // `nuxt start` and their cousins are the real thing a person deploys, not an approximation
2032
+ // of it.
2033
+ const servesItsOwnBuild = ['next', 'nuxt', '@remix-run/serve', '@remix-run/node', '@adonisjs/core'].some(has);
2034
+ if (build && servesItsOwnBuild && script('start')) {
2035
+ return {
2036
+ command: `${build} && ${script('start')}`,
2037
+ kind: 'build-and-start',
2038
+ why: 'It is built and then started the way it is deployed, so what is checked is what ships. The framework reads the PORT it is given.',
2039
+ };
2040
+ }
2041
+
2042
+ // Vite and everything built on it ship a `preview` command whose whole job is to serve the
2043
+ // build. It takes the port as a flag, so nothing has to be downloaded to serve the files.
2044
+ if (build && script('preview') && (has('vite') || has('astro') || has('@sveltejs/kit'))) {
2045
+ return {
2046
+ command: `${build} && ${script('preview')} -- --port $PORT --strictPort`,
2047
+ kind: 'build-and-serve',
2048
+ why: 'It is built, and then the build is served by the tool that made it. That is what ships — a dev server serves unbundled source with a live-reload connection in every page, which is a second thing moving under the comparison.',
2049
+ };
2050
+ }
2051
+
2052
+ // Built, but with nothing that serves the result. Anything that serves a folder on the port
2053
+ // it is given will do, and the folder is read out of the build config rather than guessed.
2054
+ if (build) {
2055
+ const outDir = staticOutputOf(dir, listing, has);
2056
+ if (outDir) {
2057
+ return {
2058
+ command: `${build} && npx --yes serve -s ${outDir} -l $PORT`,
2059
+ kind: 'build-and-serve',
2060
+ why: `It is built into ${outDir}/, and those files are then served on the port the check gives it. \`serve\` is fetched the first time this runs and cached after that.`,
2061
+ };
2062
+ }
2063
+ }
2064
+
2065
+ const dev = script('dev') ?? script('serve') ?? script('start');
2066
+ if (dev) {
2067
+ return {
2068
+ command: dev,
2069
+ kind: 'dev',
2070
+ why: 'Nothing here builds a copy that can be served on its own, so the development server is used. It never exits, and it is not meant to: the check knows it is ready when the port answers, not when the command finishes. What is checked is the development build rather than the one that ships.',
2071
+ };
2072
+ }
2073
+
2074
+ const flat = listing.files.filter((f) => f.endsWith('.html'));
2075
+ if (flat.length > 0) {
2076
+ return {
2077
+ command: 'npx --yes serve -l $PORT .',
2078
+ kind: 'static',
2079
+ why: 'These are plain files with nothing to build, so anything that serves this folder on the port it is given will do.',
2080
+ };
2081
+ }
2082
+
2083
+ return { command: null, kind: 'none', why: 'Nothing here says how to start it.' };
2084
+ }
2085
+
2086
+ /**
2087
+ * Where a build puts the files a browser asks for, read out of the build config where it says
2088
+ * so and taken from the framework's own habit where it does not.
2089
+ *
2090
+ * @param {string} dir
2091
+ * @param {{files: string[], dirs: string[]}} listing
2092
+ * @param {(name: string) => boolean} has
2093
+ * @returns {string|null}
2094
+ */
2095
+ function staticOutputOf(dir, listing, has) {
2096
+ for (const file of listing.files) {
2097
+ if (!/^(vite|rollup|webpack|rsbuild|parcel|astro|svelte)\.config\.[cm]?[jt]s$/.test(file)) continue;
2098
+ /** @type {string} */
2099
+ let text = '';
2100
+ try {
2101
+ text = fs.readFileSync(path.join(dir, file), 'utf8');
2102
+ } catch {
2103
+ continue;
2104
+ }
2105
+ const hit = /out(?:Dir|dir|put(?:Dir|Path))\s*:\s*['"]([^'"]+)['"]/.exec(text);
2106
+ if (hit) return hit[1].replace(/\/+$/, '');
2107
+ }
2108
+ /** @type {string[]} */
2109
+ const guesses = has('react-scripts') ? ['build', 'dist'] : ['dist', 'build', 'public', 'out'];
2110
+ for (const guess of guesses) {
2111
+ if (fs.existsSync(path.join(dir, guess, 'index.html'))) return guess;
2112
+ }
2113
+ // Nothing built yet, so the habit of whatever built it is the best that can be said, and
2114
+ // the settings say which one was assumed.
2115
+ if (has('vite') || has('astro') || has('rollup') || has('parcel')) return 'dist';
2116
+ if (has('react-scripts')) return 'build';
2117
+ if (has('webpack')) return 'dist';
2118
+ return null;
2119
+ }
2120
+
2121
+ // ---------------------------------------------------------------------------
2122
+ // How big this repository is, which decides whether a check can copy it
2123
+ // ---------------------------------------------------------------------------
2124
+
2125
+ /**
2126
+ * What copying this project would cost, and whether there is room for it.
2127
+ *
2128
+ * Three of the adapters copy the whole project into a scratch folder before running it, which
2129
+ * is the right thing to do — a check must never write into somebody's working copy. It also
2130
+ * means a repository carrying nine gigabytes of Xcode build output and a folder of installers
2131
+ * cannot be checked in place on a laptop with twelve gigabytes free. That is not a bug in the
2132
+ * copy, it is a fact about the folder, and the only useful moment to say it is now: the whole
2133
+ * answer is one line — run the check from a `git worktree`, which holds the tracked files and
2134
+ * nothing else.
2135
+ *
2136
+ * Bounded and early-stopping: the question is only "is this big", and once the answer is yes
2137
+ * there is nothing left to learn by counting further.
2138
+ *
2139
+ * @param {string} root
2140
+ * @returns {Promise<{bytes: number, files: number, capped: boolean, biggest: {folder: string, bytes: number}[], freeBytes: number|null, tooBig: boolean, why: string}>}
2141
+ */
2142
+ async function measureBulk(root) {
2143
+ /** @type {number|null} */
2144
+ let freeBytes = null;
2145
+ try {
2146
+ const stats = await fsp.statfs(root);
2147
+ freeBytes = Number(stats.bavail) * Number(stats.bsize);
2148
+ } catch {
2149
+ freeBytes = null;
2150
+ }
2151
+
2152
+ // Stop as soon as the answer is settled rather than at a round number. A paired run holds
2153
+ // two copies at once, so the moment two and a half copies would not fit, counting further
2154
+ // tells nobody anything new — and on a folder that is genuinely enormous, counting further
2155
+ // is the slowest thing this file does.
2156
+ const wouldNotFit = freeBytes === null ? 25 * 1024 * 1024 * 1024 : freeBytes / 2.5;
2157
+ const STOP_AT = Math.min(25 * 1024 * 1024 * 1024, Math.max(wouldNotFit * 1.05, 2 * 1024 * 1024 * 1024));
2158
+ const MOST = 60_000;
2159
+ let bytes = 0;
2160
+ let files = 0;
2161
+ let capped = false;
2162
+ /** @type {Map<string, number>} */
2163
+ const perFolder = new Map();
2164
+
2165
+ const top = await listOnce(root);
2166
+ for (const folder of [...top.dirs, '.']) {
2167
+ if (folder === '.git' || (folder !== '.' && folder.startsWith('.') && folder !== '.output')) continue;
2168
+ let here = 0;
2169
+ /**
2170
+ * @param {string} dir
2171
+ * @param {number} depth
2172
+ * @returns {Promise<void>}
2173
+ */
2174
+ const walk = async (dir, depth) => {
2175
+ if (files > MOST || bytes > STOP_AT || depth > 12) return;
2176
+ /** @type {import('node:fs').Dirent[]} */
2177
+ let entries;
2178
+ try {
2179
+ entries = await fsp.readdir(dir, { withFileTypes: true });
2180
+ } catch {
2181
+ return;
2182
+ }
2183
+ for (const entry of entries) {
2184
+ if (files > MOST || bytes > STOP_AT) {
2185
+ capped = true;
2186
+ return;
2187
+ }
2188
+ if (entry.isSymbolicLink() || entry.name === '.git') continue;
2189
+ const full = path.join(dir, entry.name);
2190
+ if (entry.isDirectory()) {
2191
+ if (folder === '.' && depth === 0) continue; // the top-level folders get their own turn
2192
+ await walk(full, depth + 1);
2193
+ continue;
2194
+ }
2195
+ files += 1;
2196
+ try {
2197
+ const info = await fsp.stat(full);
2198
+ bytes += info.size;
2199
+ here += info.size;
2200
+ } catch {
2201
+ // A file that cannot be measured is a file that will not copy either; the copy says
2202
+ // so at the time, and guessing a size for it here would help nobody.
2203
+ }
2204
+ }
2205
+ };
2206
+ await walk(folder === '.' ? root : path.join(root, folder), 0);
2207
+ if (here > 0) perFolder.set(folder === '.' ? 'the files at the top' : folder, here);
2208
+ }
2209
+
2210
+ const biggest = [...perFolder.entries()]
2211
+ .sort((a, b) => b[1] - a[1])
2212
+ .slice(0, 4)
2213
+ .map(([folder, size]) => ({ folder, bytes: size }));
2214
+
2215
+ // Two copies at once is the shape a paired run takes, so the room needed is twice the size
2216
+ // plus a margin. Under a gigabyte nothing is worth saying.
2217
+ const tooBig = bytes > 1024 * 1024 * 1024 && (freeBytes === null || freeBytes < bytes * 2.5);
2218
+ return {
2219
+ bytes,
2220
+ files,
2221
+ capped,
2222
+ biggest,
2223
+ freeBytes,
2224
+ tooBig,
2225
+ why: tooBig
2226
+ ? `This folder is ${inGigabytes(bytes)}${capped ? ' or more' : ''}${biggest.length > 0 ? ` — most of it in ${plainly(biggest.map((b) => `${b.folder}/ (${inGigabytes(b.bytes)})`))}` : ''}, and a check copies the whole thing before running it${freeBytes === null ? '' : `, with only ${inGigabytes(freeBytes)} free on this disk`}. Two copies will not fit.`
2227
+ : capped
2228
+ ? `This folder is at least ${inGigabytes(bytes)}${biggest.length > 0 ? `, most of it in ${plainly(biggest.map((b) => `${b.folder}/`))}` : ''}, and counting stopped there. A check copies the whole thing before running it${freeBytes === null ? '' : `, and there is ${inGigabytes(freeBytes)} free`}. If one ever runs out of room, run it from a \`git worktree\` copy, which holds only the tracked files.`
2229
+ : `This folder is ${inGigabytes(bytes)}, which copies without trouble.`,
2230
+ };
2231
+ }
2232
+
2233
+ /**
2234
+ * A size a person can read.
2235
+ * @param {number} bytes
2236
+ * @returns {string}
2237
+ */
2238
+ function inGigabytes(bytes) {
2239
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`;
2240
+ if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}MB`;
2241
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
2242
+ }
2243
+
2244
+ /**
2245
+ * Which folders the contract channel should read.
2246
+ *
2247
+ * The default list is the usual ones — src, lib, app and so on — and on a repository that
2248
+ * makes one thing that is right. On a repository that makes five, it reads the desktop app
2249
+ * and misses the phone client, the relay and the host, and every door behind them is counted
2250
+ * as absent rather than unread. So the folders every product lives in are named, plus any
2251
+ * folder holding real source that no product claimed, because an unclaimed folder is exactly
2252
+ * where a silent gap lives.
2253
+ *
2254
+ * @param {string} root
2255
+ * @param {Product[]} products
2256
+ * @param {{files: string[], dirs: string[]}} listing
2257
+ * @returns {Promise<string[]>}
2258
+ */
2259
+ async function proposeSourceFolders(root, products, listing) {
2260
+ /** @type {Set<string>} */
2261
+ const folders = new Set();
2262
+ const usual = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
2263
+ for (const name of usual) if (listing.dirs.includes(name)) folders.add(name);
2264
+
2265
+ for (const product of products) {
2266
+ if (product.where === '.' || product.where === '') continue;
2267
+ if (BUILD_OUTPUT_DIRS.some((out) => product.where === out || product.where.startsWith(`${out}/`))) continue;
2268
+ // A phone app is Swift or Kotlin, and this reader reads neither. Its own adapter reads
2269
+ // it. Naming its folder here would send the contract channel into a tree it can find
2270
+ // nothing in, and a folder that answers with nothing reads exactly like one that has
2271
+ // gone empty.
2272
+ if (product.kind === 'ios' || product.kind === 'android' || product.kind === 'desktopNative') continue;
2273
+ // A product's own source, one level in where there is one, so a folder of build output
2274
+ // beside it is not read as source.
2275
+ /** @type {string|null} */
2276
+ let pick = null;
2277
+ for (const inner of ['src', 'source', 'lib']) {
2278
+ if (fs.existsSync(path.join(root, product.where, inner))) {
2279
+ pick = path.posix.join(product.where, inner);
2280
+ break;
2281
+ }
2282
+ }
2283
+ folders.add(pick ?? product.where);
2284
+ }
2285
+
2286
+ for (const folder of listing.dirs) {
2287
+ if (SKIP_DIRS.has(folder) || folder.startsWith('.')) continue;
2288
+ if (NOT_A_SHIPPED_PROGRAM.has(folder) && folder !== 'scripts' && folder !== 'tools') continue;
2289
+ // Already covered, either by name or by something more precise inside it. A product that
2290
+ // said "read pwa/src" must not have "read pwa" added over the top of it — that would pull
2291
+ // in pwa/dist, and a folder of bundled output read as source is thousands of doors that
2292
+ // do not exist.
2293
+ if ([...folders].some((f) => f === folder || f.startsWith(`${folder}/`))) continue;
2294
+ // A folder a product already spoke for is not an unclaimed one. A phone app's folder
2295
+ // holds a handful of build scripts, which is enough files to look like source, and it is
2296
+ // read by the adapter that understands the language it is written in.
2297
+ if (products.some((p) => p.where === folder)) continue;
2298
+ const code = await countMatching(path.join(root, folder), /\.[cm]?[jt]sx?$/);
2299
+ if (code >= 3) folders.add(folder);
2300
+ }
2301
+
2302
+ // Only folders with code the reader can actually read, and never one that sits inside
2303
+ // another one already on the list. An iPhone app's Swift and an Android app's Kotlin are
2304
+ // real source and are read by their own adapters; naming them here would put two folders in
2305
+ // the settings that the contract channel opens, walks and finds nothing in, which reads
2306
+ // exactly like a folder that has gone empty.
2307
+ /** @type {string[]} */
2308
+ const kept = [];
2309
+ for (const folder of [...folders].sort()) {
2310
+ if (kept.some((already) => folder === already || folder.startsWith(`${already}/`))) continue;
2311
+ const code = await countMatching(path.join(root, folder), /\.[cm]?[jt]sx?$/);
2312
+ if (code === 0) continue;
2313
+ kept.push(folder);
2314
+ }
2315
+ return kept;
2316
+ }
2317
+
2318
+ /**
2319
+ * Servers hiding in folders nothing claimed.
2320
+ *
2321
+ * Runs after every other reading, on the folders nobody spoke for. Terminal Deck's `relay/` is
2322
+ * the case: three TypeScript files, no package.json, no framework, no script — and it is the
2323
+ * switchboard every phone in the product connects through. It was reported as "a folder
2324
+ * nothing could work out", which is honest and useless, while its routes went uncounted and a
2325
+ * clean run said nothing about it.
2326
+ *
2327
+ * @param {object} input
2328
+ * @param {string} input.root
2329
+ * @param {{files: string[], dirs: string[]}} input.listing
2330
+ * @param {Product[]} input.products
2331
+ * @param {Set<string>} input.available
2332
+ * @returns {Promise<Product[]>}
2333
+ */
2334
+ async function findServersInCode(input) {
2335
+ const { root, listing, products, available } = input;
2336
+ /** @type {Product[]} */
2337
+ const found = [];
2338
+ const claimed = new Set(products.map((p) => p.where));
2339
+
2340
+ for (const folder of listing.dirs) {
2341
+ if (SKIP_DIRS.has(folder) || folder.startsWith('.') || claimed.has(folder)) continue;
2342
+ if (ALREADY_COVERED.has(folder) || NOT_A_SHIPPED_PROGRAM.has(folder)) continue;
2343
+ const dir = path.join(root, folder);
2344
+ const reading = await looksLikeAServer(dir);
2345
+ if (!reading.yes) continue;
2346
+
2347
+ // How it is started, if anything here says. A shell script or a container file beside the
2348
+ // code is not a command this tool can run, but it IS the answer written down — so it is
2349
+ // named, and the agent that reads it can write one line of settings instead of a person
2350
+ // being asked a question they would have to go and look up.
2351
+ const local = await listOnce(dir);
2352
+ const recipe = local.files.find((f) => /^(deploy|start|run|serve)\.(sh|bash|mjs|js|ts)$/.test(f))
2353
+ ?? local.files.find((f) => f === 'Dockerfile' || /^(docker-)?compose\.ya?ml$/.test(f))
2354
+ ?? null;
2355
+
2356
+ found.push({
2357
+ kind: 'server',
2358
+ name: `the server in ${folder}/`,
2359
+ surface: 'server',
2360
+ adapter: available.has('http') ? 'http' : null,
2361
+ confidence: 0.7,
2362
+ why: reading.why.replace(reading.file ?? '', path.posix.join(folder, reading.file ?? '')),
2363
+ where: folder,
2364
+ evidence: [
2365
+ { where: path.posix.join(folder, reading.file ?? ''), means: 'This file opens a socket and waits for requests, which is a server whatever else is or is not here.' },
2366
+ ...(recipe ? [{ where: path.posix.join(folder, recipe), means: `${recipe} says how this is built and started. It is not a command this tool can run, but it is the answer written down.` }] : []),
2367
+ ],
2368
+ built: { found: false, where: null, how: 'nothing to build — it runs from source' },
2369
+ blockers: [
2370
+ recipe
2371
+ ? `Nothing in package.json starts it. ${path.posix.join(folder, recipe)} already says how it is built and run — read that, and put the one command it comes down to under "http" in the settings, with the port taken from PORT.`
2372
+ : `Nothing here says how to start it, so its routes can be listed from the source but none of them can be asked anything. Put {"start": "..."} under "http" in the settings, listening on the PORT it is given.`,
2373
+ ],
2374
+ suggest: { stateless: true },
2375
+ });
2376
+ }
2377
+ return found;
2378
+ }
2379
+
2380
+ /**
2381
+ * Is `project.yml` here an XcodeGen spec, rather than some other project's `project.yml`?
2382
+ *
2383
+ * Read rather than assumed, because `project.yml` is a common enough filename that treating
2384
+ * every one of them as an Apple project would report an iPhone app in repositories that have
2385
+ * never seen a Mac. An XcodeGen spec always names the project and always lists either targets
2386
+ * or schemes, and it is a small file, so the certain answer is two lines away.
2387
+ *
2388
+ * @param {string} dir
2389
+ * @param {{files: string[], dirs: string[]}} listing
2390
+ * @returns {boolean}
2391
+ */
2392
+ function isXcodeGenSpec(dir, listing) {
2393
+ const name = listing.files.find((f) => f === 'project.yml' || f === 'project.yaml');
2394
+ if (!name) return false;
2395
+ /** @type {string} */
2396
+ let text = '';
2397
+ try {
2398
+ const info = fs.statSync(path.join(dir, name));
2399
+ if (info.size > 400_000) return false;
2400
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
2401
+ } catch {
2402
+ return false;
2403
+ }
2404
+ if (!/^\s*name\s*:/m.test(text)) return false;
2405
+ return /^\s*(targets|schemes|packages|settingGroups)\s*:/m.test(text)
2406
+ || /bundleIdPrefix|deploymentTarget|SDKROOT|xcodegen/i.test(text);
2407
+ }
2408
+
2409
+ /**
2410
+ * The folder names this repository tells git to leave alone.
2411
+ *
2412
+ * Used for one question only: is this folder something a build wrote, or something somebody
2413
+ * committed? A name is not enough to answer it — `build/` is a bundler's output in one
2414
+ * repository and hand-written artwork scripts in the next — and the repository has already
2415
+ * written the answer down.
2416
+ *
2417
+ * Deliberately shallow: only plain folder names, no globs, no negations, no nested ignore
2418
+ * files. Anything cleverer would be re-implementing git's matching to answer a question where
2419
+ * being unsure costs nothing — a folder that is missed here is simply not read for a second
2420
+ * product, and everything else about the run is unchanged.
2421
+ *
2422
+ * @param {string} root
2423
+ * @returns {Promise<Set<string>>}
2424
+ */
2425
+ async function readIgnoreList(root) {
2426
+ /** @type {Set<string>} */
2427
+ const names = new Set();
2428
+ const text = await readTextIfSmall(path.join(root, '.gitignore'), 200_000);
2429
+ if (text === null) return names;
2430
+ for (const raw of text.split('\n')) {
2431
+ const line = raw.trim();
2432
+ if (line === '' || line.startsWith('#') || line.startsWith('!')) continue;
2433
+ if (line.includes('?') || line.includes('[')) continue;
2434
+ // `dist/**` and `dist/*` say the same thing about the folder as `dist` does. Anything
2435
+ // else with a star in it is a pattern rather than a folder and is left alone.
2436
+ const withoutGlob = line.replace(/\/\*+$/, '');
2437
+ if (withoutGlob.includes('*')) continue;
2438
+ const name = withoutGlob.replace(/^\/+/, '').replace(/\/+$/, '');
2439
+ if (name === '' || name.includes('/')) continue;
2440
+ names.add(name);
2441
+ }
2442
+ return names;
2443
+ }
2444
+
2445
+ /**
2446
+ * The application id out of a packaging config, when package.json does not carry one.
2447
+ *
2448
+ * electron-builder reads either, and plenty of projects keep everything about packaging in
2449
+ * its own file. The id is what lets the desktop adapter tell the window it opened from a
2450
+ * window of the same app that was already there, so losing it is not cosmetic.
2451
+ *
2452
+ * @param {string} dir
2453
+ * @param {string|null} builderFile
2454
+ * @returns {string|null}
2455
+ */
2456
+ function appIdInConfig(dir, builderFile) {
2457
+ if (!builderFile) return null;
2458
+ const text = (() => {
2459
+ try {
2460
+ const info = fs.statSync(path.join(dir, builderFile));
2461
+ if (info.size > 400_000) return '';
2462
+ return fs.readFileSync(path.join(dir, builderFile), 'utf8');
2463
+ } catch {
2464
+ return '';
2465
+ }
2466
+ })();
2467
+ const hit = /^\s*["']?appId["']?\s*[:=]\s*["']?([A-Za-z0-9_.-]+)["']?/m.exec(text);
2468
+ return hit ? hit[1] : null;
2469
+ }
2470
+
2471
+ /**
2472
+ * Does this thing keep anything between runs?
2473
+ *
2474
+ * Read off what it installs and what it is deployed beside, because that is where a database
2475
+ * announces itself. Wrong in the cautious direction: something that stores data in a way
2476
+ * nothing here recognises is treated as if it does keep data, which costs one question rather
2477
+ * than one silently unfair comparison.
2478
+ *
2479
+ * @param {Record<string, any>} deps
2480
+ * @param {{dockerfile: string|null, compose: string|null}} containers
2481
+ * @returns {boolean}
2482
+ */
2483
+ function keepsData(deps, containers) {
2484
+ const stores = [
2485
+ 'pg', 'postgres', 'mysql', 'mysql2', 'mariadb', 'sqlite3', 'better-sqlite3', 'mongodb',
2486
+ 'mongoose', 'redis', 'ioredis', 'prisma', '@prisma/client', 'drizzle-orm', 'typeorm',
2487
+ 'sequelize', 'knex', 'kysely', '@supabase/supabase-js', 'firebase-admin', 'level',
2488
+ 'lowdb', 'nedb', '@libsql/client',
2489
+ ];
2490
+ if (stores.some((name) => name in deps)) return true;
2491
+ return Boolean(containers.compose);
2492
+ }
2493
+
2494
+ /**
2495
+ * "a, b and c" — a list joined with "and" three times reads like a machine wrote it.
2496
+ *
2497
+ * @param {string[]} items
2498
+ * @returns {string}
2499
+ */
2500
+ function plainly(items) {
2501
+ if (items.length === 0) return '';
2502
+ if (items.length === 1) return items[0];
2503
+ return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
2504
+ }
2505
+
2506
+ /**
2507
+ * The source read a second time, once it is known where the products actually are.
2508
+ *
2509
+ * A chicken and an egg, resolved by reading twice. The first read has to happen before
2510
+ * anything is known about this repository, so it reads the usual folders — src, lib, app and
2511
+ * the rest — which is exactly right for a repository that makes one thing. On a repository
2512
+ * that makes five, it reads the desktop app and nothing else, and then reports "0 routes"
2513
+ * about a tree that contains a relay with routes in it. Every door behind the folders it did
2514
+ * not open is counted as absent rather than as unread, and that is the shape of silence this
2515
+ * whole tool exists to remove.
2516
+ *
2517
+ * So once the products are known, the folders they live in are known too, and if those are
2518
+ * not the ones already read, it is read again. Only then — a second full read of a large
2519
+ * repository is a second or two, and paying it when the first answer was already right would
2520
+ * be paying it for nothing.
2521
+ *
2522
+ * @param {object} input
2523
+ * @param {string} input.root
2524
+ * @param {boolean} input.readCode
2525
+ * @param {Product[]} input.merged
2526
+ * @param {{files: string[], dirs: string[]}} input.listing
2527
+ * @param {{doors: ProjectShape['doors'], routes: ProjectShape['routes'], channels: ProjectShape['channels'], envNames: ProjectShape['envNames']}} input.first
2528
+ * @returns {Promise<{doors: ProjectShape['doors'], routes: ProjectShape['routes'], channels: ProjectShape['channels'], envNames: ProjectShape['envNames'], sourceFolders: string[]}>}
2529
+ */
2530
+ async function theSourceAgain(input) {
2531
+ const { root, readCode, merged, listing, first } = input;
2532
+ const sourceFolders = await proposeSourceFolders(root, merged, listing);
2533
+ const usual = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
2534
+ const alreadyRead = new Set(usual.filter((name) => listing.dirs.includes(name)));
2535
+ const missed = sourceFolders.filter((folder) => !alreadyRead.has(folder));
2536
+ if (!readCode || missed.length === 0) return { ...first, sourceFolders };
2537
+
2538
+ const second = await readTheSource(root, sourceFolders);
2539
+ // A second read that went worse than the first is not an improvement. This cannot normally
2540
+ // happen, and if it ever does the honest answer is the one that saw more, not the newer one.
2541
+ if (!second.doors.read || second.doors.filesRead < first.doors.filesRead) return { ...first, sourceFolders };
2542
+ return { ...second, sourceFolders };
2543
+ }
2544
+
2545
+ /**
2546
+ * The scheme `xcodebuild` would be given, read out of the project rather than left blank.
2547
+ *
2548
+ * @param {string} dir
2549
+ * @param {{files: string[], dirs: string[]}} listing
2550
+ * @param {string|null} xcode
2551
+ * @returns {string|null}
2552
+ */
2553
+ function schemeName(dir, listing, xcode) {
2554
+ if (xcode) return xcode.replace(/\.(xcodeproj|xcworkspace)$/, '');
2555
+ const spec = listing.files.find((f) => f === 'project.yml' || f === 'project.yaml');
2556
+ if (!spec) return null;
2557
+ try {
2558
+ const info = fs.statSync(path.join(dir, spec));
2559
+ if (info.size > 400_000) return null;
2560
+ const hit = /^\s*name\s*:\s*['"]?([A-Za-z0-9_.-]+)['"]?\s*$/m.exec(fs.readFileSync(path.join(dir, spec), 'utf8'));
2561
+ return hit ? hit[1] : null;
2562
+ } catch {
2563
+ return null;
2564
+ }
2565
+ }
2566
+
2567
+ /**
2568
+ * A screen reading whose file names are written the way somebody would type them from the
2569
+ * project root.
2570
+ *
2571
+ * The reader works inside one product's folder and names files relative to it, which is
2572
+ * right for the reader and wrong for the report: `src/main.ts` is the phone client's router
2573
+ * AND the desktop app's entry point, and in a repository that holds both, an unqualified one
2574
+ * sends whoever reads it to the wrong file.
2575
+ *
2576
+ * @template {{router: Router}} T
2577
+ * @param {T} reading
2578
+ * @param {string} where
2579
+ * @returns {T}
2580
+ */
2581
+ function fromHere(reading, where) {
2582
+ if (where === '.' || !reading.router.where) return reading;
2583
+ const full = path.posix.join(where, reading.router.where);
2584
+ return {
2585
+ ...reading,
2586
+ router: {
2587
+ ...reading.router,
2588
+ where: full,
2589
+ why: reading.router.why.split(reading.router.where).join(full),
2590
+ },
2591
+ };
2592
+ }