@code-yeongyu/senpi-codemode 2026.9.23 → 2026.9.24-3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,104 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.24-3] - 2026-09-24
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ ### Removed
26
+
27
+ ## [2026.9.24-2] - 2026-09-24
28
+
29
+ ### Breaking Changes
30
+
31
+ ### Added
32
+
33
+ ### Changed
34
+
35
+ - Dense JavaScript eval cells are previewed with Bun's built-in printer when the TUI runs on Bun: one statement per line with normal spacing, while every string, number, template, name, and comment keeps its exact text. On Node, JavaScript cells are shown as sent. Dense Python cells are formatted by your own Python (ruff or black when installed, otherwise the standard library for comment-free cells), appearing formatted a moment after the cell is shown. Anything that cannot be shown faithfully is shown as sent, and the code that runs is unchanged. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
36
+
37
+ ### Fixed
38
+
39
+ - The `eval` tool's run contract is no longer a trap: the schema descriptions now mark `language` and `code` as required for run, and a run call missing either fails with an error that names the enabled languages or the missing cell body instead of a bare "eval run requires language".
40
+ - An omitted `eval` `language` now names only the kernels enabled in this session (`js` and `py` by default), instead of advertising gated Ruby and Julia.
41
+
42
+ ### Removed
43
+
44
+ ## [2026.9.24] - 2026-09-24
45
+
46
+ ### Breaking Changes
47
+
48
+ ### Added
49
+
50
+ ### Changed
51
+
52
+ ### Fixed
53
+
54
+ - Eval code previews no longer show code that means something different from the cell: breaking a long JavaScript array keeps the parentheses around each element, and a Python formatter result that changes string or docstring text (or is not valid Python) is discarded so the cell is shown as sent. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
55
+ - Python eval previews give your ruff most of the 5-second formatting budget instead of a fixed 4 seconds, so a slow first ruff start still formats the cell. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
56
+
57
+ ### Removed
58
+
59
+ ## [2026.9.23-5] - 2026-09-23
60
+
61
+ ### Breaking Changes
62
+
63
+ ### Added
64
+
65
+ ### Changed
66
+
67
+ ### Fixed
68
+
69
+ ### Removed
70
+
71
+ ## [2026.9.23-4] - 2026-09-23
72
+
73
+ ### Breaking Changes
74
+
75
+ ### Added
76
+
77
+ ### Changed
78
+
79
+ - Eval cards no longer display truncation or full-output artifact footer warnings. Model-facing eval text and grouping are unchanged; content explicitly addressed only to the model is omitted from the text fallback. ([#2041](https://github.com/code-yeongyu/senpi/issues/2041))
80
+
81
+ ### Fixed
82
+
83
+ ### Removed
84
+
85
+ ## [2026.9.23-3] - 2026-09-23
86
+
87
+ ### Breaking Changes
88
+
89
+ ### Added
90
+
91
+ ### Changed
92
+
93
+ - Dense one-line JavaScript eval cells are previewed in the TUI broken at statement, block, and long-array boundaries with indentation, keeping the original tokens and comments. Unparseable, non-JavaScript, and already readable cells are shown as sent, and the code that runs is unchanged. ([#2050](https://github.com/code-yeongyu/senpi/issues/2050))
94
+ - The `eval` `summary` guide asks for a progress update saying what the agent is doing and why, in the language the user writes in, and summaries no longer have a length limit (the 80-character truncation is gone). A collapsed eval block shows the first three summary lines; expanding it shows the rest. ([#2050](https://github.com/code-yeongyu/senpi/issues/2050))
95
+ - Codemode CI tests use at most two fork workers, matching the coding-agent suite's interpreter-heavy test scheduling. Test deadlines, assertions, and local worker defaults are unchanged. ([#2039](https://github.com/code-yeongyu/senpi/issues/2039))
96
+
97
+ ### Fixed
98
+
99
+ ### Removed
100
+
101
+ ## [2026.9.23-2] - 2026-09-23
102
+
103
+ ### Breaking Changes
104
+
105
+ ### Added
106
+
107
+ ### Changed
108
+
109
+ ### Fixed
110
+
111
+ ### Removed
112
+
15
113
  ## [2026.9.23] - 2026-09-23
16
114
 
17
115
  ### Breaking Changes
package/README.md CHANGED
@@ -29,7 +29,9 @@ task-tool names are known.
29
29
  session-adjacent spill files for large streams.
30
30
  - TUI and HTML-export rendering for syntax-highlighted cells, status rows,
31
31
  task progress, structured display values, truncation warnings, and image
32
- fallbacks.
32
+ fallbacks. A JavaScript cell sent as dense one-line code is previewed broken
33
+ at statement, block, and long-array boundaries; the cell itself runs exactly
34
+ as sent.
33
35
  - Runtime identity badges in eval headers — `eval py (3.14.7, ~/.venv/bin/python3)`,
34
36
  `eval js (node 26.7.0, /opt/…/bin/node)` — with the same `runtime` info on
35
37
  `EvalToolDetails` and its `cells` for RPC consumers; interpreter detection
@@ -205,15 +207,19 @@ that implements it. Reset only removes kernel variables: save `pool_id` and use
205
207
  arguments in other languages). An open pool's adapter can be recreated with the
206
208
  same name/spec/mode; no worker state is reconstructed in the prelude.
207
209
 
208
- ## Required summary
209
-
210
- Every `eval` run call MUST include a `summary` — one line in the user's
211
- conversational language stating what the cell does and for what purpose (e.g.
212
- a Korean conversation produces a Korean summary such as "src 전체에서
213
- legacyClient 사용처 집계"). The summary is shown in the TUI while the cell
214
- runs and in the finished result, so you can always tell what is running and
215
- why. Values longer than 80 characters are force-truncated. A run request
216
- without a `summary` fails with a teaching error.
210
+ ## Required run fields
211
+
212
+ Every `eval` run call MUST include a `language` (the kernel that runs the
213
+ cell, one of the enabled languages), the `code` cell body, and a `summary` —
214
+ one line in the language the
215
+ user writes in: a progress update saying what the agent is doing and why, not
216
+ a label for the code. The
217
+ summary is shown in the TUI while the cell runs and in the finished result, so
218
+ you can always tell what is running and why. It has no length limit; a
219
+ collapsed block shows its first three lines. The schema marks all three
220
+ optional only because the control actions (`peek`, `stop`, `list`) share it; a
221
+ run request missing any of them fails with a teaching error that names what to
222
+ add.
217
223
 
218
224
  ## Detached cells
219
225
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.9.23",
3
+ "version": "2026.9.24-3",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -30,14 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@babel/parser": "8.0.4",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.23",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.24-3",
34
34
  "typebox": "1.3.34"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.9.23"
37
+ "@code-yeongyu/senpi": "2026.9.24-3"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.23"
40
+ "@code-yeongyu/senpi": "2026.9.24-3"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -14,6 +14,7 @@ anchor most suites).
14
14
  | Cell execution, settlement | `cell-handler.ts`, `cell-execution.ts`, `cell-runtime.ts` |
15
15
  | Detached cells | `detached-cell-manager.ts` + `detached-cell-{state,snapshot,notification}.ts`, `detached-notification-queue.ts`, `detached-eval-result.ts` |
16
16
  | Call/result rendering | `render.ts`, `runtime-label.ts`, `json-tree.ts`, `image.ts`, `tool-widgets.ts` |
17
+ | Code preview layout | `code-preview.ts` -> `display-code.ts` (JS: `display-js*.ts` Bun-only masked `Bun.Transpiler`; Python: `display-python*.ts` user interpreter, background + repaint) |
17
18
  | Status events, execution events | `status-events.ts`, `eval-execution-event.ts` |
18
19
  | Interrupt, capture | `interrupt-note.ts`, `call-capture.ts` |
19
20
 
@@ -0,0 +1,31 @@
1
+ import { highlightCode, type Theme } from "@code-yeongyu/senpi";
2
+ import { displayCode } from "./display-code.ts";
3
+ import type { EvalLanguage } from "./types.ts";
4
+
5
+ function languageForHighlighter(language: EvalLanguage): "python" | "javascript" | "ruby" | "julia" {
6
+ switch (language) {
7
+ case "py":
8
+ return "python";
9
+ case "js":
10
+ return "javascript";
11
+ case "rb":
12
+ return "ruby";
13
+ case "jl":
14
+ return "julia";
15
+ default: {
16
+ const unreachable: never = language;
17
+ throw new TypeError(`Unhandled eval language: ${String(unreachable)}`);
18
+ }
19
+ }
20
+ }
21
+
22
+ export function highlightedCode(
23
+ code: string,
24
+ language: EvalLanguage,
25
+ theme: Theme | undefined,
26
+ onFormatted?: () => void,
27
+ ): string {
28
+ const normalizedCode = code.trim().length > 0 ? code : "...";
29
+ const lines = highlightCode(displayCode(normalizedCode, language, onFormatted), languageForHighlighter(language));
30
+ return (theme === undefined ? lines.map((line) => line.replace(/\u001b\[[0-9;]*m/gu, "")) : lines).join("\n");
31
+ }
@@ -0,0 +1,48 @@
1
+ import { bunJsPrinter, prettifyJs } from "./display-js.ts";
2
+ import { pythonDisplay } from "./display-python.ts";
3
+ import type { EvalLanguage } from "./types.ts";
4
+
5
+ // Display-only (senpi#2050, senpi#2076): models often send a cell as one long line of joined
6
+ // statements. The tool arguments are never touched (senpi#1472).
7
+ const DENSE_LINE_LENGTH = 100;
8
+ const CACHE_LIMIT = 64;
9
+
10
+ const jsCache = new Map<string, string>();
11
+
12
+ function isDense(code: string): boolean {
13
+ return code.split("\n").some((line) => line.length > DENSE_LINE_LENGTH);
14
+ }
15
+
16
+ function displayJs(code: string): string {
17
+ const print = bunJsPrinter();
18
+ if (print === null) return code;
19
+ const cached = jsCache.get(code);
20
+ if (cached !== undefined) return cached;
21
+ const formatted = prettifyJs(code, print) ?? code;
22
+ if (jsCache.size >= CACHE_LIMIT) jsCache.delete(jsCache.keys().next().value ?? "");
23
+ jsCache.set(code, formatted);
24
+ return formatted;
25
+ }
26
+
27
+ /**
28
+ * The preview text for a cell. Dense JavaScript is laid out by the built-in Bun printer only when
29
+ * the renderer runs on Bun; dense Python is formatted by the user's own interpreter in the
30
+ * background, so the first render shows the cell as sent and `onFormatted` asks for a repaint.
31
+ * Pass `onFormatted` only for complete code; without it no formatter process starts.
32
+ */
33
+ export function displayCode(code: string, language: EvalLanguage, onFormatted?: () => void): string {
34
+ if (!isDense(code)) return code;
35
+ switch (language) {
36
+ case "js":
37
+ return displayJs(code);
38
+ case "py":
39
+ return pythonDisplay.display(code, onFormatted);
40
+ case "rb":
41
+ case "jl":
42
+ return code;
43
+ default: {
44
+ const unreachable: never = language;
45
+ return unreachable;
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,65 @@
1
+ import { parse } from "@babel/parser";
2
+
3
+ export type SourceNode = {
4
+ readonly type: string;
5
+ readonly start: number;
6
+ readonly end: number;
7
+ readonly record: Readonly<Record<string, unknown>>;
8
+ };
9
+
10
+ export type Edit = { readonly start: number; readonly end: number; readonly text: string };
11
+
12
+ export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+
16
+ export function sourceNode(value: unknown): SourceNode | undefined {
17
+ if (!isRecord(value)) return undefined;
18
+ const { type, start, end } = value;
19
+ if (typeof type !== "string" || typeof start !== "number" || typeof end !== "number") return undefined;
20
+ return { type, start, end, record: value };
21
+ }
22
+
23
+ export function sourceNodes(value: unknown): SourceNode[] {
24
+ if (!Array.isArray(value)) return [];
25
+ return value.flatMap((item) => sourceNode(item) ?? []);
26
+ }
27
+
28
+ export function childNodes(node: SourceNode): SourceNode[] {
29
+ return Object.entries(node.record).flatMap(([key, value]) => {
30
+ if (key === "loc" || key === "extra" || key.endsWith("Comments")) return [];
31
+ return Array.isArray(value) ? sourceNodes(value) : (sourceNode(value) ?? []);
32
+ });
33
+ }
34
+
35
+ /** Parses a cell the way the js kernel accepts it: module syntax, top-level `await` and `return`. */
36
+ export function parseFile(code: string): SourceNode | undefined {
37
+ try {
38
+ return sourceNode(
39
+ parse(code, {
40
+ sourceType: "module",
41
+ allowAwaitOutsideFunction: true,
42
+ allowReturnOutsideFunction: true,
43
+ allowImportExportEverywhere: true,
44
+ }),
45
+ );
46
+ } catch (error) {
47
+ if (error instanceof SyntaxError) return undefined;
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ export function programOf(file: SourceNode | undefined): SourceNode | undefined {
53
+ return file === undefined ? undefined : sourceNode(file.record.program);
54
+ }
55
+
56
+ export function applyEdits(code: string, edits: readonly Edit[]): string {
57
+ const sorted = [...edits].sort((left, right) => left.start - right.start);
58
+ let output = "";
59
+ let cursor = 0;
60
+ for (const edit of sorted) {
61
+ output += code.slice(cursor, edit.start) + edit.text;
62
+ cursor = edit.end;
63
+ }
64
+ return output + code.slice(cursor);
65
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ applyEdits,
3
+ childNodes,
4
+ type Edit,
5
+ parseFile,
6
+ programOf,
7
+ type SourceNode,
8
+ sourceNode,
9
+ sourceNodes,
10
+ } from "./display-js-ast.ts";
11
+
12
+ const ARRAY_BREAK_LENGTH = 60;
13
+ const INDENT = " ";
14
+
15
+ function forHeaderEdits(code: string, node: SourceNode, edits: Edit[]): void {
16
+ if (node.type === "ForStatement") {
17
+ const body = sourceNode(node.record.body);
18
+ const open = code.indexOf("(", node.start);
19
+ const close = body === undefined ? -1 : code.lastIndexOf(")", body.start);
20
+ if (open >= 0 && close > open) {
21
+ const parts = ["init", "test", "update"].map((key) => {
22
+ const part = sourceNode(node.record[key]);
23
+ return part === undefined ? "" : code.slice(part.start, part.end);
24
+ });
25
+ const header = parts.every((part) => part.length === 0) ? ";;" : parts.join("; ");
26
+ if (code.slice(open + 1, close) !== header) edits.push({ start: open + 1, end: close, text: header });
27
+ }
28
+ }
29
+ for (const child of childNodes(node)) forHeaderEdits(code, child, edits);
30
+ }
31
+
32
+ function lineIndent(code: string, position: number): string {
33
+ const line = code.slice(code.lastIndexOf("\n", position - 1) + 1);
34
+ return line.slice(0, line.length - line.trimStart().length);
35
+ }
36
+
37
+ // Only whitespace in a gap is rewritten: a gap between babel element nodes can also hold the
38
+ // parentheses of a parenthesized element, and those must stay (senpi#2076).
39
+ function gapTokens(code: string, start: number, end: number): string {
40
+ return code.slice(start, end).replace(/\s+/gu, "");
41
+ }
42
+
43
+ // A one-line array longer than ARRAY_BREAK_LENGTH puts each element on its own line (senpi#2050).
44
+ // `forcedIndent` is the indentation of an element line an enclosing break just created.
45
+ function arrayEdits(code: string, node: SourceNode, forcedIndent: string | undefined, edits: Edit[]): void {
46
+ const rawElements = node.record.elements;
47
+ const elements = sourceNodes(rawElements);
48
+ const first = elements[0];
49
+ const last = elements.at(-1);
50
+ const text = code.slice(node.start, node.end);
51
+ const breaks =
52
+ node.type === "ArrayExpression" &&
53
+ Array.isArray(rawElements) &&
54
+ elements.length === rawElements.length &&
55
+ elements.length >= 2 &&
56
+ text.length > ARRAY_BREAK_LENGTH &&
57
+ !text.includes("\n");
58
+ if (!breaks || first === undefined || last === undefined) {
59
+ for (const child of childNodes(node)) arrayEdits(code, child, forcedIndent, edits);
60
+ return;
61
+ }
62
+ const base = forcedIndent ?? lineIndent(code, node.start);
63
+ const inner = `${base}${INDENT}`;
64
+ edits.push({
65
+ start: node.start + 1,
66
+ end: first.start,
67
+ text: `\n${inner}${gapTokens(code, node.start + 1, first.start)}`,
68
+ });
69
+ elements.slice(1).forEach((element, index) => {
70
+ const start = elements[index]?.end ?? element.start;
71
+ const [before = "", after = ""] = gapTokens(code, start, element.start).split(",");
72
+ edits.push({ start, end: element.start, text: `${before},\n${inner}${after}` });
73
+ });
74
+ edits.push({ start: last.end, end: node.end - 1, text: `${gapTokens(code, last.end, node.end - 1)}\n${base}` });
75
+ for (const element of elements) arrayEdits(code, element, inner, edits);
76
+ }
77
+
78
+ /** Printer fix-ups on the restored cell: `for (a; b; c)` headers, then long-array breaks. */
79
+ export function finishLayout(code: string): string | undefined {
80
+ const program = programOf(parseFile(code));
81
+ if (program === undefined) return undefined;
82
+ const headerEdits: Edit[] = [];
83
+ forHeaderEdits(code, program, headerEdits);
84
+ const withHeaders = applyEdits(code, headerEdits);
85
+ const reparsed = programOf(parseFile(withHeaders));
86
+ if (reparsed === undefined) return undefined;
87
+ const breaks: Edit[] = [];
88
+ arrayEdits(withHeaders, reparsed, undefined, breaks);
89
+ return applyEdits(withHeaders, breaks);
90
+ }
@@ -0,0 +1,178 @@
1
+ import { applyEdits, childNodes, type Edit, type SourceNode, sourceNode } from "./display-js-ast.ts";
2
+
3
+ const LITERAL_TYPES = new Set([
4
+ "StringLiteral",
5
+ "NumericLiteral",
6
+ "BigIntLiteral",
7
+ "RegExpLiteral",
8
+ "BooleanLiteral",
9
+ "NullLiteral",
10
+ "TemplateLiteral",
11
+ "TaggedTemplateExpression",
12
+ "DirectiveLiteral",
13
+ ]);
14
+ // One placeholder per spelling, so `break outer` still resolves against `outer:` for Bun's parser.
15
+ const NAME_TYPES = new Set(["Identifier", "PrivateName"]);
16
+ // The grammar requires a string literal under these parents (module specifiers, attributes, and
17
+ // string export names), so the placeholder stays a string there.
18
+ const STRING_SLOT_PARENTS = new Set([
19
+ "ImportDeclaration",
20
+ "ExportAllDeclaration",
21
+ "ExportNamedDeclaration",
22
+ "ImportAttribute",
23
+ "ImportSpecifier",
24
+ "ExportSpecifier",
25
+ "ExportNamespaceSpecifier",
26
+ "ExportDefaultSpecifier",
27
+ ]);
28
+ const STATEMENT_LISTS = new Set(["Program", "BlockStatement", "StaticBlock", "ClassBody", "SwitchCase"]);
29
+
30
+ type SourceComment = { readonly start: number; readonly end: number; readonly line: boolean };
31
+ type MaskedRange = SourceNode & { readonly stringSlot: boolean };
32
+ export type Placeholder = { readonly id: string; readonly raw: string; readonly expected: number };
33
+ export type CommentPlaceholder = Placeholder & { readonly trailing: boolean };
34
+ export type Mask = {
35
+ readonly prefix: string;
36
+ readonly masked: string;
37
+ readonly values: ReadonlyMap<string, Placeholder>;
38
+ readonly comments: readonly CommentPlaceholder[];
39
+ };
40
+
41
+ function fileComments(file: SourceNode): SourceComment[] | undefined {
42
+ const comments = file.record.comments;
43
+ if (!Array.isArray(comments)) return undefined;
44
+ const parsed: SourceComment[] = [];
45
+ for (const comment of comments) {
46
+ const node = sourceNode(comment);
47
+ if (node === undefined) return undefined;
48
+ parsed.push({ start: node.start, end: node.end, line: node.type === "CommentLine" });
49
+ }
50
+ return parsed;
51
+ }
52
+
53
+ function innermostContainer(node: SourceNode, start: number, end: number): SourceNode {
54
+ for (const child of childNodes(node)) {
55
+ if (child.start <= start && end <= child.end) return innermostContainer(child, start, end);
56
+ }
57
+ return node;
58
+ }
59
+
60
+ function sitsInStatementList(code: string, program: SourceNode, comment: SourceComment): boolean {
61
+ const container = innermostContainer(program, comment.start, comment.end);
62
+ if (!STATEMENT_LISTS.has(container.type)) return false;
63
+ if (container.type !== "SwitchCase") return true;
64
+ const test = sourceNode(container.record.test);
65
+ const colon = code.indexOf(":", test?.end ?? container.start);
66
+ return colon >= 0 && colon < comment.start;
67
+ }
68
+
69
+ function followsCodeOnItsLine(code: string, comment: SourceComment, comments: readonly SourceComment[]): boolean {
70
+ let index = comment.start - 1;
71
+ while (index >= 0 && code[index] !== "\n") {
72
+ const position = index;
73
+ const covering = comments.find((other) => other.start <= position && position < other.end);
74
+ if (covering !== undefined) {
75
+ index = covering.start - 1;
76
+ continue;
77
+ }
78
+ if (code[index] !== " " && code[index] !== "\t") return true;
79
+ index -= 1;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ function placeholderPrefix(code: string): string {
85
+ let prefix = "$display";
86
+ while (code.includes(prefix)) prefix = `${prefix}_`;
87
+ return prefix;
88
+ }
89
+
90
+ function collectRanges(node: SourceNode, parent: SourceNode | undefined, ranges: Map<string, MaskedRange>): void {
91
+ if (LITERAL_TYPES.has(node.type) || NAME_TYPES.has(node.type)) {
92
+ const stringSlot = node.type === "StringLiteral" && parent !== undefined && STRING_SLOT_PARENTS.has(parent.type);
93
+ const key = `${node.start}:${node.end}`;
94
+ if (!ranges.has(key)) ranges.set(key, { ...node, stringSlot });
95
+ return;
96
+ }
97
+ for (const child of childNodes(node)) collectRanges(child, node, ranges);
98
+ }
99
+
100
+ /**
101
+ * Swaps every literal, name, and statement-level comment for an opaque placeholder so a printer
102
+ * can only move them. Undefined when a comment sits where a placeholder statement cannot stand.
103
+ */
104
+ export function maskSource(code: string, file: SourceNode): Mask | undefined {
105
+ const program = sourceNode(file.record.program);
106
+ const comments = fileComments(file);
107
+ if (program === undefined || comments === undefined) return undefined;
108
+ const ranges = new Map<string, MaskedRange>();
109
+ collectRanges(program, undefined, ranges);
110
+ const prefix = placeholderPrefix(code);
111
+ const edits: Edit[] = [];
112
+ const values = new Map<string, Placeholder>();
113
+ const nameIds = new Map<string, string>();
114
+ let counter = 0;
115
+ for (const range of ranges.values()) {
116
+ const raw = code.slice(range.start, range.end);
117
+ const reused = NAME_TYPES.has(range.type) ? nameIds.get(raw) : undefined;
118
+ const id = reused ?? `${prefix}${counter++}`;
119
+ if (NAME_TYPES.has(range.type)) nameIds.set(raw, id);
120
+ values.set(id, { id, raw, expected: (values.get(id)?.expected ?? 0) + 1 });
121
+ edits.push({ start: range.start, end: range.end, text: range.stringSlot ? `"${id}"` : id });
122
+ }
123
+ const masked: CommentPlaceholder[] = [];
124
+ for (const comment of comments) {
125
+ if (!sitsInStatementList(code, program, comment)) return undefined;
126
+ const id = `${prefix}${counter++}`;
127
+ const trailing = comment.line && followsCodeOnItsLine(code, comment, comments);
128
+ masked.push({ id, raw: code.slice(comment.start, comment.end), expected: 1, trailing });
129
+ edits.push({ start: comment.start, end: comment.end, text: `\n${id};\n` });
130
+ }
131
+ return { prefix, masked: applyEdits(code, edits), values, comments: masked };
132
+ }
133
+
134
+ export function restoreComments(printed: string, comments: readonly CommentPlaceholder[]): string | undefined {
135
+ const lines = printed.split("\n");
136
+ for (const comment of comments) {
137
+ const matches = lines.flatMap((line, index) => (line.trim() === `${comment.id};` ? [index] : []));
138
+ const index = matches[0];
139
+ if (matches.length !== 1 || index === undefined) return undefined;
140
+ const line = lines[index] ?? "";
141
+ let previous = index - 1;
142
+ while (previous >= 0 && (lines[previous] ?? "").trim().length === 0) previous -= 1;
143
+ const target = lines[previous];
144
+ if (comment.trailing && target !== undefined && !target.includes("//")) {
145
+ lines[previous] = `${target} ${comment.raw}`;
146
+ lines.splice(index, 1);
147
+ continue;
148
+ }
149
+ lines[index] = `${line.slice(0, line.length - line.trimStart().length)}${comment.raw}`;
150
+ }
151
+ return lines.join("\n");
152
+ }
153
+
154
+ function escapeRegExp(text: string): string {
155
+ return text.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&");
156
+ }
157
+
158
+ export function restoreValues(printed: string, mask: Mask): string | undefined {
159
+ const id = `${escapeRegExp(mask.prefix)}\\d+`;
160
+ const pattern = new RegExp(`"(${id})"|'(${id})'|(?<![\\w$])(${id})(?![\\w$])`, "gu");
161
+ const counts = new Map<string, number>();
162
+ let unknown = false;
163
+ const restored = printed.replace(pattern, (match, double: unknown, single: unknown, bare: unknown) => {
164
+ const key = [double, single, bare].find((group): group is string => typeof group === "string") ?? "";
165
+ const value = mask.values.get(key);
166
+ if (value === undefined) {
167
+ unknown = true;
168
+ return match;
169
+ }
170
+ counts.set(key, (counts.get(key) ?? 0) + 1);
171
+ return value.raw;
172
+ });
173
+ if (unknown) return undefined;
174
+ for (const value of mask.values.values()) {
175
+ if (counts.get(value.id) !== value.expected) return undefined;
176
+ }
177
+ return restored;
178
+ }