@henols/c64-re-tools 2.0.1 → 2.1.0-rc.2

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 (104) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +24 -9
  4. package/skills/c64-assembler/scripts/assembler.ts +95 -0
  5. package/skills/c64-basic/SKILL.md +17 -9
  6. package/skills/c64-basic/scripts/basic.ts +26 -36
  7. package/skills/c64-disk/SKILL.md +17 -7
  8. package/skills/c64-disk/scripts/disk.ts +130 -104
  9. package/skills/c64-disk/scripts/lib/containers.ts +10 -12
  10. package/skills/c64-docs/SKILL.md +47 -28
  11. package/skills/c64-docs/references/style.md +6 -6
  12. package/skills/c64-docs/scripts/docs.ts +323 -0
  13. package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
  14. package/skills/c64-emulator/SKILL.md +11 -7
  15. package/skills/c64-knowledge/SKILL.md +34 -21
  16. package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
  17. package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
  18. package/skills/c64-memory-map/SKILL.md +18 -10
  19. package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
  20. package/skills/c64-provenance/SKILL.md +82 -26
  21. package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
  22. package/skills/c64-provenance/scripts/lib/leftovers.ts +442 -0
  23. package/skills/c64-provenance/scripts/lib/library.ts +15 -25
  24. package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
  25. package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
  26. package/skills/c64-provenance/scripts/provenance.ts +222 -252
  27. package/skills/c64-reverse-engineering/SKILL.md +18 -15
  28. package/skills/c64-source/SKILL.md +28 -16
  29. package/skills/c64-source/scripts/lib/loader.ts +52 -77
  30. package/skills/c64-source/scripts/lib/source.ts +33 -42
  31. package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
  32. package/skills/c64-static-analysis/SKILL.md +24 -13
  33. package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
  34. package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
  35. package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
  36. package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
  37. package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
  38. package/skills/c64-testing/SKILL.md +94 -19
  39. package/skills/c64-testing/references/recording-format.md +125 -0
  40. package/skills/c64-testing/references/scenario-format.md +24 -19
  41. package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
  42. package/skills/c64-testing/scripts/lib/diff.ts +5 -3
  43. package/skills/c64-testing/scripts/lib/play.ts +334 -0
  44. package/skills/c64-testing/scripts/lib/recording.ts +662 -0
  45. package/skills/c64-testing/scripts/lib/replay.ts +105 -0
  46. package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
  47. package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
  48. package/skills/c64-testing/scripts/testing.ts +189 -0
  49. package/skills/c64-unpacker/SKILL.md +42 -25
  50. package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
  51. package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
  52. package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
  53. package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
  54. package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
  55. package/src/c64.ts +35 -20
  56. package/src/cli/main.ts +2 -2
  57. package/src/errors.ts +38 -0
  58. package/src/graphics.ts +3 -1
  59. package/src/host/server.ts +3 -2
  60. package/src/host/vice/session.ts +4 -4
  61. package/src/host-client/entry-state.ts +106 -0
  62. package/src/host-client/image.ts +21 -10
  63. package/src/host-client/steps.ts +285 -108
  64. package/src/host-client/transfer.ts +21 -15
  65. package/src/host-client/vice-session.ts +1 -1
  66. package/src/knowledge/database.ts +3 -23
  67. package/src/knowledge/expression.ts +8 -11
  68. package/src/knowledge/history.ts +2 -2
  69. package/src/knowledge/import.ts +13 -14
  70. package/src/knowledge/names.ts +50 -0
  71. package/src/knowledge/program.ts +11 -10
  72. package/src/knowledge/read.ts +16 -21
  73. package/src/knowledge/schema.ts +9 -3
  74. package/src/knowledge/text.ts +5 -5
  75. package/src/knowledge/write.ts +93 -137
  76. package/src/mcp/inputs.ts +105 -0
  77. package/src/mcp/output.ts +2 -13
  78. package/src/mcp/server.ts +12 -60
  79. package/src/mcp/tools/debug.ts +17 -12
  80. package/src/mcp/tools/execution.ts +5 -32
  81. package/src/mcp/tools/input.ts +7 -32
  82. package/src/mcp/tools/media.ts +6 -5
  83. package/src/mcp/tools/memory.ts +11 -38
  84. package/src/mcp/tools/video.ts +6 -5
  85. package/src/native/c64run.ts +162 -0
  86. package/src/native/discover.ts +8 -0
  87. package/src/native/dxa.ts +7 -6
  88. package/src/native/ghidra/analyze.ts +2 -1
  89. package/src/native/ghidra/index.ts +2 -1
  90. package/src/native/processes.ts +4 -3
  91. package/src/native/status.ts +25 -4
  92. package/src/native/types.ts +3 -7
  93. package/src/opcodes.ts +13 -13
  94. package/src/platform.ts +9 -9
  95. package/src/project.ts +16 -26
  96. package/src/protocol/messages.ts +3 -21
  97. package/src/protocol/vice.ts +7 -7
  98. package/src/script.ts +491 -49
  99. package/skills/c64-assembler/scripts/assemble.ts +0 -138
  100. package/skills/c64-docs/scripts/generate.ts +0 -109
  101. package/skills/c64-docs/scripts/link.ts +0 -239
  102. package/skills/c64-memory-map/scripts/memmap.ts +0 -92
  103. package/skills/c64-testing/scripts/checklist.ts +0 -51
  104. package/skills/c64-testing/scripts/test.ts +0 -65
@@ -549,8 +549,8 @@ export interface ViceOperations {
549
549
  };
550
550
  screenBaselines: { params: Record<string, never>; result: { baselines: string[] } };
551
551
  snapshot: {
552
- params: { action: "save" | "restore" | "discard"; name: string } | { action: "list" };
553
- result: { saved: boolean; name: string } | { restored: boolean; state: RunState } | { snapshots: string[] } | { discarded: boolean };
552
+ params: { action: "save" | "restore" | "remove"; name: string } | { action: "list" };
553
+ result: { saved: boolean; name: string } | { restored: boolean; state: RunState } | { snapshots: string[] } | { removed: boolean };
554
554
  };
555
555
  /**
556
556
  * Snapshot files: export sends the bytes of a named snapshot back as
@@ -560,7 +560,7 @@ export interface ViceOperations {
560
560
  params: { action: "export"; name: string } | { action: "restore" };
561
561
  result: { exported: boolean; bytes: number } | { restored: boolean; state: RunState };
562
562
  };
563
- screenDiscard: { params: { baseline: string }; result: { discarded: boolean } };
563
+ screenDiscard: { params: { baseline: string }; result: { removed: boolean } };
564
564
  warp: { params: { enabled: boolean }; result: { enabled: boolean } };
565
565
  window: { params: { action: (typeof WINDOW_ACTIONS)[number] }; result: WindowResult };
566
566
  }
@@ -633,8 +633,8 @@ export function validateViceParams<O extends ViceOperation>(op: O, params: unkno
633
633
  case "snapshot": {
634
634
  onlyFields(params, params.action === "list" ? ["action"] : ["action", "name"]);
635
635
  if (params.action === "list") return { action: "list" } as ViceOperations[O]["params"];
636
- if (params.action !== "save" && params.action !== "restore" && params.action !== "discard") {
637
- invalid("action must be save, restore, list or discard");
636
+ if (params.action !== "save" && params.action !== "restore" && params.action !== "remove") {
637
+ invalid("action must be save, restore, list or remove");
638
638
  }
639
639
  return { action: params.action, name: validName(params.name, "name") } as ViceOperations[O]["params"];
640
640
  }
@@ -1231,7 +1231,7 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1231
1231
  break;
1232
1232
  }
1233
1233
  case "screenDiscard": {
1234
- if (value.discarded !== true) throw new ProtocolError("screenDiscard result is malformed");
1234
+ if (value.removed !== true) throw new ProtocolError("screenDiscard result is malformed");
1235
1235
  break;
1236
1236
  }
1237
1237
  case "snapshot": {
@@ -1239,7 +1239,7 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1239
1239
  (value.saved === true && typeof value.name === "string") ||
1240
1240
  (value.restored === true && isOneOf(RUN_STATES, value.state)) ||
1241
1241
  (Array.isArray(value.snapshots) && value.snapshots.every((name) => typeof name === "string")) ||
1242
- value.discarded === true;
1242
+ value.removed === true;
1243
1243
  if (!ok) throw new ProtocolError("snapshot result is malformed");
1244
1244
  break;
1245
1245
  }
package/src/script.ts CHANGED
@@ -1,29 +1,351 @@
1
- // The one error envelope for the skill scripts and the CLI: a script prints one JSON line,
2
- // a refusal prints {"error":{code,message}} with exit status 1, and a usage error prints the
3
- // usage text as an invalid-input error with exit status 2. Every script takes --project <folder>.
1
+ // The one call contract of the skill scripts. A script is
2
+ //
3
+ // node skills/c64-<X>/scripts/<X>.ts <command> [subjects...] [--option value ...]
4
+ //
5
+ // The command word comes first. The subjects are the files, addresses, ranges or
6
+ // names that the command acts on. Everything else is a named option; an option
7
+ // with a name from OPTIONS has the same meaning and parser in every script.
8
+ // A script prints one JSON object on one line. An error is
9
+ // {"error":{"code","message"}}. Exit status: 0 done or PASS, 1 FAIL, 2 error,
10
+ // 3 INCONCLUSIVE. Nothing is thrown out of a script: every failure becomes an error.
4
11
 
5
- import { ProjectError, useProject } from "./project.ts";
6
- import { shownMessage, WireFailure } from "./protocol/messages.ts";
12
+ import { parseArgs } from "node:util";
13
+
14
+ import { c64AddressOf, formatC64Address, SPACE_PATTERN, SYMBOL_NAME, type AddressRange } from "./c64.ts";
15
+ import { isRefusal, Refusal, type ErrorCode } from "./errors.ts";
16
+ import { projectRoot, resolveProjectPath, useProject } from "./project.ts";
17
+ import { shownMessage } from "./protocol/messages.ts";
7
18
  import { elapsedMs, startTrace, trace, type TraceFields } from "./trace.ts";
8
19
 
9
- /** A wrong command line. The message names the problem; an empty message asks for the usage text alone. */
10
- export class UsageError extends Error {}
20
+ /** A wrong command line: an invalid-input refusal that also shows the usage of the command. */
21
+ export class UsageError extends Refusal {
22
+ override name = "UsageError";
23
+
24
+ constructor(message: string) {
25
+ super("invalid-input", message);
26
+ }
27
+ }
28
+
29
+ /** A fault of the toolkit, not of the call: a script that declares its options wrong. */
30
+ function fault(message: string): Refusal {
31
+ return new Refusal("operation-failed", `${message}. This is a fault of the toolkit, not of the call.`);
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Declarations
36
+
37
+ /** How an option value parses. */
38
+ export type OptionKind =
39
+ | "flag" // no value: true when given
40
+ | "text" // any text
41
+ | "path" // a path relative to the project folder
42
+ | "count" // a whole number in decimal
43
+ | "number" // a value: $ and hex digits, decimal, or % and binary digits
44
+ | "address" // $ and 1 to 4 hex digits, or a name
45
+ | "range" // <start>-<end>, each an address
46
+ | "space" // c64, drive8, or bank0 to bank255
47
+ | "choice"; // one of `choices`
48
+
49
+ export interface OptionSpec {
50
+ kind: OptionKind;
51
+ /** The value in the usage, for example <file>. Not for a flag. */
52
+ value?: string;
53
+ help: string;
54
+ multiple?: boolean;
55
+ required?: boolean;
56
+ choices?: readonly string[];
57
+ /** count and number: the smallest and the largest value. */
58
+ min?: number;
59
+ max?: number;
60
+ /** The value when the option is not given. */
61
+ default?: number | string;
62
+ }
63
+
64
+ /** The options that have one meaning in every script. A command can change the help, the value and whether it is required, never the kind. */
65
+ export const OPTIONS = {
66
+ out: { kind: "path", value: "<path>", help: "write the output here, relative to the project folder" },
67
+ space: { kind: "space", value: "<space>", help: "the knowledge space: c64 (the default), drive8 for 1541 drive code, or bank0 to bank255 for a cartridge bank", default: "c64" },
68
+ range: { kind: "range", value: "<start>-<end>", help: "only the addresses from start to end, for example '$c000-$cfff'" },
69
+ entry: { kind: "address", value: "<address>", help: "an entry point of the program" },
70
+ steps: { kind: "path", value: "<file>", help: "a JSON file of action steps that run after the start" },
71
+ frames: { kind: "count", value: "<n>", help: "run for this number of video frames", min: 1, max: 1_000_000 },
72
+ "timeout-frames": { kind: "count", value: "<n>", help: "stop a wait after this number of video frames", min: 1, max: 1_000_000 },
73
+ "max-listed": { kind: "count", value: "<n>", help: "list at most n items of each list (default 50). The counts are always given", min: 0, max: 100_000, default: 50 },
74
+ symbols: { kind: "path", value: "<file>", help: "the symbols of a build: the c64-assembler output, or a JSON map of name to $ address" },
75
+ origin: { kind: "text", value: "<origin>", help: "user, llm, dxa, ghidra, heuristic, semantic (user and llm) or analyzer (dxa, ghidra and heuristic), or a comma list" },
76
+ reason: { kind: "text", value: "<text>", help: "why: kept with the revision" },
77
+ evidence: { kind: "text", value: "<kind:ref>", help: "what the change rests on: coverage:<map>, test:<file>, file:<file> or note:<text>", multiple: true },
78
+ "expect-revision": { kind: "text", value: "<revision>", help: "refuse the write when the knowledge changed after this revision" },
79
+ } as const satisfies Record<string, OptionSpec>;
80
+
81
+ export interface SubjectSpec {
82
+ /** The name in the usage, for example <address>. */
83
+ name: string;
84
+ help: string;
85
+ optional?: boolean;
86
+ /** The last subject only: one or more (or zero or more when optional). */
87
+ list?: boolean;
88
+ }
89
+
90
+ export interface CommandSpec {
91
+ name: string;
92
+ /** One line: what the command does. */
93
+ summary: string;
94
+ subjects?: readonly SubjectSpec[];
95
+ options?: Readonly<Record<string, OptionSpec>>;
96
+ /** More text for the usage of the command. */
97
+ notes?: string;
98
+ run: (call: Call) => unknown;
99
+ }
100
+
101
+ /** Gives the address of a name in a space; refuses a name that it does not know. */
102
+ export type NameResolver = (name: string, space: string) => number;
103
+
104
+ export interface ScriptSpec {
105
+ /** The script name without .ts, for example knowledge. */
106
+ name: string;
107
+ summary: string;
108
+ commands: readonly CommandSpec[];
109
+ /** Resolves names in addresses and ranges. Without it, a name is refused. */
110
+ names?: NameResolver;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Values
115
+
116
+ const NUMBER_PATTERNS = [/^\$([0-9a-fA-F]{1,8})$/, /^%([01]{1,32})$/, /^(\d{1,10})$/];
117
+
118
+ /** A value as $ and hex digits, % and binary digits, or decimal digits; undefined for other text. */
119
+ export function numberOf(text: string): number | undefined {
120
+ const [hex, binary, decimal] = NUMBER_PATTERNS.map((pattern) => pattern.exec(text)?.[1]);
121
+ if (hex !== undefined) return Number.parseInt(hex, 16);
122
+ if (binary !== undefined) return Number.parseInt(binary, 2);
123
+ if (decimal !== undefined) return Number(decimal);
124
+ return undefined;
125
+ }
126
+
127
+ /** True when the text has the form of an address: $ and 1 to 4 hex digits, or a name. */
128
+ export function isAddressText(text: string): boolean {
129
+ return c64AddressOf(text) !== undefined || SYMBOL_NAME.test(text);
130
+ }
131
+
132
+ /** Splits <start>-<end>. A name has no hyphen, so the first hyphen is the split. */
133
+ function rangeParts(text: string): [string, string] | undefined {
134
+ const parts = text.split("-");
135
+ return parts.length === 2 && isAddressText(parts[0]!) && isAddressText(parts[1]!) ? [parts[0]!, parts[1]!] : undefined;
136
+ }
137
+
138
+ const ADDRESS_FORM = "$ and 1 to 4 hex digits, for example '$c000' or '$fb', or a name";
139
+
140
+ /** The parsed command line of one command, with the parsers for its values. */
141
+ export class Call {
142
+ readonly script: string;
143
+ readonly command: string;
144
+ readonly subjects: readonly string[];
145
+ readonly #options: Readonly<Record<string, OptionSpec>>;
146
+ readonly #values: Readonly<Record<string, string | boolean | string[] | undefined>>;
147
+ readonly #names: NameResolver | undefined;
148
+
149
+ constructor(script: string, command: string, subjects: readonly string[], options: Readonly<Record<string, OptionSpec>>, values: Readonly<Record<string, string | boolean | string[] | undefined>>, names: NameResolver | undefined) {
150
+ this.script = script;
151
+ this.command = command;
152
+ this.subjects = subjects;
153
+ this.#options = options;
154
+ this.#values = values;
155
+ this.#names = names;
156
+ }
157
+
158
+ /** The knowledge space of the command: --space, else c64. */
159
+ get space(): string {
160
+ return "space" in this.#options ? this.string("space")! : "c64";
161
+ }
162
+
163
+ /** Subject `index` as text. */
164
+ subject(index: number): string {
165
+ const value = this.subjects[index];
166
+ if (value === undefined) throw new UsageError(`${this.command} needs subject ${index + 1}`);
167
+ return value;
168
+ }
169
+
170
+ /** The subjects from `index` to the end. */
171
+ rest(index: number): string[] {
172
+ return this.subjects.slice(index);
173
+ }
174
+
175
+ // Parsers for text: a subject, or a value in a JSON file. `what` names the value in an error.
176
+
177
+ /** An address: $ and 1 to 4 hex digits, or a name in `space` (default: the space of the command). */
178
+ parseAddress(text: string, what: string, space = this.space): number {
179
+ const value = c64AddressOf(text);
180
+ if (value !== undefined) return value;
181
+ if (!SYMBOL_NAME.test(text)) throw new UsageError(`${what} must be ${ADDRESS_FORM}, not ${JSON.stringify(text)}`);
182
+ if (this.#names === undefined) throw new UsageError(`${what}: ${this.script}.ts ${this.command} takes no names. Give $ and 1 to 4 hex digits, not ${text}`);
183
+ try {
184
+ return this.#names(text, space);
185
+ } catch (error) {
186
+ if (isRefusal(error)) throw new UsageError(`${what}: ${error.message}`);
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ /** A range <start>-<end>, each an address; the start is not after the end. */
192
+ parseRange(text: string, what: string, space = this.space): AddressRange {
193
+ const parts = rangeParts(text);
194
+ if (parts === undefined) throw new UsageError(`${what} must be <start>-<end>, each ${ADDRESS_FORM}, for example '$c000-$cfff', not ${JSON.stringify(text)}`);
195
+ const start = this.parseAddress(parts[0], `${what} start`, space);
196
+ const end = this.parseAddress(parts[1], `${what} end`, space);
197
+ if (end < start) throw new UsageError(`${what} ends at ${formatC64Address(end)}, before its start ${formatC64Address(start)}`);
198
+ return { start, end };
199
+ }
200
+
201
+ /** A value: $ and hex digits, decimal, or % and binary digits, from `min` to `max`. */
202
+ parseNumber(text: string, what: string, min = 0, max = 0xffff): number {
203
+ const value = numberOf(text);
204
+ if (value === undefined || value < min || value > max) throw new UsageError(`${what} must be a number from ${min} to ${max} ($ and hex, decimal, or % and binary), not ${JSON.stringify(text)}`);
205
+ return value;
206
+ }
207
+
208
+ /** A whole number in decimal, from `min` to `max`. */
209
+ parseCount(text: string, what: string, min = 0, max = Number.MAX_SAFE_INTEGER): number {
210
+ if (!/^\d{1,15}$/.test(text) || Number(text) < min || Number(text) > max) throw new UsageError(`${what} must be a whole number from ${min} to ${max}, not ${JSON.stringify(text)}`);
211
+ return Number(text);
212
+ }
213
+
214
+ // Options by name. Each getter checks that the command declares the option with a fitting kind.
215
+
216
+ #spec(name: string, kinds: readonly OptionKind[]): OptionSpec {
217
+ const spec = this.#options[name];
218
+ if (spec === undefined || !kinds.includes(spec.kind)) throw fault(`${this.script}.ts ${this.command}: the option --${name} is not declared as ${kinds.join(" or ")}`);
219
+ return spec;
220
+ }
221
+
222
+ #texts(name: string, kinds: readonly OptionKind[]): string[] {
223
+ const spec = this.#spec(name, kinds);
224
+ const value = this.#values[name];
225
+ if (value === undefined) return spec.default === undefined ? [] : [String(spec.default)];
226
+ return Array.isArray(value) ? value : [String(value)];
227
+ }
228
+
229
+ flag(name: string): boolean {
230
+ this.#spec(name, ["flag"]);
231
+ return this.#values[name] === true;
232
+ }
233
+
234
+ /** text, path, space or choice: the value, or the default, or undefined. */
235
+ string(name: string): string | undefined {
236
+ return this.#texts(name, ["text", "path", "space", "choice"])[0];
237
+ }
238
+
239
+ strings(name: string): string[] {
240
+ return this.#texts(name, ["text", "path", "space", "choice"]);
241
+ }
242
+
243
+ /** count or number: the value, or the default, or undefined. */
244
+ count(name: string): number | undefined {
245
+ return this.counts(name)[0];
246
+ }
247
+
248
+ counts(name: string): number[] {
249
+ const spec = this.#spec(name, ["count", "number"]);
250
+ return this.#texts(name, ["count", "number"]).map((text) => (spec.kind === "count" ? this.parseCount(text, `--${name}`, spec.min, spec.max) : this.parseNumber(text, `--${name}`, spec.min, spec.max)));
251
+ }
252
+
253
+ address(name: string): number | undefined {
254
+ return this.addresses(name)[0];
255
+ }
256
+
257
+ addresses(name: string): number[] {
258
+ return this.#texts(name, ["address"]).map((text) => this.parseAddress(text, `--${name}`));
259
+ }
260
+
261
+ range(name: string): AddressRange | undefined {
262
+ return this.ranges(name)[0];
263
+ }
264
+
265
+ ranges(name: string): AddressRange[] {
266
+ return this.#texts(name, ["range"]).map((text) => this.parseRange(text, `--${name}`));
267
+ }
268
+ }
269
+
270
+ /**
271
+ * A list in a result: its count and its first `max` items (--max-listed). When
272
+ * the list is cut, rest tells how to get all of it.
273
+ */
274
+ export function listed<T, U = T>(items: readonly T[], max: number, show: (item: T) => U = (item) => item as unknown as U, rest = "a larger --max-listed"): { count: number; listed: U[]; rest?: string } {
275
+ return { count: items.length, listed: items.slice(0, max).map(show), ...(items.length > max ? { rest } : {}) };
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Usage
11
280
 
12
- /** A refusal class: an Error with a stable `code` (WireFailure, KnowledgeError, ...). */
13
- export type RefusalClass = abstract new (...args: never[]) => Error & { code: string };
281
+ /** The text at the end of every usage. */
282
+ export const CONTRACT_USAGE = `Addresses are $ and 1 to 4 hex digits, for example '$c000' or '$fb', or a name: a symbol, else an analyzer name that only one analyzer gives. A range is <start>-<end>, for example '$c000-$cfff'. Quote them in a shell.
283
+ Paths are relative to the project folder. Every script takes --project <folder>: the project folder, relative to the working directory. Without it, the script uses the nearest folder at or above the working directory that holds .c64-re-tools/.
284
+ The result is one JSON object on one line. An error is {"error":{"code":"...","message":"..."}}.
285
+ Exit status: 0 done or PASS, 1 FAIL, 2 error, 3 INCONCLUSIVE.`;
14
286
 
15
- /** True for an error that node:util parseArgs throws for a wrong command line. */
16
- function isParseArgsError(error: unknown): error is Error {
17
- const code = (error as NodeJS.ErrnoException | undefined)?.code;
18
- return error instanceof Error && typeof code === "string" && code.startsWith("ERR_PARSE_ARGS_");
287
+ function optionLabel(name: string, spec: OptionSpec): string {
288
+ return spec.kind === "flag" ? `--${name}` : `--${name} ${spec.value ?? "<value>"}`;
19
289
  }
20
290
 
21
- /** The line that every usage text ends with. */
22
- export const PROJECT_USAGE = "Every script takes --project <folder>: the project folder, relative to the working directory. Without it, the script uses the nearest folder at or above the working directory that holds .c64-re-tools/.";
291
+ function synopsis(script: string, command: CommandSpec): string {
292
+ const parts = [`${script}.ts`, command.name];
293
+ for (const subject of command.subjects ?? []) {
294
+ const name = subject.list ? `${subject.name}...` : subject.name;
295
+ parts.push(subject.optional ? `[${name}]` : name);
296
+ }
297
+ for (const [name, spec] of Object.entries(command.options ?? {})) {
298
+ const label = optionLabel(name, spec);
299
+ parts.push(spec.required ? label : `[${label}]${spec.multiple ? "..." : ""}`);
300
+ }
301
+ return parts.join(" ");
302
+ }
303
+
304
+ function table(rows: Array<[string, string]>): string[] {
305
+ const width = Math.max(0, ...rows.map(([label]) => label.length)) + 2;
306
+ return rows.map(([label, help]) => ` ${label.padEnd(width)}${help}`);
307
+ }
308
+
309
+ export function commandUsage(script: ScriptSpec, command: CommandSpec): string {
310
+ const rows: Array<[string, string]> = [
311
+ ...(command.subjects ?? []).map((subject): [string, string] => [subject.name, subject.help]),
312
+ ...Object.entries(command.options ?? {}).map(([name, spec]): [string, string] => [optionLabel(name, spec), `${spec.help}${spec.multiple ? " (repeatable)" : ""}`]),
313
+ ];
314
+ return [synopsis(script.name, command), "", command.summary, ...(rows.length === 0 ? [] : ["", ...table(rows)]), ...(command.notes === undefined ? [] : ["", command.notes]), "", CONTRACT_USAGE].join("\n");
315
+ }
316
+
317
+ export function scriptUsage(script: ScriptSpec): string {
318
+ return [
319
+ `${script.name}.ts <command> [subjects...] [options]`,
320
+ "",
321
+ script.summary,
322
+ "",
323
+ "Commands:",
324
+ ...table(script.commands.map((command) => [command.name, command.summary])),
325
+ "",
326
+ `Give ${script.name}.ts <command> --help for the subjects and options of one command.`,
327
+ "",
328
+ CONTRACT_USAGE,
329
+ ].join("\n");
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Parsing
334
+
335
+ /** The options of a command, checked against OPTIONS: a shared name keeps its kind. */
336
+ function commandOptions(command: CommandSpec): Record<string, OptionSpec> {
337
+ const options = { ...(command.options ?? {}) };
338
+ for (const [name, spec] of Object.entries(options)) {
339
+ const shared = (OPTIONS as Record<string, OptionSpec>)[name];
340
+ if (shared !== undefined && shared.kind !== spec.kind) throw fault(`--${name} of ${command.name} is a ${spec.kind}, but OPTIONS has it as a ${shared.kind}`);
341
+ if (name === "help" || name === "project") throw fault(`--${name} of ${command.name}: the contract owns this option`);
342
+ }
343
+ return options;
344
+ }
23
345
 
24
346
  /**
25
347
  * Takes --project <folder> and --project=<folder> out of a command line, up to "--".
26
- * Returns the folder and the other arguments. Throws UsageError for a --project without a folder or a second --project.
348
+ * Returns the folder and the other arguments. Refuses a --project without a folder or a second --project.
27
349
  */
28
350
  export function takeProject(argv: readonly string[]): { project?: string; argv: string[] } {
29
351
  const rest: string[] = [];
@@ -52,48 +374,168 @@ export function takeProject(argv: readonly string[]): { project?: string; argv:
52
374
  return project === undefined ? { argv: rest } : { project, argv: rest };
53
375
  }
54
376
 
55
- function printLine(value: unknown): void {
56
- process.stdout.write(`${JSON.stringify(value)}\n`);
377
+ /** True when --help comes before "--". */
378
+ function asksHelp(argv: readonly string[]): boolean {
379
+ const end = argv.indexOf("--");
380
+ return (end < 0 ? argv : argv.slice(0, end)).includes("--help");
381
+ }
382
+
383
+ /** Refuses a path that is not relative to the project folder or that leads out of it. A refusal of the project lookup itself passes on. */
384
+ function checkPath(text: string, what: string): void {
385
+ const root = projectRoot();
386
+ try {
387
+ resolveProjectPath(text, root);
388
+ } catch (error) {
389
+ if (isRefusal(error)) throw new UsageError(`${what}: ${error.message}`);
390
+ throw error;
391
+ }
392
+ }
393
+
394
+ /** Parses the command line of one command. Refuses a wrong count of subjects, an unknown option, a missing required option or a value of the wrong form. */
395
+ export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readonly string[]): Call {
396
+ const options = commandOptions(command);
397
+ let parsed: ReturnType<typeof parseArgs>;
398
+ try {
399
+ parsed = parseArgs({
400
+ args: [...argv],
401
+ strict: true,
402
+ allowPositionals: true,
403
+ options: Object.fromEntries(Object.entries(options).map(([name, spec]) => [name, { type: spec.kind === "flag" ? "boolean" : "string", ...(spec.multiple ? { multiple: true } : {}) }])),
404
+ });
405
+ } catch (error) {
406
+ throw new UsageError((error as Error).message);
407
+ }
408
+ const values = parsed.values as Record<string, string | boolean | string[] | undefined>;
409
+ const subjects = parsed.positionals;
410
+ const specs = command.subjects ?? [];
411
+ const last = specs.at(-1);
412
+ const least = specs.filter((subject) => !subject.optional).length;
413
+ const most = last?.list ? Number.POSITIVE_INFINITY : specs.length;
414
+ if (subjects.length < least || subjects.length > most) {
415
+ const named = specs.length === 0 ? "no subjects" : specs.map((subject) => (subject.list ? `${subject.name}...` : subject.name)).join(" ");
416
+ throw new UsageError(subjects.length < least ? `${command.name} needs ${named}` : `${command.name} takes ${named}. ${JSON.stringify(subjects[most])} is one too many`);
417
+ }
418
+ for (const [name, spec] of Object.entries(options)) {
419
+ const value = values[name];
420
+ if (value === undefined) {
421
+ if (spec.required) throw new UsageError(`${command.name} needs ${optionLabel(name, spec)}`);
422
+ continue;
423
+ }
424
+ for (const text of Array.isArray(value) ? value : [value]) {
425
+ if (typeof text !== "string") continue;
426
+ if (text === "") throw new UsageError(`--${name} needs a value`);
427
+ if (spec.kind === "address" && !isAddressText(text)) throw new UsageError(`--${name} must be ${ADDRESS_FORM}, not ${JSON.stringify(text)}`);
428
+ if (spec.kind === "range" && rangeParts(text) === undefined) throw new UsageError(`--${name} must be <start>-<end>, each ${ADDRESS_FORM}, for example '$c000-$cfff', not ${JSON.stringify(text)}`);
429
+ if (spec.kind === "space" && !SPACE_PATTERN.test(text)) throw new UsageError(`--${name} must be c64, drive8, or bank0 to bank255, not ${JSON.stringify(text)}`);
430
+ if (spec.kind === "choice" && !(spec.choices ?? []).includes(text)) throw new UsageError(`--${name} must be one of ${(spec.choices ?? []).join(", ")}, not ${JSON.stringify(text)}`);
431
+ if (spec.kind === "path") checkPath(text, `--${name}`);
432
+ }
433
+ }
434
+ const call = new Call(script.name, command.name, subjects, options, values, script.names);
435
+ // Counts and numbers have no names: check them now, so that a wrong one is refused before the command starts.
436
+ for (const [name, spec] of Object.entries(options)) if ((spec.kind === "count" || spec.kind === "number") && values[name] !== undefined) call.counts(name);
437
+ return call;
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Running
442
+
443
+ /** The exit status of a result: 1 for FAIL, 3 for INCONCLUSIVE, else 0. */
444
+ function exitStatusOf(result: unknown): number {
445
+ const outcome = (result as { result?: unknown } | null | undefined)?.result;
446
+ return outcome === "FAIL" ? 1 : outcome === "INCONCLUSIVE" ? 3 : 0;
447
+ }
448
+
449
+ /** What the envelope needs of a script: its name for messages, and the usage for a wrong command line. */
450
+ interface Envelope {
451
+ name: string;
452
+ /** The usage to show for a wrong command line, by the command that was found (or undefined). */
453
+ usage: (command: string | undefined) => string;
454
+ /** Runs with the command line without --project. Calls found() with the command once it is known. Returns the value to print; { text } for help. */
455
+ perform: (argv: string[], found: (command: string) => void) => unknown;
57
456
  }
58
457
 
59
458
  /**
60
- * Runs a script body with the command line of the process, without --project: that sets the project first.
61
- * Prints its result as one JSON line when it returns a value (undefined prints nothing).
62
- * A refusal (WireFailure and ProjectError always, plus `refusals`) prints {"error":{code,message}} with exit status 1.
63
- * A UsageError or a node:util parseArgs error (code ERR_PARSE_ARGS_*) prints {"error":{"code":"invalid-input",
64
- * "message": message === "" ? usage : message + "\n\n" + usage}} with exit status 2. Anything else is rethrown.
65
- * The body may set process.exitCode itself for an extra exit status; a value it returns is printed as usual.
459
+ * The envelope of every script. It takes --project and runs the script. It
460
+ * prints the result as one JSON line. Every failure, also one outside the
461
+ * command (an exception or a rejection that nothing caught), prints one
462
+ * {"error":{code,message}} line with exit status 2. A refusal keeps its code; a
463
+ * wrong command line is invalid-input with the usage; any other failure is
464
+ * operation-failed, and its stack goes to the trace only.
66
465
  */
67
- export async function runScript(body: (argv: string[]) => unknown, options: { usage: string; refusals?: readonly RefusalClass[] }): Promise<void> {
68
- const usage = `${options.usage}\n\n${PROJECT_USAGE}`;
69
- // The trace of the script, when C64RT_TRACE is set; its start event carries the script and its arguments.
466
+ async function envelope(script: Envelope): Promise<void> {
70
467
  startTrace("script", { warn: (line) => process.stderr.write(`${line}\n`) });
71
468
  const started = performance.now();
72
- const end = (fields: TraceFields) => trace().event("script.end", { ms: elapsedMs(started), status: process.exitCode ?? 0, ...fields });
73
- let result: unknown;
469
+ let printed = false;
470
+ let commandName: string | undefined;
471
+
472
+ const print = (value: unknown, status: number) => {
473
+ if (printed) return;
474
+ printed = true;
475
+ process.stdout.write(`${JSON.stringify(value)}\n`);
476
+ process.exitCode = status;
477
+ };
478
+
479
+ const fail = (error: unknown) => {
480
+ let code: ErrorCode;
481
+ let message: string;
482
+ if (error instanceof UsageError) {
483
+ code = "invalid-input";
484
+ message = `${error.message}\n\n${script.usage(commandName)}`;
485
+ } else if (isRefusal(error)) {
486
+ code = error.code;
487
+ message = shownMessage(error);
488
+ } else {
489
+ code = "operation-failed";
490
+ const where = commandName === undefined ? `${script.name}.ts` : `${script.name}.ts ${commandName}`;
491
+ message = `${where} failed inside c64-re-tools: ${error instanceof Error ? error.message : String(error)}. This is a fault of the toolkit, not of the call. Set C64RT_TRACE to get its details in the trace.`;
492
+ }
493
+ trace().event("script.end", { ms: elapsedMs(started), status: 2, error: { code, message }, ...(isRefusal(error) ? {} : { stack: error instanceof Error ? error.stack : String(error) }) } as TraceFields);
494
+ print({ error: { code, message } }, 2);
495
+ };
496
+
497
+ // A failure that nothing caught, for example in a callback of a child process: one error line, then the end.
498
+ // The write finishes before the exit: a pipe on Windows writes later.
499
+ const fatal = (error: unknown) => {
500
+ fail(error);
501
+ process.stdout.write("", () => process.exit(2));
502
+ };
503
+ process.on("uncaughtException", fatal);
504
+ process.on("unhandledRejection", fatal);
505
+
74
506
  try {
75
507
  const { project, argv } = takeProject(process.argv.slice(2));
76
508
  if (project !== undefined) useProject(project);
77
- result = await body(argv);
509
+ const result = await script.perform(argv, (command) => {
510
+ commandName = command;
511
+ });
512
+ const status = exitStatusOf(result);
513
+ print(result ?? {}, status);
514
+ trace().event("script.end", { ms: elapsedMs(started), status, result });
78
515
  } catch (error) {
79
- if ([WireFailure, ProjectError, ...(options.refusals ?? [])].some((refusal) => error instanceof refusal)) {
80
- const { code } = error as Error & { code: string };
81
- const message = shownMessage(error as Error & { code: string });
82
- printLine({ error: { code, message } });
83
- process.exitCode = 1;
84
- end({ error: { code, message } });
85
- return;
86
- }
87
- if (error instanceof UsageError || isParseArgsError(error)) {
88
- const { message } = error;
89
- printLine({ error: { code: "invalid-input", message: message === "" ? usage : `${message}\n\n${usage}` } });
90
- process.exitCode = 2;
91
- end({ error: { code: "invalid-input", message } });
92
- return;
93
- }
94
- end({ status: 1, error });
95
- throw error;
516
+ fail(error);
96
517
  }
97
- if (result !== undefined) printLine(result);
98
- end({ result });
518
+ }
519
+
520
+ /** Runs a script of the command contract: the command word, then its subjects and options. */
521
+ export async function runScript(script: ScriptSpec): Promise<void> {
522
+ await envelope({
523
+ name: script.name,
524
+ usage: (name) => {
525
+ const command = script.commands.find((item) => item.name === name);
526
+ return command === undefined ? scriptUsage(script) : commandUsage(script, command);
527
+ },
528
+ perform: (argv, found) => {
529
+ const [first, ...rest] = argv;
530
+ if (first === undefined || first.startsWith("-")) {
531
+ if (asksHelp(argv)) return { text: scriptUsage(script) };
532
+ throw new UsageError(`give a command: ${script.commands.map((command) => command.name).join(", ")}`);
533
+ }
534
+ const command = script.commands.find((item) => item.name === first);
535
+ if (command === undefined) throw new UsageError(`${first} is not a command of ${script.name}.ts. The commands are ${script.commands.map((item) => item.name).join(", ")}`);
536
+ found(command.name);
537
+ if (asksHelp(rest)) return { text: commandUsage(script, command) };
538
+ return command.run(parseCall(script, command, rest));
539
+ },
540
+ });
99
541
  }