staysfixed 0.7.1 → 0.8.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 (59) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/README.md +193 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +549 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +345 -47
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
package/src/v2/detect.js CHANGED
@@ -189,6 +189,14 @@ const ALREADY_COVERED = new Set([
189
189
  * whether a screen has an address at all or only a click.
190
190
  * @property {string} [startNote] Why the start command is the one it is, so the settings can
191
191
  * say it beside the line itself.
192
+ * @property {{language: string, reads: string|null}} [sourceBlind]
193
+ * Set on a product this tool CAN boot and CAN run but cannot
194
+ * fully read. `reads` names the one thing the source channel
195
+ * does see, or is null when it sees nothing at all. The
196
+ * language is carried by name because "some of it is not
197
+ * checked" is useless and "nothing here reads Go" is not, and
198
+ * because a product that is partly covered and reported as
199
+ * covered is worse than one nothing looks at.
192
200
  */
193
201
 
194
202
  /**
@@ -389,50 +397,6 @@ export async function detectProject(options = {}) {
389
397
  };
390
398
  }
391
399
 
392
- /**
393
- * The same thing, said out loud, short enough to paste into a message to a person.
394
- *
395
- * @param {ProjectShape} shape
396
- * @returns {string[]}
397
- */
398
- export function describeShape(shape) {
399
- /** @type {string[]} */
400
- const lines = [];
401
- lines.push(shape.summary);
402
- lines.push('');
403
- for (const product of shape.products) {
404
- const sure = product.confidence >= 0.8 ? '' : product.confidence >= 0.5 ? ' (fairly sure)' : ' (a guess)';
405
- lines.push(`${product.name}${sure} — ${product.why}`);
406
- if (product.built.found) lines.push(` built and ready: ${product.built.where}`);
407
- for (const blocker of product.blockers) lines.push(` in the way: ${blocker}`);
408
- }
409
- if (shape.products.length > 0) lines.push('');
410
- if (shape.tests.runner) {
411
- const how = shape.tests.command ? `, run by \`${short(shape.tests.command)}\`` : '';
412
- 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.`);
413
- } else {
414
- lines.push('No test suite was found, so every journey has to come from the code or from a recording.');
415
- }
416
- if (shape.doors.read) {
417
- const many = (/** @type {number} */ n, /** @type {string} */ one, /** @type {string} */ lots) => `${n} ${n === 1 ? one : lots}`;
418
- 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')}.`);
419
- }
420
- for (const doubt of shape.unsure) lines.push(doubt);
421
- return lines;
422
- }
423
-
424
- /**
425
- * The single most important product, when something has to pick one — the front page of a
426
- * report, the default when a command takes one name. It is the most certain, and ties break
427
- * towards the one a person would name first.
428
- *
429
- * @param {ProjectShape} shape
430
- * @returns {Product|null}
431
- */
432
- export function mainProduct(shape) {
433
- return shape.products[0] ?? null;
434
- }
435
-
436
400
  // ---------------------------------------------------------------------------
437
401
  // Working out the products in one folder
438
402
  // ---------------------------------------------------------------------------
@@ -475,12 +439,14 @@ async function productsIn(input) {
475
439
  * @param {Record<string, any>} [spec.suggest]
476
440
  * @param {Router} [spec.router]
477
441
  * @param {string} [spec.startNote]
442
+ * @param {{language: string, reads: string|null}} [spec.sourceBlind]
478
443
  */
479
444
  const add = (kind, spec) => {
480
445
  const meta = PRODUCT_KINDS[kind];
481
446
  found.push({
482
447
  kind,
483
448
  name: spec.name,
449
+ sourceBlind: spec.sourceBlind,
484
450
  surface: meta.surface,
485
451
  adapter: meta.adapter && available.has(meta.adapter) ? meta.adapter : null,
486
452
  confidence: spec.confidence,
@@ -593,7 +559,14 @@ async function productsIn(input) {
593
559
  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.',
594
560
  evidence: clues,
595
561
  built: { found: Boolean(apk), where: apk ? path.relative(root, apk) : null, how: apk ? 'a built package was found' : 'nothing built was found' },
596
- suggest: apk ? { apk: path.relative(root, apk) } : {},
562
+ // The command that would build one, carried out of here rather than worked out later.
563
+ // Without it init could see that nothing was built and still had nothing to hand
564
+ // anybody — so it said nothing at all, and an Android app with no APK anywhere read as
565
+ // covered in full right up until the check said there was nothing to walk.
566
+ suggest: {
567
+ ...(apk ? { apk: path.relative(root, apk) } : {}),
568
+ ...(gradlew || gradle ? { buildWith: `${gradlew ? './gradlew' : 'gradle'} ${folder('app') ? ':app:assembleDebug' : 'assembleDebug'}` } : {}),
569
+ },
597
570
  blockers: available.has('android')
598
571
  ? ['It runs on an emulator. Whether two emulator snapshots restore identically is unproven, so a run says which mode it used.']
599
572
  : ['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.'],
@@ -602,10 +575,17 @@ async function productsIn(input) {
602
575
 
603
576
  // ── Native desktop that is not Electron ───────────────────────────────────
604
577
  if (folder('src-tauri') || (file('Cargo.toml') && folder('src-tauri'))) {
578
+ // Whether a build is sitting there is a fact, and it was never looked for — so the answer
579
+ // defaulted to "nothing to build, it runs from source", which is untrue of a native app
580
+ // and left init with no reason to ask for one.
581
+ const builtHere = await findBuiltApp(dir);
605
582
  add('desktopNative', {
606
583
  name: 'the Tauri desktop app',
607
584
  confidence: 0.9,
608
585
  why: 'There is a src-tauri folder, which is how a Tauri desktop app is built.',
586
+ built: builtHere?.where
587
+ ? { found: true, where: path.relative(root, builtHere.where), how: builtHere.how }
588
+ : { found: false, where: null, how: 'nothing built was found' },
609
589
  evidence: [{ where: at('src-tauri'), means: 'Tauri wraps a web front end in a native window, so the window is not an Electron one.' }],
610
590
  blockers: available.has('windows')
611
591
  ? ['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.']
@@ -696,26 +676,46 @@ async function productsIn(input) {
696
676
 
697
677
  // ── Server ────────────────────────────────────────────────────────────────
698
678
  const serverFramework = ['express', 'fastify', 'hono', 'koa', '@hapi/hapi', '@nestjs/core', 'polka', 'restify'].find(has) ?? null;
699
- const serverish = Boolean(serverFramework) || doors.route > 0 || Boolean(containers.dockerfile && scripts.start);
679
+ // A hand-written server declares itself nowhere but in its own code, so the code is asked.
680
+ // Without this, a repository that answers requests all day was read as making only the one
681
+ // command in its package.json, and its whole HTTP surface went unwatched in silence.
682
+ const handWritten = serverFramework ? { yes: false, file: null, readsPort: false } : await handWrittenServerIn(dir);
683
+ const serverish = Boolean(serverFramework) || doors.route > 0 || handWritten.yes || Boolean(containers.dockerfile && scripts.start);
700
684
  if (serverish) {
701
685
  /** @type {Clue[]} */
702
686
  const clues = [];
703
687
  if (serverFramework) clues.push({ where: at('package.json'), means: `It depends on ${serverFramework}, which serves requests.` });
704
688
  if (doors.route > 0) clues.push({ where: 'the source', means: `${doors.route} route${doors.route === 1 ? '' : 's'} are declared in the code.` });
689
+ if (handWritten.yes && handWritten.file) clues.push({ where: at(handWritten.file), means: 'It opens an HTTP server on node\'s own http module and starts listening, with no framework under it.' });
705
690
  if (containers.dockerfile) clues.push({ where: containers.dockerfile, means: 'It ships as a container, so there is a known way to start it.' });
706
691
  // Next.js and its cousins are a website first. Their API routes are real and worth
707
692
  // checking, but calling the whole thing "a server" as well as "a website" would report
708
693
  // one product twice.
709
694
  const alreadyAWebsite = found.some((p) => p.kind === 'web') && !serverFramework;
695
+ // With no `start` script the entry file is the next best thing, and it is a real answer
696
+ // rather than a guess: it is the file that was just proven to open the socket. Leaving
697
+ // the start command empty because package.json was silent is how a server that IS
698
+ // checkable ends up listed as one nothing can walk.
699
+ const startsWith = scripts.start
700
+ ? inFolder(npmRun(scripts, scripts.start), where)
701
+ : handWritten.file ? inFolder(`node ${handWritten.file}`, where) : null;
710
702
  if (!alreadyAWebsite) {
711
703
  add('server', {
712
704
  name: where === '.' ? 'the server' : `the server in ${where}/`,
713
- confidence: serverFramework ? 0.9 : doors.route > 3 ? 0.6 : 0.4,
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.`,
705
+ confidence: serverFramework ? 0.9 : handWritten.yes ? 0.8 : doors.route > 3 ? 0.6 : 0.4,
706
+ why: serverFramework
707
+ ? `It uses ${serverFramework} and ${doors.route} route${doors.route === 1 ? '' : 's'} are written in the code.`
708
+ : handWritten.yes
709
+ ? `${handWritten.file} opens an HTTP server by hand and listens on it, and ${doors.route} route${doors.route === 1 ? '' : 's'} could be read out of the code.`
710
+ : `${doors.route} route${doors.route === 1 ? '' : 's'} are written in the code, though no web framework is installed.`,
715
711
  evidence: clues,
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.'],
712
+ blockers: [
713
+ ...(startsWith ? [] : ['There is no command that starts it. The routes can be listed from the source without one, but none of them can be walked.']),
714
+ ...(handWritten.yes && !handWritten.readsPort ? [`${handWritten.file} names its port itself rather than taking one out of the environment. Two builds cannot be booted side by side on one port, so make it read PORT before this can be walked.`] : []),
715
+ ],
716
+ startNote: !scripts.start && startsWith ? `package.json names no start script, so this is the file that was found opening the socket: ${handWritten.file}.` : undefined,
717
717
  suggest: {
718
- ...(scripts.start ? { start: inFolder(npmRun(scripts, scripts.start), where) } : {}),
718
+ ...(startsWith ? { start: startsWith } : {}),
719
719
  // Whether this server keeps anything. Both builds have to see the same rows, so a
720
720
  // server with a database needs a command that puts the data back — and a server
721
721
  // with NO database needs no such command, and must not be asked for one. Asking is
@@ -759,6 +759,48 @@ async function productsIn(input) {
759
759
  });
760
760
  }
761
761
 
762
+ // ── A product in a language nothing here READS ────────────────────────────
763
+ // Reading it and driving it are two different questions, and answering the second with the
764
+ // first is what made this tool turn away every Flask app it ever met. It can boot one and
765
+ // it can run one; it just cannot read one. So the surfaces it CAN reach are offered, and
766
+ // the one it cannot is carried on the product by name so nothing downstream can quietly
767
+ // report a half-covered product as covered.
768
+ const foreign = await foreignProjectIn(dir, listing);
769
+ if (foreign) {
770
+ // Python is the one whose addresses ARE read, so its server is half-sighted rather than
771
+ // blind and has to say which half. Every other language here is read not at all.
772
+ const readsAddresses = foreign.language === 'Python' && foreign.routes > 0;
773
+ /** @param {boolean} server */
774
+ const blindly = (server) => ({
775
+ language: foreign.language,
776
+ reads: server && readsAddresses ? 'the addresses it answers on, and nothing else' : null,
777
+ });
778
+ if (foreign.start) {
779
+ add('server', {
780
+ name: where === '.' ? `the ${foreign.language} server` : `the ${foreign.language} server in ${where}/`,
781
+ confidence: 0.75,
782
+ why: foreign.routes > 0
783
+ ? `It serves requests with ${foreign.framework}, and ${foreign.routes} address${foreign.routes === 1 ? '' : 'es'} were read out of its own source.`
784
+ : `${foreign.startWhy} Nothing here reads ${foreign.language} source, so its addresses are not known and only the boot itself is watched.`,
785
+ evidence: foreign.evidence,
786
+ sourceBlind: blindly(true),
787
+ startNote: foreign.startWhy,
788
+ blockers: foreign.readsPort ? [] : [`It names its own port rather than taking one out of the environment, so two builds cannot be booted side by side. Make it read PORT before this can be walked.`],
789
+ suggest: { start: inFolder(foreign.start, where), stateless: !keepsData(deps, containers) },
790
+ });
791
+ }
792
+ if (foreign.commands.length > 0) {
793
+ add('cli', {
794
+ name: foreign.commands.length === 1 ? `the \`${foreign.commands[0].name.replace(/ --help$/, '')}\` command` : `${foreign.commands.length} ${foreign.language} commands`,
795
+ confidence: 0.7,
796
+ why: `${foreign.language} commands were found that can be typed and asked to describe themselves, and running one needs no ${foreign.language} read at all.`,
797
+ evidence: foreign.evidence,
798
+ sourceBlind: blindly(false),
799
+ suggest: { commands: foreign.commands },
800
+ });
801
+ }
802
+ }
803
+
762
804
  // ── Something real that nothing here can drive ────────────────────────────
763
805
  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;
764
806
  if (otherLanguage && found.length === 0) {
@@ -1324,16 +1366,6 @@ function entryPointOf(pkg) {
1324
1366
  return '.';
1325
1367
  }
1326
1368
 
1327
- /**
1328
- * A command short enough to read. The whole of it stays in the data; only the sentence is cut.
1329
- * @param {string} text
1330
- * @returns {string}
1331
- */
1332
- function short(text) {
1333
- const flat = text.replace(/\s+/g, ' ').trim();
1334
- return flat.length <= 70 ? flat : `${flat.slice(0, 67)}...`;
1335
- }
1336
-
1337
1369
  /**
1338
1370
  * @param {Clue[]} clues
1339
1371
  * @returns {Clue[]}
@@ -1745,6 +1777,222 @@ async function findCommandPrograms(input) {
1745
1777
  return found;
1746
1778
  }
1747
1779
 
1780
+ /**
1781
+ * How each language declares itself, and what it takes to start and to type.
1782
+ *
1783
+ * Not a table of languages this tool understands. It is a table of what can be OFFERED, and
1784
+ * the difference is the whole point of it. Two of this tool's adapters never read a line of
1785
+ * anybody's source — one runs a command and compares what it printed, the other boots a
1786
+ * server on a spare port and asks it for routes — so a Go server and a Ruby app have always
1787
+ * been checkable here, and were being turned away with "a language nothing here drives".
1788
+ * That sentence was true of the source reader and false of everything else, and the person
1789
+ * went away with nothing rather than with most of what they came for.
1790
+ */
1791
+ const FOREIGN_LANGUAGES = Object.freeze({
1792
+ Python: { manifests: ['pyproject.toml', 'requirements.txt', 'Pipfile', 'setup.py'], sources: /\.py$/ },
1793
+ Go: { manifests: ['go.mod'], sources: /\.go$/ },
1794
+ Rust: { manifests: ['Cargo.toml'], sources: /\.rs$/ },
1795
+ Ruby: { manifests: ['Gemfile', 'config.ru'], sources: /\.rb$/ },
1796
+ PHP: { manifests: ['composer.json'], sources: /\.php$/ },
1797
+ });
1798
+
1799
+ /**
1800
+ * What can honestly be offered for a project in a language this tool does not read.
1801
+ *
1802
+ * Python is read properly — its addresses come out of its own source, so the HTTP half works
1803
+ * the way it does for JavaScript. The other four are offered a boot and a command and
1804
+ * nothing more, and that limit is carried out of here by name rather than left to be
1805
+ * noticed. Going further into reading four more route syntaxes would multiply the places
1806
+ * this tool can invent an address that does not exist, and inventing one is worse than
1807
+ * missing one.
1808
+ *
1809
+ * @param {string} dir
1810
+ * @param {{files: string[], dirs: string[]}} listing
1811
+ * @returns {Promise<null|{language: string, manifest: string, framework: string|null, start: string|null, startWhy: string, readsPort: boolean, commands: {name: string, run: string, describe: string}[], routes: number, evidence: Clue[]}>}
1812
+ */
1813
+ async function foreignProjectIn(dir, listing) {
1814
+ const language = Object.keys(FOREIGN_LANGUAGES).find(
1815
+ (name) => FOREIGN_LANGUAGES[/** @type {keyof typeof FOREIGN_LANGUAGES} */ (name)].manifests.some((m) => listing.files.includes(m)),
1816
+ );
1817
+ if (!language) return null;
1818
+ const spec = FOREIGN_LANGUAGES[/** @type {keyof typeof FOREIGN_LANGUAGES} */ (language)];
1819
+ const manifest = spec.manifests.find((m) => listing.files.includes(m)) ?? spec.manifests[0];
1820
+ const manifestText = await readTextIfSmall(path.join(dir, manifest)) ?? '';
1821
+ /** @type {Clue[]} */
1822
+ const evidence = [{ where: manifest, means: `This is how a ${language} project declares itself.` }];
1823
+ /** @type {{name: string, run: string, describe: string}[]} */
1824
+ const commands = [];
1825
+ let framework = null;
1826
+ let start = null;
1827
+ let startWhy = '';
1828
+ let readsPort = false;
1829
+ let routes = 0;
1830
+
1831
+ if (language === 'Python') {
1832
+ const { readPython } = await import('./adapters/python.js');
1833
+ const reading = await readPython(dir);
1834
+ routes = reading.doors.length;
1835
+ // Always the plain interpreter, never the one inside the project's environment folder,
1836
+ // and this was learned the hard way rather than chosen.
1837
+ //
1838
+ // Pointing the start command at `./.venv/bin/python` worked beautifully on the build in
1839
+ // front of you and failed on every other one. An environment folder is not committed, so
1840
+ // the OLD build — a clean checkout, which is the entire point of a paired run — has no
1841
+ // such file, and every paired comparison quietly degraded to an unpaired one. The same
1842
+ // path also runs commands inside a throwaway copy of the project, where the link out to
1843
+ // the real interpreter is deliberately not followed.
1844
+ //
1845
+ // `python3` is whatever the person's shell has, which inside an activated environment is
1846
+ // the environment's own. Same answer on both builds, every time, is worth more here than
1847
+ // a cleverer answer that is only right on one of them.
1848
+ const py = 'python3';
1849
+ const typed = 'python3';
1850
+ framework = reading.frameworks[0] ?? null;
1851
+ if (framework === 'flask' && reading.appTarget) {
1852
+ start = `${py} -m flask --app ${reading.appTarget} run --port $PORT`;
1853
+ startWhy = `${reading.appFile} builds a Flask application, and this is how Flask is asked to serve one on a port it is given.`;
1854
+ } else if (framework === 'fastapi' && reading.appTarget) {
1855
+ start = `${py} -m uvicorn ${reading.appTarget} --port $PORT`;
1856
+ startWhy = `${reading.appFile} builds a FastAPI application, and uvicorn is what serves one.`;
1857
+ } else if (framework === 'django' && reading.managePy) {
1858
+ start = `${py} ${reading.managePy} runserver $PORT`;
1859
+ startWhy = `${reading.managePy} is Django's own way in, and runserver takes the port it is given.`;
1860
+ }
1861
+ if (start) {
1862
+ readsPort = true;
1863
+ evidence.push({ where: reading.appFile ?? reading.managePy ?? manifest, means: `It serves requests with ${framework}, and ${routes} address${routes === 1 ? '' : 'es'} were read out of its own source.` });
1864
+ }
1865
+ for (const entry of reading.entries) {
1866
+ const name = path.basename(entry, '.py');
1867
+ commands.push({ name: `${name} --help`, run: `${typed} ${entry} --help`, describe: `ask ${name} to print its help, and compare every word of it` });
1868
+ }
1869
+ // A console script is a command somebody types after installing, exactly like package.json's bin.
1870
+ const scripts = /\[project\.scripts\]([\s\S]*?)(\n\[|$)/.exec(manifestText);
1871
+ for (const line of (scripts?.[1] ?? '').split('\n')) {
1872
+ const named = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line);
1873
+ if (named && !commands.some((c) => c.name.startsWith(`${named[1]} `))) {
1874
+ commands.push({ name: `${named[1]} --help`, run: `${named[1]} --help`, describe: `ask ${named[1]} to print its help, and compare every word of it` });
1875
+ }
1876
+ }
1877
+ } else {
1878
+ const listens = await foreignServerIn(dir, spec.sources, language);
1879
+ framework = listens.framework;
1880
+ readsPort = listens.readsPort;
1881
+ if (listens.yes) {
1882
+ if (language === 'Go') { start = 'go run .'; startWhy = 'go.mod makes this a module go can build and run where it stands.'; }
1883
+ if (language === 'Rust') { start = 'cargo run'; startWhy = 'Cargo.toml makes this a crate cargo can build and run where it stands.'; }
1884
+ if (language === 'Ruby') {
1885
+ start = listing.files.includes('config.ru') ? 'bundle exec rackup -p $PORT' : 'bundle exec bin/rails server -p $PORT';
1886
+ startWhy = listing.files.includes('config.ru') ? 'config.ru is the file rack serves.' : 'A Rails app is served by its own bin/rails.';
1887
+ }
1888
+ if (language === 'PHP') {
1889
+ const web = listing.dirs.includes('public') ? ' -t public' : '';
1890
+ start = `php -S 127.0.0.1:$PORT${web}`;
1891
+ startWhy = 'PHP has a server of its own, and it takes the address it is given.';
1892
+ }
1893
+ if (listens.file) evidence.push({ where: listens.file, means: `It opens a server and listens on it${framework ? `, using ${framework}` : ''}.` });
1894
+ }
1895
+ }
1896
+
1897
+ // A Makefile target is a command somebody types, in any language at all. Only `help` is
1898
+ // taken, and for the same reason package.json's commands are only ever asked for their
1899
+ // help: `make deploy` is sitting right there in the same file, and running one because it
1900
+ // was there would be this tool causing the very kind of damage it exists to catch.
1901
+ if (listing.files.includes('Makefile')) {
1902
+ const makefile = await readTextIfSmall(path.join(dir, 'Makefile')) ?? '';
1903
+ if (/^help\s*:/m.test(makefile)) {
1904
+ commands.push({ name: 'make help', run: 'make help', describe: 'ask the Makefile to print its help, and compare every word of it' });
1905
+ }
1906
+ }
1907
+
1908
+ if (!start && commands.length === 0) return null;
1909
+ return { language, manifest, framework, start, startWhy, readsPort, commands, routes, evidence };
1910
+ }
1911
+
1912
+ /**
1913
+ * Does a folder hold a server in a language this tool cannot read?
1914
+ *
1915
+ * This looks for one thing only — that a server is started — and never for what it answers.
1916
+ * Knowing a server is there is enough to boot it and watch it come up; claiming to know its
1917
+ * addresses without reading them would be the invention this whole file exists to avoid.
1918
+ *
1919
+ * @param {string} dir
1920
+ * @param {RegExp} sources
1921
+ * @param {string} language
1922
+ * @returns {Promise<{yes: boolean, file: string|null, framework: string|null, readsPort: boolean}>}
1923
+ */
1924
+ async function foreignServerIn(dir, sources, language) {
1925
+ const listens = /** @type {Record<string, RegExp>} */ ({
1926
+ Go: /\bhttp\.ListenAndServe\b|\bhttp\.Server\s*\{|\.ListenAndServe\s*\(/,
1927
+ Rust: /\bHttpServer::new\b|\baxum::(Server|serve)\b|\brocket::(build|ignite)\b|\bwarp::serve\b|\bTcpListener::bind\b/,
1928
+ Ruby: /\bSinatra::Base\b|\brun\s+Sinatra\b|\bRails\.application\b|\bRack::Server\b/,
1929
+ PHP: /\$app\s*->\s*run\s*\(|\bApp::run\b|\bKernel::handle\b|\brequire.*autoload/,
1930
+ })[language];
1931
+ const names = /** @type {Record<string, RegExp>} */ ({
1932
+ Go: /\b(gin-gonic\/gin|labstack\/echo|go-chi\/chi|gofiber\/fiber|gorilla\/mux)\b/,
1933
+ Rust: /\b(actix-web|axum|rocket|warp|tide)\b/,
1934
+ Ruby: /\b(sinatra|rails|hanami|roda)\b/,
1935
+ PHP: /\b(laravel\/framework|symfony\/framework-bundle|slim\/slim|laminas)\b/,
1936
+ })[language];
1937
+ if (!listens) return { yes: false, file: null, framework: null, readsPort: false };
1938
+
1939
+ const { files } = await readSome(dir, { match: sources, most: 80, depth: 3 });
1940
+ for (const one of files) {
1941
+ const where = one.rel.split(path.sep).join('/');
1942
+ if (/(^|\/)(tests?|spec|fixtures|examples?|vendor)\//.test(where)) continue;
1943
+ if (!listens.test(one.text)) continue;
1944
+ return {
1945
+ yes: true,
1946
+ file: one.rel,
1947
+ framework: names?.exec(one.text)?.[1] ?? null,
1948
+ readsPort: /\bPORT\b/.test(one.text),
1949
+ };
1950
+ }
1951
+ return { yes: false, file: null, framework: null, readsPort: false };
1952
+ }
1953
+
1954
+ /**
1955
+ * Does a folder hold a server somebody wrote by hand, on node's own http module?
1956
+ *
1957
+ * A product with no framework in its package.json used to be invisible here, and the whole
1958
+ * HTTP half of it went unwatched while init said the repository made one command and nothing
1959
+ * was being left out. There is no dependency to find, so the code itself has to say it.
1960
+ *
1961
+ * Three things have to be true in one file, and the third is the one that matters. This
1962
+ * tool's own source imports node:net, calls createServer and calls listen — five files do —
1963
+ * and not one of them is a server: they are port probes, `createServer()` with nothing
1964
+ * inside the brackets. A server is handed something to answer requests with. That single
1965
+ * character of difference is what keeps this from calling every repository a server.
1966
+ *
1967
+ * @param {string} dir
1968
+ * @returns {Promise<{yes: boolean, file: string|null, readsPort: boolean}>}
1969
+ */
1970
+ async function handWrittenServerIn(dir) {
1971
+ const { files } = await readSome(dir, { most: 80, depth: 3 });
1972
+ for (const one of files) {
1973
+ const where = one.rel.split(path.sep).join('/');
1974
+ // A server standing in a fixtures folder is a prop for somebody's test, not the product.
1975
+ // This tool's own repository has one, and without this line it reported ITSELF as a
1976
+ // server — which is exactly the kind of confident wrong answer that gets a tool switched
1977
+ // off. The filename rule alone is not enough; the folder is what gives it away.
1978
+ if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(where)) continue;
1979
+ if (/(^|\/)(__tests__|__mocks__|tests?|e2e|fixtures|examples?|samples?|demos?)\//.test(where)) continue;
1980
+ if (!/\bnode:(http|https|net)\b|require\(\s*['"](?:node:)?(?:http|https|net)['"]\s*\)/.test(one.text)) continue;
1981
+ if (!/\bcreateServer\s*\(\s*[^)\s]/.test(one.text)) continue;
1982
+ if (!/\.listen\s*\(/.test(one.text)) continue;
1983
+ // And it has to listen somewhere a person could go. A server on `listen(0)` took whatever
1984
+ // port was free, which is what a program does when it is talking to itself — this tool's
1985
+ // own Android driver stands up a real HTTP server that way to catch an app's outbound
1986
+ // calls, and it is nobody's product. A server this tool can drive has to take the port it
1987
+ // is given, or at least name one.
1988
+ const readsPort = /process\.env\.PORT|env\.PORT|Deno\.env\.get\(\s*['"]PORT/.test(one.text);
1989
+ const namesAPort = /\.listen\s*\(\s*[1-9][0-9]{2,4}\b/.test(one.text);
1990
+ if (!readsPort && !namesAPort) continue;
1991
+ return { yes: true, file: one.rel, readsPort };
1992
+ }
1993
+ return { yes: false, file: null, readsPort: false };
1994
+ }
1995
+
1748
1996
  /**
1749
1997
  * Does a folder hold something that listens on a port?
1750
1998
  *