staysfixed 0.12.0 → 0.13.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 +32 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +332 -34
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +562 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
package/src/v2/init.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
import path from 'node:path';
|
|
42
42
|
import fsp from 'node:fs/promises';
|
|
43
|
-
import { existsSync } from 'node:fs';
|
|
43
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
44
44
|
|
|
45
45
|
import { EXIT, messageOf } from '../core/errors.js';
|
|
46
46
|
import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
|
|
@@ -573,9 +573,32 @@ function productNeeds(product, project) {
|
|
|
573
573
|
// adapter refuses outright in that state, and nothing here said so: doctor's answer for
|
|
574
574
|
// this surface is hardcoded to an empty list, so an empty needs list met an empty machine
|
|
575
575
|
// list and the product read as ready over an adapter that would not start.
|
|
576
|
-
|
|
576
|
+
// Counted from the commands that will really RUN, not from the commands that were proposed.
|
|
577
|
+
// A product whose every command answers "command not found" has nothing to run, and reading
|
|
578
|
+
// the proposed list instead let a Python tool with two unrunnable console scripts report as
|
|
579
|
+
// ready and covered in full. Measured 2026-08-31.
|
|
580
|
+
const judged = product.kind === 'cli' ? commandsThatRun(project, product) : { ready: [], unsure: [] };
|
|
581
|
+
const somethingToRun = (product.kind === 'cli' ? judged.ready.length : (Array.isArray(suggest.commands) ? suggest.commands.length : 0))
|
|
577
582
|
+ (Array.isArray(suggest.imports) ? suggest.imports.length : 0);
|
|
578
|
-
|
|
583
|
+
|
|
584
|
+
// Some commands were found and none of them can be run as they stand. That is a different
|
|
585
|
+
// situation from finding none at all, and it is owed a different sentence: the work is not
|
|
586
|
+
// "think of a command", it is "write down the one you already type", and the blanks are
|
|
587
|
+
// sitting in the settings file with a line each saying what goes in them.
|
|
588
|
+
if (product.kind === 'cli' && judged.unsure.length > 0 && judged.ready.length === 0) {
|
|
589
|
+
const one = judged.unsure.length === 1;
|
|
590
|
+
needs.push({
|
|
591
|
+
what: `a command for ${plainList(judged.unsure.map((u) => u.name.replace(/\s+--help$/, '')))} that runs without installing anything first`,
|
|
592
|
+
why: `${one ? 'It is' : 'They are'} declared in this project, but what ${one ? 'it names is' : 'they name are'} only there after somebody installs the package — so on a fresh clone ${one ? 'it' : 'each of them'} answers "command not found". Writing ${one ? 'it' : 'them'} down anyway would make your first check red about nothing, so ${one ? 'it is' : 'they are'} left blank instead.`,
|
|
593
|
+
unlocks: 'every word of what the command prints, what it exits with, and every file it touches',
|
|
594
|
+
fix: `Open the settings file and fill in the commented-out ${one ? 'command' : 'commands'} under "process" — each one has a line above it saying what to put there. It is the command you type yourself when you run this from the source.`,
|
|
595
|
+
who: 'a person',
|
|
596
|
+
product: product.name,
|
|
597
|
+
topic: 'commands',
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if ((product.kind === 'cli' || product.kind === 'library') && somethingToRun === 0 && judged.unsure.length === 0) {
|
|
579
602
|
needs.push({
|
|
580
603
|
what: product.kind === 'library' ? 'something to import and compare' : 'a list of commands worth running',
|
|
581
604
|
why: 'Nothing was worked out here that this could actually run or import, and a run with nothing to do proves nothing about anything.',
|
|
@@ -638,11 +661,11 @@ function productNeeds(product, project) {
|
|
|
638
661
|
const build = typeof suggest.buildWith === 'string' ? String(suggest.buildWith) : null;
|
|
639
662
|
needs.push({
|
|
640
663
|
what: `${product.name}, built`,
|
|
641
|
-
why:
|
|
664
|
+
why: `It is built into a folder that is not committed, so on a fresh copy of this repository there is nothing to run yet. Nothing in ${manifestBehind(project, product)} names it either, which is why it is easy to miss entirely.`,
|
|
642
665
|
unlocks: 'every word of its help, what it exits with, and every file it touches',
|
|
643
666
|
fix: build
|
|
644
667
|
? `${build}, then \`staysfixed init --force\` — the commands are filled in exactly from what the build wrote, and nothing has been edited by hand yet.`
|
|
645
|
-
: `Nothing in
|
|
668
|
+
: `Nothing in ${manifestBehind(project, product)} says how to build it. Name the command that builds it and the command that runs the result under "process" in the settings.`,
|
|
646
669
|
who: build ? 'the agent' : 'a person',
|
|
647
670
|
product: product.name,
|
|
648
671
|
topic: 'app',
|
|
@@ -852,7 +875,11 @@ function topicOf(text) {
|
|
|
852
875
|
// code.
|
|
853
876
|
if (/built (?:[a-z]+ )?(?:app|bundle|package|program)|built as a (?:package|bundle)|app\.binary|electron\.binary|android\.apk|\bapk\b|windows\.exe|remoteexe/.test(words)) return 'app';
|
|
854
877
|
if (/machine with a windows desktop|windows machine|ssh host|"host":/.test(words)) return 'host';
|
|
855
|
-
|
|
878
|
+
// "a command to run, or something to import" is doctor's spelling of the same missing
|
|
879
|
+
// thing, and it was not matching — so a Python project was told, in one breath, to fill in
|
|
880
|
+
// the blanks in its settings and to add `run: "node bin/cli.js --help"`, which is not a
|
|
881
|
+
// command that exists in a Python project at all.
|
|
882
|
+
if (/commands worth running|to import and compare|process\.commands|process: \{ commands|a command to run/.test(words)) return 'commands';
|
|
856
883
|
if (/device id|identity|identityenv/.test(words)) return 'identity';
|
|
857
884
|
if (/sample|real value/.test(words)) return 'samples';
|
|
858
885
|
if (/browser|playwright|chromium/.test(words)) return 'browser';
|
|
@@ -958,6 +985,463 @@ function dedupeNeeds(needs) {
|
|
|
958
985
|
return [...seen.values()];
|
|
959
986
|
}
|
|
960
987
|
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
// What a project calls itself, and where that answer came from
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
/*
|
|
993
|
+
* Two small lies were coming out of this command, and both were about a file.
|
|
994
|
+
*
|
|
995
|
+
* Run on a real Python command-line tool on 2026-08-31, `init` called the product after the
|
|
996
|
+
* FOLDER it happened to be checked out into, and said — twice — that it had read the commands
|
|
997
|
+
* "from package.json". There is no package.json in a Python project. The project said its own
|
|
998
|
+
* name in `pyproject.toml`, in the line every Python tool reads, and nothing here looked.
|
|
999
|
+
*
|
|
1000
|
+
* A name taken from a folder is a small wrongness; a source named that does not exist is a
|
|
1001
|
+
* different thing entirely. Everything else on that page — what is covered, what is missing,
|
|
1002
|
+
* what a check will walk — is a claim about somebody's project that they cannot check for
|
|
1003
|
+
* themselves in the moment they read it. The one claim they CAN check is the file name, and
|
|
1004
|
+
* getting that wrong is how a page stops being believed.
|
|
1005
|
+
*
|
|
1006
|
+
* So: read the manifest that is really there, and when this command says where a value came
|
|
1007
|
+
* from, name the file it really came from.
|
|
1008
|
+
*/
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* How a project declares its own name, in the order the answer should be trusted.
|
|
1012
|
+
*
|
|
1013
|
+
* package.json is first only because it is first in this tool's own world; a repository with
|
|
1014
|
+
* both a package.json and a pyproject.toml is a JavaScript project with Python in it far more
|
|
1015
|
+
* often than the other way round.
|
|
1016
|
+
*
|
|
1017
|
+
* @type {{file: string, read: (text: string) => string|null}[]}
|
|
1018
|
+
*/
|
|
1019
|
+
const NAME_DECLARATIONS = [
|
|
1020
|
+
{ file: 'package.json', read: (text) => { try { const j = JSON.parse(text); return typeof j?.name === 'string' && j.name ? j.name : null; } catch { return null; } } },
|
|
1021
|
+
{ file: 'pyproject.toml', read: (text) => tomlValue(text, 'project', 'name') ?? tomlValue(text, 'tool.poetry', 'name') },
|
|
1022
|
+
{ file: 'setup.cfg', read: (text) => tomlValue(text, 'metadata', 'name') },
|
|
1023
|
+
{ file: 'setup.py', read: (text) => /\bname\s*=\s*['"]([^'"]+)['"]/.exec(text)?.[1] ?? null },
|
|
1024
|
+
{ file: 'Cargo.toml', read: (text) => tomlValue(text, 'package', 'name') },
|
|
1025
|
+
{ file: 'composer.json', read: (text) => { try { const j = JSON.parse(text); return typeof j?.name === 'string' && j.name ? j.name.split('/').pop() : null; } catch { return null; } } },
|
|
1026
|
+
{ file: 'go.mod', read: (text) => /^\s*module\s+(\S+)/m.exec(text)?.[1]?.split('/').pop() ?? null },
|
|
1027
|
+
];
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* The name a project gives itself, and the file it gave it in.
|
|
1031
|
+
*
|
|
1032
|
+
* `from` is never guessed. When nothing on disk declares a name the answer is the folder, and
|
|
1033
|
+
* it says so — "the folder name" is an honest source, and a person reading it knows at once
|
|
1034
|
+
* that nothing was found and can put one in if they want a better one.
|
|
1035
|
+
*
|
|
1036
|
+
* Exported so a test can ask about one folder without building a whole project.
|
|
1037
|
+
*
|
|
1038
|
+
* @param {string} root
|
|
1039
|
+
* @returns {{name: string, from: string}}
|
|
1040
|
+
*/
|
|
1041
|
+
export function whatItCallsItself(root) {
|
|
1042
|
+
for (const declaration of NAME_DECLARATIONS) {
|
|
1043
|
+
const text = readTextIfThere(path.join(root, declaration.file));
|
|
1044
|
+
if (text === null) continue;
|
|
1045
|
+
const found = declaration.read(text);
|
|
1046
|
+
if (found) return { name: found, from: declaration.file };
|
|
1047
|
+
}
|
|
1048
|
+
return { name: path.basename(root), from: 'the folder name' };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* The manifest one product's facts were read out of.
|
|
1053
|
+
*
|
|
1054
|
+
* Detect already records the file it recognised the project by, in the product's own
|
|
1055
|
+
* evidence, so this reads that rather than guessing a second time. Where a product has no
|
|
1056
|
+
* manifest behind it at all — a program found by reading the source — the answer is "the
|
|
1057
|
+
* source", which is the truth and is also more useful than naming a file.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {ProjectShape} project
|
|
1060
|
+
* @param {Product} product
|
|
1061
|
+
* @returns {string}
|
|
1062
|
+
*/
|
|
1063
|
+
export function manifestBehind(project, product) {
|
|
1064
|
+
const named = (product.evidence ?? [])
|
|
1065
|
+
.map((clue) => String(clue.where ?? '').split('/').pop() ?? '')
|
|
1066
|
+
.find((file) => NAME_DECLARATIONS.some((d) => d.file === file) || file === 'requirements.txt' || file === 'Pipfile' || file === 'Gemfile' || file === 'pubspec.yaml');
|
|
1067
|
+
if (named) return named;
|
|
1068
|
+
return existsSync(path.join(project.root, product.where === '.' ? '' : product.where, 'package.json')) ? 'package.json' : 'the source';
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* One `key = "value"` out of one `[table]` of a TOML or INI file.
|
|
1073
|
+
*
|
|
1074
|
+
* Deliberately not a TOML parser. Everything read here is a single quoted string on its own
|
|
1075
|
+
* line at the top level of a named table — `[project] name`, `[tool.poetry] name`, and the
|
|
1076
|
+
* entry-point tables below — and pulling in a parser to read seven of those would be a
|
|
1077
|
+
* dependency in a tool whose whole promise is that nothing changes underneath you.
|
|
1078
|
+
*
|
|
1079
|
+
* @param {string} text
|
|
1080
|
+
* @param {string} table 'project', 'tool.poetry', 'metadata'.
|
|
1081
|
+
* @param {string} key
|
|
1082
|
+
* @returns {string|null}
|
|
1083
|
+
*/
|
|
1084
|
+
function tomlValue(text, table, key) {
|
|
1085
|
+
const body = tomlTable(text, table);
|
|
1086
|
+
if (body === null) return null;
|
|
1087
|
+
// Quoted OR bare, because setup.cfg is an INI file and writes `name = old-style` with no
|
|
1088
|
+
// quotes at all. Insisting on quotes read every setup.cfg as declaring nothing, and the
|
|
1089
|
+
// project fell back to being named after its folder — the exact wrongness being fixed here.
|
|
1090
|
+
const found = new RegExp(`^\\s*${key}\\s*=\\s*(?:['"]([^'"]*)['"]|([^\\s#'"][^#\\n]*?))\\s*(?:#.*)?$`, 'm').exec(body);
|
|
1091
|
+
return (found?.[1] ?? found?.[2])?.trim() || null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Everything under one `[table]` heading, up to the next heading.
|
|
1096
|
+
* @param {string} text
|
|
1097
|
+
* @param {string} table
|
|
1098
|
+
* @returns {string|null}
|
|
1099
|
+
*/
|
|
1100
|
+
function tomlTable(text, table) {
|
|
1101
|
+
const heading = new RegExp(`^\\s*\\[${table.replace(/\./g, '\\.')}\\]\\s*$`, 'm').exec(text);
|
|
1102
|
+
if (!heading) return null;
|
|
1103
|
+
const after = text.slice(heading.index + heading[0].length);
|
|
1104
|
+
const next = /^\s*\[/m.exec(after);
|
|
1105
|
+
return next ? after.slice(0, next.index) : after;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* A file's text, or null if it is not there or cannot be read.
|
|
1110
|
+
* @param {string} file
|
|
1111
|
+
* @returns {string|null}
|
|
1112
|
+
*/
|
|
1113
|
+
function readTextIfThere(file) {
|
|
1114
|
+
try {
|
|
1115
|
+
return readFileSync(file, 'utf8');
|
|
1116
|
+
} catch {
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
// Commands that actually run
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* A command that has been checked and will run on a fresh clone.
|
|
1127
|
+
*
|
|
1128
|
+
* @typedef {object} SoundCommand
|
|
1129
|
+
* @property {string} name
|
|
1130
|
+
* @property {string} run
|
|
1131
|
+
* @property {string} describe
|
|
1132
|
+
* @property {Record<string,string>} [env] Only when the command needs one, e.g. PYTHONPATH.
|
|
1133
|
+
* @property {boolean} [repaired] True when this is not what was first proposed.
|
|
1134
|
+
*/
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* A command that could not be worked out with confidence, and what to say about it.
|
|
1138
|
+
*
|
|
1139
|
+
* @typedef {object} UnsureCommand
|
|
1140
|
+
* @property {string} name
|
|
1141
|
+
* @property {string} why Why the obvious command would not run. One sentence.
|
|
1142
|
+
* @property {string} whatToPut What to write instead. One sentence, addressed to a person.
|
|
1143
|
+
*/
|
|
1144
|
+
|
|
1145
|
+
/*
|
|
1146
|
+
* Why this exists at all.
|
|
1147
|
+
*
|
|
1148
|
+
* On a real Python command-line tool on 2026-08-31, `init` wrote three commands into the
|
|
1149
|
+
* settings and two of them could not run. `[project.scripts]` in pyproject.toml declares
|
|
1150
|
+
* `lint-lens` and `lenscheck`, so both were written down as `lint-lens --help` and
|
|
1151
|
+
* `lenscheck --help` — and a console script is a file pip WRITES when somebody installs the
|
|
1152
|
+
* package. On a fresh clone neither name exists. Both answered "command not found", exit 127.
|
|
1153
|
+
*
|
|
1154
|
+
* So the very first `staysfixed check` a person ran came back red, about their own project,
|
|
1155
|
+
* over two commands this tool had invented for them thirty seconds earlier. A tool whose one
|
|
1156
|
+
* promise is that a red result means something real cannot open by handing somebody a red
|
|
1157
|
+
* result that means nothing.
|
|
1158
|
+
*
|
|
1159
|
+
* The rule this file now follows: write a command down only when it will run on a clone of
|
|
1160
|
+
* this repository with nothing installed. Where the manifest says enough to build one that
|
|
1161
|
+
* will, build that one. Where it does not, write the command COMMENTED OUT with a sentence
|
|
1162
|
+
* saying what to put there — an honest blank is worth more than a broken default, because a
|
|
1163
|
+
* person can fill in a blank and cannot tell a broken default from a broken product.
|
|
1164
|
+
*/
|
|
1165
|
+
|
|
1166
|
+
/** Programs that take a file or a module and run it, so what matters is the argument. */
|
|
1167
|
+
const INTERPRETERS = /^(python3?|node|ruby|perl|php|deno|bun)$/;
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Every command that will really run, and every one that could not be worked out.
|
|
1171
|
+
*
|
|
1172
|
+
* @param {ProjectShape} project
|
|
1173
|
+
* @param {Product} product
|
|
1174
|
+
* @returns {{ready: SoundCommand[], unsure: UnsureCommand[]}}
|
|
1175
|
+
*/
|
|
1176
|
+
export function commandsThatRun(project, product) {
|
|
1177
|
+
const dir = path.join(project.root, product.where === '.' ? '' : product.where);
|
|
1178
|
+
const proposed = Array.isArray(product.suggest?.commands) ? product.suggest.commands : [];
|
|
1179
|
+
const entryPoints = pythonEntryPoints(dir);
|
|
1180
|
+
|
|
1181
|
+
/** @type {SoundCommand[]} */
|
|
1182
|
+
const ready = [];
|
|
1183
|
+
/** @type {UnsureCommand[]} */
|
|
1184
|
+
const unsure = [];
|
|
1185
|
+
|
|
1186
|
+
for (const raw of proposed) {
|
|
1187
|
+
const name = String(raw?.name ?? '').trim();
|
|
1188
|
+
const run = String(raw?.run ?? '').trim();
|
|
1189
|
+
const describe = String(raw?.describe ?? '');
|
|
1190
|
+
if (!name || !run) continue;
|
|
1191
|
+
|
|
1192
|
+
const verdict = judgeCommand(dir, name, run, entryPoints);
|
|
1193
|
+
if (verdict.ok) {
|
|
1194
|
+
ready.push({ name, run: verdict.run, describe, ...(verdict.env ? { env: verdict.env } : {}), ...(verdict.run === run ? {} : { repaired: true }) });
|
|
1195
|
+
} else {
|
|
1196
|
+
unsure.push({ name, why: verdict.why, whatToPut: verdict.whatToPut });
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Two names for one command is one command. `cli --help` found by reading the folder and
|
|
1201
|
+
// `lint-lens --help` read out of [project.scripts] are the same program on the same module,
|
|
1202
|
+
// and once both are repaired they become the same line — so the settings would have carried
|
|
1203
|
+
// it twice, and every check would have run it twice and compared it against itself. The
|
|
1204
|
+
// name a person actually types wins, which is the one the manifest declared.
|
|
1205
|
+
/** @type {Map<string, SoundCommand>} */
|
|
1206
|
+
const byRun = new Map();
|
|
1207
|
+
for (const command of ready) {
|
|
1208
|
+
const already = byRun.get(command.run);
|
|
1209
|
+
if (!already) { byRun.set(command.run, command); continue; }
|
|
1210
|
+
const declared = entryPoints.has(command.name.replace(/\s+--help$/, ''));
|
|
1211
|
+
if (declared) byRun.set(command.run, command);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
return { ready: [...byRun.values()], unsure };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Will this one command run on a fresh clone, and if not, can a better one be worked out?
|
|
1219
|
+
*
|
|
1220
|
+
* @param {string} dir
|
|
1221
|
+
* @param {string} name
|
|
1222
|
+
* @param {string} run
|
|
1223
|
+
* @param {Map<string,string>} entryPoints
|
|
1224
|
+
* @returns {{ok: true, run: string, env?: Record<string,string>} | {ok: false, why: string, whatToPut: string}}
|
|
1225
|
+
*/
|
|
1226
|
+
function judgeCommand(dir, name, run, entryPoints) {
|
|
1227
|
+
const words = run.split(/\s+/);
|
|
1228
|
+
const program = words[0] ?? '';
|
|
1229
|
+
const rest = words.slice(1).join(' ');
|
|
1230
|
+
|
|
1231
|
+
// An interpreter with a file after it. The file has to be there, and — for Python — it has
|
|
1232
|
+
// to be a file that can be run by path at all.
|
|
1233
|
+
if (INTERPRETERS.test(program) && words[1] && !words[1].startsWith('-')) {
|
|
1234
|
+
const relative = words[1];
|
|
1235
|
+
if (!existsSync(path.join(dir, relative))) {
|
|
1236
|
+
return {
|
|
1237
|
+
ok: false,
|
|
1238
|
+
why: `${relative} is not in this repository, so this command cannot run on a fresh clone of it.`,
|
|
1239
|
+
whatToPut: `The command somebody types to run ${name.replace(/\s+--help$/, '')}, as it would be typed in a clone of this repository with nothing installed.`,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
// Everything after the FILE, never after the interpreter. Getting this wrong wrote
|
|
1243
|
+
// `python3 -m lint_lens.cli src/lint_lens/cli.py --help` — the module form with the file
|
|
1244
|
+
// path still stuck on the end of it, which argparse reads as an argument nobody asked for.
|
|
1245
|
+
if (/^python3?$/.test(program)) return judgePythonFile(dir, relative, words.slice(2).join(' '), entryPoints);
|
|
1246
|
+
return { ok: true, run };
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// A bare name. It is either a program on the machine or a console script the manifest
|
|
1250
|
+
// declares — and a console script is written by an INSTALL, so it is never there on a
|
|
1251
|
+
// fresh clone however plainly it is declared. Whether this machine happens to have one
|
|
1252
|
+
// installed is not the question being asked.
|
|
1253
|
+
if (!program.includes('/') && !program.includes('.')) {
|
|
1254
|
+
const target = entryPoints.get(program);
|
|
1255
|
+
if (target) {
|
|
1256
|
+
const built = pythonRunFor(dir, target);
|
|
1257
|
+
if (built) return { ok: true, run: `${built.run}${rest ? ` ${rest}` : ''}`, ...(built.env ? { env: built.env } : {}) };
|
|
1258
|
+
return {
|
|
1259
|
+
ok: false,
|
|
1260
|
+
why: `\`${program}\` is a console script — pyproject.toml declares it as ${target}, and the file with that name only exists after somebody installs this package. Nothing here could work out how to run ${target.split(':')[0]} straight from the source.`,
|
|
1261
|
+
whatToPut: `Either \`pip install -e .\` in a way every check can rely on, or the command that runs ${target.split(':')[0]} from the source in this repository.`,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
// Anything else with no path in it — `make help`, a program the project assumes — is
|
|
1265
|
+
// left exactly as it was. This function repairs what it understands and never rewrites
|
|
1266
|
+
// what it does not.
|
|
1267
|
+
return { ok: true, run };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// A path. It only has to be there.
|
|
1271
|
+
const first = program.replace(/^\.\//, '');
|
|
1272
|
+
if (!existsSync(path.join(dir, first))) {
|
|
1273
|
+
return {
|
|
1274
|
+
ok: false,
|
|
1275
|
+
why: `${program} is not in this repository, so this command cannot run on a fresh clone of it.`,
|
|
1276
|
+
whatToPut: `The command somebody types to run ${name.replace(/\s+--help$/, '')}, as it would be typed in a clone of this repository with nothing installed.`,
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
return { ok: true, run };
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* A Python file named on the command line: can it be run that way, and if not, how?
|
|
1284
|
+
*
|
|
1285
|
+
* Running a file by its path puts that file's OWN folder on the import path and nothing
|
|
1286
|
+
* above it, so a module that lives inside a package cannot import its neighbours. Measured
|
|
1287
|
+
* 2026-08-31 on a src-layout project: `python3 src/deep_tool/cli.py --help` came back
|
|
1288
|
+
* `ImportError: attempted relative import with no known parent package`, exit 1, before it
|
|
1289
|
+
* printed a word. Nothing about the project was wrong. The command was.
|
|
1290
|
+
*
|
|
1291
|
+
* @param {string} dir
|
|
1292
|
+
* @param {string} relative
|
|
1293
|
+
* @param {string} rest
|
|
1294
|
+
* @param {Map<string,string>} entryPoints
|
|
1295
|
+
* @returns {{ok: true, run: string, env?: Record<string,string>} | {ok: false, why: string, whatToPut: string}}
|
|
1296
|
+
*/
|
|
1297
|
+
function judgePythonFile(dir, relative, rest, entryPoints) {
|
|
1298
|
+
const file = path.join(dir, relative);
|
|
1299
|
+
const insideAPackage = existsSync(path.join(path.dirname(file), '__init__.py'));
|
|
1300
|
+
if (!insideAPackage) return { ok: true, run: `python3 ${relative}${rest ? ` ${rest}` : ''}` };
|
|
1301
|
+
|
|
1302
|
+
const module = pythonModuleName(dir, relative);
|
|
1303
|
+
if (module) {
|
|
1304
|
+
// What the manifest declares comes first, and that is a deduplication decision as much as
|
|
1305
|
+
// a correctness one. This same module is usually also reachable as a console script, and
|
|
1306
|
+
// if the two routes are written differently the settings carry the same program twice
|
|
1307
|
+
// under two names — so every check runs it twice and compares it against itself.
|
|
1308
|
+
for (const [, target] of entryPoints) {
|
|
1309
|
+
if (target.split(':')[0] !== module.name) continue;
|
|
1310
|
+
const viaEntry = pythonRunFor(dir, target);
|
|
1311
|
+
if (viaEntry) return { ok: true, run: `${viaEntry.run}${rest ? ` ${rest}` : ''}`, ...(viaEntry.env ? { env: viaEntry.env } : {}) };
|
|
1312
|
+
}
|
|
1313
|
+
const built = pythonRunFor(dir, module.name);
|
|
1314
|
+
if (built) return { ok: true, run: `${built.run}${rest ? ` ${rest}` : ''}`, ...(built.env ? { env: built.env } : {}) };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// It is inside a package, and nothing here could work out a way in. If it imports nothing
|
|
1318
|
+
// of its own it still runs by path today, so that is what gets written — with no claim
|
|
1319
|
+
// that it is the best way to run it.
|
|
1320
|
+
const text = readTextIfThere(file) ?? '';
|
|
1321
|
+
const top = module ? module.name.split('.')[0] : '';
|
|
1322
|
+
const importsItsOwn = /^\s*from\s+\./m.test(text) || (top !== '' && new RegExp(`^\\s*(from|import)\\s+${top}\\b`, 'm').test(text));
|
|
1323
|
+
if (!importsItsOwn) return { ok: true, run: `python3 ${relative}${rest ? ` ${rest}` : ''}` };
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
ok: false,
|
|
1327
|
+
why: `${relative} sits inside a Python package and imports from it, so running it by its path fails with "attempted relative import with no known parent package" before it prints anything. It has no \`if __name__ == "__main__"\` block either, so \`python3 -m\` would import it and do nothing at all.`,
|
|
1328
|
+
whatToPut: `The command you type to run this yourself — usually the console script from pyproject.toml, or \`python3 -c "import sys; from ${module?.name ?? 'your.module'} import main; sys.exit(main())"\` with PYTHONPATH set to the folder the package sits in.`,
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Every console script this project declares, whichever way it declares them.
|
|
1334
|
+
*
|
|
1335
|
+
* Three spellings, all of them ordinary and all of them meaning the same thing: PEP 621's
|
|
1336
|
+
* `[project.scripts]`, Poetry's `[tool.poetry.scripts]`, and the older
|
|
1337
|
+
* `[project.entry-points.console_scripts]`. Reading only the first was why a Poetry project
|
|
1338
|
+
* had no commands worked out for it at all.
|
|
1339
|
+
*
|
|
1340
|
+
* @param {string} dir
|
|
1341
|
+
* @returns {Map<string,string>} command name -> 'module.path:function'
|
|
1342
|
+
*/
|
|
1343
|
+
export function pythonEntryPoints(dir) {
|
|
1344
|
+
/** @type {Map<string,string>} */
|
|
1345
|
+
const found = new Map();
|
|
1346
|
+
const text = readTextIfThere(path.join(dir, 'pyproject.toml'));
|
|
1347
|
+
if (text !== null) {
|
|
1348
|
+
for (const table of ['project.scripts', 'tool.poetry.scripts', 'project.entry-points.console_scripts']) {
|
|
1349
|
+
const body = tomlTable(text, table);
|
|
1350
|
+
if (body === null) continue;
|
|
1351
|
+
for (const line of body.split('\n')) {
|
|
1352
|
+
const named = /^\s*['"]?([A-Za-z0-9_.-]+)['"]?\s*=\s*['"]([^'"]+)['"]/.exec(line);
|
|
1353
|
+
if (named && !found.has(named[1])) found.set(named[1], named[2]);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// setup.cfg writes them as indented lines under a console_scripts key rather than as a
|
|
1358
|
+
// table of their own, so it is read on its own terms rather than forced into the same shape.
|
|
1359
|
+
const cfg = readTextIfThere(path.join(dir, 'setup.cfg'));
|
|
1360
|
+
if (cfg !== null && /console_scripts\s*=/.test(cfg)) {
|
|
1361
|
+
const after = cfg.slice(cfg.indexOf('console_scripts'));
|
|
1362
|
+
for (const line of after.split('\n').slice(1)) {
|
|
1363
|
+
if (/^\S/.test(line)) break;
|
|
1364
|
+
const named = /^\s+([A-Za-z0-9_.-]+)\s*=\s*(\S+)/.exec(line);
|
|
1365
|
+
if (named && !found.has(named[1])) found.set(named[1], named[2]);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return found;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* A command that really runs one Python module or entry point, straight from the source.
|
|
1373
|
+
*
|
|
1374
|
+
* The ladder is in order of how exactly each form matches what a person gets when they
|
|
1375
|
+
* install the package, and every rung of it was measured on 2026-08-31 rather than reasoned
|
|
1376
|
+
* about:
|
|
1377
|
+
*
|
|
1378
|
+
* 1. `python3 -m the.module` — only when the module has an `if __name__ == "__main__"`
|
|
1379
|
+
* block. Without one this is the worst possible answer: python imports the module,
|
|
1380
|
+
* finds nothing to do, and exits 0 having printed nothing. A command that succeeds
|
|
1381
|
+
* silently is exactly the shape of thing this tool exists to catch, and writing one
|
|
1382
|
+
* into somebody's settings would have it comparing an empty string for ever.
|
|
1383
|
+
* 2. `python3 -c "import sys; from the.module import main; sys.exit(main())"` — which is
|
|
1384
|
+
* what pip's own console script does, line for line, so it prints the same help and
|
|
1385
|
+
* exits the same way. Only written when that function really is defined in that file.
|
|
1386
|
+
*
|
|
1387
|
+
* `PYTHONPATH` is set only for a src layout, and it is carried as the command's environment
|
|
1388
|
+
* rather than written in front of the command as `PYTHONPATH=src python3 ...`, because that
|
|
1389
|
+
* spelling is a shell feature that Windows does not have.
|
|
1390
|
+
*
|
|
1391
|
+
* @param {string} dir
|
|
1392
|
+
* @param {string} target 'the.module' or 'the.module:function'.
|
|
1393
|
+
* @returns {{run: string, env?: Record<string,string>}|null}
|
|
1394
|
+
*/
|
|
1395
|
+
export function pythonRunFor(dir, target) {
|
|
1396
|
+
const [module, func] = String(target).split(':');
|
|
1397
|
+
if (!module) return null;
|
|
1398
|
+
const parts = module.split('.');
|
|
1399
|
+
|
|
1400
|
+
for (const layout of ['', 'src']) {
|
|
1401
|
+
const base = path.join(dir, layout);
|
|
1402
|
+
const asFile = path.join(base, ...parts) + '.py';
|
|
1403
|
+
const asPackage = path.join(base, ...parts, '__init__.py');
|
|
1404
|
+
const file = existsSync(asFile) ? asFile : existsSync(asPackage) ? asPackage : null;
|
|
1405
|
+
if (!file) continue;
|
|
1406
|
+
const env = layout ? { PYTHONPATH: layout } : undefined;
|
|
1407
|
+
const text = readTextIfThere(file) ?? '';
|
|
1408
|
+
// A NAMED function wins over the module's `__main__` block, always. One module can hold
|
|
1409
|
+
// two entry points — `lint_lens.cli:main` and `lint_lens.cli:check_main` — and its
|
|
1410
|
+
// `__main__` block calls exactly one of them. Taking the block first wrote the same
|
|
1411
|
+
// command down for both, so `lenscheck --help` ran `main` and compared the wrong
|
|
1412
|
+
// program's help for ever. Measured 2026-08-31.
|
|
1413
|
+
if (func && new RegExp(`^\\s*(async\\s+)?def\\s+${func.replace(/[^A-Za-z0-9_]/g, '')}\\s*\\(`, 'm').test(text)) {
|
|
1414
|
+
return { run: `python3 -c "import sys; from ${module} import ${func}; sys.exit(${func}())"`, ...(env ? { env } : {}) };
|
|
1415
|
+
}
|
|
1416
|
+
if (!func && /^\s*if\s+__name__\s*==\s*['"]__main__['"]\s*:/m.test(text)) {
|
|
1417
|
+
return { run: `python3 -m ${module}`, ...(env ? { env } : {}) };
|
|
1418
|
+
}
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
return null;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* The module name a Python file would be imported under, and the folder that has to be on
|
|
1426
|
+
* the import path for that to work.
|
|
1427
|
+
*
|
|
1428
|
+
* @param {string} dir
|
|
1429
|
+
* @param {string} relative
|
|
1430
|
+
* @returns {{name: string, layout: string}|null}
|
|
1431
|
+
*/
|
|
1432
|
+
function pythonModuleName(dir, relative) {
|
|
1433
|
+
const parts = relative.split(/[\\/]/).filter((one) => one !== '' && one !== '.');
|
|
1434
|
+
if (parts.length === 0 || !parts[parts.length - 1].endsWith('.py')) return null;
|
|
1435
|
+
parts[parts.length - 1] = parts[parts.length - 1].replace(/\.py$/, '');
|
|
1436
|
+
// Walk up while each folder is a package, so the module name starts at the top-level
|
|
1437
|
+
// package and never above it. `src/deep_tool/cli.py` is `deep_tool.cli`, mounted at `src`.
|
|
1438
|
+
let start = parts.length - 1;
|
|
1439
|
+
while (start > 0 && existsSync(path.join(dir, ...parts.slice(0, start), '__init__.py'))) start -= 1;
|
|
1440
|
+
const layout = parts.slice(0, start).join('/');
|
|
1441
|
+
if (layout !== '' && layout !== 'src') return null;
|
|
1442
|
+
return { name: parts.slice(start).join('.'), layout };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
961
1445
|
// ---------------------------------------------------------------------------
|
|
962
1446
|
// Journeys, proposed rather than demanded
|
|
963
1447
|
// ---------------------------------------------------------------------------
|
|
@@ -991,12 +1475,19 @@ export function proposeJourneys(project) {
|
|
|
991
1475
|
|
|
992
1476
|
for (const product of project.products) {
|
|
993
1477
|
const suggest = product.suggest ?? {};
|
|
1478
|
+
// The file this product's facts really came from. It used to say "package.json" whatever
|
|
1479
|
+
// the project was written in, so a Python tool was told twice on one screen that its
|
|
1480
|
+
// commands had been read out of a file it does not have. See manifestBehind above.
|
|
1481
|
+
const manifest = manifestBehind(project, product);
|
|
994
1482
|
if (product.kind === 'cli' && Array.isArray(suggest.commands)) {
|
|
995
|
-
|
|
1483
|
+
// Only the commands that will really run are offered as journeys. One listed here is a
|
|
1484
|
+
// promise that a check will walk it, and two of the three listed for a Python tool on
|
|
1485
|
+
// 2026-08-31 could not run at all.
|
|
1486
|
+
for (const command of commandsThatRun(project, product).ready) {
|
|
996
1487
|
out.push({
|
|
997
|
-
name:
|
|
998
|
-
what: `run \`${
|
|
999
|
-
from: product.where === '.' ?
|
|
1488
|
+
name: command.name,
|
|
1489
|
+
what: `run \`${command.run}\` and compare what it printed, what it exited with and every file it touched`,
|
|
1490
|
+
from: product.where === '.' ? manifest : `the program built in ${product.where}/`,
|
|
1000
1491
|
surface: 'cli',
|
|
1001
1492
|
automatic: false,
|
|
1002
1493
|
ready: true,
|
|
@@ -1009,7 +1500,7 @@ export function proposeJourneys(project) {
|
|
|
1009
1500
|
out.push({
|
|
1010
1501
|
name: String(entry.name),
|
|
1011
1502
|
what: `import ${module} and compare what it exports`,
|
|
1012
|
-
from:
|
|
1503
|
+
from: manifest,
|
|
1013
1504
|
surface: 'library',
|
|
1014
1505
|
automatic: false,
|
|
1015
1506
|
// Only if the file is really there. package.json naming an entry does not put one
|
|
@@ -1182,8 +1673,13 @@ export function configText(project) {
|
|
|
1182
1673
|
w('/**');
|
|
1183
1674
|
w(' * Stays Fixed — settings for this project.');
|
|
1184
1675
|
w(' *');
|
|
1676
|
+
// What this project calls itself, and the file it said so in. Both are printed, and the
|
|
1677
|
+
// file has to be the real one: saying "package.json" over a Python project — which this
|
|
1678
|
+
// did, on 2026-08-31 — is a claim a person can check in one second and find false, in the
|
|
1679
|
+
// header of a file that goes on to make thirty claims they cannot check as easily.
|
|
1680
|
+
const called = whatItCallsItself(project.root);
|
|
1185
1681
|
w(` * Written by \`staysfixed init\` on ${new Date().toISOString().slice(0, 10)}, from what is actually in this`);
|
|
1186
|
-
w(
|
|
1682
|
+
w(` * repository. Everything below was read out of the code, the folder names and ${called.from};`);
|
|
1187
1683
|
w(' * nothing was guessed, and nothing was asked.');
|
|
1188
1684
|
w(' *');
|
|
1189
1685
|
w(` * WHAT THIS REPOSITORY MAKES: ${project.summary}`);
|
|
@@ -1228,7 +1724,11 @@ export function configText(project) {
|
|
|
1228
1724
|
w(' // settings below describe. To check one of the others, run `staysfixed check` from');
|
|
1229
1725
|
w(' // inside that package, or point at its settings with `--config <file>`.');
|
|
1230
1726
|
}
|
|
1231
|
-
|
|
1727
|
+
// The name the project gives ITSELF, not the folder it happens to be checked out into.
|
|
1728
|
+
// A Python tool that calls itself `lint-lens` in pyproject.toml was recorded here as
|
|
1729
|
+
// "pytool", after the directory, on 2026-08-31. The record is kept under this name, so a
|
|
1730
|
+
// wrong one quietly splits one product's history in two the day somebody renames a folder.
|
|
1731
|
+
w(` product: ${JSON.stringify(called.name)},`);
|
|
1232
1732
|
w('');
|
|
1233
1733
|
|
|
1234
1734
|
// ── source ────────────────────────────────────────────────────────────────
|
|
@@ -1267,14 +1767,36 @@ export function configText(project) {
|
|
|
1267
1767
|
w(' // does safely — and its help text is a precise description of everything it offers, so');
|
|
1268
1768
|
w(' // a command that quietly disappears is caught by comparing it.');
|
|
1269
1769
|
const cliProducts = all('cli');
|
|
1270
|
-
|
|
1770
|
+
// Every command is checked before it is written down, and the ones that cannot be worked
|
|
1771
|
+
// out are written commented out with a sentence saying what to put there. Two commands
|
|
1772
|
+
// that answered "command not found" went into a real project's settings on 2026-08-31, so
|
|
1773
|
+
// the first check somebody ran was red about nothing. See commandsThatRun above.
|
|
1774
|
+
/** @type {SoundCommand[]} */
|
|
1271
1775
|
const commands = [];
|
|
1272
|
-
|
|
1273
|
-
const
|
|
1776
|
+
/** @type {UnsureCommand[]} */
|
|
1777
|
+
const unsure = [];
|
|
1778
|
+
for (const one of cliProducts) {
|
|
1779
|
+
const judged = commandsThatRun(project, one);
|
|
1780
|
+
commands.push(...judged.ready);
|
|
1781
|
+
unsure.push(...judged.unsure);
|
|
1782
|
+
}
|
|
1783
|
+
// A product that runs straight from the source is NOT an unbuilt one, and saying it is
|
|
1784
|
+
// put a paragraph in the settings of a Python tool and of a plain Node one telling each of
|
|
1785
|
+
// them to go and build something, over a program that was sitting right there and that the
|
|
1786
|
+
// same run had already worked out how to run. It also said "nothing in package.json names
|
|
1787
|
+
// it" about commands that package.json names in its own `bin` field. Two untrue sentences
|
|
1788
|
+
// about somebody's project, in the file this tool wrote for them. Measured 2026-08-31.
|
|
1789
|
+
//
|
|
1790
|
+
// The same two signals the needs list uses, and either is enough: the detector saying there
|
|
1791
|
+
// is nothing to build, and a command already worked out for it.
|
|
1792
|
+
const unbuilt = cliProducts.filter((one) => !one.built.found
|
|
1793
|
+
&& !/nothing to build/i.test(String(one.built.how ?? ''))
|
|
1794
|
+
&& commandsThatRun(project, one).ready.length === 0);
|
|
1274
1795
|
if (commands.length > 0) {
|
|
1275
1796
|
w(' commands: [');
|
|
1276
1797
|
for (const command of commands) {
|
|
1277
|
-
|
|
1798
|
+
const env = command.env ? `, env: { ${Object.entries(command.env).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }` : '';
|
|
1799
|
+
w(` { name: ${JSON.stringify(command.name)}, run: ${JSON.stringify(command.run)}, describe: ${JSON.stringify(command.describe ?? '')}${env} },`);
|
|
1278
1800
|
}
|
|
1279
1801
|
w(' ],');
|
|
1280
1802
|
w(' // Add any other command whose output you would notice changing. Each entry also takes:');
|
|
@@ -1284,10 +1806,18 @@ export function configText(project) {
|
|
|
1284
1806
|
w(" // commands: [{ name: 'help', run: 'node bin/cli.js --help', describe: 'print the help' }],");
|
|
1285
1807
|
w(' commands: [],');
|
|
1286
1808
|
}
|
|
1809
|
+
for (const one of unsure) {
|
|
1810
|
+
w('');
|
|
1811
|
+
w(` // \`${one.name}\` is left blank on purpose, because a command that does not run would`);
|
|
1812
|
+
w(' // make your very first check red about nothing.');
|
|
1813
|
+
for (const line of wrapProse(one.why, 88)) w(` // ${line}`);
|
|
1814
|
+
for (const line of wrapProse(`Fill this in and delete the comment: ${one.whatToPut}`, 88)) w(` // ${line}`);
|
|
1815
|
+
w(` // { name: ${JSON.stringify(one.name)}, run: '...', describe: 'ask it to print its help, and compare every word of it' },`);
|
|
1816
|
+
}
|
|
1287
1817
|
for (const one of unbuilt) {
|
|
1288
1818
|
const build = typeof one.suggest?.buildWith === 'string' ? String(one.suggest.buildWith) : null;
|
|
1289
1819
|
w('');
|
|
1290
|
-
w(` // ${one.where}/ holds a real command-line program that nothing in
|
|
1820
|
+
w(` // ${one.where}/ holds a real command-line program that nothing in ${manifestBehind(project, one)} names, so it`);
|
|
1291
1821
|
w(' // was found by reading the code rather than the manifest — and it has not been built here');
|
|
1292
1822
|
w(` // yet${one.suggest?.outDir ? `, so ${String(one.suggest.outDir)}/ is empty` : ''}. There is nothing to run until it is:`);
|
|
1293
1823
|
w(` // ${build ?? 'build it the way this project builds it'}`);
|
|
@@ -1424,7 +1954,18 @@ export function configText(project) {
|
|
|
1424
1954
|
w(`${webOn}// the run says so by name and the fix is one word here.`);
|
|
1425
1955
|
}
|
|
1426
1956
|
} else if (project.pages.length > 0) {
|
|
1427
|
-
|
|
1957
|
+
// WHY THESE ARE A COMMENT AND NOT A LIST. Every one is already turned into a journey by
|
|
1958
|
+
// the page reader in adapters/web.js, so writing them under `screens:` would walk each
|
|
1959
|
+
// page of the site twice and double every run for nothing. But a person who cannot SEE
|
|
1960
|
+
// which pages those are cannot tell a site being covered from a site being glanced at —
|
|
1961
|
+
// which on 2026-08-31 is exactly what "covers the website in full" was printed over, on a
|
|
1962
|
+
// three-page site where two pages were never opened.
|
|
1963
|
+
if (web?.router?.why) for (const line of wrapProse(web.router.why, 76)) w(`${webOn}// ${line}`);
|
|
1964
|
+
w(`${webOn}// These ${project.pages.length} address${project.pages.length === 1 ? ' is' : 'es are'} opened automatically. The list is here so you can see them, not so you have to write them:`);
|
|
1965
|
+
for (const page of project.pages.slice(0, 40)) {
|
|
1966
|
+
w(`${webOn}// ${page.url}${page.needs.length > 0 ? ` — waiting on a value for ${page.needs.join(' and ')}` : ''}`);
|
|
1967
|
+
}
|
|
1968
|
+
if (project.pages.length > 40) w(`${webOn}// and ${project.pages.length - 40} more.`);
|
|
1428
1969
|
w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
|
|
1429
1970
|
// `fill:` and `with:` are not words this tool knows — the verb is `type:` and the value
|
|
1430
1971
|
// is `text:`. An unknown key used to be skipped in silence, so this example, handed to
|
|
@@ -1663,8 +2204,8 @@ function whatItCovers(readiness) {
|
|
|
1663
2204
|
|
|
1664
2205
|
/** @type {string[]} */
|
|
1665
2206
|
const parts = [];
|
|
1666
|
-
if (covered.length > 0) parts.push(`Right now a check here
|
|
1667
|
-
else parts.push('Right now a check here
|
|
2207
|
+
if (covered.length > 0) parts.push(`Right now a check here can walk ${plainList(covered)}. How much of ${covered.length === 1 ? 'it' : 'them'} a run actually opens is a different question, and \`staysfixed coverage\` is the one that answers it.`);
|
|
2208
|
+
else parts.push('Right now a check here can walk nothing.');
|
|
1668
2209
|
if (waiting.length > 0) parts.push(`${plainList(waiting, true)} ${waiting.length === 1 ? 'is' : 'are'} not covered yet, and the list below says exactly what is in the way and who has to do it.`);
|
|
1669
2210
|
if (notCovered.length > 0) parts.push(`${plainList(notCovered, true)} ${notCovered.length === 1 ? 'is' : 'are'} not checked at all, so a clean result says nothing whatever about ${notCovered.length === 1 ? 'it' : 'them'}.`);
|
|
1670
2211
|
// "on this machine", because that is the only thing this sentence knows. It is built from
|