staysfixed 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Reading a Python project without running any of it.
3
+ *
4
+ * Two of this tool's adapters never read a line of the product's source: the process adapter
5
+ * runs a command and compares what it printed, and the HTTP adapter boots a server on a
6
+ * spare port and asks it for routes. Neither one cares what language anything is written in.
7
+ * So a Flask app was always checkable here, and the tool turned it away anyway — it said "a
8
+ * Python project is in a language nothing here drives", which was true of the source reader
9
+ * and false of everything else, and the person went away with nothing.
10
+ *
11
+ * What was actually missing was the addresses. The HTTP adapter can only ask for routes
12
+ * somebody found first, and the JavaScript reader cannot see a Python one. This file finds
13
+ * them, and it finds nothing else: it does not read what a view returns, what a function
14
+ * does, or what any of it means. That limit is real and is reported by name rather than
15
+ * quietly folded into a clean result.
16
+ *
17
+ * The addresses come out in the same shape the rest of the tool already uses, so a part of
18
+ * an address that changes reads as ":pid" whether it was written for Flask, for FastAPI or
19
+ * for Django — and the flow that asks a person for one real value works unchanged.
20
+ */
21
+
22
+ import path from 'node:path';
23
+ import fsp from 'node:fs/promises';
24
+
25
+ /** @typedef {import('./source.js').Door} Door */
26
+
27
+ /** Folders that never hold a route, and would cost a great deal to walk. */
28
+ const SKIP_DIRS = new Set([
29
+ '.git', '.venv', 'venv', 'env', '__pycache__', 'site-packages', 'node_modules',
30
+ 'migrations', 'build', 'dist', '.tox', '.mypy_cache', '.pytest_cache', '.eggs',
31
+ 'static', 'staticfiles', 'media', '.staysfixed',
32
+ ]);
33
+
34
+ /** The verbs Flask and FastAPI hang a route off directly. */
35
+ const VERB_DECORATORS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
36
+
37
+ /** What a route reads as when nothing in the code says which verb it answers. */
38
+ const METHOD_UNKNOWN = 'ANY';
39
+
40
+ /** The calls that put an address into a Django urlpatterns list. */
41
+ const DJANGO_CALLS = new Set(['path', 're_path', 'url']);
42
+
43
+ /** How many Python files to open before stopping, and how big one may be. */
44
+ const MOST_FILES = 400;
45
+ const MOST_BYTES = 2_000_000;
46
+
47
+ /**
48
+ * Blank out comments and docstrings, keeping every newline so line numbers still line up.
49
+ *
50
+ * Not optional. A docstring showing somebody how to use the library is full of example
51
+ * route decorators, and reading those as real routes would put addresses in the report that
52
+ * the product does not serve — which is worse than finding none at all.
53
+ *
54
+ * @param {string} text
55
+ * @returns {string}
56
+ */
57
+ export function withoutCommentsAndDocstrings(text) {
58
+ let out = '';
59
+ let i = 0;
60
+ const n = text.length;
61
+ while (i < n) {
62
+ const c = text[i];
63
+ if (c === '#') {
64
+ while (i < n && text[i] !== '\n') { out += ' '; i++; }
65
+ continue;
66
+ }
67
+ if (c === '"' || c === "'") {
68
+ const triple = text.slice(i, i + 3);
69
+ if (triple === '"""' || triple === "'''") {
70
+ const close = text.indexOf(triple, i + 3);
71
+ const end = close === -1 ? n : close + 3;
72
+ for (let j = i; j < end; j++) out += text[j] === '\n' ? '\n' : ' ';
73
+ i = end;
74
+ continue;
75
+ }
76
+ out += c;
77
+ i++;
78
+ let escaped = false;
79
+ while (i < n) {
80
+ const d = text[i];
81
+ out += d;
82
+ i++;
83
+ if (escaped) { escaped = false; continue; }
84
+ if (d === '\\') { escaped = true; continue; }
85
+ if (d === c || d === '\n') break;
86
+ }
87
+ continue;
88
+ }
89
+ out += c;
90
+ i++;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Index of the bracket closing the one at `open`, or -1 when the file does not balance.
97
+ * Quotes are stepped over, because an address is allowed to contain a bracket.
98
+ * @param {string} text
99
+ * @param {number} open
100
+ */
101
+ function matchParen(text, open) {
102
+ let depth = 0;
103
+ for (let i = open; i < text.length; i++) {
104
+ const c = text[i];
105
+ if (c === '"' || c === "'") {
106
+ i++;
107
+ while (i < text.length && text[i] !== c) { if (text[i] === '\\') i++; i++; }
108
+ continue;
109
+ }
110
+ if (c === '(' || c === '[' || c === '{') depth++;
111
+ else if (c === ')' || c === ']' || c === '}') { depth--; if (depth === 0) return i; }
112
+ }
113
+ return -1;
114
+ }
115
+
116
+ /** The line a character offset falls on, counting from 1. @param {string} text @param {number} at */
117
+ function lineAt(text, at) {
118
+ let line = 1;
119
+ for (let i = 0; i < at && i < text.length; i++) if (text[i] === '\n') line++;
120
+ return line;
121
+ }
122
+
123
+ /**
124
+ * The first plain string written inside a call's brackets, when the first thing given is one.
125
+ * A name, an f-string or anything worked out while it runs answers null, because a route
126
+ * whose address is assembled at run time is a door we know is there and cannot name.
127
+ * @param {string} args
128
+ * @returns {string|null}
129
+ */
130
+ function firstString(args) {
131
+ const found = /^\s*(?:r|rb|br|R)?(['"])((?:[^'"\\]|\\.)*)\1/.exec(args);
132
+ return found ? found[2] : null;
133
+ }
134
+
135
+ /** Every string in a `name=[...]` list inside a call's brackets. @param {string} args @param {string} key */
136
+ function stringsNamed(args, key) {
137
+ const list = new RegExp(`\\b${key}\\s*=\\s*[\\[(]([^\\])]*)[\\])]`).exec(args);
138
+ if (!list) return [];
139
+ return [...list[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1]);
140
+ }
141
+
142
+ /** A single `name='value'` string inside a call's brackets. @param {string} args @param {string} key */
143
+ function stringNamed(args, key) {
144
+ const found = new RegExp(`\\b${key}\\s*=\\s*(['"])((?:[^'"\\\\]|\\\\.)*)\\1`).exec(args);
145
+ return found ? found[2] : null;
146
+ }
147
+
148
+ /**
149
+ * Rewrite the parts of an address that change into the one shape this tool asks about.
150
+ *
151
+ * Flask and Django spell a changing part one way, FastAPI another, and the tool already has
152
+ * a flow that says "a real value for pid — somebody has to". Turning all of them into that
153
+ * spelling is what lets a Flask route reach that flow without a line of it being rewritten.
154
+ *
155
+ * @param {string} raw
156
+ * @returns {string}
157
+ */
158
+ export function changingParts(raw) {
159
+ return raw
160
+ .replace(/<\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*:\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*>/g, (_all, name) => `:${name}`)
161
+ .replace(/\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^}]*)?\}/g, (_all, name) => `:${name}`);
162
+ }
163
+
164
+ /**
165
+ * An address out of a Django regular expression, or null when it cannot be read plainly.
166
+ *
167
+ * Anything with regex punctuation still in it after the named groups come out is refused.
168
+ * A route reported as `/archive/[0-9]{4}` is an address nobody can ask for, and putting one
169
+ * in the list would mean the tool asking for it, getting a 404, and calling the product
170
+ * broken. Missing it is the lesser wrong, and the count of what was refused is reported.
171
+ *
172
+ * @param {string} raw
173
+ * @returns {string|null}
174
+ */
175
+ export function addressFromRegex(raw) {
176
+ const named = raw.replace(/\(\?P<([A-Za-z_][A-Za-z0-9_]*)>[^)]*\)/g, (_all, name) => `:${name}`);
177
+ const bare = named.replace(/^\^/, '').replace(/\$$/, '');
178
+ if (/[\\[\]()*+?|^$]/.test(bare)) return null;
179
+ return bare;
180
+ }
181
+
182
+ /**
183
+ * Join a mount point and an address without doubling or losing the slash between them.
184
+ * @param {string} prefix
185
+ * @param {string} rest
186
+ */
187
+ function joinRoute(prefix, rest) {
188
+ const left = (prefix ?? '').replace(/\/+$/, '');
189
+ const right = rest.startsWith('/') ? rest : `/${rest}`;
190
+ const joined = `${left}${right}`;
191
+ return joined.startsWith('/') ? joined : `/${joined}`;
192
+ }
193
+
194
+ /**
195
+ * A route this product really answers on, or null.
196
+ * Same standard of care the JavaScript reader holds itself to: a string that merely looks
197
+ * like an address is not one, and it is better to miss a route than to invent one.
198
+ * @param {string} value
199
+ */
200
+ function usableRoute(value) {
201
+ if (value.length > 120) return null;
202
+ if (/[\\<>{}()\s]/.test(value)) return null;
203
+ return value;
204
+ }
205
+
206
+ /**
207
+ * @param {string} name
208
+ * @param {string} detail
209
+ * @param {string} file
210
+ * @param {number} line
211
+ * @param {boolean} inTest
212
+ * @param {string} via
213
+ * @returns {Door}
214
+ */
215
+ function routeDoor(name, detail, file, line, inTest, via) {
216
+ return { kind: 'route', name, detail, file, line, inTest, named: true, via };
217
+ }
218
+
219
+ /** @param {string} rel */
220
+ function looksLikeATest(rel) {
221
+ const where = rel.split(path.sep).join('/');
222
+ return /(^|\/)(tests?|__tests__|e2e|fixtures)\//.test(where) || /(^|\/)(test_[^/]*|[^/]*_test)\.py$/.test(where);
223
+ }
224
+
225
+ /**
226
+ * Every Python file in a project, bounded, with its text.
227
+ * @param {string} root
228
+ * @returns {Promise<{files: {rel: string, text: string}[], problems: string[]}>}
229
+ */
230
+ async function collectPython(root) {
231
+ /** @type {{rel: string, text: string}[]} */
232
+ const files = [];
233
+ /** @type {string[]} */
234
+ const problems = [];
235
+ /** @param {string} here @param {number} depth @returns {Promise<void>} */
236
+ const walk = async (here, depth) => {
237
+ if (files.length >= MOST_FILES || depth > 6) return;
238
+ /** @type {import('node:fs').Dirent[]} */
239
+ let entries;
240
+ try {
241
+ entries = await fsp.readdir(here, { withFileTypes: true });
242
+ } catch {
243
+ return;
244
+ }
245
+ for (const entry of entries) {
246
+ if (files.length >= MOST_FILES) return;
247
+ if (entry.isSymbolicLink()) continue;
248
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
249
+ const full = path.join(here, entry.name);
250
+ if (entry.isDirectory()) {
251
+ if (!SKIP_DIRS.has(entry.name)) await walk(full, depth + 1);
252
+ continue;
253
+ }
254
+ if (!entry.name.endsWith('.py')) continue;
255
+ const rel = path.relative(root, full);
256
+ try {
257
+ const stat = await fsp.stat(full);
258
+ if (stat.size > MOST_BYTES) {
259
+ problems.push(`${rel} is bigger than this reader will open, so any route in it is invisible to this run.`);
260
+ continue;
261
+ }
262
+ files.push({ rel, text: await fsp.readFile(full, 'utf8') });
263
+ } catch (error) {
264
+ problems.push(`${rel} could not be opened: ${error instanceof Error ? error.message : String(error)}`);
265
+ }
266
+ }
267
+ };
268
+ await walk(root, 0);
269
+ return { files, problems };
270
+ }
271
+
272
+ /**
273
+ * What one Python file says about itself.
274
+ *
275
+ * @typedef {object} PythonFileReading
276
+ * @property {Door[]} doors
277
+ * @property {Set<string>} frameworks 'flask', 'fastapi' or 'django', when the imports say so.
278
+ * @property {{variable: string, framework: string}[]} apps Names holding a web application.
279
+ * @property {number} refused Addresses seen and deliberately not claimed.
280
+ */
281
+
282
+ /**
283
+ * Read one Python file.
284
+ *
285
+ * @param {string} rel
286
+ * @param {string} raw
287
+ * @param {Record<string, string>} mountedAt Module path to the address it is included at.
288
+ * @returns {PythonFileReading}
289
+ */
290
+ export function readPythonFile(rel, raw, mountedAt = {}) {
291
+ const text = withoutCommentsAndDocstrings(raw);
292
+ const inTest = looksLikeATest(rel);
293
+ /** @type {Door[]} */
294
+ const doors = [];
295
+ /** @type {Set<string>} */
296
+ const frameworks = new Set();
297
+ /** @type {{variable: string, framework: string}[]} */
298
+ const apps = [];
299
+ let refused = 0;
300
+
301
+ if (/\bfrom\s+flask\b|\bimport\s+flask\b/i.test(text)) frameworks.add('flask');
302
+ if (/\bfrom\s+fastapi\b|\bimport\s+fastapi\b/i.test(text)) frameworks.add('fastapi');
303
+ if (/\bfrom\s+django\b|\bimport\s+django\b|\bdjango\.urls\b/i.test(text)) frameworks.add('django');
304
+
305
+ // Which names hold something routes can be hung off, and what each one is mounted under.
306
+ // A router built with a prefix serves every one of its routes under that prefix, and a
307
+ // report that left the prefix off would name addresses the product answers 404 on.
308
+ /** @type {Map<string, string>} */
309
+ const prefixOf = new Map();
310
+ const factory = /(^|\n)[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(Flask|FastAPI|APIRouter|Blueprint)[ \t]*\(/g;
311
+ for (let found = factory.exec(text); found; found = factory.exec(text)) {
312
+ const open = text.indexOf('(', found.index + found[0].length - 1);
313
+ const close = matchParen(text, open);
314
+ const args = close === -1 ? '' : text.slice(open + 1, close);
315
+ const variable = found[2];
316
+ const built = found[3];
317
+ prefixOf.set(variable, stringNamed(args, 'prefix') ?? stringNamed(args, 'url_prefix') ?? '');
318
+ if (built === 'Flask') { frameworks.add('flask'); apps.push({ variable, framework: 'flask' }); }
319
+ if (built === 'FastAPI') { frameworks.add('fastapi'); apps.push({ variable, framework: 'fastapi' }); }
320
+ }
321
+
322
+ // Route decorators: the line above a function that says which address reaches it.
323
+ const decorator = /(^|\n)[ \t]*@[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*\.[ \t]*([A-Za-z_]+)[ \t]*\(/g;
324
+ for (let found = decorator.exec(text); found; found = decorator.exec(text)) {
325
+ const receiver = found[2];
326
+ const verb = found[3];
327
+ const isRoute = verb === 'route' || VERB_DECORATORS.has(verb);
328
+ if (!isRoute) continue;
329
+ // A receiver has to have been built from a framework, or be one of the two names every
330
+ // Flask and FastAPI tutorial uses in a file that imports one. Without this an ordinary
331
+ // decorator such as a cache or a retry helper would start producing routes.
332
+ const proven = prefixOf.has(receiver);
333
+ const conventional = frameworks.size > 0 && ['app', 'router', 'api', 'bp', 'blueprint'].includes(receiver);
334
+ if (!proven && !conventional) continue;
335
+
336
+ const open = found.index + found[0].length - 1;
337
+ const close = matchParen(text, open);
338
+ if (close === -1) continue;
339
+ const args = text.slice(open + 1, close);
340
+ const written = firstString(args);
341
+ if (written === null) { refused++; continue; }
342
+ const address = usableRoute(joinRoute(prefixOf.get(receiver) ?? '', changingParts(written)));
343
+ if (address === null) { refused++; continue; }
344
+
345
+ const line = lineAt(text, found.index + (found[1] ? 1 : 0));
346
+ const listed = stringsNamed(args, 'methods');
347
+ const verbs = verb === 'route'
348
+ ? (listed.length > 0 ? listed.map((m) => m.toUpperCase()) : ['GET'])
349
+ : [verb.toUpperCase()];
350
+ for (const method of verbs) doors.push(routeDoor(address, method, rel, line, inTest, 'a route decorator in the Python source'));
351
+ }
352
+
353
+ // Django keeps its addresses in a list instead, and the list is the only place they are.
354
+ const patterns = /\burlpatterns\b[ \t]*\+?=[ \t]*\[/g;
355
+ for (let found = patterns.exec(text); found; found = patterns.exec(text)) {
356
+ const open = text.indexOf('[', found.index);
357
+ const close = matchParen(text, open);
358
+ if (close === -1) continue;
359
+ const body = text.slice(open + 1, close);
360
+ const mounted = mountedAt[moduleNameOf(rel)] ?? '';
361
+ const call = /\b(path|re_path|url)[ \t]*\(/g;
362
+ for (let one = call.exec(body); one; one = call.exec(body)) {
363
+ if (!DJANGO_CALLS.has(one[1])) continue;
364
+ const openCall = one.index + one[0].length - 1;
365
+ const closeCall = matchParen(body, openCall);
366
+ if (closeCall === -1) continue;
367
+ const args = body.slice(openCall + 1, closeCall);
368
+ const written = firstString(args);
369
+ if (written === null) { refused++; continue; }
370
+ // A line that mounts another module's addresses is not itself an address.
371
+ if (/\binclude\s*\(/.test(args)) continue;
372
+ const plain = one[1] === 'path' ? changingParts(written) : addressFromRegex(written);
373
+ if (plain === null) { refused++; continue; }
374
+ const address = usableRoute(joinRoute(mounted, plain));
375
+ if (address === null) { refused++; continue; }
376
+ const line = lineAt(text, open + 1 + one.index);
377
+ // Django hands every verb to the same view and the view decides, so nothing in the
378
+ // list says which one answers. Writing GET here would be putting a fact in the report
379
+ // that is not in the code.
380
+ doors.push(routeDoor(address, METHOD_UNKNOWN, rel, line, inTest, 'the urlpatterns list'));
381
+ }
382
+ }
383
+
384
+ return { doors, frameworks, apps, refused };
385
+ }
386
+
387
+ /** The dotted module name a file would be imported as. @param {string} rel */
388
+ function moduleNameOf(rel) {
389
+ return rel.split(path.sep).join('/').replace(/\.py$/, '').replace(/\/__init__$/, '').split('/').join('.');
390
+ }
391
+
392
+ /**
393
+ * Where each Django app's addresses are mounted, read out of the include() calls.
394
+ * @param {{rel: string, text: string}[]} files
395
+ * @returns {Record<string, string>}
396
+ */
397
+ function includePrefixes(files) {
398
+ /** @type {Record<string, string>} */
399
+ const mounted = {};
400
+ for (const one of files) {
401
+ const text = withoutCommentsAndDocstrings(one.text);
402
+ const call = /\bpath[ \t]*\(\s*(['"])((?:[^'"\\]|\\.)*)\1\s*,\s*include\s*\(\s*(['"])((?:[^'"\\]|\\.)*)\3/g;
403
+ for (let found = call.exec(text); found; found = call.exec(text)) {
404
+ mounted[found[4].replace(/\.urls$/, '.urls')] = `/${found[2]}`.replace(/\/+/g, '/');
405
+ }
406
+ }
407
+ return mounted;
408
+ }
409
+
410
+ /**
411
+ * Everything this reader can say about a Python project.
412
+ *
413
+ * @typedef {object} PythonReading
414
+ * @property {Door[]} doors
415
+ * @property {string[]} problems
416
+ * @property {number} filesRead
417
+ * @property {string[]} frameworks Web frameworks actually imported, by name.
418
+ * @property {string|null} appFile The file holding the application object.
419
+ * @property {string|null} appTarget What a server would be pointed at: 'app:app'.
420
+ * @property {string|null} managePy Django's own entry point, when there is one.
421
+ * @property {string[]} entries Files that look like a command somebody types.
422
+ * @property {number} refused Addresses seen and deliberately not claimed.
423
+ */
424
+
425
+ /**
426
+ * Read a whole Python project.
427
+ * @param {string} root
428
+ * @returns {Promise<PythonReading>}
429
+ */
430
+ export async function readPython(root) {
431
+ const { files, problems } = await collectPython(root);
432
+ const mountedAt = includePrefixes(files);
433
+
434
+ /** @type {Door[]} */
435
+ const doors = [];
436
+ /** @type {Set<string>} */
437
+ const frameworks = new Set();
438
+ let appFile = null;
439
+ let appTarget = null;
440
+ let managePy = null;
441
+ let refused = 0;
442
+ /** @type {string[]} */
443
+ const entries = [];
444
+
445
+ for (const one of files) {
446
+ const reading = readPythonFile(one.rel, one.text, mountedAt);
447
+ doors.push(...reading.doors);
448
+ for (const name of reading.frameworks) frameworks.add(name);
449
+ refused += reading.refused;
450
+ if (!appFile && reading.apps.length > 0) {
451
+ appFile = one.rel;
452
+ appTarget = `${moduleNameOf(one.rel)}:${reading.apps[0].variable}`;
453
+ }
454
+ const base = path.basename(one.rel);
455
+ if (base === 'manage.py') managePy = one.rel;
456
+ // A file somebody types the name of. Both halves have to agree, because a module that
457
+ // merely reads sys.argv could be a helper nobody ever runs directly.
458
+ const looksTyped = /^(cli|main|__main__|run|manage|app)\.py$/.test(base);
459
+ const readsArgv = /\bsys\.argv\b|\bargparse\b|\bclick\b|\btyper\b/.test(one.text);
460
+ if (looksTyped && readsArgv && !looksLikeATest(one.rel)) entries.push(one.rel);
461
+ }
462
+
463
+ doors.sort((a, b) => a.name.localeCompare(b.name) || a.detail.localeCompare(b.detail) || a.file.localeCompare(b.file));
464
+
465
+ return {
466
+ doors: doors.filter((d) => !d.inTest),
467
+ problems,
468
+ filesRead: files.length,
469
+ frameworks: [...frameworks],
470
+ appFile,
471
+ appTarget,
472
+ managePy,
473
+ entries,
474
+ refused,
475
+ };
476
+ }
477
+
478
+ /**
479
+ * The routes of a Python project, in the shape the rest of the tool reads doors in.
480
+ * @param {string} root
481
+ * @returns {Promise<{doors: Door[], problems: string[]}>}
482
+ */
483
+ export async function readPythonRoutes(root) {
484
+ try {
485
+ const reading = await readPython(root);
486
+ const problems = [...reading.problems];
487
+ if (reading.refused > 0) {
488
+ const many = reading.refused !== 1;
489
+ problems.push(`${reading.refused} Python address${many ? 'es were' : ' was'} written in a way this reader could not turn into an address anybody can ask for — built while it runs, or a regular expression with more than a name in it — so ${many ? 'they are' : 'it is'} not in the route list and nothing here watches ${many ? 'them' : 'it'}.`);
490
+ }
491
+ return { doors: reading.doors, problems };
492
+ } catch (error) {
493
+ return { doors: [], problems: [`The Python source could not be read: ${error instanceof Error ? error.message : String(error)}`] };
494
+ }
495
+ }