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.
- package/CHANGELOG.md +364 -0
- package/README.md +193 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +345 -47
- package/src/v2/init.js +162 -61
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +10 -11
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +40 -10
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +29 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
...(
|
|
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
|
*
|