@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 +1 -1
- package/skills/c64-docs/scripts/generate.ts +13 -6
- package/skills/c64-docs/scripts/lib/links.ts +14 -2
- package/skills/c64-docs/scripts/link.ts +44 -12
- package/skills/c64-knowledge/SKILL.md +2 -2
- package/skills/c64-knowledge/scripts/knowledge.ts +23 -12
- package/src/host-client/image.ts +7 -4
- package/src/host-client/transfer.ts +5 -1
- package/src/knowledge/history.ts +7 -2
- package/src/knowledge/program.ts +18 -10
- package/src/knowledge/schema.ts +3 -2
- package/src/knowledge/text.ts +7 -7
- package/src/knowledge/write.ts +5 -2
- package/src/project.ts +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henols/c64-re-tools",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
88
|
+
text = statSync(real).isFile() ? readFileSync(real, "utf8") : undefined;
|
|
79
89
|
} catch {
|
|
80
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 (
|
|
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
|
|
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
|
|
655
|
-
if (
|
|
656
|
-
if (kind === "note") return { kind, ref };
|
|
657
|
-
|
|
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).
|
|
662
|
-
|
|
663
|
-
|
|
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) => ({
|
|
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 };
|
package/src/host-client/image.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
package/src/knowledge/history.ts
CHANGED
|
@@ -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 => ({
|
|
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
|
|
package/src/knowledge/program.ts
CHANGED
|
@@ -146,20 +146,28 @@ function currentCoverage(db: DatabaseSync, space: Space): Map<CoverageKind, Uint
|
|
|
146
146
|
return maps;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/**
|
|
150
|
-
export
|
|
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
|
|
153
|
-
name
|
|
154
|
-
|
|
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
|
|
162
|
-
* that the
|
|
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;
|
package/src/knowledge/schema.ts
CHANGED
|
@@ -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 =
|
|
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();
|
package/src/knowledge/text.ts
CHANGED
|
@@ -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
|
}
|
package/src/knowledge/write.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|