staysfixed 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +342 -0
- package/README.md +191 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +285 -45
- package/src/v2/init.js +162 -61
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +10 -11
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +40 -10
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +29 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
|
@@ -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
|
+
}
|