@henols/c64-re-tools 2.1.1 → 2.1.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.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +6 -3
  3. package/skills/c64-assembler/scripts/assembler.ts +19 -11
  4. package/skills/c64-assembler/scripts/lib/compare.ts +4 -3
  5. package/skills/c64-basic/SKILL.md +1 -0
  6. package/skills/c64-disk/SKILL.md +1 -0
  7. package/skills/c64-disk/scripts/disk.ts +2 -2
  8. package/skills/c64-disk/scripts/lib/containers.ts +4 -4
  9. package/skills/c64-docs/SKILL.md +2 -1
  10. package/skills/c64-docs/scripts/docs.ts +1 -1
  11. package/skills/c64-docs/scripts/lib/links.ts +2 -0
  12. package/skills/c64-emulator/SKILL.md +4 -1
  13. package/skills/c64-knowledge/SKILL.md +12 -6
  14. package/skills/c64-knowledge/scripts/knowledge.ts +41 -25
  15. package/skills/c64-knowledge/scripts/lib/listing.ts +16 -31
  16. package/skills/c64-memory-map/SKILL.md +3 -0
  17. package/skills/c64-memory-map/scripts/memory-map.ts +23 -4
  18. package/skills/c64-provenance/SKILL.md +5 -3
  19. package/skills/c64-provenance/scripts/lib/library.ts +28 -5
  20. package/skills/c64-provenance/scripts/lib/separate.ts +34 -6
  21. package/skills/c64-provenance/scripts/provenance.ts +22 -10
  22. package/skills/c64-source/SKILL.md +2 -1
  23. package/skills/c64-source/scripts/lib/source.ts +25 -33
  24. package/skills/c64-source/scripts/source.ts +13 -17
  25. package/skills/c64-static-analysis/SKILL.md +6 -3
  26. package/skills/c64-static-analysis/scripts/lib/findings.ts +23 -8
  27. package/skills/c64-static-analysis/scripts/lib/pointers.ts +38 -25
  28. package/skills/c64-static-analysis/scripts/static-analysis.ts +31 -19
  29. package/skills/c64-testing/SKILL.md +9 -5
  30. package/skills/c64-testing/references/scenario-format.md +5 -3
  31. package/skills/c64-testing/scripts/lib/diff-files.ts +41 -10
  32. package/skills/c64-testing/scripts/lib/recording.ts +3 -3
  33. package/skills/c64-testing/scripts/lib/replay.ts +1 -1
  34. package/skills/c64-testing/scripts/lib/runs.ts +45 -9
  35. package/skills/c64-testing/scripts/lib/scenario.ts +130 -81
  36. package/skills/c64-testing/scripts/testing.ts +9 -6
  37. package/skills/c64-unpacker/SKILL.md +10 -7
  38. package/skills/c64-unpacker/scripts/lib/loads.ts +15 -6
  39. package/skills/c64-unpacker/scripts/lib/machine.ts +61 -3
  40. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  41. package/skills/c64-unpacker/scripts/unpacker.ts +21 -17
  42. package/src/host/vice/adapter.ts +4 -1
  43. package/src/host/vice/session.ts +144 -11
  44. package/src/host-client/image.ts +27 -13
  45. package/src/host-client/steps.ts +41 -5
  46. package/src/host-client/tools.ts +4 -3
  47. package/src/host-client/transfer.ts +22 -29
  48. package/src/knowledge/history.ts +55 -2
  49. package/src/knowledge/import.ts +79 -7
  50. package/src/knowledge/program.ts +31 -2
  51. package/src/knowledge/read.ts +7 -4
  52. package/src/knowledge/schema.ts +3 -2
  53. package/src/knowledge/text.ts +1 -0
  54. package/src/knowledge/write.ts +86 -42
  55. package/src/mcp/inputs.ts +6 -3
  56. package/src/mcp/output.ts +1 -1
  57. package/src/mcp/server.ts +9 -1
  58. package/src/mcp/tools/debug.ts +31 -4
  59. package/src/mcp/tools/machine.ts +3 -1
  60. package/src/mcp/tools/media.ts +5 -1
  61. package/src/mcp/tools/memory.ts +4 -3
  62. package/src/media.ts +57 -0
  63. package/src/opcodes.ts +81 -18
  64. package/src/platform.ts +14 -1
  65. package/src/project.ts +118 -3
  66. package/src/protocol/vice.ts +4 -1
  67. package/src/script.ts +118 -5
package/src/media.ts ADDED
@@ -0,0 +1,57 @@
1
+ // Media file signatures and sizes. The host checks a file with them before
2
+ // VICE gets it: VICE takes a file that is not of its type in another way, and
3
+ // a CRT or T64 that it cannot read loads as a PRG with a successful autostart.
4
+
5
+ import { Refusal } from "./errors.ts";
6
+
7
+ /** The signature at the start of every VICE snapshot file (.vsf). */
8
+ export const VSF_SIGNATURE = "VICE Snapshot File\x1a";
9
+
10
+ /** The signature at the start of each media type that has one. */
11
+ const SIGNATURES: Readonly<Record<string, readonly string[]>> = {
12
+ crt: ["C64 CARTRIDGE "],
13
+ t64: ["C64"],
14
+ tap: ["C64-TAPE-RAW"],
15
+ g64: ["GCR-1541"],
16
+ p00: ["C64File"],
17
+ vsf: [VSF_SIGNATURE],
18
+ };
19
+
20
+ /** True when `bytes` start with a signature of `type`, or `type` has none. */
21
+ export function hasSignature(type: string, bytes: Uint8Array): boolean {
22
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
23
+ const signatures = SIGNATURES[type];
24
+ return signatures === undefined || signatures.some((signature) => buffer.toString("latin1", 0, signature.length) === signature);
25
+ }
26
+
27
+ /** The file sizes that VICE reads for each disk image type without a signature. */
28
+ const SIZES: Readonly<Record<string, readonly number[]>> = {
29
+ d64: [174848, 175531, 196608, 197376, 205312, 206114],
30
+ d71: [349696, 351062],
31
+ d81: [819200, 822400],
32
+ };
33
+
34
+ /** The x64 header: 64 bytes that start with these four, then a d64 image. */
35
+ const X64_MAGIC = Buffer.from([0x43, 0x15, 0x41, 0x64]);
36
+
37
+ /** The refusal of a file that is no VICE snapshot file (.vsf): its signature is missing. `path` names the file. */
38
+ export function checkSnapshotFile(path: string, bytes: Uint8Array): void {
39
+ if (!hasSignature("vsf", bytes)) throw new Refusal("invalid-input", `${path} is not a VICE snapshot file (.vsf): it does not start with "VICE Snapshot File". Give a file that c64_snapshot save or a save step of a run wrote.`);
40
+ }
41
+
42
+ /** Refuses a file whose bytes are not of `type` (its extension), with media-error. */
43
+ export function checkMedia(type: string, bytes: Buffer): void {
44
+ const refuse = (why: string) => {
45
+ throw new Refusal("media-error", `The file is not a valid .${type} file: ${why}. Give a file of that type.`);
46
+ };
47
+ if (type === "crt" && hasSignature(type, bytes)) {
48
+ // The header gives its own length; a CHIP packet with the ROM data follows it.
49
+ const header = bytes.length >= 0x40 ? bytes.readUInt32BE(0x10) : 0;
50
+ if (bytes.length < 0x40) refuse("the header is shorter than 64 bytes");
51
+ if (bytes.toString("latin1", header, header + 4) !== "CHIP") refuse("it has no CHIP packet with ROM data after the header");
52
+ }
53
+ if (!hasSignature(type, bytes)) refuse(`it does not start with ${SIGNATURES[type]!.map((signature) => `"${signature.trimEnd()}"`).join(" or ")}`);
54
+ const sizes = SIZES[type];
55
+ if (sizes !== undefined && !sizes.includes(bytes.length)) refuse(`it has ${bytes.length} bytes, and a .${type} image has ${sizes.join(", ")}`);
56
+ if (type === "x64" && (!bytes.subarray(0, 4).equals(X64_MAGIC) || !SIZES.d64!.includes(bytes.length - 64))) refuse("it has no x64 header before a d64 image");
57
+ }
package/src/opcodes.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // spelling, ACME writes only one of them: the generator finds those with a
5
5
  // probe and writes the others as bytes.
6
6
 
7
- import { formatC64Byte, formatC64Address } from "./c64.ts";
7
+ import { formatC64Byte, formatC64Address, SYMBOL_NAME } from "./c64.ts";
8
8
 
9
9
  export type Mode = "imp" | "imm" | "zp" | "zpx" | "zpy" | "abs" | "abx" | "aby" | "ind" | "izx" | "izy" | "rel";
10
10
 
@@ -45,6 +45,17 @@ export const MODE_LENGTH: Record<Mode, 1 | 2 | 3> = { imp: 1, imm: 2, zp: 2, zpx
45
45
  /** Every mnemonic in the table: a symbol with one of these names would read as an instruction. */
46
46
  export const MNEMONICS: ReadonlySet<string> = new Set(OPCODES.map((opcode) => opcode.mnemonic));
47
47
 
48
+ /**
49
+ * Why ACME cannot take a name as a label, or undefined when it can. A label
50
+ * has label syntax (SYMBOL_NAME) and is no mnemonic in upper or lower case.
51
+ * The one rule for a symbol of the knowledge and a label of c64-source.
52
+ */
53
+ export function labelProblem(name: string): string | undefined {
54
+ if (!SYMBOL_NAME.test(name)) return "A symbol name is a letter or underscore, then letters, digits or underscores (at most 64), with an optional leading dot.";
55
+ if (MNEMONICS.has(name.toLowerCase())) return `${name} is the name of a 6502 instruction, which ACME cannot read as a label. Give another name.`;
56
+ return undefined;
57
+ }
58
+
48
59
  const ZERO_PAGE_OF: Partial<Record<Mode, Mode>> = { abs: "zp", abx: "zpx", aby: "zpy" };
49
60
 
50
61
  /** True when the mnemonic also has the zero-page form of an absolute mode: ACME would pick it for a small operand. */
@@ -65,6 +76,34 @@ export type UseKind = (typeof USE_KINDS)[number];
65
76
  const WRITES = new Set(["sta", "stx", "sty", "sax", "sha", "shx", "shy", "tas"]);
66
77
  const MODIFIES = new Set(["inc", "dec", "asl", "lsr", "rol", "ror", "slo", "rla", "sre", "rra", "dcp", "isc"]);
67
78
 
79
+ /**
80
+ * The byte that an instruction wrote to or read from its operand address:
81
+ * a store gives its register, a load the register after it. For another
82
+ * access the byte at the address now (`memoryNow`) is the byte, when the
83
+ * caller can read it back. Undefined when it is not known.
84
+ */
85
+ export function accessedValue(opcode: number, before: { a: number; x: number; y: number }, after: { a: number; x: number; y: number }, memoryNow: number | undefined): number | undefined {
86
+ switch (OPCODES[opcode]!.mnemonic) {
87
+ case "sta":
88
+ return before.a;
89
+ case "stx":
90
+ return before.x;
91
+ case "sty":
92
+ return before.y;
93
+ case "sax":
94
+ return before.a & before.x;
95
+ case "lda":
96
+ case "lax":
97
+ return after.a;
98
+ case "ldx":
99
+ return after.x;
100
+ case "ldy":
101
+ return after.y;
102
+ default:
103
+ return memoryNow;
104
+ }
105
+ }
106
+
68
107
  /** The use of the operand address of an instruction that takes an address. Through (zp),y and (zp,x) the address is a pointer. */
69
108
  export function useKind(opcode: number): UseKind {
70
109
  const { mnemonic, mode } = OPCODES[opcode]!;
@@ -78,26 +117,50 @@ export function useKind(opcode: number): UseKind {
78
117
  }
79
118
 
80
119
 
81
- /** The text of the instruction in `bytes` at `pc`, for example "sta $d011" or "bne $c002". */
82
- export function instructionText(bytes: readonly number[], pc: number): string {
83
- const { mnemonic, mode } = OPCODES[bytes[0]!]!;
84
- const low = bytes[1] ?? 0;
85
- const word = low | ((bytes[2] ?? 0) << 8);
120
+ /**
121
+ * The ACME text of an instruction with its operand as an expression: the
122
+ * mnemonic, the size suffix (+1 or +2, or none) and the operand in the syntax
123
+ * of its addressing mode, for example "lda #<table", "sta screen,x" or
124
+ * "lda (pointer),y". ACME reads an operand in brackets as indirect, so a
125
+ * non-immediate expression that starts with a bracket gets 0+ in front.
126
+ * The one place that writes an instruction: the generated source, the
127
+ * listing of the knowledge and the disassembly of the emulator use it.
128
+ */
129
+ export function instructionWith(opcode: number, expression: string, suffix = ""): string {
130
+ const { mnemonic, mode } = OPCODES[opcode]!;
131
+ const value = mode !== "imm" && expression.startsWith("(") ? `0+${expression}` : expression;
86
132
  const operand: Record<Mode, string> = {
87
133
  imp: "",
88
- imm: `#${formatC64Byte(low)}`,
89
- zp: formatC64Byte(low),
90
- zpx: `${formatC64Byte(low)},x`,
91
- zpy: `${formatC64Byte(low)},y`,
92
- abs: formatC64Address(word),
93
- abx: `${formatC64Address(word)},x`,
94
- aby: `${formatC64Address(word)},y`,
95
- ind: `(${formatC64Address(word)})`,
96
- izx: `(${formatC64Byte(low)},x)`,
97
- izy: `(${formatC64Byte(low)}),y`,
98
- rel: formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff),
134
+ imm: `#${value}`,
135
+ zp: value,
136
+ zpx: `${value},x`,
137
+ zpy: `${value},y`,
138
+ abs: value,
139
+ abx: `${value},x`,
140
+ aby: `${value},y`,
141
+ ind: `(${value})`,
142
+ izx: `(${value},x)`,
143
+ izy: `(${value}),y`,
144
+ rel: value,
99
145
  };
100
- return operand[mode] === "" ? mnemonic : `${mnemonic} ${operand[mode]}`;
146
+ return operand[mode] === "" ? `${mnemonic}${suffix}` : `${mnemonic}${suffix} ${operand[mode]}`;
147
+ }
148
+
149
+ /** The value of the operand of the instruction in `bytes` at `pc` as a number: a byte, a word, or the target of a branch. Empty for no operand. */
150
+ export function operandNumber(bytes: readonly number[], pc: number): string {
151
+ const { mode } = OPCODES[bytes[0]!]!;
152
+ const low = bytes[1] ?? 0;
153
+ if (mode === "imp") return "";
154
+ if (mode === "rel") return formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff);
155
+ return MODE_LENGTH[mode] === 3 ? formatC64Address(low | ((bytes[2] ?? 0) << 8)) : formatC64Byte(low);
156
+ }
157
+
158
+ /** The text of the instruction in `bytes` at `pc` as the monitor shows it, for example "STA $d011" or "BNE $c002". */
159
+ export function instructionText(bytes: readonly number[], pc: number): string {
160
+ const { mnemonic } = OPCODES[bytes[0]!]!;
161
+ const operand = instructionWith(bytes[0]!, operandNumber(bytes, pc)).slice(mnemonic.length);
162
+ // The monitor writes the index register in capitals: LDA ($c1),Y.
163
+ return `${mnemonic.toUpperCase()}${operand.replace(/,([xy])\b/g, (_, index: string) => `,${index.toUpperCase()}`)}`;
101
164
  }
102
165
 
103
166
  /**
package/src/platform.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Names follow "Mapping the Commodore 64". Only platform facts live here:
4
4
  // an address without one belongs to the application.
5
5
 
6
- import { d018Offsets, formatC64Address, vicBankBase } from "./c64.ts";
6
+ import { COVERAGE, d018Offsets, formatC64Address, vicBankBase } from "./c64.ts";
7
7
  import { Refusal } from "./errors.ts";
8
8
 
9
9
  export interface Entry {
@@ -420,6 +420,19 @@ export function romUseOfPort(port: number): RomUse {
420
420
  return { basic: (port & 0x03) === 0x03, kernal: (port & 0x02) !== 0 };
421
421
  }
422
422
 
423
+ /**
424
+ * The ROMs that a program uses, and what tells it: the coverage when the
425
+ * knowledge has it, else $01 of the entry state, else both ROMs. c64-source and
426
+ * c64-static-analysis give the platform names of platformNamesFor of it, so
427
+ * they use the same names.
428
+ */
429
+ export function romUseOfProgram(coverage: Uint8Array | undefined, state: Record<string, unknown> | undefined): RomUse & { from: "coverage" | "entry state" | "none" } {
430
+ if (coverage !== undefined) return { ...romUseOf(coverage, COVERAGE.romExecute), from: "coverage" };
431
+ const port = (state?.cpuPort as Record<string, unknown> | undefined)?.$01;
432
+ if (typeof port === "number") return { ...romUseOfPort(port), from: "entry state" };
433
+ return { basic: true, kernal: true, from: "none" };
434
+ }
435
+
423
436
  export type Decoded = Record<string, unknown>;
424
437
 
425
438
  const bits = (value: number) => [0, 1, 2, 3, 4, 5, 6, 7].filter((bit) => (value & (1 << bit)) !== 0);
package/src/project.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // the agent saves for itself and ignores itself in git.
6
6
 
7
7
  import { Refusal } from "./errors.ts";
8
- import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
11
 
@@ -37,8 +37,13 @@ let chosen: string | undefined;
37
37
  /** The project that the project lookup found from a working directory, kept for the next call from the same directory. */
38
38
  let found: { cwd: string; root: string } | undefined;
39
39
 
40
+ /** True when `folder` holds .c64-re-tools/. A folder that cannot be read (no permission) is not a project. */
40
41
  function isProject(folder: string): boolean {
41
- return statSync(join(folder, PROJECT_DIRECTORY), { throwIfNoEntry: false })?.isDirectory() === true;
42
+ try {
43
+ return statSync(join(folder, PROJECT_DIRECTORY), { throwIfNoEntry: false })?.isDirectory() === true;
44
+ } catch {
45
+ return false;
46
+ }
42
47
  }
43
48
 
44
49
  /** `start` and each folder above it, nearest first, below the home folder when `start` is under it. */
@@ -117,7 +122,8 @@ export function namedProject(folder: string, cwd: string = process.cwd()): strin
117
122
  if (stat === undefined) throw new Refusal("not-found", `There is no folder ${folder}.`);
118
123
  if (!stat.isDirectory()) throw new Refusal("invalid-input", `${folder} is not a folder.`);
119
124
  if (path === homedir()) throw new Refusal("invalid-input", "The home folder is not a project folder.");
120
- const outer = foldersUpFrom(path).slice(1).find(isProject);
125
+ // A project inside another project is a project of its own, as the lookup from a working directory in it finds.
126
+ const outer = isProject(path) ? undefined : foldersUpFrom(path).slice(1).find(isProject);
121
127
  if (outer !== undefined) {
122
128
  throw new Refusal("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || outer}. Give that project folder.`);
123
129
  }
@@ -180,6 +186,115 @@ export function resolveProjectPath(path: string, root = projectRoot()): string {
180
186
  throw new Refusal("invalid-input", `${path} leads out of the project folder: give a path that stays inside it.`);
181
187
  }
182
188
 
189
+ /** The folder of the committed knowledge text in PROJECT_DIRECTORY. The knowledge (src/knowledge/database.ts) has the same name. */
190
+ export const KNOWLEDGE_TEXT_DIRECTORY = "knowledge";
191
+
192
+ /** The index of the knowledge in LOCAL_DIRECTORY, and the files that SQLite keeps beside it. The knowledge has the same name. */
193
+ const KNOWLEDGE_INDEX = /^knowledge\.db(?:-wal|-shm|-journal)?$/;
194
+
195
+ /**
196
+ * The part of the knowledge that a path relative to the project folder is in:
197
+ * the text in .c64-re-tools/knowledge/ or the index in .c64-re-tools/local/.
198
+ * Undefined for every other path. Letter case does not count: some file
199
+ * systems do not tell the cases apart.
200
+ */
201
+ export function knowledgePart(fromRoot: string): string | undefined {
202
+ const parts = fromRoot.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").map((part) => part.toLowerCase());
203
+ if (parts[0] !== PROJECT_DIRECTORY) return undefined;
204
+ if (parts[1] === KNOWLEDGE_TEXT_DIRECTORY) return `${PROJECT_DIRECTORY}/${KNOWLEDGE_TEXT_DIRECTORY}/`;
205
+ if (parts[1] === LOCAL_DIRECTORY && parts.length === 3 && KNOWLEDGE_INDEX.test(parts[2]!)) return `${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/${parts[2]}`;
206
+ return undefined;
207
+ }
208
+
209
+ /** What a command writes at an output path: one file, or files in a folder. */
210
+ export type OutputKind = "file" | "folder";
211
+
212
+ /** Why a file cannot be written, by the error code of the file system; the codes that are a fault of the path. */
213
+ const WRITE_PROBLEMS: Record<string, string> = {
214
+ EISDIR: "is a folder. Give the path of a file",
215
+ EEXIST: "has a file where a folder must be. Give a path whose folders are folders",
216
+ ENOTDIR: "has a file where a folder must be. Give a path whose folders are folders",
217
+ ENOENT: "is in a folder that does not exist",
218
+ EACCES: "cannot be written: there is no permission. Give a path that you can write",
219
+ EPERM: "cannot be written: there is no permission. Give a path that you can write",
220
+ EROFS: "is on a file system that is read-only. Give another path",
221
+ ENAMETOOLONG: "is too long. Give a shorter path",
222
+ };
223
+
224
+ /**
225
+ * The message for a write that the file system refused because of the path
226
+ * (a folder, a file in place of a folder, no permission): the path and what
227
+ * to do. Undefined for another error, which is no fault of the path.
228
+ */
229
+ export function writeProblem(error: unknown, path: string, what?: string): string | undefined {
230
+ const problem = WRITE_PROBLEMS[(error as NodeJS.ErrnoException | undefined)?.code ?? ""];
231
+ return problem === undefined ? undefined : `${what === undefined ? "" : `${what}: `}${path} ${problem}.`;
232
+ }
233
+
234
+ /**
235
+ * Checks a path that a command writes, before the command does any work. The
236
+ * path must stay inside the project, also through symbolic links, and must
237
+ * not be in the knowledge (only the c64-knowledge skill writes it). A file
238
+ * must not be a folder or a symbolic link; a folder must be a folder. The
239
+ * nearest folder that exists must be a folder that can be written. Returns
240
+ * the absolute path. Throws a Refusal (invalid-input) whose message starts
241
+ * with `what` (for example --out). writeProjectFile does the same check for
242
+ * each file that it writes.
243
+ */
244
+ export function checkOutputPath(path: string, kind: OutputKind, options: { root?: string; what?: string } = {}): string {
245
+ const root = options.root ?? projectRoot();
246
+ const what = options.what;
247
+ const outside = () => new Refusal("invalid-input", `${what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
248
+ const problem = (error: unknown) => {
249
+ const message = writeProblem(error, path, what);
250
+ return message === undefined ? error : new Refusal("invalid-input", message);
251
+ };
252
+ const inKnowledge = (fromRoot: string) => {
253
+ const part = knowledgePart(fromRoot);
254
+ if (part !== undefined) {
255
+ throw new Refusal("invalid-input", `${what === undefined ? "" : `${what}: `}${path} is in ${part}, the knowledge of the project. Only the c64-knowledge skill writes it: a write there destroys the knowledge. Give a path outside ${PROJECT_DIRECTORY}/${KNOWLEDGE_TEXT_DIRECTORY}/, for example ${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/<file>.`);
256
+ }
257
+ };
258
+ let target: string;
259
+ try {
260
+ target = resolveProjectPath(path, root);
261
+ } catch {
262
+ throw outside();
263
+ }
264
+ inKnowledge(relative(root, target));
265
+ // The nearest entry that exists: the path itself, or a folder above it.
266
+ let existing = target;
267
+ for (;;) {
268
+ let found;
269
+ try {
270
+ found = lstatSync(existing, { throwIfNoEntry: false });
271
+ } catch (error) {
272
+ throw problem(error);
273
+ }
274
+ if (found !== undefined) break;
275
+ existing = dirname(existing);
276
+ }
277
+ const realRoot = realpathSync(root);
278
+ if (existing === target && kind === "file" && lstatSync(target).isSymbolicLink()) throw outside();
279
+ const real = realpathSync(existing);
280
+ if (!isInside(realRoot, real)) throw outside();
281
+ inKnowledge(relative(realRoot, join(real, relative(existing, target))));
282
+ const stat = statSync(real);
283
+ if (existing === target && kind === "file") {
284
+ if (stat.isDirectory()) throw problem({ code: "EISDIR" });
285
+ } else if (existing === target && !stat.isDirectory()) {
286
+ throw new Refusal("invalid-input", `${what === undefined ? "" : `${what}: `}${path} is a file, but ${what ?? "the path"} needs a folder. Give the path of a folder, or a new path for the folder.`);
287
+ } else if (!stat.isDirectory()) {
288
+ throw problem({ code: "ENOTDIR" });
289
+ }
290
+ try {
291
+ accessSync(real, constants.W_OK);
292
+ } catch (error) {
293
+ throw problem(error);
294
+ }
295
+ return target;
296
+ }
297
+
183
298
  /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Refuses a path as resolveProjectPath does. */
184
299
  export function projectRelative(path: string, root = projectRoot()): string {
185
300
  return relative(root, resolveProjectPath(path, root)).split(sep).join("/");
@@ -385,6 +385,8 @@ export interface MachineStatus {
385
385
  stopReason?: PointStopReason;
386
386
  /** The id of the breakpoint or watchpoint that stopped the machine. */
387
387
  pointId?: number;
388
+ /** With pc: the CPU whose address pc is, drive8 when a point in the drive stopped the machine. */
389
+ space?: "c64" | "drive8";
388
390
  }
389
391
 
390
392
  /** What stopped the machine outside a request that stops it. */
@@ -1092,7 +1094,8 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1092
1094
  (value.pc === undefined || isInteger(value.pc, 0, 0xffff)) &&
1093
1095
  typeof value.cartridge === "boolean" &&
1094
1096
  (value.stopReason === undefined || isOneOf(POINT_STOP_REASONS, value.stopReason)) &&
1095
- (value.pointId === undefined || isInteger(value.pointId, 1, Number.MAX_SAFE_INTEGER));
1097
+ (value.pointId === undefined || isInteger(value.pointId, 1, Number.MAX_SAFE_INTEGER)) &&
1098
+ (value.space === undefined || isOneOf(SPACES, value.space));
1096
1099
  if (!ok) throw new ProtocolError("status result is malformed");
1097
1100
  break;
1098
1101
  }
package/src/script.ts CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from "node:util";
13
13
 
14
14
  import { c64AddressOf, formatC64Address, SPACE_PATTERN, SYMBOL_NAME, type AddressRange } from "./c64.ts";
15
15
  import { isRefusal, Refusal, type ErrorCode } from "./errors.ts";
16
- import { projectRoot, resolveProjectPath, useProject } from "./project.ts";
16
+ import { checkOutputPath, projectRoot, resolveProjectPath, useProject, type OutputKind } from "./project.ts";
17
17
  import { shownMessage } from "./protocol/messages.ts";
18
18
  import { elapsedMs, startTrace, trace, type TraceFields } from "./trace.ts";
19
19
 
@@ -59,6 +59,12 @@ export interface OptionSpec {
59
59
  max?: number;
60
60
  /** The value when the option is not given. */
61
61
  default?: number | string;
62
+ /**
63
+ * path only: the command writes there, a file or files in a folder. The
64
+ * path is checked before the command runs (checkOutputPath). Every option
65
+ * --out and --out-<name> has it.
66
+ */
67
+ output?: OutputKind;
62
68
  }
63
69
 
64
70
  /** 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. */
@@ -157,7 +163,7 @@ export class Call {
157
163
 
158
164
  /** The knowledge space of the command: --space, else c64. */
159
165
  get space(): string {
160
- return "space" in this.#options ? this.string("space")! : "c64";
166
+ return ("space" in this.#options ? this.string("space") : undefined) ?? "c64";
161
167
  }
162
168
 
163
169
  /** Subject `index` as text. */
@@ -339,6 +345,8 @@ function commandOptions(command: CommandSpec): Record<string, OptionSpec> {
339
345
  const shared = (OPTIONS as Record<string, OptionSpec>)[name];
340
346
  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
347
  if (name === "help" || name === "project") throw fault(`--${name} of ${command.name}: the contract owns this option`);
348
+ if (spec.kind === "path" && (name === "out" || name.startsWith("out-")) && spec.output === undefined) throw fault(`--${name} of ${command.name} has no output kind (file or folder)`);
349
+ if (spec.output !== undefined && spec.kind !== "path") throw fault(`--${name} of ${command.name} has an output kind, but it is a ${spec.kind}, not a path`);
342
350
  }
343
351
  return options;
344
352
  }
@@ -391,9 +399,109 @@ function checkPath(text: string, what: string): void {
391
399
  }
392
400
  }
393
401
 
402
+ /**
403
+ * Refuses a range in two parts: two subjects '$1bc2' '$1bd0' where the command
404
+ * takes one <range>, or --start and --end where it takes a range option. The
405
+ * message gives the range as one token.
406
+ */
407
+ function refuseSplitRange(command: CommandSpec, options: Readonly<Record<string, OptionSpec>>, argv: readonly string[], subjects?: readonly string[]): void {
408
+ if (subjects === undefined) {
409
+ const end = argv.indexOf("--");
410
+ const words = end < 0 ? argv : argv.slice(0, end);
411
+ const valueOf = (name: string) => {
412
+ const index = words.findIndex((word) => word === `--${name}` || word.startsWith(`--${name}=`));
413
+ if (index < 0) return undefined;
414
+ const word = words[index]!;
415
+ return word.includes("=") ? word.slice(word.indexOf("=") + 1) : (words[index + 1] ?? "");
416
+ };
417
+ const [start, last] = [valueOf("start"), valueOf("end")];
418
+ const range = Object.entries(options).find(([, spec]) => spec.kind === "range")?.[0];
419
+ if ((start === undefined && last === undefined) || range === undefined || "start" in options || "end" in options) return;
420
+ throw new UsageError(`${command.name} takes no --start or --end. Give the range as one option: --${range} '${start || "<start>"}-${last || "<end>"}'`);
421
+ }
422
+ const specs = command.subjects ?? [];
423
+ for (let index = 0; index + 1 < subjects.length; index++) {
424
+ const spec = specs[Math.min(index, specs.length - 1)];
425
+ const [first, second] = [subjects[index]!, subjects[index + 1]!];
426
+ if (spec === undefined || !spec.name.includes("range") || rangeParts(first) !== undefined || !isAddressText(first) || c64AddressOf(second) === undefined) continue;
427
+ throw new UsageError(`${spec.name} is one token <start>-<end>: give '${first}-${second}', not '${first}' '${second}'`);
428
+ }
429
+ }
430
+
431
+ /** The options of a command for a message: --a, --b and --c, or a word that it has none. */
432
+ function optionList(options: Readonly<Record<string, OptionSpec>>): string {
433
+ const names = Object.keys(options).map((name) => `--${name}`);
434
+ if (names.length === 0) return "It takes no options";
435
+ return `Its options are ${names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`}`;
436
+ }
437
+
438
+ /** True for a word that the command line reads as an option: - and one or more characters. A lone - is a subject (standard input). */
439
+ const isOptionWord = (word: string) => word.length > 1 && word.startsWith("-");
440
+
441
+ /**
442
+ * Refuses the words of a command line that parseArgs refuses, with a message
443
+ * that names the option and tells what to give: an option that the command
444
+ * does not have, a flag with a value, an option without its value, a value
445
+ * that starts with -, and a range option in two parts.
446
+ */
447
+ function refuseOptionWords(command: CommandSpec, options: Readonly<Record<string, OptionSpec>>, argv: readonly string[]): void {
448
+ const end = argv.indexOf("--");
449
+ const words = end < 0 ? argv : argv.slice(0, end);
450
+ for (let index = 0; index < words.length; index++) {
451
+ const word = words[index]!;
452
+ if (!isOptionWord(word)) continue;
453
+ if (!word.startsWith("--")) {
454
+ throw new UsageError(`${word} is not an option of ${command.name}: an option starts with --. ${optionList(options)}. Give a subject that starts with - after --, for example -- ${word}`);
455
+ }
456
+ const equals = word.indexOf("=");
457
+ const name = equals < 0 ? word.slice(2) : word.slice(2, equals);
458
+ const spec = options[name];
459
+ if (spec === undefined) throw new UsageError(`${command.name} has no option --${name}. ${optionList(options)}`);
460
+ if (spec.kind === "flag") {
461
+ if (equals >= 0) throw new UsageError(`--${name} is a flag: give --${name} without a value`);
462
+ continue;
463
+ }
464
+ let value: string;
465
+ let valueIndex = index;
466
+ if (equals >= 0) {
467
+ value = word.slice(equals + 1);
468
+ } else {
469
+ valueIndex = index + 1;
470
+ const next = words[valueIndex];
471
+ if (next === undefined) throw new UsageError(`--${name} needs a value: give --${name} ${spec.value ?? "<value>"}`);
472
+ if (isOptionWord(next)) {
473
+ if ((spec.kind === "count" || spec.kind === "number") && /^-\d/.test(next)) {
474
+ const range = spec.kind === "count" ? `a whole number from ${spec.min ?? 0} to ${spec.max ?? Number.MAX_SAFE_INTEGER}` : `a number from ${spec.min ?? 0} to ${spec.max ?? 0xffff}`;
475
+ throw new UsageError(`--${name} must be ${range}, not ${JSON.stringify(next)}`);
476
+ }
477
+ throw new UsageError(`--${name} needs a value: give --${name} ${spec.value ?? "<value>"}, not ${next}. Give a value that starts with - as --${name}=${next}`);
478
+ }
479
+ value = next;
480
+ index++;
481
+ }
482
+ // A range option in two parts: --range '$1bc2' '$1c00'.
483
+ const second = words[valueIndex + 1];
484
+ if (spec.kind === "range" && rangeParts(value) === undefined && isAddressText(value) && second !== undefined && c64AddressOf(second) !== undefined) {
485
+ throw new UsageError(`--${name} is one token <start>-<end>: give --${name} '${value}-${second}', not '${value}' '${second}'`);
486
+ }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Refuses an output path before the command does any work: a path that
492
+ * leaves the project, a path in the knowledge, a folder where a file must be,
493
+ * a file where a folder must be, or a folder that cannot be written.
494
+ */
495
+ function checkOutput(text: string, what: string, kind: OutputKind): void {
496
+ checkPath(text, what);
497
+ checkOutputPath(text, kind, { root: projectRoot(), what });
498
+ }
499
+
394
500
  /** 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
501
  export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readonly string[]): Call {
396
502
  const options = commandOptions(command);
503
+ refuseSplitRange(command, options, argv);
504
+ refuseOptionWords(command, options, argv);
397
505
  let parsed: ReturnType<typeof parseArgs>;
398
506
  try {
399
507
  parsed = parseArgs({
@@ -402,11 +510,13 @@ export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readon
402
510
  allowPositionals: true,
403
511
  options: Object.fromEntries(Object.entries(options).map(([name, spec]) => [name, { type: spec.kind === "flag" ? "boolean" : "string", ...(spec.multiple ? { multiple: true } : {}) }])),
404
512
  });
405
- } catch (error) {
406
- throw new UsageError((error as Error).message);
513
+ } catch {
514
+ // refuseOptionWords gives a message of its own for each word that parseArgs refuses; this is the last guard.
515
+ throw new UsageError(`the command line of ${command.name} is not correct. Give each option as --name <value>, each flag as --name, and a subject that starts with - after --`);
407
516
  }
408
517
  const values = parsed.values as Record<string, string | boolean | string[] | undefined>;
409
518
  const subjects = parsed.positionals;
519
+ refuseSplitRange(command, options, argv, subjects);
410
520
  const specs = command.subjects ?? [];
411
521
  const last = specs.at(-1);
412
522
  const least = specs.filter((subject) => !subject.optional).length;
@@ -428,7 +538,10 @@ export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readon
428
538
  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
539
  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
540
  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}`);
541
+ if (spec.kind === "path") {
542
+ if (spec.output === undefined) checkPath(text, `--${name}`);
543
+ else checkOutput(text, `--${name}`, spec.output);
544
+ }
432
545
  }
433
546
  }
434
547
  const call = new Call(script.name, command.name, subjects, options, values, script.names);