@henols/c64-re-tools 2.0.0-rc.21 → 2.0.0-rc.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/c64-re-tools",
3
- "version": "2.0.0-rc.21",
3
+ "version": "2.0.0-rc.22",
4
4
  "description": "Agent-friendly Commodore 64 engineering toolkit for reverse engineering, debugging, development and testing.",
5
5
  "license": "MIT",
6
6
  "author": "Henrik Olsson",
@@ -11,7 +11,7 @@ import { writeProjectFile } from "#src/host-client/transfer.ts";
11
11
  import { conflicts } from "#src/knowledge/conflicts.ts";
12
12
  import { KnowledgeError, openForRead } from "#src/knowledge/database.ts";
13
13
  import { currentRevision, listComments, listModules, listReferences, listRegions, listSymbols } from "#src/knowledge/read.ts";
14
- import { resolveProjectPath } from "#src/project.ts";
14
+ import { resolveProjectFile, resolveProjectPath } from "#src/project.ts";
15
15
  import { runScript, UsageError } from "#src/script.ts";
16
16
  import { buildReference, GENERATED_MARK } from "./lib/reference.ts";
17
17
  import { readSourceMap } from "./lib/source-map.ts";
@@ -28,9 +28,8 @@ When the knowledge has the program of the source (knowledge.ts image of the
28
28
  c64-knowledge skill, D83), the reference decodes the tables and shows the
29
29
  graphics. The result is one JSON object.`;
30
30
 
31
- /** The files of an earlier run, or a refusal that names a file of a person. */
32
- function oldFiles(out: string): string[] {
33
- const directory = resolveProjectPath(out);
31
+ /** The files of an earlier run in `directory`, the real folder of --out, or a refusal that names a file of a person. */
32
+ function oldFiles(out: string, directory: string): string[] {
34
33
  if (!existsSync(directory)) return [];
35
34
  const found: string[] = [];
36
35
  const walk = (relative: string) => {
@@ -62,6 +61,15 @@ function run(argv: string[]): Record<string, unknown> {
62
61
  throw new UsageError(`${option} must be a path relative to the project folder that stays inside it, not ${path}`);
63
62
  }
64
63
  }
64
+ // The files of an earlier run are deleted: only in a folder that is inside the project, also after its symbolic links.
65
+ let directory = resolveProjectPath(values.out);
66
+ if (existsSync(directory)) {
67
+ try {
68
+ directory = resolveProjectFile(values.out);
69
+ } catch {
70
+ throw new UsageError(`--out must be a path relative to the project folder that stays inside it, also through symbolic links, not ${values.out}`);
71
+ }
72
+ }
65
73
  const map = readSourceMap(values.source);
66
74
  const db = openForRead();
67
75
  let knowledge;
@@ -86,14 +94,13 @@ function run(argv: string[]): Record<string, unknown> {
86
94
  } finally {
87
95
  db?.close();
88
96
  }
89
- const old = oldFiles(values.out);
97
+ const old = oldFiles(values.out, directory);
90
98
  const built = buildReference({
91
99
  out: posix.normalize(values.out),
92
100
  map,
93
101
  ...knowledge,
94
102
  ...(image === undefined ? {} : { image: { byteAt: image.byteAt, inImage: (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end) } }),
95
103
  });
96
- const directory = resolveProjectPath(values.out);
97
104
  for (const path of old) rmSync(join(directory, path), { force: true });
98
105
  for (const file of built.files) writeProjectFile(file.path, file.bytes, { what: "--out" });
99
106
  return { out: values.out, revision: knowledge.revision, index: posix.join(posix.normalize(values.out), "index.md"), ...built.counts, program: image === undefined ? "none: put the program into the knowledge with knowledge.ts image to decode the tables and show the graphics" : "the knowledge" };
@@ -60,7 +60,14 @@ const relative = (page: string, target: string) => {
60
60
  /** The project path of a link target, without its fragment; undefined for a link out of the project (a URL, or a page anchor only). */
61
61
  export function targetPath(page: string, target: string): string | undefined {
62
62
  if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#")) return undefined;
63
- return posix.normalize(posix.join(posix.dirname(page), decodeURIComponent(target.split("#")[0]!)));
63
+ const raw = target.split("#")[0]!;
64
+ let path = raw;
65
+ try {
66
+ path = decodeURIComponent(raw);
67
+ } catch {
68
+ // A % that starts no escape ("100%.md") is part of the name.
69
+ }
70
+ return posix.normalize(posix.join(posix.dirname(page), path));
64
71
  }
65
72
 
66
73
  /** Writes the links of the name mentions of a page; returns the new text, the number of links changed and the problems. */
@@ -129,6 +136,11 @@ export function checkLinks(page: string, text: string, files: Files): Problem[]
129
136
  if (!anchors.has(path)) anchors.set(path, anchorsOf(path === page ? text : (files.text(path) ?? "")));
130
137
  return anchors.get(path)!;
131
138
  };
139
+ const lineCounts = new Map<string, number>();
140
+ const lineCount = (path: string) => {
141
+ if (!lineCounts.has(path)) lineCounts.set(path, (files.text(path) ?? "").split("\n").length);
142
+ return lineCounts.get(path)!;
143
+ };
132
144
  for (const link of linksOf(text)) {
133
145
  const report = (kind: ProblemKind, message: string) => problems.push({ page, line: link.line, kind, message });
134
146
  const fragment = link.target.includes("#") ? link.target.slice(link.target.indexOf("#") + 1) : undefined;
@@ -147,7 +159,7 @@ export function checkLinks(page: string, text: string, files: Files): Problem[]
147
159
  if (path.endsWith(".md")) {
148
160
  if (!anchorsAt(path).has(fragment)) report("missing-anchor", `${link.target}: ${path} has no anchor ${fragment}.`);
149
161
  } else if (line !== null) {
150
- const count = (files.text(path) ?? "").split("\n").length;
162
+ const count = lineCount(path);
151
163
  if (Number(line[1]) > count) report("missing-line", `${link.target}: ${path} has ${count} lines.`);
152
164
  }
153
165
  }
@@ -41,8 +41,13 @@ const isDirectory = (path: string) => {
41
41
  };
42
42
 
43
43
  function markdownFiles(directory: string): string[] {
44
- const root = resolveProjectPath(directory);
45
44
  if (!isDirectory(directory)) throw new WireFailure("not-found", `There is no directory ${directory} in the project folder.`);
45
+ let root: string;
46
+ try {
47
+ root = resolveProjectFile(directory);
48
+ } catch {
49
+ throw new WireFailure("invalid-input", `${directory} leads out of the project folder through a symbolic link.`);
50
+ }
46
51
  const found: string[] = [];
47
52
  const walk = (relative: string) => {
48
53
  for (const entry of readdirSync(join(root, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
@@ -55,6 +60,9 @@ function markdownFiles(directory: string): string[] {
55
60
  return found;
56
61
  }
57
62
 
63
+ /** The text of each file that the run read: a page, a source file or a reference page is read once. */
64
+ const texts = new Map<string, string | undefined>();
65
+
58
66
  const files: Files = {
59
67
  inside: (path) => {
60
68
  try {
@@ -73,15 +81,22 @@ const files: Files = {
73
81
  }
74
82
  },
75
83
  text: (path) => {
84
+ if (texts.has(path)) return texts.get(path);
85
+ let text: string | undefined;
76
86
  try {
77
87
  const real = resolveProjectFile(path);
78
- return statSync(real).isFile() ? readFileSync(real, "utf8") : undefined;
88
+ text = statSync(real).isFile() ? readFileSync(real, "utf8") : undefined;
79
89
  } catch {
80
- return undefined;
90
+ text = undefined;
81
91
  }
92
+ texts.set(path, text);
93
+ return text;
82
94
  },
83
95
  };
84
96
 
97
+ /** A .md entry of a directory that is a symbolic link to no file, or to a file outside the project. */
98
+ const unreadable = (page: string): Problem => ({ page, line: 1, kind: "missing-file", message: `${page} is a symbolic link to no file or to a file outside the project folder.` });
99
+
85
100
  function run(argv: string[]): { output: Record<string, unknown>; failed: boolean } {
86
101
  const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { source: { type: "string" }, reference: { type: "string" }, check: { type: "boolean" }, help: { type: "boolean" } } });
87
102
  if (values.help) throw new UsageError("");
@@ -118,14 +133,21 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
118
133
  const routines = symbols.filter((symbol) => symbol.kind === "routine").map((symbol) => symbol.address).sort((a, b) => a - b);
119
134
  const named = [...new Set(symbols.map((symbol) => symbol.address))].sort((a, b) => a - b);
120
135
  const kinds = new Map(symbols.map((symbol) => [symbol.name, symbol.kind]));
136
+ const bodyEnds = new Map<string, number | undefined>();
121
137
  const bodyEnd = (name: string) => {
138
+ if (bodyEnds.has(name)) return bodyEnds.get(name);
122
139
  const address = addresses.get(name);
123
- if (address === undefined) return undefined;
124
- const next = ((kinds.get(name) === "routine" ? routines : named).find((other) => other > address) ?? 0x10000) - 1;
125
- const file = map.lineOf(address)?.file;
126
- if (file === undefined) return Math.min(next, address + MAX_BODY - 1);
127
- let end = address;
128
- while (end < next && map.lineOf(end + 1)?.file === file) end++;
140
+ let end: number | undefined;
141
+ if (address !== undefined) {
142
+ const next = ((kinds.get(name) === "routine" ? routines : named).find((other) => other > address) ?? 0x10000) - 1;
143
+ const file = map.lineOf(address)?.file;
144
+ if (file === undefined) end = Math.min(next, address + MAX_BODY - 1);
145
+ else {
146
+ end = address;
147
+ while (end < next && map.lineOf(end + 1)?.file === file) end++;
148
+ }
149
+ }
150
+ bodyEnds.set(name, end);
129
151
  return end;
130
152
  };
131
153
  const nameAt = new Map(symbols.map((symbol) => [symbol.address, symbol.name]));
@@ -144,7 +166,11 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
144
166
  const entries = new Map<string, string>();
145
167
  const referencePages = reference === undefined ? [] : markdownFiles(reference);
146
168
  for (const page of referencePages) {
147
- const text = files.text(page)!;
169
+ const text = files.text(page);
170
+ if (text === undefined) {
171
+ problems.push(unreadable(page));
172
+ continue;
173
+ }
148
174
  const stamp = /^<!-- Generated by c64-docs from knowledge revision (\S+) /.exec(text);
149
175
  if (text.startsWith(GENERATED_MARK) && stamp !== null && stamp[1] !== map.revision) {
150
176
  problems.push({ page, line: 1, kind: "stale-reference", message: `${page} is from knowledge revision ${stamp[1]}, and the source from ${map.revision}. Run generate.ts again.` });
@@ -176,7 +202,11 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
176
202
  const changedPages: string[] = [];
177
203
  let links = 0;
178
204
  for (const page of pages) {
179
- const text = files.text(page)!;
205
+ const text = files.text(page);
206
+ if (text === undefined) {
207
+ problems.push(unreadable(page));
208
+ continue;
209
+ }
180
210
  // A generated page is right by its making: it is only checked.
181
211
  const generated = text.startsWith(GENERATED_MARK);
182
212
  const linked = generated ? { text, changed: 0, problems: [] } : linkPage(text, { page, ...context });
@@ -184,7 +214,9 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
184
214
  if (linked.changed === 0) continue;
185
215
  links += linked.changed;
186
216
  changedPages.push(page);
187
- if (!values.check) writeProjectFile(page, Buffer.from(linked.text, "utf8"));
217
+ if (values.check) continue;
218
+ writeProjectFile(page, Buffer.from(linked.text, "utf8"));
219
+ texts.set(page, linked.text);
188
220
  }
189
221
  const byKind: Record<string, number> = {};
190
222
  for (const problem of problems) byKind[problem.kind] = (byKind[problem.kind] ?? 0) + 1;
@@ -109,7 +109,7 @@ Write:
109
109
 
110
110
  1. Read the current value with `at`. Note the `revision` in the result.
111
111
  2. Decide the change. Keep observed facts apart from your conclusions.
112
- 3. Write it with `--reason` and `--expect-revision` set to the revision you read. Add `--evidence` for each thing that the change rests on: `coverage:<map>`, `test:<file>`, `file:<file>` or `note:<text>`. `<map>` is a coverage map that `coverage` put into the knowledge, by its path at that time. The script keeps the SHA-256 of each file and map.
112
+ 3. Write it with `--reason` and `--expect-revision` set to the revision you read. Add `--evidence` for each thing that the change rests on: `coverage:<map>`, `test:<file>`, `file:<file>` or `note:<text>`. `<map>` is a coverage map that `coverage` put into the knowledge, by its path at that time. The script uses the last import of the map into the space of the write. The script keeps the SHA-256 of each file and map.
113
113
  4. Read the result. A `revision` of `null` means that nothing changed.
114
114
 
115
115
  Each write is one revision. A classification inside a region splits that region, so regions never overlap.
@@ -124,7 +124,7 @@ The knowledge holds the program, so a project needs no image or coverage files.
124
124
  4. Read what the knowledge holds with `program`: the segments, the entry, and the coverage maps that it has.
125
125
 
126
126
  - `image` writes only the bytes that changed. The same image again writes nothing. A new image replaces the program of the space. The history keeps the old bytes.
127
- - `coverage` joins the maps. It keeps each address that a run executed, read or wrote. It also keeps each address that one run wrote and executed: self-modified code. A map that the knowledge has is not added again.
127
+ - `coverage` joins the maps. It keeps each address that a run executed, read or wrote. It also keeps each address that one run wrote and executed: self-modified code. `coverage` puts each map into the space of `--space`. You can add the same map to each space. A map that the space has is not added again.
128
128
  - The capture and the maps are not necessary after the import. Keep them in `.c64-re-tools/local/`.
129
129
 
130
130
  For more than a few writes, use `apply -` and give the writes on standard input as one JSON list. Each write has `op` and the arguments of the command with the same name, for example:
@@ -100,7 +100,7 @@ import {
100
100
  import { platformNames } from "#src/platform.ts";
101
101
  import { addCoverage, coverageRuns, programCoverage, programImage, requireProgram, setImage, type ImageRequest } from "#src/knowledge/program.ts";
102
102
  import { WireFailure } from "#src/host-client/tools.ts";
103
- import { isInside, LOCAL_DIRECTORY, PROJECT_DIRECTORY, projectRoot, resolveProjectPath } from "#src/project.ts";
103
+ import { isInside, LOCAL_DIRECTORY, PROJECT_DIRECTORY, projectRelative, projectRoot, resolveProjectPath } from "#src/project.ts";
104
104
  import { listing, listingText, type ListingSource } from "./lib/listing.ts";
105
105
  import { runScript, UsageError } from "#src/script.ts";
106
106
 
@@ -424,7 +424,7 @@ function run(argv: string[]): unknown {
424
424
  const context: WriteContext = { origin: values.origin === undefined ? "llm" : oneOf(["user", "llm"] as const, values.origin, "--origin") };
425
425
  if (values.reason !== undefined) context.description = values.reason;
426
426
  if (values["expect-revision"] !== undefined) context.expectedRevision = values["expect-revision"];
427
- if (values.evidence !== undefined) context.evidence = values.evidence.map(evidenceOf);
427
+ if (values.evidence !== undefined) context.evidence = values.evidence.map((value) => evidenceOf(value, space));
428
428
  // Every argument is checked before the database opens: a refused write creates no database.
429
429
  if (values.title !== undefined && command !== "module") throw new UsageError("--title works only with module");
430
430
  for (const option of ["lo", "hi", "value", "expr", "offset"] as const) {
@@ -562,7 +562,7 @@ function readAnswer(db: DatabaseSync | undefined, command: string, args: string[
562
562
  state: image.state !== undefined,
563
563
  stages: image.stages?.length ?? 0,
564
564
  },
565
- coverage: { maps: coverageRuns(db).map((run) => run.name), ranges: Object.fromEntries(ranges.map((row) => [row.kind, row.count])) },
565
+ coverage: { maps: [...new Set(coverageRuns(db).filter((run) => run.space === space).map((run) => run.name))], ranges: Object.fromEntries(ranges.map((row) => [row.kind, row.count])) },
566
566
  };
567
567
  }
568
568
  case "export": {
@@ -644,23 +644,34 @@ function originFilter(value: string): Origin[] {
644
644
  return [...origins];
645
645
  }
646
646
 
647
+ /** A project path as the knowledge names it: one spelling for each file, so ./runs/a.bin and runs/a.bin are the same file. */
648
+ function knowledgePath(path: string, what: string): string {
649
+ try {
650
+ return projectRelative(path);
651
+ } catch {
652
+ throw new UsageError(`${what} must be a path relative to the project folder that stays inside it, not ${path}`);
653
+ }
654
+ }
655
+
647
656
  /**
648
657
  * --evidence kind:ref. A file kind names a project file and keeps its SHA-256, so a later change of the file shows.
649
658
  * A file in .c64-re-tools/local/ is refused (D82): git ignores it, and the move that keeps it changes the path.
650
659
  */
651
- function evidenceOf(value: string): Evidence {
660
+ function evidenceOf(value: string, space: string): Evidence {
652
661
  const colon = value.indexOf(":");
653
662
  const kind = oneOf(EVIDENCE_KINDS, colon < 0 ? value : value.slice(0, colon), "--evidence kind");
654
- const ref = colon < 0 ? "" : value.slice(colon + 1);
655
- if (ref === "") throw new UsageError(`--evidence needs ${kind}:<${kind === "note" ? "text" : "file"}>`);
656
- if (kind === "note") return { kind, ref };
657
- // A coverage map is evidence by the name that coverage imported it with (D83).
663
+ const given = colon < 0 ? "" : value.slice(colon + 1);
664
+ if (given === "") throw new UsageError(`--evidence needs ${kind}:<${kind === "note" ? "text" : "file"}>`);
665
+ if (kind === "note") return { kind, ref: given };
666
+ const ref = knowledgePath(given, `--evidence ${kind}:<file>`);
667
+ // A coverage map is evidence by the name that coverage imported it with (D83): the latest import into the space of the write (D86).
658
668
  if (kind === "coverage") {
659
669
  const db = openForRead();
660
670
  try {
661
- const run = coverageRuns(db).find((item) => item.name === ref);
662
- if (run === undefined) throw new UsageError(`--evidence coverage:${ref} names no coverage map of the knowledge: import the map with coverage first`);
663
- return { kind, ref, sha256: run.sha256 };
671
+ const run = coverageRuns(db).filter((item) => item.name === ref && item.space === space).at(-1);
672
+ const where = space === DEFAULT_SPACE ? "" : ` in ${space}`;
673
+ if (run === undefined) throw new UsageError(`--evidence coverage:${given} names no coverage map of the knowledge${where}: import the map with coverage${space === DEFAULT_SPACE ? "" : ` --space ${space}`} first`);
674
+ return { kind, ref, sha256: run.sha256, ...(run.space === DEFAULT_SPACE ? {} : { space: run.space }) };
664
675
  } finally {
665
676
  db?.close();
666
677
  }
@@ -719,7 +730,7 @@ function programOperation(command: "image" | "coverage", args: string[], context
719
730
  };
720
731
  }
721
732
  if (args.length === 0) throw new UsageError("coverage needs one or more coverage maps");
722
- const maps = args.map((path) => ({ name: path, bytes: readProjectFile(path).bytes }));
733
+ const maps = args.map((path) => ({ bytes: readProjectFile(path).bytes, name: knowledgePath(path, "coverage <map>") }));
723
734
  return (db) => {
724
735
  const { revision, current } = addCoverage(db, context, { space, maps });
725
736
  return { revision, ...spaceOf(current), imported: current.imported, known: current.known, ranges: current.ranges };
@@ -37,16 +37,19 @@ export function readImage(path: string): Image {
37
37
  const fail = (what: string): never => {
38
38
  throw new ImageError(`${path} is not an image.json of the c64-unpacker skill: ${what}`);
39
39
  };
40
- let manifest: Record<string, unknown>;
40
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
41
+ let parsed: unknown;
41
42
  try {
42
- manifest = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
43
+ parsed = JSON.parse(bytes.toString("utf8"));
43
44
  } catch (error) {
44
45
  return fail((error as Error).message);
45
46
  }
47
+ const manifest = isObject(parsed) ? parsed : fail("it is not a JSON object");
46
48
  const address = (value: unknown, what: string) => (typeof value === "string" && /^\$[0-9a-f]{4}$/i.test(value) ? Number.parseInt(value.slice(1), 16) : fail(`${what} is not a $ address`));
47
49
  if (typeof manifest.image !== "string") fail("it has no image");
48
50
  if (!Array.isArray(manifest.segments) || manifest.segments.length === 0) fail("it has no segments");
49
- const segments = (manifest.segments as Array<Record<string, unknown>>)
51
+ const segments = (manifest.segments as unknown[])
52
+ .map((segment, index) => (isObject(segment) ? segment : fail(`segment ${index + 1} is not an object`)))
50
53
  .map((segment, index) => ({ start: address(segment.start, `segment ${index + 1} start`), end: address(segment.end, `segment ${index + 1} end`) }))
51
54
  .sort((a, b) => a.start - b.start);
52
55
  segments.forEach((segment, index) => {
@@ -58,6 +61,6 @@ export function readImage(path: string): Image {
58
61
  if (memory.length !== 0x10000) fail(`its image has ${memory.length} bytes, not 65536`);
59
62
  const image: Image = { segments, byteAt: (at) => memory[at]!, statePath: posix.join(directory, "entry_state.json") };
60
63
  if (manifest.entry !== undefined) image.entry = address(manifest.entry, "entry");
61
- if (Array.isArray(manifest.stages)) image.stages = manifest.stages as Array<Record<string, unknown>>;
64
+ if (Array.isArray(manifest.stages)) image.stages = manifest.stages.map((stage, index) => (isObject(stage) ? stage : fail(`stage ${index + 1} is not an object`)));
62
65
  return image;
63
66
  }
@@ -1,7 +1,7 @@
1
1
  // Reads project files on the client side for transfer to the Host Runtime.
2
2
  // The host never sees a client path: only bytes and the file's type.
3
3
 
4
- import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
5
  import { dirname, extname, join, relative, sep } from "node:path";
6
6
 
7
7
  import { isInside, ProjectError, projectRoot, resolveProject, resolveProjectFile, resolveProjectPath } from "../project.ts";
@@ -80,6 +80,10 @@ export function writeProjectFile(path: string, bytes: Uint8Array, options: { roo
80
80
  } catch {
81
81
  throw refuse();
82
82
  }
83
+ // The nearest folder that exists must be inside the project before a folder is made below it.
84
+ let existing = dirname(file);
85
+ while (!existsSync(existing)) existing = dirname(existing);
86
+ if (!isInside(realpathSync(root), realpathSync(existing))) throw refuse();
83
87
  mkdirSync(dirname(file), { recursive: true });
84
88
  if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
85
89
  let link = false;
@@ -149,9 +149,14 @@ export function revisionNote(db: DatabaseSync | undefined, id: string): { reason
149
149
 
150
150
  /** The revision with its evidence, when it has any. */
151
151
  function withEvidence(db: DatabaseSync, revision: Revision): Revision {
152
- const rows = db.prepare("SELECT kind, ref, sha256 FROM evidence WHERE revision = ? ORDER BY id").all(revision.id) as Raw[];
152
+ const rows = db.prepare("SELECT kind, ref, sha256, space FROM evidence WHERE revision = ? ORDER BY id").all(revision.id) as Raw[];
153
153
  if (rows.length === 0) return revision;
154
- const evidence = rows.map((row): Evidence => ({ kind: row.kind as EvidenceKind, ref: row.ref as string, ...(row.sha256 === null ? {} : { sha256: row.sha256 as string }) }));
154
+ const evidence = rows.map((row): Evidence => ({
155
+ kind: row.kind as EvidenceKind,
156
+ ref: row.ref as string,
157
+ ...(row.sha256 === null ? {} : { sha256: row.sha256 as string }),
158
+ ...(row.space === null ? {} : { space: row.space as Space }),
159
+ }));
155
160
  return { ...revision, evidence };
156
161
  }
157
162
 
@@ -146,20 +146,28 @@ function currentCoverage(db: DatabaseSync, space: Space): Map<CoverageKind, Uint
146
146
  return maps;
147
147
  }
148
148
 
149
- /** The project paths of the coverage maps that the knowledge imported, with their SHA-256. */
150
- export function coverageRuns(db: DatabaseSync | undefined): Array<{ name: string; sha256: string }> {
149
+ /** One import of a coverage map: its project path, its SHA-256, the space it went into (D86) and the revision. */
150
+ export interface CoverageRun {
151
+ name: string;
152
+ sha256: string;
153
+ space: Space;
154
+ revision: string;
155
+ }
156
+
157
+ /** The imports of coverage maps, by name and then oldest first: the last import of a name is the latest. */
158
+ export function coverageRuns(db: DatabaseSync | undefined): CoverageRun[] {
151
159
  if (db === undefined) return [];
152
- return db.prepare("SELECT DISTINCT e.ref AS name, e.sha256 AS sha256 FROM evidence e JOIN revisions r ON r.id = e.revision WHERE r.operation = 'coverage' AND e.kind = 'coverage' ORDER BY e.ref").all() as Array<{
153
- name: string;
154
- sha256: string;
155
- }>;
160
+ return db
161
+ .prepare(`SELECT DISTINCT e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space, e.revision AS revision FROM evidence e JOIN revisions r ON r.id = e.revision WHERE r.operation = 'coverage' AND e.kind = 'coverage' ORDER BY e.ref, e.revision`)
162
+ .all() as unknown as CoverageRun[];
156
163
  }
157
164
 
158
165
  /**
159
166
  * Joins coverage maps into the coverage of a space (D83): adds the ranges of
160
167
  * the flags that no imported run set before, and as wx the addresses that one
161
- * run both wrote and executed. Each new map is evidence of the revision. A map
162
- * that the knowledge has with the same name and bytes is not imported again.
168
+ * run both wrote and executed. Each new map is evidence of the revision, with
169
+ * its space. A map that the space has with the same name and bytes is not
170
+ * imported again.
163
171
  */
164
172
  export function addCoverage(
165
173
  db: DatabaseSync,
@@ -172,9 +180,9 @@ export function addCoverage(
172
180
  if (map.bytes.length !== SIZE) throw new KnowledgeError("invalid-input", `${map.name} has ${map.bytes.length} bytes; a coverage map has 65536, one for each address.`);
173
181
  }
174
182
  const hashed = request.maps.map((map) => ({ ...map, sha256: createHash("sha256").update(map.bytes).digest("hex") }));
175
- const known = new Set(coverageRuns(db).map((run) => `${run.name}\n${run.sha256}`));
183
+ const known = new Set(coverageRuns(db).filter((run) => run.space === space).map((run) => `${run.name}\n${run.sha256}`));
176
184
  const fresh = hashed.filter((map) => !known.has(`${map.name}\n${map.sha256}`));
177
- const evidence: Evidence[] = [...(context.evidence ?? []), ...fresh.map((map): Evidence => ({ kind: "coverage", ref: map.name, sha256: map.sha256 }))];
185
+ const evidence: Evidence[] = [...(context.evidence ?? []), ...fresh.map((map): Evidence => ({ kind: "coverage", ref: map.name, sha256: map.sha256, ...(space === DEFAULT_SPACE ? {} : { space }) }))];
178
186
  return write(db, { ...context, evidence }, "coverage", (change) => {
179
187
  const result = { space, imported: fresh.map((map) => map.name), known: hashed.filter((map) => !fresh.includes(map)).map((map) => map.name), ranges: 0 };
180
188
  if (fresh.length === 0) return result;
@@ -151,7 +151,8 @@ function createSchema(db: DatabaseSync): void {
151
151
  revision TEXT NOT NULL REFERENCES revisions (id),
152
152
  kind TEXT NOT NULL CHECK (kind IN (${list(EVIDENCE_KINDS)})),
153
153
  ref TEXT NOT NULL CHECK (length(ref) > 0),
154
- sha256 TEXT CHECK (sha256 IS NULL OR length(sha256) = 64)
154
+ sha256 TEXT CHECK (sha256 IS NULL OR length(sha256) = 64),
155
+ space TEXT CHECK (space IS NULL OR ${SPACE_CHECK})
155
156
  );
156
157
  CREATE INDEX evidence_revision ON evidence (revision);
157
158
  CREATE TABLE comments (
@@ -309,7 +310,7 @@ function createSchema(db: DatabaseSync): void {
309
310
  }
310
311
 
311
312
  /** The version of the index this build makes. An index with another version is emptied and made again. */
312
- export const INDEX_VERSION = 5;
313
+ export const INDEX_VERSION = 6;
313
314
 
314
315
  function indexVersion(db: DatabaseSync): number | undefined {
315
316
  const hasMeta = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
@@ -231,10 +231,10 @@ export function tableText(db: DatabaseSync, spec: TableSpec): string {
231
231
 
232
232
  /** The text of the revision log: every revision with its evidence, oldest first. */
233
233
  export function revisionsText(db: DatabaseSync): string {
234
- const evidence = db.prepare("SELECT kind, ref, sha256 FROM evidence WHERE revision = ? ORDER BY id");
234
+ const evidence = db.prepare("SELECT kind, ref, sha256, space FROM evidence WHERE revision = ? ORDER BY id");
235
235
  return (db.prepare("SELECT * FROM revisions ORDER BY id").all() as Raw[])
236
236
  .map((raw) => {
237
- const items = (evidence.all(raw.id as string) as Raw[]).map((item) => ({ kind: item.kind, ref: item.ref, ...(item.sha256 === null ? {} : { sha256: item.sha256 }) }));
237
+ const items = (evidence.all(raw.id as string) as Raw[]).map((item) => ({ kind: item.kind, ref: item.ref, ...(item.sha256 === null ? {} : { sha256: item.sha256 }), ...(item.space === null ? {} : { space: item.space }) }));
238
238
  return `${lineOf(raw, REVISION_FIELDS, items.length === 0 ? {} : { evidence: items })}\n`;
239
239
  })
240
240
  .join("");
@@ -337,7 +337,7 @@ function joinById<T extends { id: string }>(file: string, entries: Array<{ line:
337
337
  interface RevisionLine {
338
338
  id: string;
339
339
  columns: Raw;
340
- evidence: Array<{ kind: unknown; ref: unknown; sha256: unknown }>;
340
+ evidence: Array<{ kind: unknown; ref: unknown; sha256: unknown; space: unknown }>;
341
341
  }
342
342
 
343
343
  const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
@@ -349,8 +349,8 @@ function parseRevisions(text: string): RevisionLine[] {
349
349
  const evidence = value.evidence ?? [];
350
350
  if (!Array.isArray(evidence) || evidence.some((item) => typeof item !== "object" || item === null)) bad(REVISIONS_FILE, line, "evidence must be a list of objects.");
351
351
  const items = (evidence as Raw[]).map((item) => {
352
- for (const key of Object.keys(item)) if (!["kind", "ref", "sha256"].includes(key)) bad(REVISIONS_FILE, line, `the evidence key ${JSON.stringify(key)} is not known.`);
353
- return { kind: item.kind, ref: item.ref, sha256: item.sha256 ?? null };
352
+ for (const key of Object.keys(item)) if (!["kind", "ref", "sha256", "space"].includes(key)) bad(REVISIONS_FILE, line, `the evidence key ${JSON.stringify(key)} is not known.`);
353
+ return { kind: item.kind, ref: item.ref, sha256: item.sha256 ?? null, space: item.space ?? null };
354
354
  });
355
355
  return { line, row: { id: columns.id as string, columns, evidence: items } };
356
356
  });
@@ -389,12 +389,12 @@ function parseRows(spec: TableSpec, text: string): RowLine[] {
389
389
  */
390
390
  export function loadText(db: DatabaseSync, files: ReadonlyMap<string, { text: string }>): void {
391
391
  const insertRevision = db.prepare(`INSERT INTO revisions (${REVISION_FIELDS.map((item) => item.column).join(", ")}) VALUES (${REVISION_FIELDS.map(() => "?").join(", ")})`);
392
- const insertEvidence = db.prepare("INSERT INTO evidence (revision, kind, ref, sha256) VALUES (?, ?, ?, ?)");
392
+ const insertEvidence = db.prepare("INSERT INTO evidence (revision, kind, ref, sha256, space) VALUES (?, ?, ?, ?, ?)");
393
393
  const revisions = parseRevisions(files.get(REVISIONS_FILE)?.text ?? "").sort((a, b) => compareText(a.id, b.id));
394
394
  for (const revision of revisions) {
395
395
  try {
396
396
  insertRevision.run(...(REVISION_FIELDS.map((item) => revision.columns[item.column]) as Array<string | null>));
397
- for (const item of revision.evidence) insertEvidence.run(revision.id, item.kind as string, item.ref as string, item.sha256 as string | null);
397
+ for (const item of revision.evidence) insertEvidence.run(revision.id, item.kind as string, item.ref as string, item.sha256 as string | null, item.space as string | null);
398
398
  } catch (error) {
399
399
  throw new KnowledgeError("invalid-database", `.c64-re-tools/knowledge/${REVISIONS_FILE}: the revision ${revision.id} is not valid: ${(error as Error).message}`);
400
400
  }
@@ -92,6 +92,8 @@ export interface Evidence {
92
92
  kind: EvidenceKind;
93
93
  ref: string;
94
94
  sha256?: string;
95
+ /** The space that a coverage map was imported into (D86); none is the C64, as for a row. */
96
+ space?: Space;
95
97
  }
96
98
 
97
99
  /** Who writes and why. Semantic writes come from the user or the LLM, never as an analyzer. */
@@ -212,8 +214,8 @@ export class Change {
212
214
  .prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description) VALUES (?, ?, ?, ?, ?, ?, ?)")
213
215
  .run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null);
214
216
  this.revision = id;
215
- const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256) VALUES (?, ?, ?, ?)");
216
- for (const item of this.#context.evidence ?? []) evidence.run(id, item.kind, item.ref, item.sha256 ?? null);
217
+ const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256, space) VALUES (?, ?, ?, ?, ?)");
218
+ for (const item of this.#context.evidence ?? []) evidence.run(id, item.kind, item.ref, item.sha256 ?? null, item.space ?? null);
217
219
  return id;
218
220
  }
219
221
 
@@ -348,6 +350,7 @@ function checkEvidence(evidence: Evidence[] | undefined): void {
348
350
  checkOneOf(EVIDENCE_KINDS, item.kind, "evidence kind");
349
351
  if (item.ref.trim().length === 0 || item.ref.length > MAX_EVIDENCE_REF) throw new KnowledgeError("invalid-input", `evidence has 1 to ${MAX_EVIDENCE_REF} characters after its kind.`);
350
352
  if (item.sha256 !== undefined && !/^[0-9a-f]{64}$/.test(item.sha256)) throw new KnowledgeError("invalid-input", "evidence sha256 must be 64 lowercase hex digits.");
353
+ checkSpace(item);
351
354
  }
352
355
  }
353
356
 
package/src/project.ts CHANGED
@@ -44,6 +44,9 @@ const SCRIPT_REMEDY = "Give --project <folder>";
44
44
  /** The folder that --project chose for this process, or undefined. */
45
45
  let chosen: string | undefined;
46
46
 
47
+ /** The project that the D67 lookup found from a working directory, kept for the next call from the same directory. */
48
+ let found: { cwd: string; root: string } | undefined;
49
+
47
50
  function isProject(folder: string): boolean {
48
51
  return statSync(join(folder, PROJECT_DIRECTORY), { throwIfNoEntry: false })?.isDirectory() === true;
49
52
  }
@@ -163,7 +166,13 @@ export function useProject(folder: string): void {
163
166
 
164
167
  /** The project folder of this process: the --project choice, else the D67 lookup from the working directory. */
165
168
  export function projectRoot(): string {
166
- const root = chosen ?? findProject();
169
+ let root = chosen;
170
+ if (root === undefined) {
171
+ const cwd = process.cwd();
172
+ if (found?.cwd !== cwd) found = { cwd, root: findProject(cwd) };
173
+ root = found.root;
174
+ }
175
+ // Each call: the first write of a process can make .c64-re-tools/ after the lookup.
167
176
  ensureLocal(root);
168
177
  return root;
169
178
  }
@@ -181,6 +190,11 @@ export function resolveProjectPath(path: string, root = projectRoot()): string {
181
190
  throw new TypeError("Project path escapes the project root");
182
191
  }
183
192
 
193
+ /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Throws TypeError as resolveProjectPath does. */
194
+ export function projectRelative(path: string, root = projectRoot()): string {
195
+ return relative(root, resolveProjectPath(path, root)).split(sep).join("/");
196
+ }
197
+
184
198
  /** True when `candidate` is `root` or below it. Both are absolute paths. */
185
199
  export function isInside(root: string, candidate: string): boolean {
186
200
  const fromRoot = relative(root, candidate);