@henols/c64-re-tools 2.0.0-rc.9 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -116
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +22 -6
- package/skills/c64-assembler/scripts/assemble.ts +42 -14
- package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
- package/skills/c64-basic/SKILL.md +10 -1
- package/skills/c64-basic/scripts/basic.ts +3 -3
- package/skills/c64-disk/SKILL.md +13 -4
- package/skills/c64-disk/scripts/disk.ts +7 -7
- package/skills/c64-disk/scripts/{containers.ts → lib/containers.ts} +2 -2
- package/skills/c64-docs/SKILL.md +138 -0
- package/skills/c64-docs/references/page-types.md +141 -0
- package/skills/c64-docs/references/style.md +43 -0
- package/skills/c64-docs/scripts/generate.ts +109 -0
- package/skills/c64-docs/scripts/lib/links.ts +167 -0
- package/skills/c64-docs/scripts/lib/markdown.ts +79 -0
- package/skills/c64-docs/scripts/lib/reference.ts +406 -0
- package/skills/c64-docs/scripts/lib/source-map.ts +81 -0
- package/skills/c64-docs/scripts/link.ts +239 -0
- package/skills/c64-emulator/SKILL.md +16 -5
- package/skills/c64-knowledge/SKILL.md +140 -18
- package/skills/c64-knowledge/scripts/knowledge.ts +737 -112
- package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
- package/skills/c64-memory-map/SKILL.md +11 -2
- package/skills/c64-memory-map/scripts/memmap.ts +2 -2
- package/skills/c64-provenance/SKILL.md +66 -11
- package/skills/c64-provenance/scripts/{compare.ts → lib/compare.ts} +3 -3
- package/skills/c64-provenance/scripts/lib/library.ts +584 -0
- package/skills/c64-provenance/scripts/{runtime.ts → lib/runtime.ts} +2 -2
- package/skills/c64-provenance/scripts/{separate.ts → lib/separate.ts} +3 -3
- package/skills/c64-provenance/scripts/provenance.ts +242 -40
- package/skills/c64-reverse-engineering/SKILL.md +51 -26
- package/skills/c64-source/SKILL.md +76 -28
- package/skills/c64-source/scripts/generate.ts +425 -127
- package/skills/c64-source/scripts/{loader.ts → lib/loader.ts} +84 -25
- package/skills/c64-source/scripts/lib/source.ts +1121 -0
- package/skills/c64-static-analysis/SKILL.md +35 -18
- package/skills/c64-static-analysis/scripts/analyze.ts +184 -55
- package/skills/c64-static-analysis/scripts/{coverage.ts → lib/coverage.ts} +19 -25
- package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +6 -2
- package/skills/c64-static-analysis/scripts/{idioms.ts → lib/idioms.ts} +21 -12
- package/skills/c64-static-analysis/scripts/{instructions.ts → lib/instructions.ts} +1 -1
- package/skills/c64-static-analysis/scripts/lib/macros.ts +233 -0
- package/skills/c64-static-analysis/scripts/lib/pitfalls.ts +68 -0
- package/skills/c64-static-analysis/scripts/lib/pointers.ts +108 -0
- package/skills/c64-testing/SKILL.md +36 -7
- package/skills/c64-testing/references/scenario-format.md +12 -8
- package/skills/c64-testing/scripts/checklist.ts +3 -3
- package/skills/c64-testing/scripts/diff.ts +167 -0
- package/skills/c64-testing/scripts/lib/diff.ts +70 -0
- package/skills/c64-testing/scripts/{scenario.ts → lib/scenario.ts} +45 -34
- package/skills/c64-testing/scripts/run.ts +73 -21
- package/skills/c64-testing/scripts/test.ts +5 -5
- package/skills/c64-unpacker/SKILL.md +23 -9
- package/skills/c64-unpacker/scripts/{evidence.ts → lib/evidence.ts} +1 -1
- package/skills/c64-unpacker/scripts/{loads.ts → lib/loads.ts} +4 -4
- package/skills/c64-unpacker/scripts/{machine.ts → lib/machine.ts} +4 -4
- package/skills/c64-unpacker/scripts/{stages.ts → lib/stages.ts} +17 -5
- package/skills/c64-unpacker/scripts/unpack.ts +41 -24
- package/src/c64.ts +103 -5
- package/src/cli/cleanup.ts +1 -1
- package/src/cli/main.ts +14 -5
- package/src/cli/status.ts +70 -3
- package/src/graphics.ts +158 -0
- package/src/host/main.ts +25 -5
- package/src/host/server.ts +10 -3
- package/src/host/tools/petcat.ts +1 -1
- package/src/host/tools/status.ts +1 -1
- package/src/host/vice/adapter.ts +4 -3
- package/src/host/vice/process.ts +2 -2
- package/src/host/vice/screen.ts +1 -42
- package/src/host/vice/session.ts +196 -23
- package/src/host-client/autostart.ts +1 -1
- package/src/host-client/connect.ts +1 -1
- package/src/host-client/coverage.ts +2 -29
- package/src/host-client/image.ts +66 -0
- package/src/host-client/steps.ts +9 -4
- package/src/host-client/tools.ts +1 -0
- package/src/host-client/transfer.ts +39 -11
- package/src/host-client/vice-session.ts +33 -16
- package/src/knowledge/conflicts.ts +161 -0
- package/src/knowledge/database.ts +150 -38
- package/src/knowledge/expression.ts +209 -0
- package/src/knowledge/graph.ts +104 -0
- package/src/knowledge/history.ts +70 -22
- package/src/knowledge/import.ts +64 -55
- package/src/knowledge/join.ts +211 -0
- package/src/knowledge/program.ts +266 -0
- package/src/knowledge/read.ts +258 -27
- package/src/knowledge/schema.ts +199 -75
- package/src/knowledge/text.ts +415 -0
- package/src/knowledge/write.ts +740 -57
- package/src/mcp/main.ts +8 -2
- package/src/mcp/output.ts +15 -8
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools/debug.ts +97 -16
- package/src/mcp/tools/execution.ts +38 -5
- package/src/mcp/tools/machine.ts +11 -2
- package/src/mcp/tools/media.ts +18 -13
- package/src/mcp/tools/memory.ts +17 -8
- package/src/mcp/tools/video.ts +9 -9
- package/src/native/discover.ts +2 -2
- package/src/native/ghidra/analyze.ts +2 -2
- package/src/native/ghidra/index.ts +1 -1
- package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
- package/src/native/ghidra/scripts/C64Export.java +1 -1
- package/src/native/ghidra/scripts/C64Prepare.java +7 -1
- package/src/native/types.ts +14 -8
- package/{skills/c64-source/scripts → src}/opcodes.ts +75 -3
- package/src/package.ts +13 -3
- package/src/platform.ts +70 -7
- package/src/project.ts +180 -3
- package/src/protocol/messages.ts +1 -1
- package/src/protocol/tools.ts +2 -16
- package/src/protocol/vice.ts +124 -15
- package/src/script.ts +46 -7
- package/skills/c64-assembler/scripts/compare.ts +0 -84
- package/skills/c64-source/scripts/source.ts +0 -595
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// The committed knowledge as text: .c64-re-tools/knowledge/ holds
|
|
2
|
+
// revisions.jsonl and one <table>.jsonl for each fact table. Each line is one
|
|
3
|
+
// JSON object with its keys in a fixed order, and the lines are sorted, so a
|
|
4
|
+
// change of the knowledge is a small diff and git can join the work of two
|
|
5
|
+
// computers line by line (merge=union). The index in local/ is made from
|
|
6
|
+
// this text.
|
|
7
|
+
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { isoCet } from "../time.ts";
|
|
14
|
+
import { KnowledgeError } from "./database.ts";
|
|
15
|
+
|
|
16
|
+
/** The value of "no revision": empty knowledge. It sorts before every revision id. */
|
|
17
|
+
export const NO_REVISION = "0";
|
|
18
|
+
|
|
19
|
+
/** A revision id: the CET time in ISO 8601 basic form to the millisecond, then six random characters. */
|
|
20
|
+
export const REVISION_ID = /^[0-9]{8}T[0-9]{6}\.[0-9]{3}-[0-9a-z]{6}$/;
|
|
21
|
+
|
|
22
|
+
/** A row id: the id of the revision that added the row, then the order of the row in that revision. */
|
|
23
|
+
const ROW_ID = /^([0-9]{8}T[0-9]{6}\.[0-9]{3}-[0-9a-z]{6})\/([1-9][0-9]*)$/;
|
|
24
|
+
|
|
25
|
+
const CET_OFFSET_MS = 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** `time` as `20261015T214501.123`: CET at +01:00, ISO 8601 basic form. */
|
|
28
|
+
function stamp(ms: number): string {
|
|
29
|
+
return isoCet(new Date(ms)).slice(0, 23).replace(/[-:]/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The time of a revision id, in milliseconds. */
|
|
33
|
+
function timeOf(id: string): number {
|
|
34
|
+
const [, year, month, day, hour, minute, second, milli] = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})\.(\d{3})/.exec(id)!;
|
|
35
|
+
return Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second), Number(milli)) - CET_OFFSET_MS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Compares text by code unit, as SQLite does: revision ids sort in time order this way. */
|
|
39
|
+
export const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
|
40
|
+
|
|
41
|
+
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A new revision id that sorts after `latest`: the time now, or one
|
|
45
|
+
* millisecond after `latest` when the clock is not ahead of it. The random
|
|
46
|
+
* part keeps the ids of two computers apart.
|
|
47
|
+
*/
|
|
48
|
+
export function newRevisionId(latest: string, now: Date = new Date(), random: Uint8Array = randomBytes(6)): string {
|
|
49
|
+
const time = latest === NO_REVISION ? now.getTime() : Math.max(now.getTime(), timeOf(latest) + 1);
|
|
50
|
+
return `${stamp(time)}-${Array.from(random.subarray(0, 6), (byte) => ALPHABET[byte % ALPHABET.length]).join("")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function rowId(revision: string, order: number): string {
|
|
54
|
+
return `${revision}/${order}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How a value of a column is written: an address as "$c000", a whole number,
|
|
59
|
+
* text, bytes as lowercase hex digits, or a JSON value that the index keeps as
|
|
60
|
+
* JSON text.
|
|
61
|
+
*/
|
|
62
|
+
type Format = "address" | "integer" | "text" | "hex" | "json";
|
|
63
|
+
|
|
64
|
+
interface Field {
|
|
65
|
+
key: string;
|
|
66
|
+
column: string;
|
|
67
|
+
format: Format;
|
|
68
|
+
optional?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One fact table: its file, its fields in line order, and the column that sorts its lines after the space (an address, or a name). */
|
|
72
|
+
export interface TableSpec {
|
|
73
|
+
table: string;
|
|
74
|
+
file: string;
|
|
75
|
+
fields: readonly Field[];
|
|
76
|
+
sort: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const field = (key: string, column: string, format: Format, optional = false): Field => ({ key, column, format, ...(optional ? { optional } : {}) });
|
|
80
|
+
const space = field("space", "space", "text");
|
|
81
|
+
const origin = field("origin", "origin", "text");
|
|
82
|
+
|
|
83
|
+
/** The fact tables. Each line has `id`, these fields, then `added` and `closed` (the revisions; closed is null while the row is current). */
|
|
84
|
+
export const TABLES: readonly TableSpec[] = [
|
|
85
|
+
{ table: "symbols", file: "symbols.jsonl", sort: "address", fields: [space, field("address", "address", "address"), field("name", "name", "text"), field("kind", "kind", "text"), origin] },
|
|
86
|
+
// The names that analyzers give: a second name of an address, for reference.
|
|
87
|
+
{ table: "analyzer_names", file: "analyzer_names.jsonl", sort: "address", fields: [space, field("address", "address", "address"), field("name", "name", "text"), field("kind", "kind", "text"), origin] },
|
|
88
|
+
{
|
|
89
|
+
table: "regions",
|
|
90
|
+
file: "regions.jsonl",
|
|
91
|
+
sort: "start_address",
|
|
92
|
+
fields: [space, field("start", "start_address", "address"), field("end", "end_address", "address"), field("type", "type", "text"), field("element", "element", "integer", true), origin],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
table: "comments",
|
|
96
|
+
file: "comments.jsonl",
|
|
97
|
+
sort: "address",
|
|
98
|
+
fields: [space, field("address", "address", "address"), field("placement", "placement", "text"), field("text", "text", "text"), origin],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
table: "references",
|
|
102
|
+
file: "references.jsonl",
|
|
103
|
+
sort: "from_address",
|
|
104
|
+
fields: [space, field("from", "from_address", "address"), field("to", "to_address", "address"), field("kind", "kind", "text"), origin],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
table: "suppressions",
|
|
108
|
+
file: "suppressions.jsonl",
|
|
109
|
+
sort: "start_address",
|
|
110
|
+
fields: [
|
|
111
|
+
space,
|
|
112
|
+
field("entity", "entity", "text"),
|
|
113
|
+
field("start", "start_address", "address"),
|
|
114
|
+
field("end", "end_address", "address"),
|
|
115
|
+
field("to", "to_address", "address", true),
|
|
116
|
+
field("kind", "kind", "text", true),
|
|
117
|
+
origin,
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
table: "modules",
|
|
122
|
+
file: "modules.jsonl",
|
|
123
|
+
sort: "start_address",
|
|
124
|
+
fields: [space, field("start", "start_address", "address"), field("file", "file", "text"), field("title", "title", "text", true), origin],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
table: "operands",
|
|
128
|
+
file: "operands.jsonl",
|
|
129
|
+
sort: "address",
|
|
130
|
+
fields: [
|
|
131
|
+
space,
|
|
132
|
+
field("address", "address", "address"),
|
|
133
|
+
field("select", "selector", "text"),
|
|
134
|
+
field("symbol", "symbol", "text", true),
|
|
135
|
+
field("offset", "offset", "integer"),
|
|
136
|
+
field("expression", "expression", "text", true),
|
|
137
|
+
origin,
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
table: "patches",
|
|
142
|
+
file: "patches.jsonl",
|
|
143
|
+
sort: "start_address",
|
|
144
|
+
fields: [
|
|
145
|
+
space,
|
|
146
|
+
field("start", "start_address", "address"),
|
|
147
|
+
field("end", "end_address", "address"),
|
|
148
|
+
field("switch", "switch_name", "text"),
|
|
149
|
+
field("code", "code", "text"),
|
|
150
|
+
field("comment", "comment", "text", true),
|
|
151
|
+
origin,
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
table: "macros",
|
|
156
|
+
file: "macros.jsonl",
|
|
157
|
+
sort: "name",
|
|
158
|
+
fields: [space, field("name", "name", "text"), field("params", "params", "text"), field("body", "body", "text"), field("comment", "comment", "text", true), origin],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
table: "macro_uses",
|
|
162
|
+
file: "macro_uses.jsonl",
|
|
163
|
+
sort: "start_address",
|
|
164
|
+
fields: [space, field("start", "start_address", "address"), field("end", "end_address", "address"), field("macro", "macro", "text"), field("args", "args", "text"), origin],
|
|
165
|
+
},
|
|
166
|
+
// The program itself: its bytes, at most 64 to a row, so a changed byte changes one line.
|
|
167
|
+
{ table: "memory", file: "memory.jsonl", sort: "address", fields: [space, field("at", "address", "address"), field("bytes", "bytes", "hex"), origin] },
|
|
168
|
+
// Where the program starts, the machine state there, and the stages of its unpacker.
|
|
169
|
+
{
|
|
170
|
+
table: "entries",
|
|
171
|
+
file: "entry.jsonl",
|
|
172
|
+
sort: "address",
|
|
173
|
+
fields: [space, field("entry", "address", "address"), field("state", "state", "json", true), field("stages", "stages", "json", true), origin],
|
|
174
|
+
},
|
|
175
|
+
// What the runs did: the union of the coverage maps as ranges of one kind each.
|
|
176
|
+
{ table: "coverage", file: "coverage.jsonl", sort: "start_address", fields: [space, field("kind", "kind", "text"), field("start", "start_address", "address"), field("end", "end_address", "address"), origin] },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
export const REVISIONS_FILE = "revisions.jsonl";
|
|
180
|
+
|
|
181
|
+
/** The revision fields in line order; `evidence` comes last. */
|
|
182
|
+
const REVISION_FIELDS: readonly Field[] = [
|
|
183
|
+
field("id", "id", "text"),
|
|
184
|
+
field("time", "created_at", "text"),
|
|
185
|
+
field("origin", "origin", "text"),
|
|
186
|
+
field("operation", "operation", "text"),
|
|
187
|
+
field("reason", "description", "text", true),
|
|
188
|
+
field("inputHash", "input_hash", "text", true),
|
|
189
|
+
field("toolVersion", "tool_version", "text", true),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
/** The SQL name of a table: "references" is a keyword. */
|
|
193
|
+
export const sqlName = (table: string) => (table === "references" ? '"references"' : table);
|
|
194
|
+
|
|
195
|
+
type Raw = Record<string, unknown>;
|
|
196
|
+
|
|
197
|
+
function formatted(value: unknown, format: Format): unknown {
|
|
198
|
+
if (format === "address") return `$${(value as number).toString(16).padStart(4, "0")}`;
|
|
199
|
+
return format === "json" ? JSON.parse(value as string) : value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function lineOf(raw: Raw, fields: readonly Field[], extra: Raw): string {
|
|
203
|
+
const line: Raw = {};
|
|
204
|
+
for (const { key, column, format, optional } of fields) {
|
|
205
|
+
const value = raw[column];
|
|
206
|
+
if (value === null && optional) continue;
|
|
207
|
+
line[key] = formatted(value, format);
|
|
208
|
+
}
|
|
209
|
+
return JSON.stringify({ ...line, ...extra });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const spaceOrder = (space: string) => (space === "c64" ? -2 : space === "drive8" ? -1 : Number(space.slice(4)));
|
|
213
|
+
|
|
214
|
+
/** The sort key of a row: space, address (or name), the revision that added it, its order in that revision. */
|
|
215
|
+
function compareRows(spec: TableSpec) {
|
|
216
|
+
const order = (raw: Raw) => Number(ROW_ID.exec(raw.id as string)?.[2] ?? 0);
|
|
217
|
+
const key = (a: unknown, b: unknown) => (typeof a === "number" ? a - (b as number) : compareText(a as string, b as string));
|
|
218
|
+
return (a: Raw, b: Raw) =>
|
|
219
|
+
spaceOrder(a.space as string) - spaceOrder(b.space as string) ||
|
|
220
|
+
key(a[spec.sort], b[spec.sort]) ||
|
|
221
|
+
compareText(a.valid_from_revision as string, b.valid_from_revision as string) ||
|
|
222
|
+
order(a) - order(b) ||
|
|
223
|
+
compareText(a.id as string, b.id as string);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The text of one fact table: every row, current and closed, one line each, sorted. */
|
|
227
|
+
export function tableText(db: DatabaseSync, spec: TableSpec): string {
|
|
228
|
+
const rows = (db.prepare(`SELECT * FROM ${sqlName(spec.table)}`).all() as Raw[]).sort(compareRows(spec));
|
|
229
|
+
return rows.map((raw) => `${lineOf(raw, [field("id", "id", "text"), ...spec.fields], { added: raw.valid_from_revision, closed: raw.valid_to_revision })}\n`).join("");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The text of the revision log: every revision with its evidence, oldest first. */
|
|
233
|
+
export function revisionsText(db: DatabaseSync): string {
|
|
234
|
+
const evidence = db.prepare("SELECT kind, ref, sha256, space FROM evidence WHERE revision = ? ORDER BY id");
|
|
235
|
+
return (db.prepare("SELECT * FROM revisions ORDER BY id").all() as Raw[])
|
|
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 }), ...(item.space === null ? {} : { space: item.space }) }));
|
|
238
|
+
return `${lineOf(raw, REVISION_FIELDS, items.length === 0 ? {} : { evidence: items })}\n`;
|
|
239
|
+
})
|
|
240
|
+
.join("");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const sha256 = (text: string | Buffer) => createHash("sha256").update(text).digest("hex");
|
|
244
|
+
|
|
245
|
+
/** The files of the knowledge: their text and its SHA-256. A file that is not knowledge text is refused. */
|
|
246
|
+
export function readText(directory: string): Map<string, { text: string; sha256: string }> {
|
|
247
|
+
const known = new Set([REVISIONS_FILE, ...TABLES.map((spec) => spec.file)]);
|
|
248
|
+
const files = new Map<string, { text: string; sha256: string }>();
|
|
249
|
+
for (const name of readdirSync(directory).sort()) {
|
|
250
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
251
|
+
if (!known.has(name)) throw new KnowledgeError("invalid-database", `.c64-re-tools/knowledge/${name} is not a knowledge file. Knowledge files are ${[...known].join(", ")}.`);
|
|
252
|
+
const bytes = readFileSync(join(directory, name));
|
|
253
|
+
files.set(name, { text: bytes.toString("utf8"), sha256: sha256(bytes) });
|
|
254
|
+
}
|
|
255
|
+
return files;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Writes a file through a temporary file and a rename, so a reader never sees half of it. */
|
|
259
|
+
export function writeAtomic(path: string, text: string): void {
|
|
260
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
261
|
+
try {
|
|
262
|
+
writeFileSync(temporary, text);
|
|
263
|
+
renameSync(temporary, path);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
rmSync(temporary, { force: true });
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** One line of a knowledge file that cannot be read. */
|
|
271
|
+
function bad(file: string, line: number, message: string): never {
|
|
272
|
+
throw new KnowledgeError("invalid-database", `.c64-re-tools/knowledge/${file}:${line}: ${message}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function parseLines(file: string, text: string): Array<{ line: number; value: Raw }> {
|
|
276
|
+
const lines: Array<{ line: number; value: Raw }> = [];
|
|
277
|
+
text.split("\n").forEach((source, index) => {
|
|
278
|
+
if (source.trim().length === 0) return;
|
|
279
|
+
let value: unknown;
|
|
280
|
+
try {
|
|
281
|
+
value = JSON.parse(source);
|
|
282
|
+
} catch {
|
|
283
|
+
bad(file, index + 1, "the line is not JSON.");
|
|
284
|
+
}
|
|
285
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) bad(file, index + 1, "the line is not a JSON object.");
|
|
286
|
+
lines.push({ line: index + 1, value: value as Raw });
|
|
287
|
+
});
|
|
288
|
+
return lines;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The column values of one line, checked against its fields. */
|
|
292
|
+
function columnsOf(file: string, line: number, value: Raw, fields: readonly Field[], extra: readonly string[]): Raw {
|
|
293
|
+
const allowed = new Set([...fields.map((item) => item.key), ...extra]);
|
|
294
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) bad(file, line, `the key ${JSON.stringify(key)} is not known.`);
|
|
295
|
+
const columns: Raw = {};
|
|
296
|
+
for (const { key, column, format, optional } of fields) {
|
|
297
|
+
const item = value[key];
|
|
298
|
+
if (item === undefined || item === null) {
|
|
299
|
+
if (!optional) bad(file, line, `the key ${JSON.stringify(key)} is missing.`);
|
|
300
|
+
columns[column] = null;
|
|
301
|
+
} else if (format === "address") {
|
|
302
|
+
if (typeof item !== "string" || !/^\$[0-9a-f]{4}$/.test(item)) bad(file, line, `${key} must be an address such as "$c000".`);
|
|
303
|
+
columns[column] = Number.parseInt(item.slice(1), 16);
|
|
304
|
+
} else if (format === "integer") {
|
|
305
|
+
if (!Number.isInteger(item)) bad(file, line, `${key} must be a whole number.`);
|
|
306
|
+
columns[column] = item;
|
|
307
|
+
} else if (format === "hex") {
|
|
308
|
+
if (typeof item !== "string" || !/^(?:[0-9a-f]{2})+$/.test(item)) bad(file, line, `${key} must be bytes as lowercase hex digits.`);
|
|
309
|
+
columns[column] = item;
|
|
310
|
+
} else if (format === "json") {
|
|
311
|
+
if (typeof item !== "object") bad(file, line, `${key} must be a JSON object or list.`);
|
|
312
|
+
columns[column] = JSON.stringify(item);
|
|
313
|
+
} else {
|
|
314
|
+
if (typeof item !== "string") bad(file, line, `${key} must be text.`);
|
|
315
|
+
columns[column] = item;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return columns;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The lines of one file joined by id: git can keep the same line of both sides of a merge. */
|
|
322
|
+
function joinById<T extends { id: string }>(file: string, entries: Array<{ line: number; row: T }>, join: (a: T, b: T) => T | undefined): T[] {
|
|
323
|
+
const byId = new Map<string, { line: number; row: T }>();
|
|
324
|
+
for (const entry of entries) {
|
|
325
|
+
const known = byId.get(entry.row.id);
|
|
326
|
+
if (known === undefined) {
|
|
327
|
+
byId.set(entry.row.id, entry);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const joined = join(known.row, entry.row);
|
|
331
|
+
if (joined === undefined) bad(file, entry.line, `the id ${entry.row.id} is also on line ${known.line} with other values.`);
|
|
332
|
+
byId.set(entry.row.id, { line: known.line, row: joined });
|
|
333
|
+
}
|
|
334
|
+
return [...byId.values()].map((entry) => entry.row);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
interface RevisionLine {
|
|
338
|
+
id: string;
|
|
339
|
+
columns: Raw;
|
|
340
|
+
evidence: Array<{ kind: unknown; ref: unknown; sha256: unknown; space: unknown }>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
344
|
+
|
|
345
|
+
function parseRevisions(text: string): RevisionLine[] {
|
|
346
|
+
const entries = parseLines(REVISIONS_FILE, text).map(({ line, value }) => {
|
|
347
|
+
const columns = columnsOf(REVISIONS_FILE, line, value, REVISION_FIELDS, ["evidence"]);
|
|
348
|
+
if (!REVISION_ID.test(columns.id as string)) bad(REVISIONS_FILE, line, `${JSON.stringify(columns.id)} is not a revision id.`);
|
|
349
|
+
const evidence = value.evidence ?? [];
|
|
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
|
+
const items = (evidence as Raw[]).map((item) => {
|
|
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
|
+
});
|
|
355
|
+
return { line, row: { id: columns.id as string, columns, evidence: items } };
|
|
356
|
+
});
|
|
357
|
+
return joinById(REVISIONS_FILE, entries, (a, b) => (same(a, b) ? a : undefined));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
interface RowLine {
|
|
361
|
+
id: string;
|
|
362
|
+
columns: Raw;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function parseRows(spec: TableSpec, text: string): RowLine[] {
|
|
366
|
+
const entries = parseLines(spec.file, text).map(({ line, value }) => {
|
|
367
|
+
const columns = columnsOf(spec.file, line, value, [field("id", "id", "text"), ...spec.fields, field("added", "valid_from_revision", "text"), field("closed", "valid_to_revision", "text", true)], []);
|
|
368
|
+
const id = ROW_ID.exec(columns.id as string);
|
|
369
|
+
if (id === null) bad(spec.file, line, `${JSON.stringify(columns.id)} is not a row id.`);
|
|
370
|
+
if (id[1] !== columns.valid_from_revision) bad(spec.file, line, `the row ${columns.id} must be added in revision ${id[1]}.`);
|
|
371
|
+
if (columns.valid_to_revision !== null && !REVISION_ID.test(columns.valid_to_revision as string)) bad(spec.file, line, `${JSON.stringify(columns.valid_to_revision)} is not a revision id.`);
|
|
372
|
+
return { line, row: { id: columns.id as string, columns } };
|
|
373
|
+
});
|
|
374
|
+
// A closed line wins over the open line of the same row; of two closed lines, the earlier close wins.
|
|
375
|
+
return joinById(spec.file, entries, (a, b) => {
|
|
376
|
+
const { valid_to_revision: closedA, ...restA } = a.columns;
|
|
377
|
+
const { valid_to_revision: closedB, ...restB } = b.columns;
|
|
378
|
+
if (!same(restA, restB)) return undefined;
|
|
379
|
+
if (closedA === null) return b;
|
|
380
|
+
if (closedB === null) return a;
|
|
381
|
+
return (closedA as string) <= (closedB as string) ? a : b;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Fills an empty index from the text of the knowledge. A line that the index
|
|
387
|
+
* refuses (an unknown origin, a revision that is not in the log) is refused
|
|
388
|
+
* with its file and row.
|
|
389
|
+
*/
|
|
390
|
+
export function loadText(db: DatabaseSync, files: ReadonlyMap<string, { text: string }>): void {
|
|
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, space) VALUES (?, ?, ?, ?, ?)");
|
|
393
|
+
const revisions = parseRevisions(files.get(REVISIONS_FILE)?.text ?? "").sort((a, b) => compareText(a.id, b.id));
|
|
394
|
+
for (const revision of revisions) {
|
|
395
|
+
try {
|
|
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, item.space as string | null);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
throw new KnowledgeError("invalid-database", `.c64-re-tools/knowledge/${REVISIONS_FILE}: the revision ${revision.id} is not valid: ${(error as Error).message}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const spec of TABLES) {
|
|
403
|
+
const text = files.get(spec.file)?.text;
|
|
404
|
+
if (text === undefined) continue;
|
|
405
|
+
const columns = ["id", ...spec.fields.map((item) => item.column), "valid_from_revision", "valid_to_revision"];
|
|
406
|
+
const insert = db.prepare(`INSERT INTO ${sqlName(spec.table)} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`);
|
|
407
|
+
for (const row of parseRows(spec, text)) {
|
|
408
|
+
try {
|
|
409
|
+
insert.run(...(columns.map((column) => row.columns[column]) as Array<string | number | null>));
|
|
410
|
+
} catch (error) {
|
|
411
|
+
throw new KnowledgeError("invalid-database", `.c64-re-tools/knowledge/${spec.file}: the row ${row.id} is not valid: ${(error as Error).message}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|