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.
Files changed (4) hide show
  1. package/README.md +124 -1
  2. package/cli.cjs +1555 -1179
  3. package/package.json +2 -2
  4. 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 path6 = __toESM(require("node:path"));
27
+ var path7 = __toESM(require("node:path"));
28
28
 
29
- // src/cli/runtime.ts
30
- var path2 = __toESM(require("node:path"));
29
+ // src/cli/examplar.ts
30
+ var fs3 = __toESM(require("node:fs/promises"));
31
+ var path3 = __toESM(require("node:path"));
31
32
 
32
- // src/desktop/pyodideRuntime.ts
33
- var fs = __toESM(require("node:fs"));
34
- var path = __toESM(require("node:path"));
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/deliverResult.ts
38
- function deliverRunResult(result, onEvent, fileName) {
39
- if (result.displays) {
40
- for (const display of result.displays) {
41
- deliverDisplay(display, onEvent, fileName);
42
- }
43
- }
44
- if (result.result_repr !== null && result.result_repr !== void 0) {
45
- onEvent({ kind: "result", repr: result.result_repr });
46
- }
47
- if (!result.ok && result.error_type) {
48
- onEvent({
49
- kind: "error",
50
- errorType: result.error_type,
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 deliverDisplay(display, onEvent, fileName) {
61
- switch (display.type) {
62
- case "stdout":
63
- onEvent({ kind: "stdout", text: display.text });
64
- break;
65
- case "stderr":
66
- onEvent({ kind: "stderr", text: display.text });
67
- break;
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
- onEvent({
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
- onEvent({
97
- kind: "table",
98
- columns: display.columns,
99
- rows: display.rows,
100
- rowCount: display.row_count,
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 asPlain(value) {
109
- if (value instanceof Map) {
110
- const obj = {};
111
- for (const [k, v] of value.entries()) {
112
- obj[String(k)] = asPlain(v);
113
- }
114
- return obj;
115
- }
116
- if (Array.isArray(value)) {
117
- return value.map(asPlain);
118
- }
119
- return value;
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
- function adaptTestCase(raw) {
122
- const row = asPlain(raw) ?? {};
123
- const line = row.line_number;
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
- name: String(row.name ?? ""),
126
- outcome: String(row.outcome ?? "failed"),
127
- lineNumber: typeof line === "number" ? line : null,
128
- message: row.message == null ? null : String(row.message),
129
- stdout: row.stdout == null ? null : String(row.stdout)
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
- function deliverTestResult(result, onEvent, fileName) {
133
- if (result.internal_error && result.error_type) {
134
- onEvent({
135
- kind: "error",
136
- errorType: result.error_type,
137
- message: result.error_message ?? "",
138
- traceback: result.traceback ?? "",
139
- lineNumber: result.line_number,
140
- column: result.column,
141
- fileName
142
- });
143
- onEvent({ kind: "done" });
144
- return;
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
- const tests = Array.isArray(result.tests) ? result.tests.map(adaptTestCase) : [];
147
- onEvent({
148
- kind: "testReport",
182
+ return {
183
+ id: "disallowed-keyword",
184
+ errorType: "DisallowedKeyword",
185
+ message: raw.message,
186
+ headline,
187
+ howToFix,
149
188
  fileName,
150
- passed: result.passed ?? 0,
151
- failed: result.failed ?? 0,
152
- skipped: result.skipped ?? 0,
153
- errors: result.errors ?? 0,
154
- tests
155
- });
156
- onEvent({ kind: "done" });
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/interruptBuffer.ts
160
- var INTERRUPT_SAB_BYTES = 1;
161
- var INTERRUPT_SIGINT = 2;
162
- function tryCreateInterruptBuffer() {
163
- try {
164
- return new SharedArrayBuffer(INTERRUPT_SAB_BYTES);
165
- } catch {
166
- return null;
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 signalInterrupt(sab) {
170
- Atomics.store(new Uint8Array(sab), 0, INTERRUPT_SIGINT);
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/stdinBuffer.ts
174
- var STDIN_SAB_BYTES = 64 * 1024;
175
- var STDIN_STATE_INDEX = 0;
176
- var STDIN_LENGTH_INDEX = 1;
177
- var STDIN_PAYLOAD_OFFSET = 8;
178
- var STDIN_STATE_LINE = 1;
179
- var STDIN_STATE_EOF = 2;
180
- function tryCreateStdinBuffer() {
181
- try {
182
- return new SharedArrayBuffer(STDIN_SAB_BYTES);
183
- } catch {
184
- return null;
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 writeStdinLine(sab, line) {
188
- const state = new Int32Array(sab);
189
- if (line === null) {
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 encoded = new TextEncoder().encode(line);
195
- const max = sab.byteLength - STDIN_PAYLOAD_OFFSET;
196
- const n = Math.min(encoded.length, max);
197
- const bytes = new Uint8Array(sab, STDIN_PAYLOAD_OFFSET);
198
- bytes.set(encoded.subarray(0, n));
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
- // src/common/workerRuntime.ts
205
- var WorkerPythonRuntime = class {
206
- worker = null;
207
- nextId = 1;
208
- pending = /* @__PURE__ */ new Map();
209
- initPromise = null;
210
- stdinBuffer = null;
211
- interruptBuffer = null;
212
- stdinHandler = null;
213
- /** Event sink for the in-flight runFile, so live displays can stream. */
214
- live = null;
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
- await this.initPromise;
220
- }
221
- async doInitialize() {
222
- const indexUrl = this.resolveIndexUrl();
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
- async replEval(request, onEvent) {
250
- await this.initialize();
251
- const { result } = await this.request({ type: "replEval", ...request }, "result");
252
- deliverRunResult(result, onEvent, "<repl>");
253
- }
254
- async checkReplComplete(code) {
255
- await this.initialize();
256
- const { result } = await this.request({ type: "checkSyntax", code }, "syntax");
257
- return {
258
- status: result.status,
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
- async ensurePackages(code) {
271
- await this.initialize();
272
- await this.request({ type: "loadPackages", code }, "packagesReady");
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
- async ensurePytest() {
275
- await this.initialize();
276
- await this.request({ type: "loadPytest" }, "pytestReady");
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
- async runTests(request, onEvent) {
279
- await this.initialize();
280
- const { result } = await this.request(
281
- {
282
- type: "runTests",
283
- code: request.code,
284
- fileName: request.fileName,
285
- level: request.level
286
- },
287
- "testResult"
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
- async reactorStep(reactorId, event) {
306
- await this.initialize();
307
- const { result } = await this.request(
308
- { type: "reactorStep", reactorId, event },
309
- "reactorFrame"
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
- async reactorSeek(reactorId, index) {
314
- await this.initialize();
315
- const { result } = await this.request(
316
- { type: "reactorSeek", reactorId, index },
317
- "reactorFrame"
318
- );
319
- return result;
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
- async reactorDispose(reactorId) {
322
- await this.initialize();
323
- await this.request({ type: "reactorDispose", reactorId }, "reactorDisposed");
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
- interrupt() {
326
- if (!this.interruptBuffer) {
327
- return false;
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
- setStdinHandler(handler) {
333
- this.stdinHandler = handler;
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
- dispose() {
336
- this.worker?.terminate();
337
- this.worker = null;
338
- this.initPromise = null;
339
- this.stdinBuffer = null;
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
- /** Send `msg` and resolve with the reply, asserting it is of type `expected`. */
345
- async request(msg, expected) {
346
- const worker = this.worker;
347
- if (!worker) {
348
- throw new Error("Worker not initialized");
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 id = this.nextId++;
351
- const reply = await new Promise((resolve3, reject) => {
352
- this.pending.set(id, { resolve: resolve3, reject });
353
- worker.post({ id, ...msg });
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
- return reply;
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
- handleMessage(msg) {
361
- if (msg.type === "display") {
362
- if (this.live) {
363
- deliverDisplay(msg.payload, this.live.onEvent, this.live.fileName);
364
- }
365
- return;
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
- if (msg.type === "stdinRequest") {
368
- void this.handleStdinRequest();
369
- return;
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
- const pending = this.pending.get(msg.id);
372
- if (!pending) {
373
- return;
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
- this.pending.delete(msg.id);
376
- if (msg.type === "error") {
377
- pending.reject(new Error(msg.message));
378
- } else {
379
- pending.resolve(msg);
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
- rejectAllPending(err) {
383
- for (const pending of this.pending.values()) {
384
- pending.reject(err);
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
- this.pending.clear();
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
- async handleStdinRequest() {
389
- let line = null;
390
- try {
391
- line = this.stdinHandler ? await this.stdinHandler() : null;
392
- } catch {
393
- line = null;
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
- if (this.stdinBuffer) {
396
- writeStdinLine(this.stdinBuffer, line);
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/desktop/pyodideRuntime.ts
402
- var DesktopPyodideRuntime = class extends WorkerPythonRuntime {
403
- constructor(paths) {
404
- super();
405
- this.paths = paths;
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
- paths;
408
- resolveIndexUrl() {
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
- spawn(handlers) {
418
- const worker = new import_node_worker_threads.Worker(this.paths.workerPath);
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/cli/runtime.ts
432
- function createCliRuntime() {
433
- return new DesktopPyodideRuntime({
434
- // `pyodide` is external in the bundle, so this resolves at run time
435
- // from the installed package rather than being inlined.
436
- indexUrlCandidates: [path2.dirname(require.resolve("pyodide"))],
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/run.ts
443
- var fs3 = __toESM(require("node:fs/promises"));
444
- var path4 = __toESM(require("node:path"));
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/analyzers/static/shadowingExplainer.ts
447
- function outerScopeNoun(kind) {
448
- switch (kind) {
449
- case "module":
450
- return "the file";
451
- case "function":
452
- case "lambda":
453
- return "an enclosing function";
454
- case "class":
455
- return "an enclosing class";
456
- case "comprehension":
457
- return "an enclosing comprehension";
458
- default:
459
- return "an outer scope";
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 explainShadowing(raw, level, fileName) {
463
- const name = raw.name_token ?? "this name";
464
- const outerLine = raw.outer_line_number ?? null;
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
- return {
557
- id: "disallowed-keyword",
558
- errorType: "DisallowedKeyword",
559
- message: raw.message,
560
- headline,
561
- howToFix,
562
- fileName,
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
- // src/common/analyzers/static/registry.ts
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 enrichStaticFindings(rawFindings, level, fileName) {
598
- return rawFindings.map((raw) => {
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
- // src/common/errors/pythonErrorParser.ts
605
- var NON_USER_FRAME = [
606
- "<exec>",
607
- "/pll_vendor",
608
- "site-packages",
609
- "/lib/python",
610
- "_pytest",
611
- "pluggy"
612
- ];
613
- function userTracebackFrames(traceback) {
614
- const frames = [];
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
- return frames;
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 parsePythonError(rawTraceback) {
629
- const traceback = rawTraceback.trimEnd();
630
- const lines = traceback.split(/\r?\n/);
631
- let errorType = "Error";
632
- let message = traceback;
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 (line.match(/^[A-Za-z_][A-Za-z0-9_.]*$/) && !line.startsWith(" ")) {
642
- errorType = line;
643
- message = "";
644
- break;
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
- let fileName = null;
648
- let lineNumber = null;
649
- for (let i = lines.length - 1; i >= 0; i--) {
650
- const line = lines[i];
651
- const m = line.match(/^\s*File\s+"([^"]+)",\s+line\s+(\d+)/);
652
- if (m) {
653
- fileName = m[1];
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
- let column = null;
659
- if (lineNumber !== null) {
660
- for (let i = 0; i < lines.length - 1; i++) {
661
- if (/^\s*\^+\s*$/.test(lines[i + 1] ?? "")) {
662
- const caretLine = lines[i + 1];
663
- const caretIdx = caretLine.indexOf("^");
664
- if (caretIdx >= 0) {
665
- column = caretIdx;
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
- let nameToken = null;
671
- if (errorType === "NameError") {
672
- const nm = message.match(/name '([^']+)' is not defined/);
673
- if (nm) {
674
- nameToken = nm[1];
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/common/errors/nameErrorExplainer.ts
689
- function explainNameError(parsed) {
690
- const name = parsed.nameToken ?? "this name";
691
- const didYouMean = extractDidYouMean(parsed.message);
692
- const headline = `Python doesn't know what \`${name}\` means.`;
693
- const howToFix = [
694
- `Check the spelling of \`${name}\` (Python is case-sensitive).`,
695
- `Make sure \`${name}\` is defined before this line runs.`,
696
- `If \`${name}\` should be text, put it in quotes: \`"${name}"\`.`
697
- ];
698
- if (didYouMean.length > 0) {
699
- howToFix.unshift(
700
- `Python suggested: ${didYouMean.map((s) => "`" + s + "`").join(", ")} - try one of those.`
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
- return { headline, howToFix };
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 extractDidYouMean(message) {
706
- const m = message.match(/Did you mean:\s*([^?]+)\??/i);
707
- if (!m) {
708
- return [];
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
- return m[1].split(/,|\bor\b/).map((s) => s.trim().replace(/^['"`]|['"`]$/g, "")).filter((s) => s.length > 0);
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
- // src/common/analyzers/nameErrorAnalyzer.ts
714
- var nameErrorAnalyzer = {
715
- handles: ["NameError"],
716
- analyze(input) {
717
- const { parsedError, fileName, level } = input;
718
- if (parsedError.errorType !== "NameError") {
719
- return null;
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
- // src/common/level.ts
741
- var DEFAULT_LEVEL = "raw";
742
- var HEADER_RE = /^#\s*level\s+([a-z]+)\s*$/;
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
- const match = HEADER_RE.exec(line);
757
- if (!match) {
758
- return DEFAULT_LEVEL;
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 name = match[1];
761
- for (const level of LEVEL_NAMES) {
762
- if (name === level) return level;
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
- return DEFAULT_LEVEL;
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 levelRejectsBoolAsNumber(level) {
769
- return level === "beginner" || level === "intermediate";
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/common/errors/typeCheckExplainer.ts
776
- var FRIENDLY_TYPES = {
777
- int: "a whole number",
778
- float: "a number",
779
- complex: "a complex number",
780
- str: "a string",
781
- bool: "`True` or `False`",
782
- bytes: "bytes",
783
- list: "a list",
784
- dict: "a dictionary",
785
- set: "a set",
786
- tuple: "a tuple",
787
- None: "`None`",
788
- NoneType: "`None`"
789
- };
790
- function cleanTypeName(type) {
791
- return type.startsWith("__main__.") ? type.slice("__main__.".length) : type;
792
- }
793
- function describeType(type) {
794
- const name = cleanTypeName(type);
795
- if (name === "None" || name === "NoneType") return "`None`";
796
- const friendly = FRIENDLY_TYPES[name];
797
- return friendly ? `${friendly} (\`${name}\`)` : `\`${name}\``;
798
- }
799
- function describeTypes(types) {
800
- const described = types.map(describeType);
801
- if (described.length === 0) return "a different type";
802
- if (described.length === 1) return described[0];
803
- return `${described.slice(0, -1).join(", ")} or ${described[described.length - 1]}`;
804
- }
805
- function boolAsNumberNote(expected, actual, level) {
806
- if (actual !== "bool" || expected.length === 0) return null;
807
- if (!expected.some((type) => type === "int" || type === "float")) return null;
808
- if (level !== void 0 && !levelRejectsBoolAsNumber(level)) return null;
809
- return "At `#level beginner` and `#level intermediate`, `True` and `False` are not accepted as numbers, even though Python counts them as `1` and `0`.";
810
- }
811
- function conversionFor(expected, actual) {
812
- if (expected.length !== 1 || actual === null) return null;
813
- const to = expected[0];
814
- const numeric = actual === "int" || actual === "float" || actual === "bool";
815
- if (to === "int" && (actual === "str" || actual === "float")) return "int";
816
- if (to === "float" && actual === "str") return "float";
817
- if (to === "str" && numeric) return "str";
818
- return null;
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
- var ELEMENT_RE = /^(item \d+|key .+|value of key .+|\[.+\]) of (.+)$/;
821
- function membersPhrase(element) {
822
- if (element.startsWith("the value for key")) return "every value in";
823
- if (element.startsWith("key ")) return "every key in";
824
- return "every item in";
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 describeElement(element) {
827
- if (element.startsWith("value of key ")) {
828
- return `the value for key ${element.slice("value of key ".length)}`;
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 (element.startsWith("[")) return "one of the values";
831
- return element;
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 parseTypeCheckMessage(message) {
834
- const unknown = {
835
- kind: "unknown",
836
- name: null,
837
- element: null,
838
- actual: null,
839
- expected: []
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
- } else {
853
- const predicates = [
854
- [/^(.*) is not an instance of (.+)$/, (m) => [m[2]]],
855
- [/^(.*) is neither float or int$/, () => ["float"]],
856
- [/^(.*) is not None$/, () => ["None"]],
857
- [/^(.*) is not a ([A-Za-z_][A-Za-z0-9_]*)$/, (m) => [m[2]]]
858
- ];
859
- for (const [re, pick] of predicates) {
860
- const m = first.match(re);
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 (subject === null) return unknown;
869
- let actual = null;
870
- const withActual = subject.match(/^(.*) \(([^()]*)\)$/);
871
- if (withActual) {
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
- let element = null;
876
- const elementMatch = subject.match(ELEMENT_RE);
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
- if (subject === "the return value") {
882
- return { kind: "return", name: null, element, actual, expected };
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
- const argument = subject.match(/^argument "(.+)"$/);
885
- if (argument) {
886
- return { kind: "argument", name: argument[1], element, actual, expected };
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 assigned = subject.match(/^value assigned to (.+)$/);
889
- if (assigned) {
890
- return { kind: "variable", name: assigned[1], element, actual, expected };
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
- return { ...unknown, element, actual, expected };
893
- }
894
- function explainTypeCheckError(message, functionName, level) {
895
- const parsed = parseTypeCheckMessage(message);
896
- const wanted = describeTypes(parsed.expected);
897
- const owner = functionName ? `\`${functionName}\`` : "this function";
898
- const named = parsed.name ? `\`${parsed.name}\`` : "a value";
899
- const convert = conversionFor(parsed.expected, parsed.actual);
900
- const boolNote = boolAsNumberNote(parsed.expected, parsed.actual, level);
901
- const annotation = parsed.expected.length === 1 ? cleanTypeName(parsed.expected[0]) : null;
902
- if (parsed.kind === "argument") {
903
- if (parsed.element) {
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
- if (parsed.kind === "return") {
929
- if (parsed.actual === "None" || parsed.actual === "NoneType") {
930
- return {
931
- headline: `${owner} should return ${wanted}, but it finished without returning a value.`,
932
- howToFix: [
933
- "Make sure every path through the function reaches a `return`.",
934
- "An `if` with no `else` falls off the end, and Python then returns `None`.",
935
- `Or annotate the return type as \`None\` if ${owner} is not meant to return anything.`
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
- const got = parsed.actual ? describeType(parsed.actual) : "something else";
940
- const howToFix = [];
941
- const wantsNone = parsed.expected.length === 1 && /^(None|NoneType)$/.test(parsed.expected[0]);
942
- if (wantsNone) {
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
- if (boolNote) howToFix.push(boolNote);
950
- if (annotation && parsed.actual) {
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
- if (parsed.kind === "variable") {
961
- if (parsed.element) {
962
- return {
963
- headline: `${named} is annotated so ${membersPhrase(parsed.element)} it is ${wanted}, but ${parsed.element} is not.`,
964
- howToFix: [`Look at ${parsed.element} of the value assigned to ${named}.`]
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
- const got = parsed.actual ? describeType(parsed.actual) : "a different type";
968
- const howToFix = [`Assign ${wanted} to ${named}.`];
969
- if (boolNote) howToFix.push(boolNote);
970
- if (convert) {
971
- howToFix.push(`You can convert the value with \`${convert}(...)\`.`);
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
- if (annotation && parsed.actual) {
974
- howToFix.push(
975
- `Or change ${named}'s annotation from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
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
- headline: "A value does not match the type annotation written for it.",
985
- howToFix: [
986
- message.split(/\r?\n/)[0].trim(),
987
- "Either pass a value of the annotated type, or change the annotation."
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/analyzers/typeCheckAnalyzer.ts
993
- var HANDLED = ["TypeCheckError", "typeguard.TypeCheckError"];
994
- function messageWithDetail(traceback, fallback) {
995
- const lines = traceback.split(/\r?\n/);
996
- for (let i = lines.length - 1; i >= 0; i--) {
997
- const line = lines[i];
998
- const marker = line.indexOf("TypeCheckError:");
999
- if (line.startsWith(" ") || marker < 0) continue;
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
- var typeCheckAnalyzer = {
1011
- handles: HANDLED,
1012
- analyze(input) {
1013
- const { parsedError, fileName, level } = input;
1014
- if (!HANDLED.includes(parsedError.errorType)) {
1015
- return null;
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
- const message = messageWithDetail(parsedError.traceback, parsedError.message);
1018
- const parsed = parseTypeCheckMessage(message);
1019
- const frames = userTracebackFrames(parsedError.traceback);
1020
- const innermost = frames.length > 0 ? frames[frames.length - 1] : null;
1021
- const blamed = parsed.kind === "argument" && frames.length >= 2 ? frames[frames.length - 2] : innermost;
1022
- const explanation = explainTypeCheckError(
1023
- message,
1024
- innermost ? innermost.functionName : null,
1025
- level
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
- id: "type-check",
1029
- // Not a real Python exception name; "TypeCheckError" means nothing to
1030
- // a student, and "TypeError" would name a different Python error.
1031
- errorType: "TypeMismatch",
1032
- message,
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
- // src/common/analyzers/registry.ts
1048
- var analyzers = [nameErrorAnalyzer, typeCheckAnalyzer];
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
- return null;
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
- if (parsed.column === null && event.column !== null) {
1070
- parsed.column = event.column;
1516
+ async ensurePytest() {
1517
+ await this.initialize();
1518
+ await this.request({ type: "loadPytest" }, "pytestReady");
1071
1519
  }
1072
- if (parsed.fileName === null && event.fileName) {
1073
- parsed.fileName = event.fileName;
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
- return findRuntimeFinding(source, fileName, level, parsed);
1076
- }
1077
-
1078
- // src/common/pyodideRunner.ts
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
- return name.slice(dot).toLowerCase();
1114
- }
1115
- function isSafeBasename(name) {
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
- if (name.startsWith(".")) {
1120
- return false;
1542
+ async collectWorkspaceFiles() {
1543
+ await this.initialize();
1544
+ const { files } = await this.request({ type: "collectWorkspace" }, "workspaceFiles");
1545
+ return files;
1121
1546
  }
1122
- if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
1123
- return false;
1547
+ async examplarBuild(sources) {
1548
+ await this.initialize();
1549
+ const { result } = await this.request({ type: "examplarBuild", sources }, "examplarBuilt");
1550
+ return result;
1124
1551
  }
1125
- return true;
1126
- }
1127
- function isMountableName(name) {
1128
- return isSafeBasename(name) && MOUNT_EXTENSIONS.has(extensionOf(name));
1129
- }
1130
- function isWritebackName(name) {
1131
- return isSafeBasename(name) && WRITEBACK_EXTENSIONS.has(extensionOf(name));
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
- for (let i = 0; i < data.byteLength; i++) {
1141
- if (data[i] === 0) {
1142
- return void 0;
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
- return new TextDecoder("utf-8").decode(data);
1146
- }
1147
- function selectMountableFiles(candidates) {
1148
- const selected = [];
1149
- let total = 0;
1150
- for (const candidate of candidates) {
1151
- if (selected.length >= MAX_FILES) {
1152
- break;
1153
- }
1154
- if (!isMountableName(candidate.name)) {
1155
- continue;
1156
- }
1157
- let text;
1158
- if (typeof candidate.contents === "string") {
1159
- if (utf8ByteLength(candidate.contents) > MAX_FILE_BYTES) {
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
- const size = utf8ByteLength(text);
1174
- if (total + size > MAX_TOTAL_BYTES) {
1175
- continue;
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
- selected.push({ name: candidate.name, contents: text });
1178
- total += size;
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
- return selected;
1181
- }
1182
- function selectWritebackFiles(files) {
1183
- const selected = [];
1184
- let total = 0;
1185
- for (const file of files) {
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 (!isWritebackName(file.name)) {
1190
- continue;
1622
+ if (msg.type === "stdinRequest") {
1623
+ void this.handleStdinRequest();
1624
+ return;
1191
1625
  }
1192
- const size = utf8ByteLength(file.contents);
1193
- if (size > MAX_FILE_BYTES || total + size > MAX_TOTAL_BYTES) {
1194
- continue;
1626
+ const pending = this.pending.get(msg.id);
1627
+ if (!pending) {
1628
+ return;
1195
1629
  }
1196
- if (file.contents.includes("\0")) {
1197
- continue;
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
- const candidates = [];
1215
- for (const entry of entries) {
1216
- if (!entry.isFile() || !isMountableName(entry.name)) {
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
- return selectMountableFiles(candidates);
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 fs2.writeFile(path3.join(folder, file.name), file.contents, "utf8");
1232
- written.push(file.name);
1646
+ line = this.stdinHandler ? await this.stdinHandler() : null;
1233
1647
  } catch {
1648
+ line = null;
1234
1649
  }
1235
- }
1236
- return written;
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
- try {
1314
- await runtime.ensurePackages(source);
1315
- } catch (err) {
1316
- const message = errText(err);
1317
- if (!/syntaxerror|invalid syntax/i.test(message)) {
1318
- view.note(`Could not load libraries (${message}). Continuing.`);
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
- async function maybeRunTests(runtime, view, source, fileName, level, onEvent) {
1323
- try {
1324
- if (!await runtime.hasTests(source)) {
1325
- return;
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
- await runtime.ensurePytest();
1328
- } catch (err) {
1329
- view.note(`Could not check for tests (${errText(err)}); skipping them.`);
1330
- return;
1670
+ return found;
1331
1671
  }
1332
- await runtime.runTests({ code: source, fileName, sessionKey: fileName, level }, onEvent);
1333
- }
1334
- async function writeBack(runtime, view, file) {
1335
- try {
1336
- const changed = await runtime.collectWorkspaceFiles();
1337
- if (changed.length === 0) {
1338
- return;
1339
- }
1340
- const written = await writeBackSiblingFiles(file, changed);
1341
- if (written.length > 0) {
1342
- view.note(`Saved ${written.join(", ")} next to ${path4.basename(file)}.`);
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
- function errText(err) {
1349
- return err instanceof Error ? err.message : String(err);
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((resolve3) => pending.push(resolve3));
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 fs4 = __toESM(require("node:fs"));
1396
- var path5 = __toESM(require("node:path"));
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 label = finding.column !== null ? `${finding.fileName}:${finding.lineNumber}:${finding.column + 1}` : `${finding.fileName}:${finding.lineNumber}`;
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: finding.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 = path5.join(dir, `image-${this.imageCount}.svg`);
1881
+ const target = path6.join(dir, `image-${this.imageCount}.svg`);
1536
1882
  try {
1537
- fs4.mkdirSync(dir, { recursive: true });
1538
- fs4.writeFileSync(target, svg, "utf8");
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
- // Colour only when stderr is a terminal, and never when NO_COLOR is set.
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.1.0\n");
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: path6.resolve(args.file),
2065
+ file: path7.resolve(args.file),
1690
2066
  runTests: args.runTests
1691
2067
  });
1692
2068
  } catch (err) {