@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.
- package/README.md +8 -4
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +24 -9
- package/skills/c64-assembler/scripts/assembler.ts +95 -0
- package/skills/c64-basic/SKILL.md +17 -9
- package/skills/c64-basic/scripts/basic.ts +26 -36
- package/skills/c64-disk/SKILL.md +17 -7
- package/skills/c64-disk/scripts/disk.ts +130 -104
- package/skills/c64-disk/scripts/lib/containers.ts +10 -12
- package/skills/c64-docs/SKILL.md +47 -28
- package/skills/c64-docs/references/style.md +6 -6
- package/skills/c64-docs/scripts/docs.ts +323 -0
- package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
- package/skills/c64-emulator/SKILL.md +11 -7
- package/skills/c64-knowledge/SKILL.md +34 -21
- package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
- package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
- package/skills/c64-memory-map/SKILL.md +18 -10
- package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
- package/skills/c64-provenance/SKILL.md +82 -26
- package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
- package/skills/c64-provenance/scripts/lib/leftovers.ts +442 -0
- package/skills/c64-provenance/scripts/lib/library.ts +15 -25
- package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
- package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
- package/skills/c64-provenance/scripts/provenance.ts +222 -252
- package/skills/c64-reverse-engineering/SKILL.md +18 -15
- package/skills/c64-source/SKILL.md +28 -16
- package/skills/c64-source/scripts/lib/loader.ts +52 -77
- package/skills/c64-source/scripts/lib/source.ts +33 -42
- package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
- package/skills/c64-static-analysis/SKILL.md +24 -13
- package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
- package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
- package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
- package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
- package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
- package/skills/c64-testing/SKILL.md +94 -19
- package/skills/c64-testing/references/recording-format.md +125 -0
- package/skills/c64-testing/references/scenario-format.md +24 -19
- package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
- package/skills/c64-testing/scripts/lib/diff.ts +5 -3
- package/skills/c64-testing/scripts/lib/play.ts +334 -0
- package/skills/c64-testing/scripts/lib/recording.ts +662 -0
- package/skills/c64-testing/scripts/lib/replay.ts +105 -0
- package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
- package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
- package/skills/c64-testing/scripts/testing.ts +189 -0
- package/skills/c64-unpacker/SKILL.md +42 -25
- package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
- package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
- package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
- package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
- package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
- package/src/c64.ts +35 -20
- package/src/cli/main.ts +2 -2
- package/src/errors.ts +38 -0
- package/src/graphics.ts +3 -1
- package/src/host/server.ts +3 -2
- package/src/host/vice/session.ts +4 -4
- package/src/host-client/entry-state.ts +106 -0
- package/src/host-client/image.ts +21 -10
- package/src/host-client/steps.ts +285 -108
- package/src/host-client/transfer.ts +21 -15
- package/src/host-client/vice-session.ts +1 -1
- package/src/knowledge/database.ts +3 -23
- package/src/knowledge/expression.ts +8 -11
- package/src/knowledge/history.ts +2 -2
- package/src/knowledge/import.ts +13 -14
- package/src/knowledge/names.ts +50 -0
- package/src/knowledge/program.ts +11 -10
- package/src/knowledge/read.ts +16 -21
- package/src/knowledge/schema.ts +9 -3
- package/src/knowledge/text.ts +5 -5
- package/src/knowledge/write.ts +93 -137
- package/src/mcp/inputs.ts +105 -0
- package/src/mcp/output.ts +2 -13
- package/src/mcp/server.ts +12 -60
- package/src/mcp/tools/debug.ts +17 -12
- package/src/mcp/tools/execution.ts +5 -32
- package/src/mcp/tools/input.ts +7 -32
- package/src/mcp/tools/media.ts +6 -5
- package/src/mcp/tools/memory.ts +11 -38
- package/src/mcp/tools/video.ts +6 -5
- package/src/native/c64run.ts +162 -0
- package/src/native/discover.ts +8 -0
- package/src/native/dxa.ts +7 -6
- package/src/native/ghidra/analyze.ts +2 -1
- package/src/native/ghidra/index.ts +2 -1
- package/src/native/processes.ts +4 -3
- package/src/native/status.ts +25 -4
- package/src/native/types.ts +3 -7
- package/src/opcodes.ts +13 -13
- package/src/platform.ts +9 -9
- package/src/project.ts +16 -26
- package/src/protocol/messages.ts +3 -21
- package/src/protocol/vice.ts +7 -7
- package/src/script.ts +491 -49
- package/skills/c64-assembler/scripts/assemble.ts +0 -138
- package/skills/c64-docs/scripts/generate.ts +0 -109
- package/skills/c64-docs/scripts/link.ts +0 -239
- package/skills/c64-memory-map/scripts/memmap.ts +0 -92
- package/skills/c64-testing/scripts/checklist.ts +0 -51
- package/skills/c64-testing/scripts/test.ts +0 -65
package/src/protocol/vice.ts
CHANGED
|
@@ -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" | "
|
|
553
|
-
result: { saved: boolean; name: string } | { restored: boolean; state: RunState } | { snapshots: string[] } | {
|
|
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: {
|
|
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 !== "
|
|
637
|
-
invalid("action must be save, restore, list or
|
|
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.
|
|
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.
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
6
|
-
|
|
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
|
|
10
|
-
export class UsageError extends
|
|
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
|
-
/**
|
|
13
|
-
export
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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.
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
let
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
}
|