pll-python 0.1.0 → 0.2.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/README.md +124 -1
- package/cli.cjs +1555 -1179
- package/package.json +2 -2
- package/worker.cjs +44 -781
package/cli.cjs
CHANGED
|
@@ -24,1329 +24,1674 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
));
|
|
25
25
|
|
|
26
26
|
// src/cli/main.ts
|
|
27
|
-
var
|
|
27
|
+
var path7 = __toESM(require("node:path"));
|
|
28
28
|
|
|
29
|
-
// src/cli/
|
|
30
|
-
var
|
|
29
|
+
// src/cli/examplar.ts
|
|
30
|
+
var fs3 = __toESM(require("node:fs/promises"));
|
|
31
|
+
var path3 = __toESM(require("node:path"));
|
|
31
32
|
|
|
32
|
-
// src/
|
|
33
|
-
var
|
|
34
|
-
var
|
|
35
|
-
var import_node_worker_threads = require("node:worker_threads");
|
|
33
|
+
// src/cli/run.ts
|
|
34
|
+
var fs2 = __toESM(require("node:fs/promises"));
|
|
35
|
+
var path2 = __toESM(require("node:path"));
|
|
36
36
|
|
|
37
|
-
// src/common/
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
message: result.error_message ?? "",
|
|
52
|
-
traceback: result.traceback ?? "",
|
|
53
|
-
lineNumber: result.line_number,
|
|
54
|
-
column: result.column,
|
|
55
|
-
fileName
|
|
56
|
-
});
|
|
37
|
+
// src/common/analyzers/static/shadowingExplainer.ts
|
|
38
|
+
function outerScopeNoun(kind) {
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case "module":
|
|
41
|
+
return "the file";
|
|
42
|
+
case "function":
|
|
43
|
+
case "lambda":
|
|
44
|
+
return "an enclosing function";
|
|
45
|
+
case "class":
|
|
46
|
+
return "an enclosing class";
|
|
47
|
+
case "comprehension":
|
|
48
|
+
return "an enclosing comprehension";
|
|
49
|
+
default:
|
|
50
|
+
return "an outer scope";
|
|
57
51
|
}
|
|
58
|
-
onEvent({ kind: "done" });
|
|
59
52
|
}
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
function explainShadowing(raw, level, fileName) {
|
|
54
|
+
const name = raw.name_token ?? "this name";
|
|
55
|
+
const outerLine = raw.outer_line_number ?? null;
|
|
56
|
+
const outerNoun = outerScopeNoun(raw.outer_scope_kind);
|
|
57
|
+
const knownOuterLine = outerLine !== null && outerLine > 0;
|
|
58
|
+
const outerHint = knownOuterLine ? ` (first defined on line ${outerLine}, in ${outerNoun})` : ` in ${outerNoun}`;
|
|
59
|
+
return {
|
|
60
|
+
id: "shadowing",
|
|
61
|
+
errorType: "Shadowing",
|
|
62
|
+
message: raw.message,
|
|
63
|
+
headline: `\`${name}\` is already defined${outerHint}.`,
|
|
64
|
+
howToFix: [
|
|
65
|
+
`Rename the inner \`${name}\` to something distinct (e.g. \`${name}_inner\`, \`local_${name}\`, or whatever describes its role).`,
|
|
66
|
+
`Or, if you want to use the outer \`${name}\`, just read it directly without making a new variable.`
|
|
67
|
+
],
|
|
68
|
+
fileName,
|
|
69
|
+
lineNumber: raw.line_number,
|
|
70
|
+
column: raw.column,
|
|
71
|
+
nameToken: raw.name_token,
|
|
72
|
+
severity: "error",
|
|
73
|
+
raw: JSON.stringify(raw),
|
|
74
|
+
origin: "static",
|
|
75
|
+
level
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function explainShadowingBuiltin(raw, level, fileName) {
|
|
79
|
+
const name = raw.name_token ?? "this name";
|
|
80
|
+
const headline = `\`${name}\` is the name of a Python built-in.`;
|
|
81
|
+
return {
|
|
82
|
+
id: "shadowing-builtin",
|
|
83
|
+
errorType: "Shadowing",
|
|
84
|
+
message: raw.message,
|
|
85
|
+
headline,
|
|
86
|
+
howToFix: [
|
|
87
|
+
`Pick a different name. Common patterns: \`my_${name}\`, \`${name}_value\`, \`${name}s\` (plural), or a more specific noun describing what this value represents.`
|
|
88
|
+
],
|
|
89
|
+
fileName,
|
|
90
|
+
lineNumber: raw.line_number,
|
|
91
|
+
column: raw.column,
|
|
92
|
+
nameToken: raw.name_token,
|
|
93
|
+
severity: "error",
|
|
94
|
+
raw: JSON.stringify(raw),
|
|
95
|
+
origin: "static",
|
|
96
|
+
level
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function libraryNoun(library) {
|
|
100
|
+
switch (library) {
|
|
68
101
|
case "image":
|
|
69
|
-
|
|
70
|
-
kind: "image",
|
|
71
|
-
svg: display.data,
|
|
72
|
-
width: display.width,
|
|
73
|
-
height: display.height,
|
|
74
|
-
source: fileName
|
|
75
|
-
});
|
|
76
|
-
break;
|
|
77
|
-
case "reactor":
|
|
78
|
-
onEvent({
|
|
79
|
-
kind: "reactor",
|
|
80
|
-
id: display.id,
|
|
81
|
-
title: display.title,
|
|
82
|
-
tickRate: display.tick_rate,
|
|
83
|
-
ticking: display.ticking,
|
|
84
|
-
wantsKeys: display.wants_keys,
|
|
85
|
-
wantsMouse: display.wants_mouse,
|
|
86
|
-
register: display.register,
|
|
87
|
-
frame: display.frame,
|
|
88
|
-
index: display.index,
|
|
89
|
-
length: display.length,
|
|
90
|
-
atEnd: display.at_end,
|
|
91
|
-
stopped: display.stopped,
|
|
92
|
-
valueRepr: display.value_repr
|
|
93
|
-
});
|
|
94
|
-
break;
|
|
102
|
+
return "the image library";
|
|
95
103
|
case "table":
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
shownCount: display.shown_count,
|
|
102
|
-
truncated: display.truncated,
|
|
103
|
-
source: fileName
|
|
104
|
-
});
|
|
105
|
-
break;
|
|
104
|
+
return "the table library";
|
|
105
|
+
case "reactor":
|
|
106
|
+
return "the reactor library";
|
|
107
|
+
default:
|
|
108
|
+
return "the PLL libraries";
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
111
|
+
function explainShadowingLibrary(raw, level, fileName) {
|
|
112
|
+
const name = raw.name_token ?? "this name";
|
|
113
|
+
const noun = libraryNoun(raw.library);
|
|
114
|
+
const headline = `\`${name}\` is already defined by ${noun}.`;
|
|
115
|
+
return {
|
|
116
|
+
id: "shadowing-library",
|
|
117
|
+
errorType: "Shadowing",
|
|
118
|
+
message: raw.message,
|
|
119
|
+
headline,
|
|
120
|
+
howToFix: [
|
|
121
|
+
`Pick a different name for your definition - for example \`my_${name}\`, \`${name}_value\`, or a word that describes what yours represents.`,
|
|
122
|
+
`If you meant to use the library's \`${name}\`, call it directly instead of defining a new \`${name}\` of your own.`
|
|
123
|
+
],
|
|
124
|
+
fileName,
|
|
125
|
+
lineNumber: raw.line_number,
|
|
126
|
+
column: raw.column,
|
|
127
|
+
nameToken: raw.name_token,
|
|
128
|
+
severity: "error",
|
|
129
|
+
raw: JSON.stringify(raw),
|
|
130
|
+
origin: "static",
|
|
131
|
+
level
|
|
132
|
+
};
|
|
120
133
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
|
|
135
|
+
// src/common/analyzers/static/reassignmentExplainer.ts
|
|
136
|
+
function explainReassignment(raw, level, fileName) {
|
|
137
|
+
const name = raw.name_token ?? "this name";
|
|
138
|
+
const firstLine = raw.first_line_number ?? null;
|
|
139
|
+
const firstLineHint = firstLine !== null && firstLine > 0 ? ` (first assigned on line ${firstLine})` : "";
|
|
124
140
|
return {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
141
|
+
id: "reassignment",
|
|
142
|
+
errorType: "Reassignment",
|
|
143
|
+
message: raw.message,
|
|
144
|
+
headline: `\`${name}\` is already assigned${firstLineHint}.`,
|
|
145
|
+
howToFix: level === "intermediate" ? [
|
|
146
|
+
`If you want a different value, give it a different name (e.g. \`${name}_doubled\`, \`new_${name}\`, \`${name}2\`).`,
|
|
147
|
+
`If you're trying to update a value (\`x = x + 1\`, accumulators, running totals), wrap the work in a function and rebind \`${name}\` from the function's return value: \`${name} = compute_${name}(...)\`.`
|
|
148
|
+
] : [
|
|
149
|
+
`If you want a different value, give it a different name (e.g. \`${name}_doubled\`, \`new_${name}\`, \`${name}2\`).`,
|
|
150
|
+
`If you're trying to update a value (\`x = x + 1\`, accumulators, running totals), use a built-in like \`sum(...)\`, \`max(...)\`, or a small helper function that returns the new value instead.`
|
|
151
|
+
],
|
|
152
|
+
fileName,
|
|
153
|
+
lineNumber: raw.line_number,
|
|
154
|
+
column: raw.column,
|
|
155
|
+
nameToken: raw.name_token,
|
|
156
|
+
severity: "error",
|
|
157
|
+
raw: JSON.stringify(raw),
|
|
158
|
+
origin: "static",
|
|
159
|
+
level
|
|
130
160
|
};
|
|
131
161
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
|
|
163
|
+
// src/common/analyzers/static/disallowedKeywordExplainer.ts
|
|
164
|
+
function explainDisallowedKeyword(raw, level, fileName) {
|
|
165
|
+
const keyword = raw.keyword === "nonlocal" ? "nonlocal" : "global";
|
|
166
|
+
const names = Array.isArray(raw.names) ? raw.names : [];
|
|
167
|
+
const formattedNames = names.map((n) => `\`${n}\``).join(", ");
|
|
168
|
+
const namesPhrase = formattedNames.length > 0 ? ` ${formattedNames}` : "";
|
|
169
|
+
const headline = `\`${keyword}\` is not allowed at the ${level} level.`;
|
|
170
|
+
const howToFix = [];
|
|
171
|
+
if (keyword === "global") {
|
|
172
|
+
howToFix.push(
|
|
173
|
+
`Pass${namesPhrase || " the value"} into this function as an argument instead of declaring it \`global\`.`,
|
|
174
|
+
`If the function needs to *change* a value, return the new value and let the caller decide what to do with it (e.g. \`x = update(x)\`).`
|
|
175
|
+
);
|
|
176
|
+
} else {
|
|
177
|
+
howToFix.push(
|
|
178
|
+
`Return${namesPhrase || " the new value"} from this inner function and have the outer function rebind it explicitly (e.g. \`x = inner(x)\`).`,
|
|
179
|
+
`If the inner function only needs to *read* the outer name, just use it directly - you don't need \`nonlocal\` for that.`
|
|
180
|
+
);
|
|
145
181
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
182
|
+
return {
|
|
183
|
+
id: "disallowed-keyword",
|
|
184
|
+
errorType: "DisallowedKeyword",
|
|
185
|
+
message: raw.message,
|
|
186
|
+
headline,
|
|
187
|
+
howToFix,
|
|
149
188
|
fileName,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
189
|
+
lineNumber: raw.line_number,
|
|
190
|
+
column: raw.column,
|
|
191
|
+
nameToken: raw.name_token,
|
|
192
|
+
severity: "error",
|
|
193
|
+
raw: JSON.stringify(raw),
|
|
194
|
+
origin: "static",
|
|
195
|
+
level
|
|
196
|
+
};
|
|
157
197
|
}
|
|
158
198
|
|
|
159
|
-
// src/common/
|
|
160
|
-
var
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
199
|
+
// src/common/analyzers/static/registry.ts
|
|
200
|
+
var explainers = {
|
|
201
|
+
shadowing: explainShadowing,
|
|
202
|
+
"shadowing-builtin": explainShadowingBuiltin,
|
|
203
|
+
"shadowing-library": explainShadowingLibrary,
|
|
204
|
+
reassignment: explainReassignment,
|
|
205
|
+
"disallowed-keyword": explainDisallowedKeyword
|
|
206
|
+
};
|
|
207
|
+
function fallbackExplainer(raw, level, fileName) {
|
|
208
|
+
return {
|
|
209
|
+
id: raw.id,
|
|
210
|
+
errorType: raw.error_type || "StaticError",
|
|
211
|
+
message: raw.message,
|
|
212
|
+
headline: raw.message,
|
|
213
|
+
howToFix: [],
|
|
214
|
+
fileName,
|
|
215
|
+
lineNumber: raw.line_number,
|
|
216
|
+
column: raw.column,
|
|
217
|
+
nameToken: raw.name_token,
|
|
218
|
+
severity: "error",
|
|
219
|
+
raw: JSON.stringify(raw),
|
|
220
|
+
origin: "static",
|
|
221
|
+
level
|
|
222
|
+
};
|
|
168
223
|
}
|
|
169
|
-
function
|
|
170
|
-
|
|
224
|
+
function enrichStaticFindings(rawFindings, level, fileName) {
|
|
225
|
+
return rawFindings.map((raw) => {
|
|
226
|
+
const explainer = explainers[raw.id] ?? fallbackExplainer;
|
|
227
|
+
return explainer(raw, level, fileName);
|
|
228
|
+
});
|
|
171
229
|
}
|
|
172
230
|
|
|
173
|
-
// src/common/
|
|
174
|
-
var
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
231
|
+
// src/common/errors/pythonErrorParser.ts
|
|
232
|
+
var NON_USER_FRAME = [
|
|
233
|
+
"<exec>",
|
|
234
|
+
"/pll_vendor",
|
|
235
|
+
"site-packages",
|
|
236
|
+
"/lib/python",
|
|
237
|
+
"_pytest",
|
|
238
|
+
"pluggy"
|
|
239
|
+
];
|
|
240
|
+
function userTracebackFrames(traceback) {
|
|
241
|
+
const frames = [];
|
|
242
|
+
for (const raw of traceback.split(/\r?\n/)) {
|
|
243
|
+
const m = raw.match(/^\s*File "([^"]+)", line (\d+)(?:, in (\S+))?/);
|
|
244
|
+
if (!m) continue;
|
|
245
|
+
const fileName = m[1];
|
|
246
|
+
if (NON_USER_FRAME.some((token) => fileName.includes(token))) continue;
|
|
247
|
+
frames.push({
|
|
248
|
+
fileName,
|
|
249
|
+
line: parseInt(m[2], 10),
|
|
250
|
+
functionName: m[3] && m[3] !== "<module>" ? m[3] : null
|
|
251
|
+
});
|
|
185
252
|
}
|
|
253
|
+
return frames;
|
|
186
254
|
}
|
|
187
|
-
function
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
Atomics.store(state, STDIN_STATE_INDEX, STDIN_STATE_EOF);
|
|
191
|
-
Atomics.notify(state, STDIN_STATE_INDEX);
|
|
192
|
-
return;
|
|
255
|
+
function sourceIndent(source, lineNumber) {
|
|
256
|
+
if (!source) {
|
|
257
|
+
return 0;
|
|
193
258
|
}
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
Atomics.store(state, STDIN_LENGTH_INDEX, n);
|
|
200
|
-
Atomics.store(state, STDIN_STATE_INDEX, STDIN_STATE_LINE);
|
|
201
|
-
Atomics.notify(state, STDIN_STATE_INDEX);
|
|
259
|
+
const line = source.split(/\r?\n/)[lineNumber - 1];
|
|
260
|
+
if (line === void 0) {
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
return line.length - line.trimStart().length;
|
|
202
264
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
async initialize() {
|
|
216
|
-
if (!this.initPromise) {
|
|
217
|
-
this.initPromise = this.doInitialize();
|
|
265
|
+
function parsePythonError(rawTraceback, source) {
|
|
266
|
+
const traceback = rawTraceback.trimEnd();
|
|
267
|
+
const lines = traceback.split(/\r?\n/);
|
|
268
|
+
let errorType = "Error";
|
|
269
|
+
let message = traceback;
|
|
270
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
271
|
+
const line = lines[i];
|
|
272
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_.]*):\s?(.*)$/);
|
|
273
|
+
if (m && !line.startsWith(" ")) {
|
|
274
|
+
errorType = m[1];
|
|
275
|
+
message = m[2];
|
|
276
|
+
break;
|
|
218
277
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
this.worker = this.spawn({
|
|
224
|
-
onMessage: (msg) => this.handleMessage(msg),
|
|
225
|
-
onError: (err) => this.rejectAllPending(err)
|
|
226
|
-
});
|
|
227
|
-
this.stdinBuffer = tryCreateStdinBuffer();
|
|
228
|
-
this.interruptBuffer = tryCreateInterruptBuffer();
|
|
229
|
-
await this.request(
|
|
230
|
-
{
|
|
231
|
-
type: "init",
|
|
232
|
-
indexUrl,
|
|
233
|
-
...this.stdinBuffer ? { stdinBuffer: this.stdinBuffer } : {},
|
|
234
|
-
...this.interruptBuffer ? { interruptBuffer: this.interruptBuffer } : {}
|
|
235
|
-
},
|
|
236
|
-
"ready"
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
async runFile(request, onEvent) {
|
|
240
|
-
await this.initialize();
|
|
241
|
-
this.live = { onEvent, fileName: request.fileName };
|
|
242
|
-
try {
|
|
243
|
-
const { result } = await this.request({ type: "runFile", ...request }, "result");
|
|
244
|
-
deliverRunResult(result, onEvent, request.fileName);
|
|
245
|
-
} finally {
|
|
246
|
-
this.live = null;
|
|
278
|
+
if (line.match(/^[A-Za-z_][A-Za-z0-9_.]*$/) && !line.startsWith(" ")) {
|
|
279
|
+
errorType = line;
|
|
280
|
+
message = "";
|
|
281
|
+
break;
|
|
247
282
|
}
|
|
248
283
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
errorType: result.error_type,
|
|
260
|
-
message: result.message,
|
|
261
|
-
lineNumber: result.lineno,
|
|
262
|
-
offset: result.offset
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
async hasTests(code) {
|
|
266
|
-
await this.initialize();
|
|
267
|
-
const { result } = await this.request({ type: "hasTests", code }, "hasTests");
|
|
268
|
-
return result;
|
|
284
|
+
let fileName = null;
|
|
285
|
+
let lineNumber = null;
|
|
286
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
287
|
+
const line = lines[i];
|
|
288
|
+
const m = line.match(/^\s*File\s+"([^"]+)",\s+line\s+(\d+)/);
|
|
289
|
+
if (m) {
|
|
290
|
+
fileName = m[1];
|
|
291
|
+
lineNumber = parseInt(m[2], 10);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
269
294
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
295
|
+
let column = null;
|
|
296
|
+
if (lineNumber !== null) {
|
|
297
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
298
|
+
if (/^\s*\^+\s*$/.test(lines[i + 1] ?? "")) {
|
|
299
|
+
const sourceLine = lines[i];
|
|
300
|
+
const caretIdx = lines[i + 1].indexOf("^");
|
|
301
|
+
if (caretIdx >= 0) {
|
|
302
|
+
const echoed = sourceLine.length - sourceLine.trimStart().length;
|
|
303
|
+
column = Math.max(0, caretIdx - echoed) + sourceIndent(source, lineNumber);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
273
307
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
308
|
+
let nameToken = null;
|
|
309
|
+
if (errorType === "NameError") {
|
|
310
|
+
const nm = message.match(/name '([^']+)' is not defined/);
|
|
311
|
+
if (nm) {
|
|
312
|
+
nameToken = nm[1];
|
|
313
|
+
}
|
|
277
314
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
315
|
+
return {
|
|
316
|
+
errorType,
|
|
317
|
+
message,
|
|
318
|
+
traceback,
|
|
319
|
+
fileName,
|
|
320
|
+
lineNumber,
|
|
321
|
+
column,
|
|
322
|
+
nameToken
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/common/errors/nameErrorExplainer.ts
|
|
327
|
+
function explainNameError(parsed) {
|
|
328
|
+
const name = parsed.nameToken ?? "this name";
|
|
329
|
+
const didYouMean = extractDidYouMean(parsed.message);
|
|
330
|
+
const headline = `Python doesn't know what \`${name}\` means.`;
|
|
331
|
+
const howToFix = [
|
|
332
|
+
`Check the spelling of \`${name}\` (Python is case-sensitive).`,
|
|
333
|
+
`Make sure \`${name}\` is defined before this line runs.`,
|
|
334
|
+
`If \`${name}\` should be text, put it in quotes: \`"${name}"\`.`
|
|
335
|
+
];
|
|
336
|
+
if (didYouMean.length > 0) {
|
|
337
|
+
howToFix.unshift(
|
|
338
|
+
`Python suggested: ${didYouMean.map((s) => "`" + s + "`").join(", ")} - try one of those.`
|
|
288
339
|
);
|
|
289
|
-
deliverTestResult(result, onEvent, request.fileName);
|
|
290
|
-
}
|
|
291
|
-
async staticAnalyze(request) {
|
|
292
|
-
await this.initialize();
|
|
293
|
-
const { result } = await this.request({ type: "staticAnalyze", ...request }, "static");
|
|
294
|
-
return result ?? [];
|
|
295
|
-
}
|
|
296
|
-
async mountWorkspaceFiles(files) {
|
|
297
|
-
await this.initialize();
|
|
298
|
-
await this.request({ type: "mountWorkspace", files }, "workspaceReady");
|
|
299
|
-
}
|
|
300
|
-
async collectWorkspaceFiles() {
|
|
301
|
-
await this.initialize();
|
|
302
|
-
const { files } = await this.request({ type: "collectWorkspace" }, "workspaceFiles");
|
|
303
|
-
return files;
|
|
304
340
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return result;
|
|
341
|
+
return { headline, howToFix };
|
|
342
|
+
}
|
|
343
|
+
function extractDidYouMean(message) {
|
|
344
|
+
const m = message.match(/Did you mean:\s*([^?]+)\??/i);
|
|
345
|
+
if (!m) {
|
|
346
|
+
return [];
|
|
312
347
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
348
|
+
return m[1].split(/,|\bor\b/).map((s) => s.trim().replace(/^['"`]|['"`]$/g, "")).filter((s) => s.length > 0);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/common/analyzers/nameErrorAnalyzer.ts
|
|
352
|
+
var nameErrorAnalyzer = {
|
|
353
|
+
handles: ["NameError"],
|
|
354
|
+
analyze(input) {
|
|
355
|
+
const { parsedError, fileName, level } = input;
|
|
356
|
+
if (parsedError.errorType !== "NameError") {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const explanation = explainNameError(parsedError);
|
|
360
|
+
return {
|
|
361
|
+
id: "name-error",
|
|
362
|
+
errorType: "NameError",
|
|
363
|
+
message: parsedError.message,
|
|
364
|
+
headline: explanation.headline,
|
|
365
|
+
howToFix: explanation.howToFix,
|
|
366
|
+
fileName,
|
|
367
|
+
lineNumber: parsedError.lineNumber,
|
|
368
|
+
column: parsedError.column,
|
|
369
|
+
nameToken: parsedError.nameToken,
|
|
370
|
+
severity: "error",
|
|
371
|
+
raw: parsedError.traceback,
|
|
372
|
+
origin: "runtime",
|
|
373
|
+
level
|
|
374
|
+
};
|
|
320
375
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// src/common/level.ts
|
|
379
|
+
var DEFAULT_LEVEL = "raw";
|
|
380
|
+
var HEADER_RE = /^#\s*level\s+([a-z]+)\s*$/;
|
|
381
|
+
var LEVEL_NAMES = [
|
|
382
|
+
"raw",
|
|
383
|
+
"beginner",
|
|
384
|
+
"intermediate",
|
|
385
|
+
"advanced"
|
|
386
|
+
];
|
|
387
|
+
function parseLevel(source) {
|
|
388
|
+
const lines = source.split(/\r?\n/);
|
|
389
|
+
for (const raw of lines) {
|
|
390
|
+
const line = raw.trim();
|
|
391
|
+
if (line.length === 0) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const match = HEADER_RE.exec(line);
|
|
395
|
+
if (!match) {
|
|
396
|
+
return DEFAULT_LEVEL;
|
|
397
|
+
}
|
|
398
|
+
const name = match[1];
|
|
399
|
+
for (const level of LEVEL_NAMES) {
|
|
400
|
+
if (name === level) return level;
|
|
401
|
+
}
|
|
402
|
+
return DEFAULT_LEVEL;
|
|
324
403
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
404
|
+
return DEFAULT_LEVEL;
|
|
405
|
+
}
|
|
406
|
+
function levelRejectsBoolAsNumber(level) {
|
|
407
|
+
return level === "beginner" || level === "intermediate";
|
|
408
|
+
}
|
|
409
|
+
function levelHasStaticChecks(level) {
|
|
410
|
+
return level === "beginner" || level === "intermediate";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/common/errors/typeCheckExplainer.ts
|
|
414
|
+
var FRIENDLY_TYPES = {
|
|
415
|
+
int: "a whole number",
|
|
416
|
+
float: "a number",
|
|
417
|
+
complex: "a complex number",
|
|
418
|
+
str: "a string",
|
|
419
|
+
bool: "`True` or `False`",
|
|
420
|
+
bytes: "bytes",
|
|
421
|
+
list: "a list",
|
|
422
|
+
dict: "a dictionary",
|
|
423
|
+
set: "a set",
|
|
424
|
+
tuple: "a tuple",
|
|
425
|
+
None: "`None`",
|
|
426
|
+
NoneType: "`None`"
|
|
427
|
+
};
|
|
428
|
+
function cleanTypeName(type) {
|
|
429
|
+
return type.startsWith("__main__.") ? type.slice("__main__.".length) : type;
|
|
430
|
+
}
|
|
431
|
+
function describeType(type) {
|
|
432
|
+
const name = cleanTypeName(type);
|
|
433
|
+
if (name === "None" || name === "NoneType") return "`None`";
|
|
434
|
+
const friendly = FRIENDLY_TYPES[name];
|
|
435
|
+
return friendly ? `${friendly} (\`${name}\`)` : `\`${name}\``;
|
|
436
|
+
}
|
|
437
|
+
function describeTypes(types) {
|
|
438
|
+
const described = types.map(describeType);
|
|
439
|
+
if (described.length === 0) return "a different type";
|
|
440
|
+
if (described.length === 1) return described[0];
|
|
441
|
+
return `${described.slice(0, -1).join(", ")} or ${described[described.length - 1]}`;
|
|
442
|
+
}
|
|
443
|
+
function boolAsNumberNote(expected, actual, level) {
|
|
444
|
+
if (actual !== "bool" || expected.length === 0) return null;
|
|
445
|
+
if (!expected.some((type) => type === "int" || type === "float")) return null;
|
|
446
|
+
if (level !== void 0 && !levelRejectsBoolAsNumber(level)) return null;
|
|
447
|
+
return "At `#level beginner` and `#level intermediate`, `True` and `False` are not accepted as numbers, even though Python counts them as `1` and `0`.";
|
|
448
|
+
}
|
|
449
|
+
function conversionFor(expected, actual) {
|
|
450
|
+
if (expected.length !== 1 || actual === null) return null;
|
|
451
|
+
const to = expected[0];
|
|
452
|
+
const numeric = actual === "int" || actual === "float" || actual === "bool";
|
|
453
|
+
if (to === "int" && (actual === "str" || actual === "float")) return "int";
|
|
454
|
+
if (to === "float" && actual === "str") return "float";
|
|
455
|
+
if (to === "str" && numeric) return "str";
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
var ELEMENT_RE = /^(item \d+|key .+|value of key .+|\[.+\]) of (.+)$/;
|
|
459
|
+
function membersPhrase(element) {
|
|
460
|
+
if (element.startsWith("the value for key")) return "every value in";
|
|
461
|
+
if (element.startsWith("key ")) return "every key in";
|
|
462
|
+
return "every item in";
|
|
463
|
+
}
|
|
464
|
+
function describeElement(element) {
|
|
465
|
+
if (element.startsWith("value of key ")) {
|
|
466
|
+
return `the value for key ${element.slice("value of key ".length)}`;
|
|
467
|
+
}
|
|
468
|
+
if (element.startsWith("[")) return "one of the values";
|
|
469
|
+
return element;
|
|
470
|
+
}
|
|
471
|
+
function parseTypeCheckMessage(message) {
|
|
472
|
+
const unknown = {
|
|
473
|
+
kind: "unknown",
|
|
474
|
+
name: null,
|
|
475
|
+
element: null,
|
|
476
|
+
actual: null,
|
|
477
|
+
expected: []
|
|
478
|
+
};
|
|
479
|
+
const lines = message.split(/\r?\n/);
|
|
480
|
+
const first = lines[0].trim();
|
|
481
|
+
let expected = [];
|
|
482
|
+
let subject = null;
|
|
483
|
+
const unionAt = first.indexOf(" did not match any element in the union:");
|
|
484
|
+
if (unionAt >= 0) {
|
|
485
|
+
subject = first.slice(0, unionAt);
|
|
486
|
+
for (const raw of lines.slice(1)) {
|
|
487
|
+
const m = raw.match(/^\s*([A-Za-z_][A-Za-z0-9_.\[\], ]*):/);
|
|
488
|
+
if (m) expected.push(m[1].trim());
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
const predicates = [
|
|
492
|
+
[/^(.*) is not an instance of (.+)$/, (m) => [m[2]]],
|
|
493
|
+
[/^(.*) is neither float or int$/, () => ["float"]],
|
|
494
|
+
[/^(.*) is not None$/, () => ["None"]],
|
|
495
|
+
[/^(.*) is not a ([A-Za-z_][A-Za-z0-9_]*)$/, (m) => [m[2]]]
|
|
496
|
+
];
|
|
497
|
+
for (const [re, pick] of predicates) {
|
|
498
|
+
const m = first.match(re);
|
|
499
|
+
if (m) {
|
|
500
|
+
subject = m[1];
|
|
501
|
+
expected = pick(m);
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
328
504
|
}
|
|
329
|
-
signalInterrupt(this.interruptBuffer);
|
|
330
|
-
return true;
|
|
331
505
|
}
|
|
332
|
-
|
|
333
|
-
|
|
506
|
+
if (subject === null) return unknown;
|
|
507
|
+
let actual = null;
|
|
508
|
+
const withActual = subject.match(/^(.*) \(([^()]*)\)$/);
|
|
509
|
+
if (withActual) {
|
|
510
|
+
subject = withActual[1];
|
|
511
|
+
actual = withActual[2];
|
|
334
512
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
this.interruptBuffer = null;
|
|
341
|
-
this.live = null;
|
|
342
|
-
this.rejectAllPending(new Error("Runtime disposed"));
|
|
513
|
+
let element = null;
|
|
514
|
+
const elementMatch = subject.match(ELEMENT_RE);
|
|
515
|
+
if (elementMatch) {
|
|
516
|
+
element = describeElement(elementMatch[1]);
|
|
517
|
+
subject = elementMatch[2];
|
|
343
518
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
519
|
+
if (subject === "the return value") {
|
|
520
|
+
return { kind: "return", name: null, element, actual, expected };
|
|
521
|
+
}
|
|
522
|
+
const argument = subject.match(/^argument "(.+)"$/);
|
|
523
|
+
if (argument) {
|
|
524
|
+
return { kind: "argument", name: argument[1], element, actual, expected };
|
|
525
|
+
}
|
|
526
|
+
const assigned = subject.match(/^value assigned to (.+)$/);
|
|
527
|
+
if (assigned) {
|
|
528
|
+
return { kind: "variable", name: assigned[1], element, actual, expected };
|
|
529
|
+
}
|
|
530
|
+
return { ...unknown, element, actual, expected };
|
|
531
|
+
}
|
|
532
|
+
function explainTypeCheckError(message, functionName, level) {
|
|
533
|
+
const parsed = parseTypeCheckMessage(message);
|
|
534
|
+
const wanted = describeTypes(parsed.expected);
|
|
535
|
+
const owner = functionName ? `\`${functionName}\`` : "this function";
|
|
536
|
+
const named = parsed.name ? `\`${parsed.name}\`` : "a value";
|
|
537
|
+
const convert = conversionFor(parsed.expected, parsed.actual);
|
|
538
|
+
const boolNote = boolAsNumberNote(parsed.expected, parsed.actual, level);
|
|
539
|
+
const annotation = parsed.expected.length === 1 ? cleanTypeName(parsed.expected[0]) : null;
|
|
540
|
+
if (parsed.kind === "argument") {
|
|
541
|
+
if (parsed.element) {
|
|
542
|
+
return {
|
|
543
|
+
headline: `${owner} expects ${membersPhrase(parsed.element)} ${named} to be ${wanted}, but ${parsed.element} is not.`,
|
|
544
|
+
howToFix: [
|
|
545
|
+
`Look at ${parsed.element} of the ${parsed.actual ?? "value"} you passed for ${named}.`,
|
|
546
|
+
"Every one has to match the annotation, not just the first."
|
|
547
|
+
]
|
|
548
|
+
};
|
|
349
549
|
}
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (reply.type !== expected) {
|
|
356
|
-
throw new Error(`Pyodide worker replied "${reply.type}", expected "${expected}"`);
|
|
550
|
+
const got = parsed.actual ? `, but got ${describeType(parsed.actual)}` : "";
|
|
551
|
+
const howToFix = [`Check the value you passed for ${named} on this line.`];
|
|
552
|
+
if (boolNote) howToFix.push(boolNote);
|
|
553
|
+
if (convert) {
|
|
554
|
+
howToFix.push(`If it should be ${wanted}, convert it with \`${convert}(...)\`.`);
|
|
357
555
|
}
|
|
358
|
-
|
|
556
|
+
if (annotation && parsed.actual) {
|
|
557
|
+
howToFix.push(
|
|
558
|
+
`Or, if ${named} really should be ${describeType(parsed.actual)}, change its annotation in ${owner} from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
headline: `${owner} expects ${named} to be ${wanted}${got}.`,
|
|
563
|
+
howToFix
|
|
564
|
+
};
|
|
359
565
|
}
|
|
360
|
-
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
566
|
+
if (parsed.kind === "return") {
|
|
567
|
+
if (parsed.actual === "None" || parsed.actual === "NoneType") {
|
|
568
|
+
return {
|
|
569
|
+
headline: `${owner} should return ${wanted}, but it finished without returning a value.`,
|
|
570
|
+
howToFix: [
|
|
571
|
+
"Make sure every path through the function reaches a `return`.",
|
|
572
|
+
"An `if` with no `else` falls off the end, and Python then returns `None`.",
|
|
573
|
+
`Or annotate the return type as \`None\` if ${owner} is not meant to return anything.`
|
|
574
|
+
]
|
|
575
|
+
};
|
|
366
576
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
577
|
+
const got = parsed.actual ? describeType(parsed.actual) : "something else";
|
|
578
|
+
const howToFix = [];
|
|
579
|
+
const wantsNone = parsed.expected.length === 1 && /^(None|NoneType)$/.test(parsed.expected[0]);
|
|
580
|
+
if (wantsNone) {
|
|
581
|
+
howToFix.push("Use a bare `return`, or drop the `return` entirely.");
|
|
582
|
+
} else if (convert) {
|
|
583
|
+
howToFix.push(`Convert the returned value with \`${convert}(...)\`.`);
|
|
584
|
+
} else {
|
|
585
|
+
howToFix.push(`Return ${wanted} from this line.`);
|
|
370
586
|
}
|
|
371
|
-
|
|
372
|
-
if (
|
|
373
|
-
|
|
587
|
+
if (boolNote) howToFix.push(boolNote);
|
|
588
|
+
if (annotation && parsed.actual) {
|
|
589
|
+
howToFix.push(
|
|
590
|
+
`Or change ${owner}'s return annotation from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
|
|
591
|
+
);
|
|
374
592
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
593
|
+
return {
|
|
594
|
+
headline: `${owner} says it returns ${wanted}, but this line returns ${got}.`,
|
|
595
|
+
howToFix
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (parsed.kind === "variable") {
|
|
599
|
+
if (parsed.element) {
|
|
600
|
+
return {
|
|
601
|
+
headline: `${named} is annotated so ${membersPhrase(parsed.element)} it is ${wanted}, but ${parsed.element} is not.`,
|
|
602
|
+
howToFix: [`Look at ${parsed.element} of the value assigned to ${named}.`]
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
const got = parsed.actual ? describeType(parsed.actual) : "a different type";
|
|
606
|
+
const howToFix = [`Assign ${wanted} to ${named}.`];
|
|
607
|
+
if (boolNote) howToFix.push(boolNote);
|
|
608
|
+
if (convert) {
|
|
609
|
+
howToFix.push(`You can convert the value with \`${convert}(...)\`.`);
|
|
610
|
+
}
|
|
611
|
+
if (annotation && parsed.actual) {
|
|
612
|
+
howToFix.push(
|
|
613
|
+
`Or change ${named}'s annotation from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
|
|
614
|
+
);
|
|
380
615
|
}
|
|
616
|
+
return {
|
|
617
|
+
headline: `${named} is annotated as ${wanted}, but ${got} was assigned here.`,
|
|
618
|
+
howToFix
|
|
619
|
+
};
|
|
381
620
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
621
|
+
return {
|
|
622
|
+
headline: "A value does not match the type annotation written for it.",
|
|
623
|
+
howToFix: [
|
|
624
|
+
message.split(/\r?\n/)[0].trim(),
|
|
625
|
+
"Either pass a value of the annotated type, or change the annotation."
|
|
626
|
+
]
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/common/analyzers/typeCheckAnalyzer.ts
|
|
631
|
+
var HANDLED = ["TypeCheckError", "typeguard.TypeCheckError"];
|
|
632
|
+
function messageWithDetail(traceback, fallback) {
|
|
633
|
+
const lines = traceback.split(/\r?\n/);
|
|
634
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
635
|
+
const line = lines[i];
|
|
636
|
+
const marker = line.indexOf("TypeCheckError:");
|
|
637
|
+
if (line.startsWith(" ") || marker < 0) continue;
|
|
638
|
+
const detail = [line.slice(marker + "TypeCheckError:".length).trim()];
|
|
639
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
640
|
+
if (lines[j].trim() === "") continue;
|
|
641
|
+
if (!lines[j].startsWith(" ")) break;
|
|
642
|
+
detail.push(lines[j]);
|
|
385
643
|
}
|
|
386
|
-
|
|
644
|
+
return detail.join("\n");
|
|
645
|
+
}
|
|
646
|
+
return fallback;
|
|
647
|
+
}
|
|
648
|
+
var typeCheckAnalyzer = {
|
|
649
|
+
handles: HANDLED,
|
|
650
|
+
analyze(input) {
|
|
651
|
+
const { parsedError, fileName, level } = input;
|
|
652
|
+
if (!HANDLED.includes(parsedError.errorType)) {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
const message = messageWithDetail(parsedError.traceback, parsedError.message);
|
|
656
|
+
const parsed = parseTypeCheckMessage(message);
|
|
657
|
+
const frames = userTracebackFrames(parsedError.traceback);
|
|
658
|
+
const innermost = frames.length > 0 ? frames[frames.length - 1] : null;
|
|
659
|
+
const blamed = parsed.kind === "argument" && frames.length >= 2 ? frames[frames.length - 2] : innermost;
|
|
660
|
+
const explanation = explainTypeCheckError(
|
|
661
|
+
message,
|
|
662
|
+
innermost ? innermost.functionName : null,
|
|
663
|
+
level
|
|
664
|
+
);
|
|
665
|
+
return {
|
|
666
|
+
id: "type-check",
|
|
667
|
+
// Not a real Python exception name; "TypeCheckError" means nothing to
|
|
668
|
+
// a student, and "TypeError" would name a different Python error.
|
|
669
|
+
errorType: "TypeMismatch",
|
|
670
|
+
message,
|
|
671
|
+
headline: explanation.headline,
|
|
672
|
+
howToFix: explanation.howToFix,
|
|
673
|
+
fileName: blamed ? blamed.fileName : fileName,
|
|
674
|
+
lineNumber: blamed ? blamed.line : parsedError.lineNumber,
|
|
675
|
+
column: null,
|
|
676
|
+
nameToken: parsed.name,
|
|
677
|
+
severity: "error",
|
|
678
|
+
raw: parsedError.traceback,
|
|
679
|
+
origin: "runtime",
|
|
680
|
+
level
|
|
681
|
+
};
|
|
387
682
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
// src/common/analyzers/registry.ts
|
|
686
|
+
var analyzers = [nameErrorAnalyzer, typeCheckAnalyzer];
|
|
687
|
+
function findRuntimeFinding(source, fileName, level, parsedError) {
|
|
688
|
+
for (const analyzer of analyzers) {
|
|
689
|
+
if (!analyzer.handles.includes(parsedError.errorType)) {
|
|
690
|
+
continue;
|
|
394
691
|
}
|
|
395
|
-
|
|
396
|
-
|
|
692
|
+
const finding = analyzer.analyze({ source, fileName, level, parsedError });
|
|
693
|
+
if (finding) {
|
|
694
|
+
return finding;
|
|
397
695
|
}
|
|
398
696
|
}
|
|
399
|
-
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
400
699
|
|
|
401
|
-
// src/
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
700
|
+
// src/common/analyzers/runtimeFinding.ts
|
|
701
|
+
function findingForErrorEvent(event, source, fileName, level) {
|
|
702
|
+
const traceback = event.traceback || `${event.errorType}: ${event.message}`;
|
|
703
|
+
const parsed = parsePythonError(traceback, source);
|
|
704
|
+
if (parsed.lineNumber === null && event.lineNumber != null) {
|
|
705
|
+
parsed.lineNumber = event.lineNumber;
|
|
406
706
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const found = this.paths.indexUrlCandidates.find(
|
|
410
|
-
(dir) => fs.existsSync(path.join(dir, "pyodide.asm.wasm"))
|
|
411
|
-
);
|
|
412
|
-
if (!found) {
|
|
413
|
-
throw new Error(this.paths.missingAssetsHint);
|
|
414
|
-
}
|
|
415
|
-
return found;
|
|
707
|
+
if (parsed.column === null && event.column != null) {
|
|
708
|
+
parsed.column = event.column;
|
|
416
709
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
worker.on("message", (msg) => handlers.onMessage(msg));
|
|
420
|
-
worker.on(
|
|
421
|
-
"error",
|
|
422
|
-
(err) => handlers.onError(err instanceof Error ? err : new Error(String(err)))
|
|
423
|
-
);
|
|
424
|
-
return {
|
|
425
|
-
post: (msg) => worker.postMessage(msg),
|
|
426
|
-
terminate: () => void worker.terminate()
|
|
427
|
-
};
|
|
710
|
+
if (parsed.fileName === null && event.fileName) {
|
|
711
|
+
parsed.fileName = event.fileName;
|
|
428
712
|
}
|
|
429
|
-
|
|
713
|
+
return findRuntimeFinding(source, fileName, level, parsed);
|
|
714
|
+
}
|
|
430
715
|
|
|
431
|
-
// src/
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
workerPath: path2.join(__dirname, "worker.cjs"),
|
|
438
|
-
missingAssetsHint: "Could not find Pyodide's assets. Reinstall pll-python so its `pyodide` dependency is present."
|
|
439
|
-
});
|
|
716
|
+
// src/common/pyodideRunner.ts
|
|
717
|
+
var ANY_IMPORT_RE = /(^|\n)[ \t]*(import|from)[ \t]+\S/;
|
|
718
|
+
var PANDAS_METHOD_RE = /\.\s*to_pandas\s*\(/;
|
|
719
|
+
var NEEDS_PACKAGES_RE_LIST = [PANDAS_METHOD_RE];
|
|
720
|
+
function needsPackages(code) {
|
|
721
|
+
return ANY_IMPORT_RE.test(code) || NEEDS_PACKAGES_RE_LIST.some((re) => re.test(code));
|
|
440
722
|
}
|
|
441
723
|
|
|
442
|
-
// src/cli/
|
|
443
|
-
var
|
|
444
|
-
var
|
|
724
|
+
// src/cli/files.ts
|
|
725
|
+
var fs = __toESM(require("node:fs/promises"));
|
|
726
|
+
var path = __toESM(require("node:path"));
|
|
445
727
|
|
|
446
|
-
// src/common/
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
728
|
+
// src/common/workspaceFilePolicy.ts
|
|
729
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
730
|
+
var MAX_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
731
|
+
var MAX_FILES = 50;
|
|
732
|
+
var MOUNT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
733
|
+
".csv",
|
|
734
|
+
".txt",
|
|
735
|
+
".tsv",
|
|
736
|
+
".json",
|
|
737
|
+
".md",
|
|
738
|
+
".dat",
|
|
739
|
+
".xml",
|
|
740
|
+
".py",
|
|
741
|
+
// Pictures, for `load_image`. `.svg` is not in BINARY_EXTENSIONS below
|
|
742
|
+
// because it is text, and mounting it as text is lossless.
|
|
743
|
+
".svg",
|
|
744
|
+
".png",
|
|
745
|
+
".jpg",
|
|
746
|
+
".jpeg",
|
|
747
|
+
".gif",
|
|
748
|
+
".webp"
|
|
749
|
+
]);
|
|
750
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
751
|
+
var WRITEBACK_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
752
|
+
".csv",
|
|
753
|
+
".txt",
|
|
754
|
+
".tsv",
|
|
755
|
+
".json",
|
|
756
|
+
".md",
|
|
757
|
+
".dat",
|
|
758
|
+
".xml"
|
|
759
|
+
]);
|
|
760
|
+
function extensionOf(name) {
|
|
761
|
+
const dot = name.lastIndexOf(".");
|
|
762
|
+
if (dot <= 0) {
|
|
763
|
+
return "";
|
|
460
764
|
}
|
|
765
|
+
return name.slice(dot).toLowerCase();
|
|
461
766
|
}
|
|
462
|
-
function
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const outerNoun = outerScopeNoun(raw.outer_scope_kind);
|
|
466
|
-
const knownOuterLine = outerLine !== null && outerLine > 0;
|
|
467
|
-
const outerHint = knownOuterLine ? ` (first defined on line ${outerLine}, in ${outerNoun})` : ` in ${outerNoun}`;
|
|
468
|
-
return {
|
|
469
|
-
id: "shadowing",
|
|
470
|
-
errorType: "Shadowing",
|
|
471
|
-
message: raw.message,
|
|
472
|
-
headline: `\`${name}\` is already defined${outerHint}.`,
|
|
473
|
-
howToFix: [
|
|
474
|
-
`Rename the inner \`${name}\` to something distinct (e.g. \`${name}_inner\`, \`local_${name}\`, or whatever describes its role).`,
|
|
475
|
-
`Or, if you want to use the outer \`${name}\`, just read it directly without making a new variable.`
|
|
476
|
-
],
|
|
477
|
-
fileName,
|
|
478
|
-
lineNumber: raw.line_number,
|
|
479
|
-
column: raw.column,
|
|
480
|
-
nameToken: raw.name_token,
|
|
481
|
-
severity: "error",
|
|
482
|
-
raw: JSON.stringify(raw),
|
|
483
|
-
origin: "static",
|
|
484
|
-
level
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
function explainShadowingBuiltin(raw, level, fileName) {
|
|
488
|
-
const name = raw.name_token ?? "this name";
|
|
489
|
-
const headline = `\`${name}\` is the name of a Python built-in.`;
|
|
490
|
-
return {
|
|
491
|
-
id: "shadowing-builtin",
|
|
492
|
-
errorType: "Shadowing",
|
|
493
|
-
message: raw.message,
|
|
494
|
-
headline,
|
|
495
|
-
howToFix: [
|
|
496
|
-
`Pick a different name. Common patterns: \`my_${name}\`, \`${name}_value\`, \`${name}s\` (plural), or a more specific noun describing what this value represents.`
|
|
497
|
-
],
|
|
498
|
-
fileName,
|
|
499
|
-
lineNumber: raw.line_number,
|
|
500
|
-
column: raw.column,
|
|
501
|
-
nameToken: raw.name_token,
|
|
502
|
-
severity: "error",
|
|
503
|
-
raw: JSON.stringify(raw),
|
|
504
|
-
origin: "static",
|
|
505
|
-
level
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// src/common/analyzers/static/reassignmentExplainer.ts
|
|
510
|
-
function explainReassignment(raw, level, fileName) {
|
|
511
|
-
const name = raw.name_token ?? "this name";
|
|
512
|
-
const firstLine = raw.first_line_number ?? null;
|
|
513
|
-
const firstLineHint = firstLine !== null && firstLine > 0 ? ` (first assigned on line ${firstLine})` : "";
|
|
514
|
-
return {
|
|
515
|
-
id: "reassignment",
|
|
516
|
-
errorType: "Reassignment",
|
|
517
|
-
message: raw.message,
|
|
518
|
-
headline: `\`${name}\` is already assigned${firstLineHint}.`,
|
|
519
|
-
howToFix: level === "intermediate" ? [
|
|
520
|
-
`If you want a different value, give it a different name (e.g. \`${name}_doubled\`, \`new_${name}\`, \`${name}2\`).`,
|
|
521
|
-
`If you're trying to update a value (\`x = x + 1\`, accumulators, running totals), wrap the work in a function and rebind \`${name}\` from the function's return value: \`${name} = compute_${name}(...)\`.`
|
|
522
|
-
] : [
|
|
523
|
-
`If you want a different value, give it a different name (e.g. \`${name}_doubled\`, \`new_${name}\`, \`${name}2\`).`,
|
|
524
|
-
`If you're trying to update a value (\`x = x + 1\`, accumulators, running totals), use a built-in like \`sum(...)\`, \`max(...)\`, or a small helper function that returns the new value instead.`
|
|
525
|
-
],
|
|
526
|
-
fileName,
|
|
527
|
-
lineNumber: raw.line_number,
|
|
528
|
-
column: raw.column,
|
|
529
|
-
nameToken: raw.name_token,
|
|
530
|
-
severity: "error",
|
|
531
|
-
raw: JSON.stringify(raw),
|
|
532
|
-
origin: "static",
|
|
533
|
-
level
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// src/common/analyzers/static/disallowedKeywordExplainer.ts
|
|
538
|
-
function explainDisallowedKeyword(raw, level, fileName) {
|
|
539
|
-
const keyword = raw.keyword === "nonlocal" ? "nonlocal" : "global";
|
|
540
|
-
const names = Array.isArray(raw.names) ? raw.names : [];
|
|
541
|
-
const formattedNames = names.map((n) => `\`${n}\``).join(", ");
|
|
542
|
-
const namesPhrase = formattedNames.length > 0 ? ` ${formattedNames}` : "";
|
|
543
|
-
const headline = `\`${keyword}\` is not allowed at the ${level} level.`;
|
|
544
|
-
const howToFix = [];
|
|
545
|
-
if (keyword === "global") {
|
|
546
|
-
howToFix.push(
|
|
547
|
-
`Pass${namesPhrase || " the value"} into this function as an argument instead of declaring it \`global\`.`,
|
|
548
|
-
`If the function needs to *change* a value, return the new value and let the caller decide what to do with it (e.g. \`x = update(x)\`).`
|
|
549
|
-
);
|
|
550
|
-
} else {
|
|
551
|
-
howToFix.push(
|
|
552
|
-
`Return${namesPhrase || " the new value"} from this inner function and have the outer function rebind it explicitly (e.g. \`x = inner(x)\`).`,
|
|
553
|
-
`If the inner function only needs to *read* the outer name, just use it directly - you don't need \`nonlocal\` for that.`
|
|
554
|
-
);
|
|
767
|
+
function isSafeBasename(name) {
|
|
768
|
+
if (!name || name === "." || name === "..") {
|
|
769
|
+
return false;
|
|
555
770
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
lineNumber: raw.line_number,
|
|
564
|
-
column: raw.column,
|
|
565
|
-
nameToken: raw.name_token,
|
|
566
|
-
severity: "error",
|
|
567
|
-
raw: JSON.stringify(raw),
|
|
568
|
-
origin: "static",
|
|
569
|
-
level
|
|
570
|
-
};
|
|
771
|
+
if (name.startsWith(".")) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
return true;
|
|
571
778
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
var explainers = {
|
|
575
|
-
shadowing: explainShadowing,
|
|
576
|
-
"shadowing-builtin": explainShadowingBuiltin,
|
|
577
|
-
reassignment: explainReassignment,
|
|
578
|
-
"disallowed-keyword": explainDisallowedKeyword
|
|
579
|
-
};
|
|
580
|
-
function fallbackExplainer(raw, level, fileName) {
|
|
581
|
-
return {
|
|
582
|
-
id: raw.id,
|
|
583
|
-
errorType: raw.error_type || "StaticError",
|
|
584
|
-
message: raw.message,
|
|
585
|
-
headline: raw.message,
|
|
586
|
-
howToFix: [],
|
|
587
|
-
fileName,
|
|
588
|
-
lineNumber: raw.line_number,
|
|
589
|
-
column: raw.column,
|
|
590
|
-
nameToken: raw.name_token,
|
|
591
|
-
severity: "error",
|
|
592
|
-
raw: JSON.stringify(raw),
|
|
593
|
-
origin: "static",
|
|
594
|
-
level
|
|
595
|
-
};
|
|
779
|
+
function isMountableName(name) {
|
|
780
|
+
return isSafeBasename(name) && MOUNT_EXTENSIONS.has(extensionOf(name));
|
|
596
781
|
}
|
|
597
|
-
function
|
|
598
|
-
return
|
|
599
|
-
const explainer = explainers[raw.id] ?? fallbackExplainer;
|
|
600
|
-
return explainer(raw, level, fileName);
|
|
601
|
-
});
|
|
782
|
+
function isWritebackName(name) {
|
|
783
|
+
return isSafeBasename(name) && WRITEBACK_EXTENSIONS.has(extensionOf(name));
|
|
602
784
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
"
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
for (const raw of traceback.split(/\r?\n/)) {
|
|
616
|
-
const m = raw.match(/^\s*File "([^"]+)", line (\d+)(?:, in (\S+))?/);
|
|
617
|
-
if (!m) continue;
|
|
618
|
-
const fileName = m[1];
|
|
619
|
-
if (NON_USER_FRAME.some((token) => fileName.includes(token))) continue;
|
|
620
|
-
frames.push({
|
|
621
|
-
fileName,
|
|
622
|
-
line: parseInt(m[2], 10),
|
|
623
|
-
functionName: m[3] && m[3] !== "<module>" ? m[3] : null
|
|
624
|
-
});
|
|
785
|
+
function isBinaryMountName(name) {
|
|
786
|
+
return BINARY_EXTENSIONS.has(extensionOf(name));
|
|
787
|
+
}
|
|
788
|
+
function utf8ByteLength(text) {
|
|
789
|
+
return new TextEncoder().encode(text).byteLength;
|
|
790
|
+
}
|
|
791
|
+
function contentsByteLength(contents) {
|
|
792
|
+
return typeof contents === "string" ? utf8ByteLength(contents) : contents.byteLength;
|
|
793
|
+
}
|
|
794
|
+
function decodeMountContents(data) {
|
|
795
|
+
if (data.byteLength > MAX_FILE_BYTES) {
|
|
796
|
+
return void 0;
|
|
625
797
|
}
|
|
626
|
-
|
|
798
|
+
for (let i = 0; i < data.byteLength; i++) {
|
|
799
|
+
if (data[i] === 0) {
|
|
800
|
+
return void 0;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return new TextDecoder("utf-8").decode(data);
|
|
627
804
|
}
|
|
628
|
-
function
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
634
|
-
const line = lines[i];
|
|
635
|
-
const m = line.match(/^([A-Za-z_][A-Za-z0-9_.]*):\s?(.*)$/);
|
|
636
|
-
if (m && !line.startsWith(" ")) {
|
|
637
|
-
errorType = m[1];
|
|
638
|
-
message = m[2];
|
|
805
|
+
function selectMountableFiles(candidates) {
|
|
806
|
+
const selected = [];
|
|
807
|
+
let total = 0;
|
|
808
|
+
for (const candidate of candidates) {
|
|
809
|
+
if (selected.length >= MAX_FILES) {
|
|
639
810
|
break;
|
|
640
811
|
}
|
|
641
|
-
if (
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
812
|
+
if (!isMountableName(candidate.name)) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
let contents;
|
|
816
|
+
if (isBinaryMountName(candidate.name)) {
|
|
817
|
+
const raw = typeof candidate.contents === "string" ? new TextEncoder().encode(candidate.contents) : candidate.contents;
|
|
818
|
+
if (raw.byteLength > MAX_FILE_BYTES) {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
contents = raw;
|
|
822
|
+
} else if (typeof candidate.contents === "string") {
|
|
823
|
+
if (utf8ByteLength(candidate.contents) > MAX_FILE_BYTES) {
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (candidate.contents.includes("\0")) {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
contents = candidate.contents;
|
|
830
|
+
} else {
|
|
831
|
+
const decoded = decodeMountContents(candidate.contents);
|
|
832
|
+
if (decoded === void 0) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
contents = decoded;
|
|
836
|
+
}
|
|
837
|
+
const size = contentsByteLength(contents);
|
|
838
|
+
if (total + size > MAX_TOTAL_BYTES) {
|
|
839
|
+
continue;
|
|
645
840
|
}
|
|
841
|
+
selected.push({ name: candidate.name, contents });
|
|
842
|
+
total += size;
|
|
646
843
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
lineNumber = parseInt(m[2], 10);
|
|
844
|
+
return selected;
|
|
845
|
+
}
|
|
846
|
+
function selectWritebackFiles(files) {
|
|
847
|
+
const selected = [];
|
|
848
|
+
let total = 0;
|
|
849
|
+
for (const file of files) {
|
|
850
|
+
if (selected.length >= MAX_FILES) {
|
|
655
851
|
break;
|
|
656
852
|
}
|
|
853
|
+
if (!isWritebackName(file.name)) {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (typeof file.contents !== "string") {
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
const size = utf8ByteLength(file.contents);
|
|
860
|
+
if (size > MAX_FILE_BYTES || total + size > MAX_TOTAL_BYTES) {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (file.contents.includes("\0")) {
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
selected.push(file);
|
|
867
|
+
total += size;
|
|
657
868
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
869
|
+
return selected;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// src/cli/files.ts
|
|
873
|
+
async function collectSiblingFiles(scriptPath) {
|
|
874
|
+
const folder = path.dirname(path.resolve(scriptPath));
|
|
875
|
+
let entries;
|
|
876
|
+
try {
|
|
877
|
+
entries = await fs.readdir(folder, { withFileTypes: true });
|
|
878
|
+
} catch {
|
|
879
|
+
return [];
|
|
880
|
+
}
|
|
881
|
+
const candidates = [];
|
|
882
|
+
for (const entry of entries) {
|
|
883
|
+
if (!entry.isFile() || !isMountableName(entry.name)) {
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
candidates.push({ name: entry.name, contents: await fs.readFile(path.join(folder, entry.name)) });
|
|
888
|
+
} catch {
|
|
668
889
|
}
|
|
669
890
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
891
|
+
return selectMountableFiles(candidates);
|
|
892
|
+
}
|
|
893
|
+
async function writeBackSiblingFiles(scriptPath, files) {
|
|
894
|
+
const folder = path.dirname(path.resolve(scriptPath));
|
|
895
|
+
const written = [];
|
|
896
|
+
for (const file of selectWritebackFiles(files)) {
|
|
897
|
+
try {
|
|
898
|
+
if (typeof file.contents !== "string") {
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
await fs.writeFile(path.join(folder, file.name), file.contents, "utf8");
|
|
902
|
+
written.push(file.name);
|
|
903
|
+
} catch {
|
|
675
904
|
}
|
|
676
905
|
}
|
|
677
|
-
return
|
|
678
|
-
errorType,
|
|
679
|
-
message,
|
|
680
|
-
traceback,
|
|
681
|
-
fileName,
|
|
682
|
-
lineNumber,
|
|
683
|
-
column,
|
|
684
|
-
nameToken
|
|
685
|
-
};
|
|
906
|
+
return written;
|
|
686
907
|
}
|
|
687
908
|
|
|
688
|
-
// src/
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
909
|
+
// src/cli/run.ts
|
|
910
|
+
var EXIT = {
|
|
911
|
+
ok: 0,
|
|
912
|
+
/** The program raised. */
|
|
913
|
+
programError: 1,
|
|
914
|
+
/** Level checks found problems, so the file was not run. */
|
|
915
|
+
blocked: 2,
|
|
916
|
+
/** The program ran, but its in-file tests did not all pass. */
|
|
917
|
+
testsFailed: 3,
|
|
918
|
+
/** Bad usage, or PLL itself could not run. */
|
|
919
|
+
usage: 64
|
|
920
|
+
};
|
|
921
|
+
async function runFile(runtime, view, opts) {
|
|
922
|
+
const fileName = path2.basename(opts.file);
|
|
923
|
+
let source;
|
|
924
|
+
try {
|
|
925
|
+
source = await fs2.readFile(opts.file, "utf8");
|
|
926
|
+
} catch (err) {
|
|
927
|
+
view.problem(`Cannot read ${opts.file}: ${errText(err)}`);
|
|
928
|
+
return EXIT.usage;
|
|
702
929
|
}
|
|
703
|
-
|
|
930
|
+
const level = parseLevel(source);
|
|
931
|
+
view.note(`${fileName} [${level}]`);
|
|
932
|
+
if (levelHasStaticChecks(level)) {
|
|
933
|
+
const blocked = await staticChecks(runtime, view, source, fileName, level);
|
|
934
|
+
if (blocked) {
|
|
935
|
+
return EXIT.blocked;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
await ensurePackages(runtime, view, source);
|
|
939
|
+
try {
|
|
940
|
+
await runtime.mountWorkspaceFiles(await collectSiblingFiles(opts.file));
|
|
941
|
+
} catch (err) {
|
|
942
|
+
view.note(`Could not load files next to this script (${errText(err)}).`);
|
|
943
|
+
}
|
|
944
|
+
const onEvent = (event) => {
|
|
945
|
+
if (event.kind === "error") {
|
|
946
|
+
const finding = findingForErrorEvent(event, source, fileName, level);
|
|
947
|
+
if (finding) {
|
|
948
|
+
view.runtimeFinding(finding);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
view.handle(event);
|
|
953
|
+
};
|
|
954
|
+
if (opts.runTests) {
|
|
955
|
+
await maybeRunTests(runtime, view, source, fileName, level, onEvent);
|
|
956
|
+
}
|
|
957
|
+
await runtime.runFile({ code: source, fileName, sessionKey: opts.file, level }, onEvent);
|
|
958
|
+
await writeBack(runtime, view, opts.file);
|
|
959
|
+
if (view.sawError) return EXIT.programError;
|
|
960
|
+
if (view.testFailures > 0) return EXIT.testsFailed;
|
|
961
|
+
return EXIT.ok;
|
|
704
962
|
}
|
|
705
|
-
function
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
963
|
+
async function staticChecks(runtime, view, source, fileName, level) {
|
|
964
|
+
let raw;
|
|
965
|
+
try {
|
|
966
|
+
raw = await runtime.staticAnalyze({ code: source, fileName, level });
|
|
967
|
+
} catch (err) {
|
|
968
|
+
view.note(`Static analysis failed (${errText(err)}); running anyway.`);
|
|
969
|
+
return false;
|
|
709
970
|
}
|
|
710
|
-
|
|
971
|
+
const findings = enrichStaticFindings(raw, level, fileName);
|
|
972
|
+
if (findings.length === 0) {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
view.findings(findings);
|
|
976
|
+
view.problem(`Static analysis found ${findings.length} problem(s). File not run.`);
|
|
977
|
+
return true;
|
|
711
978
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
979
|
+
async function ensurePackages(runtime, view, source) {
|
|
980
|
+
if (!needsPackages(source)) {
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
await runtime.ensurePackages(source);
|
|
985
|
+
} catch (err) {
|
|
986
|
+
const message = errText(err);
|
|
987
|
+
if (!/syntaxerror|invalid syntax/i.test(message)) {
|
|
988
|
+
view.note(`Could not load libraries (${message}). Continuing.`);
|
|
720
989
|
}
|
|
721
|
-
const explanation = explainNameError(parsedError);
|
|
722
|
-
return {
|
|
723
|
-
id: "name-error",
|
|
724
|
-
errorType: "NameError",
|
|
725
|
-
message: parsedError.message,
|
|
726
|
-
headline: explanation.headline,
|
|
727
|
-
howToFix: explanation.howToFix,
|
|
728
|
-
fileName,
|
|
729
|
-
lineNumber: parsedError.lineNumber,
|
|
730
|
-
column: parsedError.column,
|
|
731
|
-
nameToken: parsedError.nameToken,
|
|
732
|
-
severity: "error",
|
|
733
|
-
raw: parsedError.traceback,
|
|
734
|
-
origin: "runtime",
|
|
735
|
-
level
|
|
736
|
-
};
|
|
737
990
|
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
var LEVEL_NAMES = [
|
|
744
|
-
"raw",
|
|
745
|
-
"beginner",
|
|
746
|
-
"intermediate",
|
|
747
|
-
"advanced"
|
|
748
|
-
];
|
|
749
|
-
function parseLevel(source) {
|
|
750
|
-
const lines = source.split(/\r?\n/);
|
|
751
|
-
for (const raw of lines) {
|
|
752
|
-
const line = raw.trim();
|
|
753
|
-
if (line.length === 0) {
|
|
754
|
-
continue;
|
|
991
|
+
}
|
|
992
|
+
async function maybeRunTests(runtime, view, source, fileName, level, onEvent) {
|
|
993
|
+
try {
|
|
994
|
+
if (!await runtime.hasTests(source)) {
|
|
995
|
+
return;
|
|
755
996
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
997
|
+
await runtime.ensurePytest();
|
|
998
|
+
} catch (err) {
|
|
999
|
+
view.note(`Could not check for tests (${errText(err)}); skipping them.`);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
await runtime.runTests({ code: source, fileName, sessionKey: fileName, level }, onEvent);
|
|
1003
|
+
}
|
|
1004
|
+
async function writeBack(runtime, view, file) {
|
|
1005
|
+
try {
|
|
1006
|
+
const changed = await runtime.collectWorkspaceFiles();
|
|
1007
|
+
if (changed.length === 0) {
|
|
1008
|
+
return;
|
|
759
1009
|
}
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
1010
|
+
const written = await writeBackSiblingFiles(file, changed);
|
|
1011
|
+
if (written.length > 0) {
|
|
1012
|
+
view.note(`Saved ${written.join(", ")} next to ${path2.basename(file)}.`);
|
|
763
1013
|
}
|
|
764
|
-
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
view.note(`Could not save files next to this script (${errText(err)}).`);
|
|
765
1016
|
}
|
|
766
|
-
return DEFAULT_LEVEL;
|
|
767
1017
|
}
|
|
768
|
-
function
|
|
769
|
-
return
|
|
770
|
-
}
|
|
771
|
-
function levelHasStaticChecks(level) {
|
|
772
|
-
return level === "beginner" || level === "intermediate";
|
|
1018
|
+
function errText(err) {
|
|
1019
|
+
return err instanceof Error ? err.message : String(err);
|
|
773
1020
|
}
|
|
774
1021
|
|
|
775
|
-
// src/
|
|
776
|
-
var
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
function
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
if (
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1022
|
+
// src/cli/examplar.ts
|
|
1023
|
+
var EXAMPLAR_USAGE = `pll examplar - author Examplar bundles
|
|
1024
|
+
|
|
1025
|
+
pll examplar build <dir> [-o bundle.json] [--verify tests.py]
|
|
1026
|
+
|
|
1027
|
+
<dir> holds the implementations, one file each:
|
|
1028
|
+
|
|
1029
|
+
<dir>/wheats/reference.py known-good; every test must pass on these
|
|
1030
|
+
<dir>/wheats/alternative.py more than one stops tests over-fitting
|
|
1031
|
+
<dir>/chaffs/initials/1.py known-bad; each must be caught by some test
|
|
1032
|
+
<dir>/chaffs/longest/1.py
|
|
1033
|
+
|
|
1034
|
+
A chaff goes in a directory named after the function it breaks, because a
|
|
1035
|
+
student's report is per function. Its id is its filename, and that id is the
|
|
1036
|
+
only thing a student sees about a chaff they missed - so number them rather
|
|
1037
|
+
than naming them after the bug. Every function needs at least one.
|
|
1038
|
+
|
|
1039
|
+
Only bytecode is written to the bundle, so the sources stay in your
|
|
1040
|
+
repository.
|
|
1041
|
+
|
|
1042
|
+
The bytecode is compiled by the Pyodide this package pins, so it always
|
|
1043
|
+
matches the interpreter that will run it - which is the reason to build
|
|
1044
|
+
bundles with this tool rather than a local python.
|
|
1045
|
+
|
|
1046
|
+
-o FILE write here instead of stdout
|
|
1047
|
+
--verify TESTS check the bundle with your own suite: it must pass on
|
|
1048
|
+
every wheat and fail on every chaff. A chaff no test can
|
|
1049
|
+
catch is a broken chaff, and better found now. Chaffs are
|
|
1050
|
+
only checked once the wheats pass, since a suite that is
|
|
1051
|
+
wrong fails on everything.
|
|
1052
|
+
`;
|
|
1053
|
+
function parseExamplarArgs(argv) {
|
|
1054
|
+
const args = { help: false };
|
|
1055
|
+
if (argv[0] !== "build") {
|
|
1056
|
+
args.error = argv.length === 0 ? "examplar needs a subcommand" : `unknown examplar subcommand ${argv[0]}`;
|
|
1057
|
+
if (argv[0] === "-h" || argv[0] === "--help") {
|
|
1058
|
+
args.error = void 0;
|
|
1059
|
+
args.help = true;
|
|
1060
|
+
}
|
|
1061
|
+
return args;
|
|
1062
|
+
}
|
|
1063
|
+
for (let i = 1; i < argv.length; i++) {
|
|
1064
|
+
const arg = argv[i];
|
|
1065
|
+
if (arg === "-h" || arg === "--help") args.help = true;
|
|
1066
|
+
else if (arg === "-o") {
|
|
1067
|
+
args.out = argv[++i];
|
|
1068
|
+
if (args.out === void 0) {
|
|
1069
|
+
args.error = "-o needs a file";
|
|
1070
|
+
return args;
|
|
1071
|
+
}
|
|
1072
|
+
} else if (arg === "--verify") {
|
|
1073
|
+
args.verify = argv[++i];
|
|
1074
|
+
if (args.verify === void 0) {
|
|
1075
|
+
args.error = "--verify needs a test file";
|
|
1076
|
+
return args;
|
|
1077
|
+
}
|
|
1078
|
+
} else if (arg.startsWith("-")) {
|
|
1079
|
+
args.error = `unknown option ${arg}`;
|
|
1080
|
+
return args;
|
|
1081
|
+
} else if (args.dir === void 0) {
|
|
1082
|
+
args.dir = arg;
|
|
1083
|
+
} else {
|
|
1084
|
+
args.error = `unexpected extra argument ${arg}`;
|
|
1085
|
+
return args;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (!args.help && args.dir === void 0) {
|
|
1089
|
+
args.error = "no directory given";
|
|
1090
|
+
}
|
|
1091
|
+
return args;
|
|
819
1092
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1093
|
+
async function readDir(folder) {
|
|
1094
|
+
try {
|
|
1095
|
+
return await fs3.readdir(folder, { withFileTypes: true });
|
|
1096
|
+
} catch {
|
|
1097
|
+
throw new Error(`${folder} does not exist. See \`pll examplar --help\`.`);
|
|
1098
|
+
}
|
|
825
1099
|
}
|
|
826
|
-
function
|
|
827
|
-
|
|
828
|
-
|
|
1100
|
+
async function readWheats(dir) {
|
|
1101
|
+
const folder = path3.join(dir, "wheats");
|
|
1102
|
+
const out = {};
|
|
1103
|
+
for (const entry of await readDir(folder)) {
|
|
1104
|
+
if (!entry.isFile() || !entry.name.endsWith(".py")) continue;
|
|
1105
|
+
out[entry.name.slice(0, -3)] = await fs3.readFile(path3.join(folder, entry.name), "utf8");
|
|
829
1106
|
}
|
|
830
|
-
if (
|
|
831
|
-
|
|
1107
|
+
if (Object.keys(out).length === 0) {
|
|
1108
|
+
throw new Error(`${folder} has no .py files in it.`);
|
|
1109
|
+
}
|
|
1110
|
+
return out;
|
|
832
1111
|
}
|
|
833
|
-
function
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
const lines = message.split(/\r?\n/);
|
|
842
|
-
const first = lines[0].trim();
|
|
843
|
-
let expected = [];
|
|
844
|
-
let subject = null;
|
|
845
|
-
const unionAt = first.indexOf(" did not match any element in the union:");
|
|
846
|
-
if (unionAt >= 0) {
|
|
847
|
-
subject = first.slice(0, unionAt);
|
|
848
|
-
for (const raw of lines.slice(1)) {
|
|
849
|
-
const m = raw.match(/^\s*([A-Za-z_][A-Za-z0-9_.\[\], ]*):/);
|
|
850
|
-
if (m) expected.push(m[1].trim());
|
|
1112
|
+
async function readChaffs(dir) {
|
|
1113
|
+
const folder = path3.join(dir, "chaffs");
|
|
1114
|
+
const out = {};
|
|
1115
|
+
const loose = [];
|
|
1116
|
+
for (const entry of await readDir(folder)) {
|
|
1117
|
+
if (entry.isFile() && entry.name.endsWith(".py")) {
|
|
1118
|
+
loose.push(entry.name);
|
|
1119
|
+
continue;
|
|
851
1120
|
}
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
[
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if (m) {
|
|
862
|
-
subject = m[1];
|
|
863
|
-
expected = pick(m);
|
|
864
|
-
break;
|
|
865
|
-
}
|
|
1121
|
+
if (!entry.isDirectory()) continue;
|
|
1122
|
+
const inner = path3.join(folder, entry.name);
|
|
1123
|
+
const group = {};
|
|
1124
|
+
for (const file of await fs3.readdir(inner, { withFileTypes: true })) {
|
|
1125
|
+
if (!file.isFile() || !file.name.endsWith(".py")) continue;
|
|
1126
|
+
group[file.name.slice(0, -3)] = await fs3.readFile(path3.join(inner, file.name), "utf8");
|
|
1127
|
+
}
|
|
1128
|
+
if (Object.keys(group).length > 0) {
|
|
1129
|
+
out[entry.name] = group;
|
|
866
1130
|
}
|
|
867
1131
|
}
|
|
868
|
-
if (
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
subject = withActual[1];
|
|
873
|
-
actual = withActual[2];
|
|
1132
|
+
if (loose.length > 0) {
|
|
1133
|
+
throw new Error(
|
|
1134
|
+
`${path3.join(folder, loose[0])} is not inside a function's directory. Chaffs go in \`chaffs/<function>/\`, named after the function they break - move it to \`chaffs/<function>/${loose[0]}\`.`
|
|
1135
|
+
);
|
|
874
1136
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
if (elementMatch) {
|
|
878
|
-
element = describeElement(elementMatch[1]);
|
|
879
|
-
subject = elementMatch[2];
|
|
1137
|
+
if (Object.keys(out).length === 0) {
|
|
1138
|
+
throw new Error(`${folder} has no \`<function>/*.py\` in it.`);
|
|
880
1139
|
}
|
|
881
|
-
|
|
882
|
-
|
|
1140
|
+
return out;
|
|
1141
|
+
}
|
|
1142
|
+
async function runExamplar(runtime, view, argv) {
|
|
1143
|
+
const args = parseExamplarArgs(argv);
|
|
1144
|
+
if (args.help) {
|
|
1145
|
+
process.stdout.write(EXAMPLAR_USAGE);
|
|
1146
|
+
return EXIT.ok;
|
|
883
1147
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1148
|
+
if (args.error !== void 0) {
|
|
1149
|
+
process.stderr.write(`pll: ${args.error}
|
|
1150
|
+
|
|
1151
|
+
${EXAMPLAR_USAGE}`);
|
|
1152
|
+
return EXIT.usage;
|
|
887
1153
|
}
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
1154
|
+
const dir = path3.resolve(args.dir);
|
|
1155
|
+
let sources;
|
|
1156
|
+
try {
|
|
1157
|
+
sources = { wheats: await readWheats(dir), chaffs: await readChaffs(dir) };
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
view.problem(`pll: ${err instanceof Error ? err.message : String(err)}`);
|
|
1160
|
+
return EXIT.usage;
|
|
891
1161
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if (
|
|
904
|
-
return
|
|
905
|
-
headline: `${owner} expects ${membersPhrase(parsed.element)} ${named} to be ${wanted}, but ${parsed.element} is not.`,
|
|
906
|
-
howToFix: [
|
|
907
|
-
`Look at ${parsed.element} of the ${parsed.actual ?? "value"} you passed for ${named}.`,
|
|
908
|
-
"Every one has to match the annotation, not just the first."
|
|
909
|
-
]
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
const got = parsed.actual ? `, but got ${describeType(parsed.actual)}` : "";
|
|
913
|
-
const howToFix = [`Check the value you passed for ${named} on this line.`];
|
|
914
|
-
if (boolNote) howToFix.push(boolNote);
|
|
915
|
-
if (convert) {
|
|
916
|
-
howToFix.push(`If it should be ${wanted}, convert it with \`${convert}(...)\`.`);
|
|
917
|
-
}
|
|
918
|
-
if (annotation && parsed.actual) {
|
|
919
|
-
howToFix.push(
|
|
920
|
-
`Or, if ${named} really should be ${describeType(parsed.actual)}, change its annotation in ${owner} from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
|
|
921
|
-
);
|
|
1162
|
+
const built = await runtime.examplarBuild(JSON.stringify(sources));
|
|
1163
|
+
if (!built.ok || !built.bundle) {
|
|
1164
|
+
view.problem(`pll: ${built.error ?? "could not build the bundle"}`);
|
|
1165
|
+
return EXIT.usage;
|
|
1166
|
+
}
|
|
1167
|
+
const bundle = built.bundle;
|
|
1168
|
+
view.note(
|
|
1169
|
+
`built for Python ${bundle.built.python}: ${bundle.wheats.length} wheat(s), ${bundle.chaffs.length} chaff(s), providing ${bundle.provides.join(", ")}`
|
|
1170
|
+
);
|
|
1171
|
+
if (args.verify !== void 0) {
|
|
1172
|
+
const code = await verify(runtime, view, bundle, args.verify);
|
|
1173
|
+
if (code !== EXIT.ok) {
|
|
1174
|
+
return code;
|
|
922
1175
|
}
|
|
923
|
-
return {
|
|
924
|
-
headline: `${owner} expects ${named} to be ${wanted}${got}.`,
|
|
925
|
-
howToFix
|
|
926
|
-
};
|
|
927
1176
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1177
|
+
const json = JSON.stringify(bundle);
|
|
1178
|
+
if (args.out === void 0) {
|
|
1179
|
+
process.stdout.write(json + "\n");
|
|
1180
|
+
} else {
|
|
1181
|
+
await fs3.writeFile(path3.resolve(args.out), json + "\n", "utf8");
|
|
1182
|
+
view.note(`wrote ${args.out} (${json.length} bytes)`);
|
|
1183
|
+
}
|
|
1184
|
+
return EXIT.ok;
|
|
1185
|
+
}
|
|
1186
|
+
function caught(impl) {
|
|
1187
|
+
return !impl.loaded || Object.values(impl.tests).some((t) => t.outcome !== "pass");
|
|
1188
|
+
}
|
|
1189
|
+
async function verify(runtime, view, bundle, testsPath) {
|
|
1190
|
+
let testSource;
|
|
1191
|
+
try {
|
|
1192
|
+
testSource = await fs3.readFile(path3.resolve(testsPath), "utf8");
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
view.problem(`pll: cannot read ${testsPath}: ${err instanceof Error ? err.message : err}`);
|
|
1195
|
+
return EXIT.usage;
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
await runtime.ensurePytest();
|
|
1199
|
+
} catch {
|
|
1200
|
+
view.note("could not load pytest; assertion messages will be terse.");
|
|
1201
|
+
}
|
|
1202
|
+
const result = await runtime.examplarRun(
|
|
1203
|
+
testSource,
|
|
1204
|
+
JSON.stringify(bundle)
|
|
1205
|
+
);
|
|
1206
|
+
if (!result.ok) {
|
|
1207
|
+
view.problem(`pll: ${result.error ?? "the bundle could not be run"}`);
|
|
1208
|
+
return EXIT.usage;
|
|
1209
|
+
}
|
|
1210
|
+
let bad = 0;
|
|
1211
|
+
view.problem(`verifying with ${path3.basename(testsPath)}:`);
|
|
1212
|
+
for (const wheat of result.wheats ?? []) {
|
|
1213
|
+
const failures = Object.entries(wheat.tests).filter(([, t]) => t.outcome !== "pass");
|
|
1214
|
+
if (wheat.loaded && failures.length === 0) {
|
|
1215
|
+
view.note(` ok wheat ${wheat.id}: all ${Object.keys(wheat.tests).length} pass`);
|
|
1216
|
+
continue;
|
|
938
1217
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
howToFix.push("Use a bare `return`, or drop the `return` entirely.");
|
|
944
|
-
} else if (convert) {
|
|
945
|
-
howToFix.push(`Convert the returned value with \`${convert}(...)\`.`);
|
|
946
|
-
} else {
|
|
947
|
-
howToFix.push(`Return ${wanted} from this line.`);
|
|
1218
|
+
bad += 1;
|
|
1219
|
+
view.problem(` BAD wheat ${wheat.id}: your own tests do not all pass on it`);
|
|
1220
|
+
if (!wheat.loaded) {
|
|
1221
|
+
view.problem(` ${wheat.error_type}: ${wheat.error_message}`);
|
|
948
1222
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
howToFix.push(
|
|
952
|
-
`Or change ${owner}'s return annotation from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
|
|
953
|
-
);
|
|
1223
|
+
for (const [name, t] of failures) {
|
|
1224
|
+
view.problem(` ${name}: ${(t.message ?? t.outcome).split("\n")[0]}`);
|
|
954
1225
|
}
|
|
955
|
-
return {
|
|
956
|
-
headline: `${owner} says it returns ${wanted}, but this line returns ${got}.`,
|
|
957
|
-
howToFix
|
|
958
|
-
};
|
|
959
1226
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1227
|
+
for (const chaff of result.chaffs ?? []) {
|
|
1228
|
+
const where = `chaff ${chaff.targets}/${chaff.id}`;
|
|
1229
|
+
if (caught(chaff)) {
|
|
1230
|
+
const by = Object.entries(chaff.tests).filter(([, t]) => t.outcome !== "pass").map(([name]) => name);
|
|
1231
|
+
view.note(` ok ${where}: caught by ${by.join(", ")}`);
|
|
1232
|
+
continue;
|
|
966
1233
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1234
|
+
bad += 1;
|
|
1235
|
+
view.problem(` BAD ${where}: no test catches it - it would never count`);
|
|
1236
|
+
}
|
|
1237
|
+
const attributed = Object.values(result.attribution ?? {});
|
|
1238
|
+
for (const name of result.chaffs_skipped ?? []) {
|
|
1239
|
+
if (attributed.some((names) => names.includes(name))) {
|
|
1240
|
+
view.problem(` --- ${name} chaffs not checked: its tests have to pass first`);
|
|
1241
|
+
continue;
|
|
972
1242
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1243
|
+
bad += 1;
|
|
1244
|
+
view.problem(
|
|
1245
|
+
` BAD your own suite has no tests for ${name}, so its chaffs are unproven`
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
if (bad > 0) {
|
|
1249
|
+
view.problem(`pll: ${bad} problem(s) with this bundle; not written.`);
|
|
1250
|
+
return EXIT.testsFailed;
|
|
1251
|
+
}
|
|
1252
|
+
view.problem("verified.");
|
|
1253
|
+
return EXIT.ok;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// src/cli/runtime.ts
|
|
1257
|
+
var path5 = __toESM(require("node:path"));
|
|
1258
|
+
|
|
1259
|
+
// src/desktop/pyodideRuntime.ts
|
|
1260
|
+
var fs4 = __toESM(require("node:fs"));
|
|
1261
|
+
var path4 = __toESM(require("node:path"));
|
|
1262
|
+
var import_node_worker_threads = require("node:worker_threads");
|
|
1263
|
+
|
|
1264
|
+
// src/common/deliverResult.ts
|
|
1265
|
+
function deliverRunResult(result, onEvent, fileName) {
|
|
1266
|
+
if (result.displays) {
|
|
1267
|
+
for (const display of result.displays) {
|
|
1268
|
+
deliverDisplay(display, onEvent, fileName);
|
|
977
1269
|
}
|
|
978
|
-
return {
|
|
979
|
-
headline: `${named} is annotated as ${wanted}, but ${got} was assigned here.`,
|
|
980
|
-
howToFix
|
|
981
|
-
};
|
|
982
1270
|
}
|
|
1271
|
+
if (result.result_repr !== null && result.result_repr !== void 0) {
|
|
1272
|
+
onEvent({ kind: "result", repr: result.result_repr });
|
|
1273
|
+
}
|
|
1274
|
+
if (!result.ok && result.error_type) {
|
|
1275
|
+
onEvent({
|
|
1276
|
+
kind: "error",
|
|
1277
|
+
errorType: result.error_type,
|
|
1278
|
+
message: result.error_message ?? "",
|
|
1279
|
+
traceback: result.traceback ?? "",
|
|
1280
|
+
lineNumber: result.line_number,
|
|
1281
|
+
column: result.column,
|
|
1282
|
+
fileName
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
onEvent({ kind: "done" });
|
|
1286
|
+
}
|
|
1287
|
+
function deliverDisplay(display, onEvent, fileName) {
|
|
1288
|
+
switch (display.type) {
|
|
1289
|
+
case "stdout":
|
|
1290
|
+
onEvent({ kind: "stdout", text: display.text });
|
|
1291
|
+
break;
|
|
1292
|
+
case "stderr":
|
|
1293
|
+
onEvent({ kind: "stderr", text: display.text });
|
|
1294
|
+
break;
|
|
1295
|
+
case "image":
|
|
1296
|
+
onEvent({
|
|
1297
|
+
kind: "image",
|
|
1298
|
+
svg: display.data,
|
|
1299
|
+
width: display.width,
|
|
1300
|
+
height: display.height,
|
|
1301
|
+
source: fileName
|
|
1302
|
+
});
|
|
1303
|
+
break;
|
|
1304
|
+
case "reactor":
|
|
1305
|
+
onEvent({
|
|
1306
|
+
kind: "reactor",
|
|
1307
|
+
id: display.id,
|
|
1308
|
+
title: display.title,
|
|
1309
|
+
tickRate: display.tick_rate,
|
|
1310
|
+
ticking: display.ticking,
|
|
1311
|
+
wantsKeys: display.wants_keys,
|
|
1312
|
+
wantsMouse: display.wants_mouse,
|
|
1313
|
+
register: display.register,
|
|
1314
|
+
frame: display.frame,
|
|
1315
|
+
index: display.index,
|
|
1316
|
+
length: display.length,
|
|
1317
|
+
atEnd: display.at_end,
|
|
1318
|
+
stopped: display.stopped,
|
|
1319
|
+
valueRepr: display.value_repr
|
|
1320
|
+
});
|
|
1321
|
+
break;
|
|
1322
|
+
case "table":
|
|
1323
|
+
onEvent({
|
|
1324
|
+
kind: "table",
|
|
1325
|
+
columns: display.columns,
|
|
1326
|
+
rows: display.rows,
|
|
1327
|
+
rowCount: display.row_count,
|
|
1328
|
+
shownCount: display.shown_count,
|
|
1329
|
+
truncated: display.truncated,
|
|
1330
|
+
source: fileName
|
|
1331
|
+
});
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
function asPlain(value) {
|
|
1336
|
+
if (value instanceof Map) {
|
|
1337
|
+
const obj = {};
|
|
1338
|
+
for (const [k, v] of value.entries()) {
|
|
1339
|
+
obj[String(k)] = asPlain(v);
|
|
1340
|
+
}
|
|
1341
|
+
return obj;
|
|
1342
|
+
}
|
|
1343
|
+
if (Array.isArray(value)) {
|
|
1344
|
+
return value.map(asPlain);
|
|
1345
|
+
}
|
|
1346
|
+
return value;
|
|
1347
|
+
}
|
|
1348
|
+
function friendlyTypeCheckMessage(message, level) {
|
|
1349
|
+
const marker = message.indexOf("TypeCheckError:");
|
|
1350
|
+
if (marker < 0) {
|
|
1351
|
+
return message;
|
|
1352
|
+
}
|
|
1353
|
+
const detail = message.slice(marker + "TypeCheckError:".length).trim();
|
|
1354
|
+
if (!detail) {
|
|
1355
|
+
return message;
|
|
1356
|
+
}
|
|
1357
|
+
const explanation = explainTypeCheckError(detail, null, level);
|
|
1358
|
+
return [explanation.headline, ...explanation.howToFix.map((line) => `- ${line}`)].join(
|
|
1359
|
+
"\n"
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
function adaptTestCase(raw, level) {
|
|
1363
|
+
const row = asPlain(raw) ?? {};
|
|
1364
|
+
const line = row.line_number;
|
|
1365
|
+
const message = row.message == null ? null : String(row.message);
|
|
983
1366
|
return {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1367
|
+
name: String(row.name ?? ""),
|
|
1368
|
+
outcome: String(row.outcome ?? "failed"),
|
|
1369
|
+
lineNumber: typeof line === "number" ? line : null,
|
|
1370
|
+
message: message === null ? null : friendlyTypeCheckMessage(message, level),
|
|
1371
|
+
stdout: row.stdout == null ? null : String(row.stdout)
|
|
989
1372
|
};
|
|
990
1373
|
}
|
|
1374
|
+
function deliverTestResult(result, onEvent, fileName, level) {
|
|
1375
|
+
if (result.internal_error && result.error_type) {
|
|
1376
|
+
onEvent({
|
|
1377
|
+
kind: "error",
|
|
1378
|
+
errorType: result.error_type,
|
|
1379
|
+
message: result.error_message ?? "",
|
|
1380
|
+
traceback: result.traceback ?? "",
|
|
1381
|
+
lineNumber: result.line_number,
|
|
1382
|
+
column: result.column,
|
|
1383
|
+
fileName
|
|
1384
|
+
});
|
|
1385
|
+
onEvent({ kind: "done" });
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
const tests = Array.isArray(result.tests) ? result.tests.map((row) => adaptTestCase(row, level)) : [];
|
|
1389
|
+
onEvent({
|
|
1390
|
+
kind: "testReport",
|
|
1391
|
+
fileName,
|
|
1392
|
+
passed: result.passed ?? 0,
|
|
1393
|
+
failed: result.failed ?? 0,
|
|
1394
|
+
skipped: result.skipped ?? 0,
|
|
1395
|
+
errors: result.errors ?? 0,
|
|
1396
|
+
tests
|
|
1397
|
+
});
|
|
1398
|
+
onEvent({ kind: "done" });
|
|
1399
|
+
}
|
|
991
1400
|
|
|
992
|
-
// src/common/
|
|
993
|
-
var
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const detail = [line.slice(marker + "TypeCheckError:".length).trim()];
|
|
1001
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
1002
|
-
if (lines[j].trim() === "") continue;
|
|
1003
|
-
if (!lines[j].startsWith(" ")) break;
|
|
1004
|
-
detail.push(lines[j]);
|
|
1005
|
-
}
|
|
1006
|
-
return detail.join("\n");
|
|
1401
|
+
// src/common/interruptBuffer.ts
|
|
1402
|
+
var INTERRUPT_SAB_BYTES = 1;
|
|
1403
|
+
var INTERRUPT_SIGINT = 2;
|
|
1404
|
+
function tryCreateInterruptBuffer() {
|
|
1405
|
+
try {
|
|
1406
|
+
return new SharedArrayBuffer(INTERRUPT_SAB_BYTES);
|
|
1407
|
+
} catch {
|
|
1408
|
+
return null;
|
|
1007
1409
|
}
|
|
1008
|
-
return fallback;
|
|
1009
1410
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1411
|
+
function signalInterrupt(sab) {
|
|
1412
|
+
Atomics.store(new Uint8Array(sab), 0, INTERRUPT_SIGINT);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// src/common/stdinBuffer.ts
|
|
1416
|
+
var STDIN_SAB_BYTES = 64 * 1024;
|
|
1417
|
+
var STDIN_STATE_INDEX = 0;
|
|
1418
|
+
var STDIN_LENGTH_INDEX = 1;
|
|
1419
|
+
var STDIN_PAYLOAD_OFFSET = 8;
|
|
1420
|
+
var STDIN_STATE_LINE = 1;
|
|
1421
|
+
var STDIN_STATE_EOF = 2;
|
|
1422
|
+
function tryCreateStdinBuffer() {
|
|
1423
|
+
try {
|
|
1424
|
+
return new SharedArrayBuffer(STDIN_SAB_BYTES);
|
|
1425
|
+
} catch {
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
function writeStdinLine(sab, line) {
|
|
1430
|
+
const state = new Int32Array(sab);
|
|
1431
|
+
if (line === null) {
|
|
1432
|
+
Atomics.store(state, STDIN_STATE_INDEX, STDIN_STATE_EOF);
|
|
1433
|
+
Atomics.notify(state, STDIN_STATE_INDEX);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
const encoded = new TextEncoder().encode(line);
|
|
1437
|
+
const max = sab.byteLength - STDIN_PAYLOAD_OFFSET;
|
|
1438
|
+
const n = Math.min(encoded.length, max);
|
|
1439
|
+
const bytes = new Uint8Array(sab, STDIN_PAYLOAD_OFFSET);
|
|
1440
|
+
bytes.set(encoded.subarray(0, n));
|
|
1441
|
+
Atomics.store(state, STDIN_LENGTH_INDEX, n);
|
|
1442
|
+
Atomics.store(state, STDIN_STATE_INDEX, STDIN_STATE_LINE);
|
|
1443
|
+
Atomics.notify(state, STDIN_STATE_INDEX);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// src/common/workerRuntime.ts
|
|
1447
|
+
var WorkerPythonRuntime = class {
|
|
1448
|
+
worker = null;
|
|
1449
|
+
nextId = 1;
|
|
1450
|
+
pending = /* @__PURE__ */ new Map();
|
|
1451
|
+
initPromise = null;
|
|
1452
|
+
stdinBuffer = null;
|
|
1453
|
+
interruptBuffer = null;
|
|
1454
|
+
stdinHandler = null;
|
|
1455
|
+
/** Event sink for the in-flight runFile, so live displays can stream. */
|
|
1456
|
+
live = null;
|
|
1457
|
+
async initialize() {
|
|
1458
|
+
if (!this.initPromise) {
|
|
1459
|
+
this.initPromise = this.doInitialize();
|
|
1016
1460
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1461
|
+
await this.initPromise;
|
|
1462
|
+
}
|
|
1463
|
+
async doInitialize() {
|
|
1464
|
+
const indexUrl = this.resolveIndexUrl();
|
|
1465
|
+
this.worker = this.spawn({
|
|
1466
|
+
onMessage: (msg) => this.handleMessage(msg),
|
|
1467
|
+
onError: (err) => this.rejectAllPending(err)
|
|
1468
|
+
});
|
|
1469
|
+
this.stdinBuffer = tryCreateStdinBuffer();
|
|
1470
|
+
this.interruptBuffer = tryCreateInterruptBuffer();
|
|
1471
|
+
await this.request(
|
|
1472
|
+
{
|
|
1473
|
+
type: "init",
|
|
1474
|
+
indexUrl,
|
|
1475
|
+
...this.stdinBuffer ? { stdinBuffer: this.stdinBuffer } : {},
|
|
1476
|
+
...this.interruptBuffer ? { interruptBuffer: this.interruptBuffer } : {}
|
|
1477
|
+
},
|
|
1478
|
+
"ready"
|
|
1026
1479
|
);
|
|
1480
|
+
}
|
|
1481
|
+
async runFile(request, onEvent) {
|
|
1482
|
+
await this.initialize();
|
|
1483
|
+
this.live = { onEvent, fileName: request.fileName };
|
|
1484
|
+
try {
|
|
1485
|
+
const { result } = await this.request({ type: "runFile", ...request }, "result");
|
|
1486
|
+
deliverRunResult(result, onEvent, request.fileName);
|
|
1487
|
+
} finally {
|
|
1488
|
+
this.live = null;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
async replEval(request, onEvent) {
|
|
1492
|
+
await this.initialize();
|
|
1493
|
+
const { result } = await this.request({ type: "replEval", ...request }, "result");
|
|
1494
|
+
deliverRunResult(result, onEvent, "<repl>");
|
|
1495
|
+
}
|
|
1496
|
+
async checkReplComplete(code) {
|
|
1497
|
+
await this.initialize();
|
|
1498
|
+
const { result } = await this.request({ type: "checkSyntax", code }, "syntax");
|
|
1027
1499
|
return {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
headline: explanation.headline,
|
|
1034
|
-
howToFix: explanation.howToFix,
|
|
1035
|
-
fileName: blamed ? blamed.fileName : fileName,
|
|
1036
|
-
lineNumber: blamed ? blamed.line : parsedError.lineNumber,
|
|
1037
|
-
column: null,
|
|
1038
|
-
nameToken: parsed.name,
|
|
1039
|
-
severity: "error",
|
|
1040
|
-
raw: parsedError.traceback,
|
|
1041
|
-
origin: "runtime",
|
|
1042
|
-
level
|
|
1500
|
+
status: result.status,
|
|
1501
|
+
errorType: result.error_type,
|
|
1502
|
+
message: result.message,
|
|
1503
|
+
lineNumber: result.lineno,
|
|
1504
|
+
offset: result.offset
|
|
1043
1505
|
};
|
|
1044
1506
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
function findRuntimeFinding(source, fileName, level, parsedError) {
|
|
1050
|
-
for (const analyzer of analyzers) {
|
|
1051
|
-
if (!analyzer.handles.includes(parsedError.errorType)) {
|
|
1052
|
-
continue;
|
|
1053
|
-
}
|
|
1054
|
-
const finding = analyzer.analyze({ source, fileName, level, parsedError });
|
|
1055
|
-
if (finding) {
|
|
1056
|
-
return finding;
|
|
1057
|
-
}
|
|
1507
|
+
async hasTests(code) {
|
|
1508
|
+
await this.initialize();
|
|
1509
|
+
const { result } = await this.request({ type: "hasTests", code }, "hasTests");
|
|
1510
|
+
return result;
|
|
1058
1511
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
// src/common/analyzers/runtimeFinding.ts
|
|
1063
|
-
function findingForErrorEvent(event, source, fileName, level) {
|
|
1064
|
-
const traceback = event.traceback || `${event.errorType}: ${event.message}`;
|
|
1065
|
-
const parsed = parsePythonError(traceback);
|
|
1066
|
-
if (parsed.lineNumber === null && event.lineNumber !== null) {
|
|
1067
|
-
parsed.lineNumber = event.lineNumber;
|
|
1512
|
+
async ensurePackages(code) {
|
|
1513
|
+
await this.initialize();
|
|
1514
|
+
await this.request({ type: "loadPackages", code }, "packagesReady");
|
|
1068
1515
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1516
|
+
async ensurePytest() {
|
|
1517
|
+
await this.initialize();
|
|
1518
|
+
await this.request({ type: "loadPytest" }, "pytestReady");
|
|
1071
1519
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1520
|
+
async runTests(request, onEvent) {
|
|
1521
|
+
await this.initialize();
|
|
1522
|
+
const { result } = await this.request(
|
|
1523
|
+
{
|
|
1524
|
+
type: "runTests",
|
|
1525
|
+
code: request.code,
|
|
1526
|
+
fileName: request.fileName,
|
|
1527
|
+
level: request.level
|
|
1528
|
+
},
|
|
1529
|
+
"testResult"
|
|
1530
|
+
);
|
|
1531
|
+
deliverTestResult(result, onEvent, request.fileName, request.level);
|
|
1074
1532
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
var ANY_IMPORT_RE = /(^|\n)[ \t]*(import|from)[ \t]+\S/;
|
|
1080
|
-
|
|
1081
|
-
// src/cli/files.ts
|
|
1082
|
-
var fs2 = __toESM(require("node:fs/promises"));
|
|
1083
|
-
var path3 = __toESM(require("node:path"));
|
|
1084
|
-
|
|
1085
|
-
// src/common/workspaceFilePolicy.ts
|
|
1086
|
-
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
1087
|
-
var MAX_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
1088
|
-
var MAX_FILES = 50;
|
|
1089
|
-
var MOUNT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1090
|
-
".csv",
|
|
1091
|
-
".txt",
|
|
1092
|
-
".tsv",
|
|
1093
|
-
".json",
|
|
1094
|
-
".md",
|
|
1095
|
-
".dat",
|
|
1096
|
-
".xml",
|
|
1097
|
-
".py"
|
|
1098
|
-
]);
|
|
1099
|
-
var WRITEBACK_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1100
|
-
".csv",
|
|
1101
|
-
".txt",
|
|
1102
|
-
".tsv",
|
|
1103
|
-
".json",
|
|
1104
|
-
".md",
|
|
1105
|
-
".dat",
|
|
1106
|
-
".xml"
|
|
1107
|
-
]);
|
|
1108
|
-
function extensionOf(name) {
|
|
1109
|
-
const dot = name.lastIndexOf(".");
|
|
1110
|
-
if (dot <= 0) {
|
|
1111
|
-
return "";
|
|
1533
|
+
async staticAnalyze(request) {
|
|
1534
|
+
await this.initialize();
|
|
1535
|
+
const { result } = await this.request({ type: "staticAnalyze", ...request }, "static");
|
|
1536
|
+
return result ?? [];
|
|
1112
1537
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
if (!name || name === "." || name === "..") {
|
|
1117
|
-
return false;
|
|
1538
|
+
async mountWorkspaceFiles(files) {
|
|
1539
|
+
await this.initialize();
|
|
1540
|
+
await this.request({ type: "mountWorkspace", files }, "workspaceReady");
|
|
1118
1541
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1542
|
+
async collectWorkspaceFiles() {
|
|
1543
|
+
await this.initialize();
|
|
1544
|
+
const { files } = await this.request({ type: "collectWorkspace" }, "workspaceFiles");
|
|
1545
|
+
return files;
|
|
1121
1546
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1547
|
+
async examplarBuild(sources) {
|
|
1548
|
+
await this.initialize();
|
|
1549
|
+
const { result } = await this.request({ type: "examplarBuild", sources }, "examplarBuilt");
|
|
1550
|
+
return result;
|
|
1124
1551
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
}
|
|
1133
|
-
function utf8ByteLength(text) {
|
|
1134
|
-
return new TextEncoder().encode(text).byteLength;
|
|
1135
|
-
}
|
|
1136
|
-
function decodeMountContents(data) {
|
|
1137
|
-
if (data.byteLength > MAX_FILE_BYTES) {
|
|
1138
|
-
return void 0;
|
|
1552
|
+
async examplarRun(testSource, bundle) {
|
|
1553
|
+
await this.initialize();
|
|
1554
|
+
const { result } = await this.request(
|
|
1555
|
+
{ type: "examplarRun", testSource, bundle },
|
|
1556
|
+
"examplarRan"
|
|
1557
|
+
);
|
|
1558
|
+
return result;
|
|
1139
1559
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1560
|
+
async reactorStep(reactorId, event) {
|
|
1561
|
+
await this.initialize();
|
|
1562
|
+
const { result } = await this.request(
|
|
1563
|
+
{ type: "reactorStep", reactorId, event },
|
|
1564
|
+
"reactorFrame"
|
|
1565
|
+
);
|
|
1566
|
+
return result;
|
|
1144
1567
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
if (
|
|
1159
|
-
|
|
1160
|
-
continue;
|
|
1161
|
-
}
|
|
1162
|
-
if (candidate.contents.includes("\0")) {
|
|
1163
|
-
continue;
|
|
1164
|
-
}
|
|
1165
|
-
text = candidate.contents;
|
|
1166
|
-
} else {
|
|
1167
|
-
const decoded = decodeMountContents(candidate.contents);
|
|
1168
|
-
if (decoded === void 0) {
|
|
1169
|
-
continue;
|
|
1170
|
-
}
|
|
1171
|
-
text = decoded;
|
|
1568
|
+
async reactorSeek(reactorId, index) {
|
|
1569
|
+
await this.initialize();
|
|
1570
|
+
const { result } = await this.request(
|
|
1571
|
+
{ type: "reactorSeek", reactorId, index },
|
|
1572
|
+
"reactorFrame"
|
|
1573
|
+
);
|
|
1574
|
+
return result;
|
|
1575
|
+
}
|
|
1576
|
+
async reactorDispose(reactorId) {
|
|
1577
|
+
await this.initialize();
|
|
1578
|
+
await this.request({ type: "reactorDispose", reactorId }, "reactorDisposed");
|
|
1579
|
+
}
|
|
1580
|
+
interrupt() {
|
|
1581
|
+
if (!this.interruptBuffer) {
|
|
1582
|
+
return false;
|
|
1172
1583
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1584
|
+
signalInterrupt(this.interruptBuffer);
|
|
1585
|
+
return true;
|
|
1586
|
+
}
|
|
1587
|
+
setStdinHandler(handler) {
|
|
1588
|
+
this.stdinHandler = handler;
|
|
1589
|
+
}
|
|
1590
|
+
dispose() {
|
|
1591
|
+
this.worker?.terminate();
|
|
1592
|
+
this.worker = null;
|
|
1593
|
+
this.initPromise = null;
|
|
1594
|
+
this.stdinBuffer = null;
|
|
1595
|
+
this.interruptBuffer = null;
|
|
1596
|
+
this.live = null;
|
|
1597
|
+
this.rejectAllPending(new Error("Runtime disposed"));
|
|
1598
|
+
}
|
|
1599
|
+
/** Send `msg` and resolve with the reply, asserting it is of type `expected`. */
|
|
1600
|
+
async request(msg, expected) {
|
|
1601
|
+
const worker = this.worker;
|
|
1602
|
+
if (!worker) {
|
|
1603
|
+
throw new Error("Worker not initialized");
|
|
1176
1604
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1605
|
+
const id = this.nextId++;
|
|
1606
|
+
const reply = await new Promise((resolve4, reject) => {
|
|
1607
|
+
this.pending.set(id, { resolve: resolve4, reject });
|
|
1608
|
+
worker.post({ id, ...msg });
|
|
1609
|
+
});
|
|
1610
|
+
if (reply.type !== expected) {
|
|
1611
|
+
throw new Error(`Pyodide worker replied "${reply.type}", expected "${expected}"`);
|
|
1612
|
+
}
|
|
1613
|
+
return reply;
|
|
1179
1614
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
if (selected.length >= MAX_FILES) {
|
|
1187
|
-
break;
|
|
1615
|
+
handleMessage(msg) {
|
|
1616
|
+
if (msg.type === "display") {
|
|
1617
|
+
if (this.live) {
|
|
1618
|
+
deliverDisplay(msg.payload, this.live.onEvent, this.live.fileName);
|
|
1619
|
+
}
|
|
1620
|
+
return;
|
|
1188
1621
|
}
|
|
1189
|
-
if (
|
|
1190
|
-
|
|
1622
|
+
if (msg.type === "stdinRequest") {
|
|
1623
|
+
void this.handleStdinRequest();
|
|
1624
|
+
return;
|
|
1191
1625
|
}
|
|
1192
|
-
const
|
|
1193
|
-
if (
|
|
1194
|
-
|
|
1626
|
+
const pending = this.pending.get(msg.id);
|
|
1627
|
+
if (!pending) {
|
|
1628
|
+
return;
|
|
1195
1629
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1630
|
+
this.pending.delete(msg.id);
|
|
1631
|
+
if (msg.type === "error") {
|
|
1632
|
+
pending.reject(new Error(msg.message));
|
|
1633
|
+
} else {
|
|
1634
|
+
pending.resolve(msg);
|
|
1198
1635
|
}
|
|
1199
|
-
selected.push(file);
|
|
1200
|
-
total += size;
|
|
1201
|
-
}
|
|
1202
|
-
return selected;
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
// src/cli/files.ts
|
|
1206
|
-
async function collectSiblingFiles(scriptPath) {
|
|
1207
|
-
const folder = path3.dirname(path3.resolve(scriptPath));
|
|
1208
|
-
let entries;
|
|
1209
|
-
try {
|
|
1210
|
-
entries = await fs2.readdir(folder, { withFileTypes: true });
|
|
1211
|
-
} catch {
|
|
1212
|
-
return [];
|
|
1213
1636
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
continue;
|
|
1218
|
-
}
|
|
1219
|
-
try {
|
|
1220
|
-
candidates.push({ name: entry.name, contents: await fs2.readFile(path3.join(folder, entry.name)) });
|
|
1221
|
-
} catch {
|
|
1637
|
+
rejectAllPending(err) {
|
|
1638
|
+
for (const pending of this.pending.values()) {
|
|
1639
|
+
pending.reject(err);
|
|
1222
1640
|
}
|
|
1641
|
+
this.pending.clear();
|
|
1223
1642
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
async function writeBackSiblingFiles(scriptPath, files) {
|
|
1227
|
-
const folder = path3.dirname(path3.resolve(scriptPath));
|
|
1228
|
-
const written = [];
|
|
1229
|
-
for (const file of selectWritebackFiles(files)) {
|
|
1643
|
+
async handleStdinRequest() {
|
|
1644
|
+
let line = null;
|
|
1230
1645
|
try {
|
|
1231
|
-
await
|
|
1232
|
-
written.push(file.name);
|
|
1646
|
+
line = this.stdinHandler ? await this.stdinHandler() : null;
|
|
1233
1647
|
} catch {
|
|
1648
|
+
line = null;
|
|
1234
1649
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
// src/cli/run.ts
|
|
1240
|
-
var EXIT = {
|
|
1241
|
-
ok: 0,
|
|
1242
|
-
/** The program raised. */
|
|
1243
|
-
programError: 1,
|
|
1244
|
-
/** Level checks found problems, so the file was not run. */
|
|
1245
|
-
blocked: 2,
|
|
1246
|
-
/** The program ran, but its in-file tests did not all pass. */
|
|
1247
|
-
testsFailed: 3,
|
|
1248
|
-
/** Bad usage, or PLL itself could not run. */
|
|
1249
|
-
usage: 64
|
|
1250
|
-
};
|
|
1251
|
-
async function runFile(runtime, view, opts) {
|
|
1252
|
-
const fileName = path4.basename(opts.file);
|
|
1253
|
-
let source;
|
|
1254
|
-
try {
|
|
1255
|
-
source = await fs3.readFile(opts.file, "utf8");
|
|
1256
|
-
} catch (err) {
|
|
1257
|
-
view.problem(`Cannot read ${opts.file}: ${errText(err)}`);
|
|
1258
|
-
return EXIT.usage;
|
|
1259
|
-
}
|
|
1260
|
-
const level = parseLevel(source);
|
|
1261
|
-
view.note(`${fileName} [${level}]`);
|
|
1262
|
-
if (levelHasStaticChecks(level)) {
|
|
1263
|
-
const blocked = await staticChecks(runtime, view, source, fileName, level);
|
|
1264
|
-
if (blocked) {
|
|
1265
|
-
return EXIT.blocked;
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
await ensurePackages(runtime, view, source);
|
|
1269
|
-
try {
|
|
1270
|
-
await runtime.mountWorkspaceFiles(await collectSiblingFiles(opts.file));
|
|
1271
|
-
} catch (err) {
|
|
1272
|
-
view.note(`Could not load files next to this script (${errText(err)}).`);
|
|
1273
|
-
}
|
|
1274
|
-
const onEvent = (event) => {
|
|
1275
|
-
if (event.kind === "error") {
|
|
1276
|
-
const finding = findingForErrorEvent(event, source, fileName, level);
|
|
1277
|
-
if (finding) {
|
|
1278
|
-
view.runtimeFinding(finding);
|
|
1279
|
-
return;
|
|
1280
|
-
}
|
|
1650
|
+
if (this.stdinBuffer) {
|
|
1651
|
+
writeStdinLine(this.stdinBuffer, line);
|
|
1281
1652
|
}
|
|
1282
|
-
view.handle(event);
|
|
1283
|
-
};
|
|
1284
|
-
if (opts.runTests) {
|
|
1285
|
-
await maybeRunTests(runtime, view, source, fileName, level, onEvent);
|
|
1286
|
-
}
|
|
1287
|
-
await runtime.runFile({ code: source, fileName, sessionKey: opts.file, level }, onEvent);
|
|
1288
|
-
await writeBack(runtime, view, opts.file);
|
|
1289
|
-
if (view.sawError) return EXIT.programError;
|
|
1290
|
-
if (view.testFailures > 0) return EXIT.testsFailed;
|
|
1291
|
-
return EXIT.ok;
|
|
1292
|
-
}
|
|
1293
|
-
async function staticChecks(runtime, view, source, fileName, level) {
|
|
1294
|
-
let raw;
|
|
1295
|
-
try {
|
|
1296
|
-
raw = await runtime.staticAnalyze({ code: source, fileName, level });
|
|
1297
|
-
} catch (err) {
|
|
1298
|
-
view.note(`Static analysis failed (${errText(err)}); running anyway.`);
|
|
1299
|
-
return false;
|
|
1300
|
-
}
|
|
1301
|
-
const findings = enrichStaticFindings(raw, level, fileName);
|
|
1302
|
-
if (findings.length === 0) {
|
|
1303
|
-
return false;
|
|
1304
|
-
}
|
|
1305
|
-
view.findings(findings);
|
|
1306
|
-
view.problem(`Static analysis found ${findings.length} problem(s). File not run.`);
|
|
1307
|
-
return true;
|
|
1308
|
-
}
|
|
1309
|
-
async function ensurePackages(runtime, view, source) {
|
|
1310
|
-
if (!ANY_IMPORT_RE.test(source)) {
|
|
1311
|
-
return;
|
|
1312
1653
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
// src/desktop/pyodideRuntime.ts
|
|
1657
|
+
var DesktopPyodideRuntime = class extends WorkerPythonRuntime {
|
|
1658
|
+
constructor(paths) {
|
|
1659
|
+
super();
|
|
1660
|
+
this.paths = paths;
|
|
1320
1661
|
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1662
|
+
paths;
|
|
1663
|
+
resolveIndexUrl() {
|
|
1664
|
+
const found = this.paths.indexUrlCandidates.find(
|
|
1665
|
+
(dir) => fs4.existsSync(path4.join(dir, "pyodide.asm.wasm"))
|
|
1666
|
+
);
|
|
1667
|
+
if (!found) {
|
|
1668
|
+
throw new Error(this.paths.missingAssetsHint);
|
|
1326
1669
|
}
|
|
1327
|
-
|
|
1328
|
-
} catch (err) {
|
|
1329
|
-
view.note(`Could not check for tests (${errText(err)}); skipping them.`);
|
|
1330
|
-
return;
|
|
1670
|
+
return found;
|
|
1331
1671
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
}
|
|
1344
|
-
} catch (err) {
|
|
1345
|
-
view.note(`Could not save files next to this script (${errText(err)}).`);
|
|
1672
|
+
spawn(handlers) {
|
|
1673
|
+
const worker = new import_node_worker_threads.Worker(this.paths.workerPath);
|
|
1674
|
+
worker.on("message", (msg) => handlers.onMessage(msg));
|
|
1675
|
+
worker.on(
|
|
1676
|
+
"error",
|
|
1677
|
+
(err) => handlers.onError(err instanceof Error ? err : new Error(String(err)))
|
|
1678
|
+
);
|
|
1679
|
+
return {
|
|
1680
|
+
post: (msg) => worker.postMessage(msg),
|
|
1681
|
+
terminate: () => void worker.terminate()
|
|
1682
|
+
};
|
|
1346
1683
|
}
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
// src/cli/runtime.ts
|
|
1687
|
+
function createCliRuntime() {
|
|
1688
|
+
return new DesktopPyodideRuntime({
|
|
1689
|
+
// `pyodide` is external in the bundle, so this resolves at run time
|
|
1690
|
+
// from the installed package rather than being inlined.
|
|
1691
|
+
indexUrlCandidates: [path5.dirname(require.resolve("pyodide"))],
|
|
1692
|
+
workerPath: path5.join(__dirname, "worker.cjs"),
|
|
1693
|
+
missingAssetsHint: "Could not find Pyodide's assets. Reinstall pll-python so its `pyodide` dependency is present."
|
|
1694
|
+
});
|
|
1350
1695
|
}
|
|
1351
1696
|
|
|
1352
1697
|
// src/cli/stdin.ts
|
|
@@ -1381,7 +1726,7 @@ function createLineReader() {
|
|
|
1381
1726
|
return Promise.resolve(null);
|
|
1382
1727
|
}
|
|
1383
1728
|
ensure();
|
|
1384
|
-
return new Promise((
|
|
1729
|
+
return new Promise((resolve4) => pending.push(resolve4));
|
|
1385
1730
|
},
|
|
1386
1731
|
close() {
|
|
1387
1732
|
closed = true;
|
|
@@ -1392,8 +1737,8 @@ function createLineReader() {
|
|
|
1392
1737
|
}
|
|
1393
1738
|
|
|
1394
1739
|
// src/cli/view.ts
|
|
1395
|
-
var
|
|
1396
|
-
var
|
|
1740
|
+
var fs5 = __toESM(require("node:fs"));
|
|
1741
|
+
var path6 = __toESM(require("node:path"));
|
|
1397
1742
|
|
|
1398
1743
|
// src/common/analyzers/findingLocation.ts
|
|
1399
1744
|
function findingLocation(finding) {
|
|
@@ -1403,11 +1748,12 @@ function findingLocation(finding) {
|
|
|
1403
1748
|
if (finding.fileName === "<repl>" || finding.fileName === "<input>") {
|
|
1404
1749
|
return null;
|
|
1405
1750
|
}
|
|
1406
|
-
const
|
|
1751
|
+
const column = typeof finding.column === "number" && Number.isFinite(finding.column) ? finding.column : null;
|
|
1752
|
+
const label = column !== null ? `${finding.fileName}:${finding.lineNumber}:${column + 1}` : `${finding.fileName}:${finding.lineNumber}`;
|
|
1407
1753
|
return {
|
|
1408
1754
|
fileName: finding.fileName,
|
|
1409
1755
|
line: finding.lineNumber,
|
|
1410
|
-
column
|
|
1756
|
+
column,
|
|
1411
1757
|
label
|
|
1412
1758
|
};
|
|
1413
1759
|
}
|
|
@@ -1532,10 +1878,10 @@ var CliView = class {
|
|
|
1532
1878
|
);
|
|
1533
1879
|
return;
|
|
1534
1880
|
}
|
|
1535
|
-
const target =
|
|
1881
|
+
const target = path6.join(dir, `image-${this.imageCount}.svg`);
|
|
1536
1882
|
try {
|
|
1537
|
-
|
|
1538
|
-
|
|
1883
|
+
fs5.mkdirSync(dir, { recursive: true });
|
|
1884
|
+
fs5.writeFileSync(target, svg, "utf8");
|
|
1539
1885
|
this.note(this.dim(`[image ${width}x${height} -> ${target}]`));
|
|
1540
1886
|
} catch (err) {
|
|
1541
1887
|
this.problem(`Could not write ${target}: ${errText2(err)}`);
|
|
@@ -1558,6 +1904,13 @@ var CliView = class {
|
|
|
1558
1904
|
this.problem(" " + line);
|
|
1559
1905
|
}
|
|
1560
1906
|
}
|
|
1907
|
+
const printed = (test.stdout ?? "").replace(/\s+$/, "");
|
|
1908
|
+
if (printed) {
|
|
1909
|
+
this.problem(this.dim(" output:"));
|
|
1910
|
+
for (const line of printed.split("\n")) {
|
|
1911
|
+
this.problem(this.dim(" " + line));
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1561
1914
|
}
|
|
1562
1915
|
}
|
|
1563
1916
|
};
|
|
@@ -1591,6 +1944,9 @@ The level comes from the file's own \`#level\` line, exactly as in the
|
|
|
1591
1944
|
editor; there is deliberately no flag to override it, so a file behaves the
|
|
1592
1945
|
same everywhere.
|
|
1593
1946
|
|
|
1947
|
+
Subcommands
|
|
1948
|
+
examplar build author an Examplar bundle; see \`pll examplar --help\`
|
|
1949
|
+
|
|
1594
1950
|
Options
|
|
1595
1951
|
--no-tests do not run the file's \`test_*\` functions first
|
|
1596
1952
|
--save-images <dir> write pictures there as .svg (they cannot be drawn
|
|
@@ -1607,12 +1963,14 @@ Exit codes
|
|
|
1607
1963
|
0 ran, tests passed 2 level checks blocked it
|
|
1608
1964
|
1 the program raised 3 a test failed
|
|
1609
1965
|
`;
|
|
1966
|
+
function colorDefault() {
|
|
1967
|
+
return process.stderr.isTTY === true && !process.env.NO_COLOR;
|
|
1968
|
+
}
|
|
1610
1969
|
function parseArgs(argv) {
|
|
1611
1970
|
const args = {
|
|
1612
1971
|
runTests: true,
|
|
1613
1972
|
quiet: false,
|
|
1614
|
-
|
|
1615
|
-
color: process.stderr.isTTY === true && !process.env.NO_COLOR,
|
|
1973
|
+
color: colorDefault(),
|
|
1616
1974
|
help: false,
|
|
1617
1975
|
version: false
|
|
1618
1976
|
};
|
|
@@ -1649,13 +2007,31 @@ function parseArgs(argv) {
|
|
|
1649
2007
|
return args;
|
|
1650
2008
|
}
|
|
1651
2009
|
async function main(argv) {
|
|
2010
|
+
const firstPositional = argv.find((arg) => !arg.startsWith("-"));
|
|
2011
|
+
if (firstPositional === "examplar") {
|
|
2012
|
+
const at = argv.indexOf("examplar");
|
|
2013
|
+
const leading = argv.slice(0, at);
|
|
2014
|
+
const view2 = new CliView({
|
|
2015
|
+
quiet: leading.includes("-q") || leading.includes("--quiet"),
|
|
2016
|
+
color: colorDefault() && !leading.includes("--no-color")
|
|
2017
|
+
});
|
|
2018
|
+
const runtime2 = createCliRuntime();
|
|
2019
|
+
try {
|
|
2020
|
+
return await runExamplar(runtime2, view2, argv.slice(at + 1));
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
view2.problem(`pll: ${err instanceof Error ? err.message : String(err)}`);
|
|
2023
|
+
return EXIT.usage;
|
|
2024
|
+
} finally {
|
|
2025
|
+
runtime2.dispose();
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
1652
2028
|
const args = parseArgs(argv);
|
|
1653
2029
|
if (args.help) {
|
|
1654
2030
|
process.stdout.write(USAGE);
|
|
1655
2031
|
return EXIT.ok;
|
|
1656
2032
|
}
|
|
1657
2033
|
if (args.version) {
|
|
1658
|
-
process.stdout.write("0.
|
|
2034
|
+
process.stdout.write("0.2.0\n");
|
|
1659
2035
|
return EXIT.ok;
|
|
1660
2036
|
}
|
|
1661
2037
|
if (args.error !== void 0) {
|
|
@@ -1686,7 +2062,7 @@ ${USAGE}`);
|
|
|
1686
2062
|
process.on("SIGINT", onSigint);
|
|
1687
2063
|
try {
|
|
1688
2064
|
return await runFile(runtime, view, {
|
|
1689
|
-
file:
|
|
2065
|
+
file: path7.resolve(args.file),
|
|
1690
2066
|
runTests: args.runTests
|
|
1691
2067
|
});
|
|
1692
2068
|
} catch (err) {
|