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/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
- const somethingToRun = (Array.isArray(suggest.commands) ? suggest.commands.length : 0)
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
- if ((product.kind === 'cli' || product.kind === 'library') && somethingToRun === 0) {
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: '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 package.json names it either, which is why it is easy to miss entirely.',
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 package.json says how to build it. Name the command that builds it and the command that runs the result under "process" in the settings.`,
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
- if (/commands worth running|to import and compare|process\.commands/.test(words)) return 'commands';
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
- for (const command of suggest.commands) {
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: String(command.name),
998
- what: `run \`${String(command.run)}\` and compare what it printed, what it exited with and every file it touched`,
999
- from: product.where === '.' ? 'package.json' : `the program built in ${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: 'package.json',
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(' * repository. Everything below was read out of the code, the folder names and package.json;');
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
- w(` product: ${JSON.stringify(project.name)},`);
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
- /** @type {any[]} */
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
- for (const one of cliProducts) for (const command of /** @type {any[]} */ (one.suggest?.commands ?? [])) commands.push(command);
1273
- const unbuilt = cliProducts.filter((one) => !one.built.found);
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
- w(` { name: ${JSON.stringify(String(command.name))}, run: ${JSON.stringify(String(command.run))}, describe: ${JSON.stringify(String(command.describe ?? ''))} },`);
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 package.json names, so it`);
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
- w(`${webOn}// ${project.pages.length} page address${project.pages.length === 1 ? '' : 'es'} are read out of your folder names automatically — nothing to list here.`);
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 covers ${plainList(covered)} in full.`);
1667
- else parts.push('Right now a check here covers nothing in full.');
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