@henols/c64-re-tools 2.0.0-rc.2 → 2.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +19 -59
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +5 -2
  4. package/skills/c64-assembler/scripts/assemble.ts +8 -1
  5. package/skills/c64-assembler/scripts/compare.ts +84 -0
  6. package/skills/c64-basic/SKILL.md +1 -2
  7. package/skills/c64-disk/SKILL.md +20 -6
  8. package/skills/c64-disk/scripts/containers.ts +124 -0
  9. package/skills/c64-disk/scripts/disk.ts +59 -5
  10. package/skills/c64-emulator/SKILL.md +11 -4
  11. package/skills/c64-knowledge/SKILL.md +53 -3
  12. package/skills/c64-knowledge/scripts/knowledge.ts +258 -29
  13. package/skills/c64-memory-map/SKILL.md +4 -1
  14. package/skills/c64-memory-map/scripts/memmap.ts +14 -3
  15. package/skills/c64-provenance/SKILL.md +37 -9
  16. package/skills/c64-provenance/scripts/compare.ts +60 -29
  17. package/skills/c64-provenance/scripts/provenance.ts +248 -19
  18. package/skills/c64-provenance/scripts/runtime.ts +179 -0
  19. package/skills/c64-provenance/scripts/separate.ts +132 -0
  20. package/skills/c64-reverse-engineering/SKILL.md +65 -11
  21. package/skills/c64-source/SKILL.md +119 -0
  22. package/skills/c64-source/scripts/generate.ts +402 -0
  23. package/skills/c64-source/scripts/loader.ts +320 -0
  24. package/skills/c64-source/scripts/opcodes.ts +61 -0
  25. package/skills/c64-source/scripts/source.ts +558 -0
  26. package/skills/c64-static-analysis/SKILL.md +27 -4
  27. package/skills/c64-static-analysis/scripts/analyze.ts +102 -13
  28. package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
  29. package/skills/c64-static-analysis/scripts/findings.ts +6 -4
  30. package/skills/c64-static-analysis/scripts/idioms.ts +144 -0
  31. package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
  32. package/skills/c64-testing/SKILL.md +27 -3
  33. package/skills/c64-testing/references/scenario-format.md +43 -3
  34. package/skills/c64-testing/scripts/run.ts +189 -0
  35. package/skills/c64-testing/scripts/scenario.ts +153 -135
  36. package/skills/c64-testing/scripts/test.ts +16 -6
  37. package/skills/c64-unpacker/SKILL.md +65 -14
  38. package/skills/c64-unpacker/scripts/evidence.ts +86 -21
  39. package/skills/c64-unpacker/scripts/loads.ts +174 -0
  40. package/skills/c64-unpacker/scripts/machine.ts +68 -0
  41. package/skills/c64-unpacker/scripts/unpack.ts +302 -48
  42. package/src/c64.ts +91 -0
  43. package/src/cli/status.ts +3 -1
  44. package/src/host/main.ts +10 -25
  45. package/src/host/server.ts +8 -40
  46. package/src/host/vice/adapter.ts +25 -10
  47. package/src/host/vice/session.ts +64 -20
  48. package/src/host-client/autostart.ts +45 -0
  49. package/src/host-client/connect.ts +4 -5
  50. package/src/host-client/coverage.ts +59 -0
  51. package/src/host-client/steps.ts +170 -0
  52. package/src/host-client/transfer.ts +29 -3
  53. package/src/host-client/vice-session.ts +42 -13
  54. package/src/knowledge/history.ts +40 -13
  55. package/src/knowledge/import.ts +72 -56
  56. package/src/knowledge/read.ts +203 -52
  57. package/src/knowledge/schema.ts +94 -12
  58. package/src/knowledge/write.ts +432 -123
  59. package/src/mcp/main.ts +1 -4
  60. package/src/mcp/output.ts +32 -0
  61. package/src/mcp/server.ts +4 -2
  62. package/src/mcp/tools/debug.ts +110 -24
  63. package/src/mcp/tools/input.ts +1 -0
  64. package/src/mcp/tools/media.ts +22 -6
  65. package/src/mcp/tools/memory.ts +57 -10
  66. package/src/mcp/tools/video.ts +9 -3
  67. package/src/native/discover.ts +5 -2
  68. package/src/native/ghidra/index.ts +19 -3
  69. package/src/native/ghidra/scripts/C64Export.java +33 -1
  70. package/src/native/status.ts +15 -3
  71. package/src/native/types.ts +30 -1
  72. package/{skills/c64-memory-map/scripts → src}/platform.ts +18 -2
  73. package/src/protocol/messages.ts +17 -10
  74. package/src/protocol/vice.ts +72 -18
  75. package/src/script.ts +3 -2
@@ -10,34 +10,58 @@ import {
10
10
  COMMENT_COLUMNS,
11
11
  commentsAt,
12
12
  currentRevision,
13
+ MODULE_COLUMNS,
14
+ moduleAt,
15
+ moduleWithFile,
13
16
  REFERENCE_COLUMNS,
14
17
  referencesFrom,
15
18
  REGION_COLUMNS,
16
19
  regionsOverlapping,
20
+ SUPPRESSION_COLUMNS,
21
+ suppressionsOverlapping,
17
22
  SYMBOL_COLUMNS,
18
23
  symbolAt,
19
24
  symbolNamed,
20
25
  toComment,
26
+ toModule,
21
27
  toReference,
22
28
  toRegion,
29
+ toSuppression,
23
30
  toSymbol,
24
31
  type CommentRow,
32
+ type ModuleRow,
25
33
  type ReferenceRow,
26
34
  type RegionRow,
35
+ type SuppressionRow,
27
36
  type SymbolRow,
28
37
  } from "./read.ts";
29
38
  import {
30
39
  COMMENT_PLACEMENTS,
40
+ EVIDENCE_KINDS,
41
+ MAX_ELEMENT,
42
+ MODULE_FILE,
43
+ DEFAULT_SPACE,
44
+ SPACE,
45
+ NO_ELEMENT_TYPES,
31
46
  REFERENCE_KINDS,
32
47
  REGION_TYPES,
33
48
  SYMBOL_KINDS,
34
49
  type CommentPlacement,
50
+ type EvidenceKind,
35
51
  type Origin,
36
52
  type ReferenceKind,
37
53
  type RegionType,
54
+ type Space,
38
55
  type SymbolKind,
39
56
  } from "./schema.ts";
40
57
 
58
+ /** What a change rests on (D42): a coverage run, a test or another project file with its SHA-256, or a note. */
59
+ export interface Evidence {
60
+ kind: EvidenceKind;
61
+ ref: string;
62
+ sha256?: string;
63
+ }
64
+
41
65
  /** Who writes and why. Semantic writes come from the user or the LLM, never as an analyzer. */
42
66
  export interface WriteContext {
43
67
  origin: Extract<Origin, "user" | "llm">;
@@ -45,6 +69,8 @@ export interface WriteContext {
45
69
  description?: string;
46
70
  /** The revision the caller read; the write is refused if knowledge changed since. */
47
71
  expectedRevision?: number;
72
+ /** Kept with the revision when the write changes something. */
73
+ evidence?: Evidence[];
48
74
  }
49
75
 
50
76
  /** The revision a write created, or null when nothing needed to change. */
@@ -57,6 +83,7 @@ export interface WriteResult<T, P = never> {
57
83
 
58
84
  export { SYMBOL_NAME };
59
85
  const MAX_COMMENT = 4000;
86
+ const MAX_EVIDENCE_REF = 1000;
60
87
 
61
88
  export function checkAddress(value: number, what = "address"): void {
62
89
  if (!Number.isInteger(value) || value < 0 || value > 0xffff) throw new KnowledgeError("invalid-input", `${what} must be from $0000 to $ffff.`);
@@ -70,6 +97,25 @@ export function hex(value: number): string {
70
97
  return `$${value.toString(16).padStart(4, "0")}`;
71
98
  }
72
99
 
100
+ export interface AddressRange {
101
+ start: number;
102
+ end: number;
103
+ }
104
+
105
+ /** Parts of `range` that no range in `holes` covers. */
106
+ export function subtract(range: AddressRange, holes: AddressRange[]): AddressRange[] {
107
+ let pieces = [range];
108
+ for (const hole of holes) {
109
+ pieces = pieces.flatMap((piece) => {
110
+ if (hole.end < piece.start || hole.start > piece.end) return [piece];
111
+ const left = hole.start > piece.start ? [{ start: piece.start, end: hole.start - 1 }] : [];
112
+ const right = hole.end < piece.end ? [{ start: hole.end + 1, end: piece.end }] : [];
113
+ return [...left, ...right];
114
+ });
115
+ }
116
+ return pieces;
117
+ }
118
+
73
119
  /** What a revision records about its writer. Analyzer imports may add tool details. */
74
120
  export interface ChangeContext {
75
121
  origin: Origin;
@@ -77,6 +123,7 @@ export interface ChangeContext {
77
123
  expectedRevision?: number;
78
124
  inputHash?: string;
79
125
  toolVersion?: string;
126
+ evidence?: Evidence[];
80
127
  }
81
128
 
82
129
  /**
@@ -102,7 +149,7 @@ export class Change {
102
149
  }
103
150
  }
104
151
 
105
- /** The id of this write's revision, created on first use. */
152
+ /** The id of this write's revision, created on first use with its evidence. */
106
153
  id(): number {
107
154
  if (this.revision !== null) return this.revision;
108
155
  const created = this.#db
@@ -117,6 +164,8 @@ export class Change {
117
164
  ) as { id: number };
118
165
  this.revision = created.id;
119
166
  this.#db.prepare("UPDATE meta SET value = ? WHERE key = 'current_revision'").run(String(created.id));
167
+ const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256) VALUES (?, ?, ?, ?)");
168
+ for (const item of this.#context.evidence ?? []) evidence.run(created.id, item.kind, item.ref, item.sha256 ?? null);
120
169
  return created.id;
121
170
  }
122
171
 
@@ -126,31 +175,65 @@ export class Change {
126
175
  }
127
176
 
128
177
  insertSymbol(row: Omit<SymbolRow, "revision">): void {
129
- this.#db.prepare("INSERT INTO symbols (address, name, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)").run(row.address, row.name, row.kind, row.origin, this.id());
178
+ this.#db.prepare("INSERT INTO symbols (space, address, name, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)").run(row.space, row.address, row.name, row.kind, row.origin, this.id());
130
179
  }
131
180
 
132
181
  insertRegion(row: Omit<RegionRow, "revision">): void {
133
182
  this.#db
134
- .prepare("INSERT INTO regions (start_address, end_address, type, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)")
135
- .run(row.start, row.end, row.type, row.origin, this.id());
183
+ .prepare("INSERT INTO regions (space, start_address, end_address, type, element, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)")
184
+ .run(row.space, row.start, row.end, row.type, row.element ?? null, row.origin, this.id());
136
185
  }
137
186
 
138
187
  insertComment(row: Omit<CommentRow, "revision">): void {
139
- this.#db.prepare("INSERT INTO comments (address, placement, text, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)").run(row.address, row.placement, row.text, row.origin, this.id());
188
+ this.#db
189
+ .prepare("INSERT INTO comments (space, address, placement, text, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)")
190
+ .run(row.space, row.address, row.placement, row.text, row.origin, this.id());
140
191
  }
141
192
 
142
193
  insertReference(row: Omit<ReferenceRow, "revision">): void {
143
194
  this.#db
144
- .prepare('INSERT INTO "references" (from_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)')
145
- .run(row.from, row.to, row.kind, row.origin, this.id());
195
+ .prepare('INSERT INTO "references" (space, from_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)')
196
+ .run(row.space, row.from, row.to, row.kind, row.origin, this.id());
197
+ }
198
+
199
+ insertModule(row: Omit<ModuleRow, "revision">): void {
200
+ this.#db
201
+ .prepare("INSERT INTO modules (space, start_address, file, title, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)")
202
+ .run(row.space, row.start, row.file, row.title ?? null, row.origin, this.id());
203
+ }
204
+
205
+ insertSuppression(row: Omit<SuppressionRow, "revision">): void {
206
+ this.#db
207
+ .prepare("INSERT INTO suppressions (space, entity, start_address, end_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
208
+ .run(row.space, row.entity, row.start, row.end, row.to ?? null, row.kind ?? null, row.origin, this.id());
146
209
  }
147
210
  }
148
211
 
149
- /** Runs one semantic write in a transaction. */
150
- function write<T>(db: DatabaseSync, context: WriteContext, operation: string, work: (change: Change) => T): WriteResult<T> {
212
+ /** Closes the current region suppressions over [start, end] and keeps their parts outside it: a user/LLM region there decides now. */
213
+ function liftRegionSuppressions(db: DatabaseSync, change: Change, start: number, end: number, space: Space): void {
214
+ const overlapping = suppressionsOverlapping(db, "region", start, end, space);
215
+ if (overlapping.length === 0) return;
216
+ change.close("suppressions", "space = ? AND entity = 'region' AND start_address <= ? AND end_address >= ?", space, end, start);
217
+ for (const row of overlapping) {
218
+ if (row.start < start) change.insertSuppression({ ...row, end: start - 1 });
219
+ if (row.end > end) change.insertSuppression({ ...row, start: end + 1 });
220
+ }
221
+ }
222
+
223
+ function checkEvidence(evidence: Evidence[] | undefined): void {
224
+ for (const item of evidence ?? []) {
225
+ checkOneOf(EVIDENCE_KINDS, item.kind, "evidence kind");
226
+ 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.`);
227
+ if (item.sha256 !== undefined && !/^[0-9a-f]{64}$/.test(item.sha256)) throw new KnowledgeError("invalid-input", "evidence sha256 must be 64 lowercase hex digits.");
228
+ }
229
+ }
230
+
231
+ /** Runs semantic writes in one transaction, as one revision. */
232
+ function write<T>(db: DatabaseSync, context: WriteContext, operation: string, work: (change: Change) => T): { revision: number | null; current: T } {
151
233
  if (context.origin !== "user" && context.origin !== "llm") {
152
234
  throw new KnowledgeError("invalid-input", "A semantic write comes from the user or the LLM.");
153
235
  }
236
+ checkEvidence(context.evidence);
154
237
  return transaction(db, () => {
155
238
  const change = new Change(db, context, operation);
156
239
  const current = work(change);
@@ -158,160 +241,378 @@ function write<T>(db: DatabaseSync, context: WriteContext, operation: string, wo
158
241
  });
159
242
  }
160
243
 
161
- /** Names the symbol at an address (one current symbol per address, unique names). */
162
- export function renameSymbol(
163
- db: DatabaseSync,
164
- context: WriteContext,
165
- request: { address: number; name: string; kind?: SymbolKind },
166
- ): WriteResult<SymbolRow, SymbolRow> {
244
+ /** One step of a write inside a change: its current value, and the rows of another origin it replaced. */
245
+ interface Step<T, P = never> {
246
+ current: T;
247
+ previous?: P;
248
+ }
249
+
250
+ const result = <T, P>(written: { revision: number | null; current: Step<T, P> }): WriteResult<T, P> =>
251
+ written.current.previous === undefined ? { revision: written.revision, current: written.current.current } : { revision: written.revision, current: written.current.current, previous: written.current.previous };
252
+
253
+ // Each write has a check, which refuses bad input before anything opens, and a
254
+ // step, which runs inside a change. A single write and a batch share both (D42).
255
+
256
+ /** Every request names its space (D53); without one it is the C64. */
257
+ type InSpace = { space?: Space };
258
+ type SymbolRequest = InSpace & { address: number; name: string; kind?: SymbolKind };
259
+ type RegionRequest = InSpace & { start: number; end: number; type: RegionType; element?: number };
260
+ type RangeRequest = InSpace & { start: number; end: number };
261
+ type CommentRequest = InSpace & { address: number; placement: CommentPlacement; text: string };
262
+ type PlacementRequest = InSpace & { address: number; placement: CommentPlacement };
263
+ type ReferenceRequest = InSpace & { from: number; to: number; kind: ReferenceKind };
264
+ type AddressRequest = InSpace & { address: number };
265
+
266
+ const spaceOf = (request: InSpace): Space => request.space ?? DEFAULT_SPACE;
267
+
268
+ export function checkSpace(request: InSpace): void {
269
+ if (request.space !== undefined && !SPACE.test(request.space)) throw new KnowledgeError("invalid-input", "space must be c64, drive8, or bank0 to bank255.");
270
+ }
271
+
272
+ function checkSymbol(request: SymbolRequest): void {
273
+ checkSpace(request);
167
274
  checkAddress(request.address);
168
275
  if (!SYMBOL_NAME.test(request.name)) {
169
276
  throw new KnowledgeError("invalid-input", "A symbol name is a letter or underscore, then letters, digits or underscores (at most 64), with an optional leading dot.");
170
277
  }
171
278
  if (request.kind !== undefined) checkOneOf(SYMBOL_KINDS, request.kind, "kind");
172
- let previous: SymbolRow | undefined;
173
- const result = write(db, context, "rename-symbol", (change) => {
174
- const existing = symbolAt(db, request.address);
175
- const kind = request.kind ?? existing?.kind ?? "label";
176
- if (existing !== undefined && existing.name === request.name && existing.kind === kind) return existing;
177
- const owner = symbolNamed(db, request.name);
178
- if (owner !== undefined && owner.address !== request.address) {
179
- throw new KnowledgeError("conflict", `The name ${request.name} already belongs to ${hex(owner.address)}. Rename or remove that symbol first.`);
180
- }
181
- if (existing !== undefined) {
182
- change.close("symbols", "address = ?", request.address);
183
- if (existing.origin !== context.origin) previous = existing;
184
- }
185
- change.insertSymbol({ address: request.address, name: request.name, kind, origin: context.origin });
186
- return symbolAt(db, request.address)!;
187
- });
188
- return previous === undefined ? result : { ...result, previous };
189
279
  }
190
280
 
191
- export function removeSymbol(db: DatabaseSync, context: WriteContext, request: { address: number }): WriteResult<null> {
192
- checkAddress(request.address);
193
- return write(db, context, "remove-symbol", (change) => {
194
- if (symbolAt(db, request.address) === undefined) throw new KnowledgeError("not-found", `There is no symbol at ${hex(request.address)}.`);
195
- change.close("symbols", "address = ?", request.address);
196
- return null;
197
- });
281
+ function renameStep(db: DatabaseSync, change: Change, origin: Origin, request: SymbolRequest): Step<SymbolRow, SymbolRow> {
282
+ const space = spaceOf(request);
283
+ const existing = symbolAt(db, request.address, space);
284
+ const kind = request.kind ?? existing?.kind ?? "label";
285
+ if (existing !== undefined && existing.name === request.name && existing.kind === kind) return { current: existing };
286
+ const owner = symbolNamed(db, request.name, space);
287
+ if (owner !== undefined && owner.address !== request.address) {
288
+ throw new KnowledgeError("conflict", `The name ${request.name} already belongs to ${hex(owner.address)}. Rename or remove that symbol first.`);
289
+ }
290
+ if (existing !== undefined) change.close("symbols", "space = ? AND address = ?", space, request.address);
291
+ change.insertSymbol({ space, address: request.address, name: request.name, kind, origin });
292
+ change.close("suppressions", "space = ? AND entity = 'symbol' AND start_address = ?", space, request.address);
293
+ const current = symbolAt(db, request.address, space)!;
294
+ return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
198
295
  }
199
296
 
200
- /** Closes the current regions over [start, end] and keeps their parts outside it. */
201
- export function clearRange(db: DatabaseSync, change: Change, start: number, end: number): void {
202
- const overlapping = regionsOverlapping(db, start, end);
203
- if (overlapping.length === 0) return;
204
- change.close("regions", "start_address <= ? AND end_address >= ?", end, start);
205
- for (const region of overlapping) {
206
- if (region.start < start) change.insertRegion({ start: region.start, end: start - 1, type: region.type, origin: region.origin });
207
- if (region.end > end) change.insertRegion({ start: end + 1, end: region.end, type: region.type, origin: region.origin });
208
- }
297
+ function removeSymbolStep(db: DatabaseSync, change: Change, origin: Origin, request: AddressRequest): Step<null> {
298
+ const space = spaceOf(request);
299
+ if (symbolAt(db, request.address, space) === undefined) throw new KnowledgeError("not-found", `There is no symbol at ${hex(request.address)}.`);
300
+ change.close("symbols", "space = ? AND address = ?", space, request.address);
301
+ change.insertSuppression({ space, entity: "symbol", start: request.address, end: request.address, origin: origin as SuppressionRow["origin"] });
302
+ return { current: null };
209
303
  }
210
304
 
211
- /** Classifies [start, end]; overlapping regions are split so current regions never overlap. */
212
- export function classifyRegion(
213
- db: DatabaseSync,
214
- context: WriteContext,
215
- request: { start: number; end: number; type: RegionType },
216
- ): WriteResult<RegionRow[], RegionRow[]> {
305
+ function checkRange(request: RangeRequest): void {
306
+ checkSpace(request);
217
307
  checkAddress(request.start, "start");
218
308
  checkAddress(request.end, "end");
219
309
  if (request.end < request.start) throw new KnowledgeError("invalid-input", "end must not be before start.");
310
+ }
311
+
312
+ function checkRegion(request: RegionRequest): void {
313
+ checkRange(request);
220
314
  checkOneOf(REGION_TYPES, request.type, "type");
221
- let previous: RegionRow[] = [];
222
- const result = write(db, context, "classify-region", (change) => {
223
- const overlapping = regionsOverlapping(db, request.start, request.end);
224
- const same = overlapping.length === 1 && overlapping[0]!.start === request.start && overlapping[0]!.end === request.end && overlapping[0]!.type === request.type;
225
- if (!same) {
226
- clearRange(db, change, request.start, request.end);
227
- change.insertRegion({ start: request.start, end: request.end, type: request.type, origin: context.origin });
228
- previous = overlapping.filter((row) => row.origin !== context.origin);
229
- }
230
- return regionsOverlapping(db, request.start, request.end);
231
- });
232
- return previous.length === 0 ? result : { ...result, previous };
315
+ if (request.element === undefined) return;
316
+ if (NO_ELEMENT_TYPES.includes(request.type)) throw new KnowledgeError("invalid-input", `A ${request.type} region has no element size.`);
317
+ if (!Number.isInteger(request.element) || request.element < 1 || request.element > MAX_ELEMENT) throw new KnowledgeError("invalid-input", `element must be from 1 to ${MAX_ELEMENT}.`);
318
+ const length = request.end - request.start + 1;
319
+ if (length % request.element !== 0) throw new KnowledgeError("invalid-input", `The region has ${length} bytes, which is not a multiple of the element size ${request.element}.`);
233
320
  }
234
321
 
235
- /** Makes [start, end] unclassified again; parts of regions outside it stay. */
236
- export function unclassifyRegion(db: DatabaseSync, context: WriteContext, request: { start: number; end: number }): WriteResult<null> {
237
- checkAddress(request.start, "start");
238
- checkAddress(request.end, "end");
239
- if (request.end < request.start) throw new KnowledgeError("invalid-input", "end must not be before start.");
240
- return write(db, context, "unclassify-region", (change) => {
241
- if (regionsOverlapping(db, request.start, request.end).length === 0) {
242
- throw new KnowledgeError("not-found", `No region covers ${hex(request.start)}-${hex(request.end)}.`);
243
- }
244
- clearRange(db, change, request.start, request.end);
245
- return null;
246
- });
322
+ /** The element size of a piece of a region: kept when the piece still holds whole elements. */
323
+ function elementOf(piece: AddressRange, element: number | undefined): { element?: number } {
324
+ return element !== undefined && (piece.end - piece.start + 1) % element === 0 ? { element } : {};
325
+ }
326
+
327
+ /** Closes the current regions over [start, end] and keeps their parts outside it. */
328
+ export function clearRange(db: DatabaseSync, change: Change, start: number, end: number, space: Space = DEFAULT_SPACE): void {
329
+ const overlapping = regionsOverlapping(db, start, end, space);
330
+ if (overlapping.length === 0) return;
331
+ change.close("regions", "space = ? AND start_address <= ? AND end_address >= ?", space, end, start);
332
+ for (const region of overlapping) {
333
+ const left = { start: region.start, end: start - 1 };
334
+ const right = { start: end + 1, end: region.end };
335
+ if (region.start < start) change.insertRegion({ space, ...left, type: region.type, ...elementOf(left, region.element), origin: region.origin });
336
+ if (region.end > end) change.insertRegion({ space, ...right, type: region.type, ...elementOf(right, region.element), origin: region.origin });
337
+ }
338
+ }
339
+
340
+ function classifyStep(db: DatabaseSync, change: Change, origin: Origin, request: RegionRequest): Step<RegionRow[], RegionRow[]> {
341
+ const space = spaceOf(request);
342
+ const overlapping = regionsOverlapping(db, request.start, request.end, space);
343
+ const [only] = overlapping;
344
+ const same = overlapping.length === 1 && only!.start === request.start && only!.end === request.end && only!.type === request.type && only!.element === request.element;
345
+ if (same) return { current: overlapping };
346
+ clearRange(db, change, request.start, request.end, space);
347
+ change.insertRegion({ space, start: request.start, end: request.end, type: request.type, ...(request.element === undefined ? {} : { element: request.element }), origin });
348
+ liftRegionSuppressions(db, change, request.start, request.end, space);
349
+ const previous = overlapping.filter((row) => row.origin !== origin);
350
+ const current = regionsOverlapping(db, request.start, request.end, space);
351
+ return previous.length === 0 ? { current } : { current, previous };
352
+ }
353
+
354
+ function unclassifyStep(db: DatabaseSync, change: Change, origin: Origin, request: RangeRequest): Step<null> {
355
+ const space = spaceOf(request);
356
+ if (regionsOverlapping(db, request.start, request.end, space).length === 0) {
357
+ throw new KnowledgeError("not-found", `No region covers ${hex(request.start)}-${hex(request.end)}.`);
358
+ }
359
+ clearRange(db, change, request.start, request.end, space);
360
+ // Suppress only the parts that no region suppression holds yet, so suppressions never overlap.
361
+ for (const piece of subtract({ start: request.start, end: request.end }, suppressionsOverlapping(db, "region", request.start, request.end, space))) {
362
+ change.insertSuppression({ space, entity: "region", ...piece, origin: origin as SuppressionRow["origin"] });
363
+ }
364
+ return { current: null };
247
365
  }
248
366
 
249
- export function setComment(
250
- db: DatabaseSync,
251
- context: WriteContext,
252
- request: { address: number; placement: CommentPlacement; text: string },
253
- ): WriteResult<CommentRow> {
367
+ function checkComment(request: CommentRequest): string {
368
+ checkSpace(request);
254
369
  checkAddress(request.address);
255
370
  checkOneOf(COMMENT_PLACEMENTS, request.placement, "placement");
256
371
  const text = request.text.trim();
257
372
  if (text.length === 0 || text.length > MAX_COMMENT) throw new KnowledgeError("invalid-input", `A comment has 1 to ${MAX_COMMENT} characters.`);
258
- return write(db, context, "set-comment", (change) => {
259
- const existing = commentsAt(db, request.address).find((comment) => comment.placement === request.placement);
260
- if (existing?.text === text) return existing;
261
- if (existing !== undefined) change.close("comments", "address = ? AND placement = ?", request.address, request.placement);
262
- change.insertComment({ address: request.address, placement: request.placement, text, origin: context.origin });
263
- return commentsAt(db, request.address).find((comment) => comment.placement === request.placement)!;
264
- });
373
+ return text;
265
374
  }
266
375
 
267
- export function removeComment(db: DatabaseSync, context: WriteContext, request: { address: number; placement: CommentPlacement }): WriteResult<null> {
376
+ function commentStep(db: DatabaseSync, change: Change, origin: Origin, request: CommentRequest): Step<CommentRow> {
377
+ const space = spaceOf(request);
378
+ const text = request.text.trim();
379
+ const existing = commentsAt(db, request.address, space).find((comment) => comment.placement === request.placement);
380
+ if (existing?.text === text) return { current: existing };
381
+ if (existing !== undefined) change.close("comments", "space = ? AND address = ? AND placement = ?", space, request.address, request.placement);
382
+ change.insertComment({ space, address: request.address, placement: request.placement, text, origin });
383
+ return { current: commentsAt(db, request.address, space).find((comment) => comment.placement === request.placement)! };
384
+ }
385
+
386
+ function checkPlacement(request: PlacementRequest): void {
387
+ checkSpace(request);
268
388
  checkAddress(request.address);
269
389
  checkOneOf(COMMENT_PLACEMENTS, request.placement, "placement");
270
- return write(db, context, "remove-comment", (change) => {
271
- if (!commentsAt(db, request.address).some((comment) => comment.placement === request.placement)) {
272
- throw new KnowledgeError("not-found", `There is no ${request.placement} comment at ${hex(request.address)}.`);
273
- }
274
- change.close("comments", "address = ? AND placement = ?", request.address, request.placement);
275
- return null;
276
- });
277
390
  }
278
391
 
279
- export function addReference(
280
- db: DatabaseSync,
281
- context: WriteContext,
282
- request: { from: number; to: number; kind: ReferenceKind },
283
- ): WriteResult<ReferenceRow> {
284
- checkAddress(request.from, "from");
285
- checkAddress(request.to, "to");
286
- checkOneOf(REFERENCE_KINDS, request.kind, "kind");
287
- return write(db, context, "add-reference", (change) => {
288
- const find = () => referencesFrom(db, request.from).find((reference) => reference.to === request.to && reference.kind === request.kind);
289
- const existing = find();
290
- if (existing !== undefined) return existing;
291
- change.insertReference({ ...request, origin: context.origin });
292
- return find()!;
293
- });
392
+ function uncommentStep(db: DatabaseSync, change: Change, _origin: Origin, request: PlacementRequest): Step<null> {
393
+ const space = spaceOf(request);
394
+ if (!commentsAt(db, request.address, space).some((comment) => comment.placement === request.placement)) {
395
+ throw new KnowledgeError("not-found", `There is no ${request.placement} comment at ${hex(request.address)}.`);
396
+ }
397
+ change.close("comments", "space = ? AND address = ? AND placement = ?", space, request.address, request.placement);
398
+ return { current: null };
294
399
  }
295
400
 
296
- export function removeReference(db: DatabaseSync, context: WriteContext, request: { from: number; to: number; kind: ReferenceKind }): WriteResult<null> {
401
+ function checkReference(request: ReferenceRequest): void {
402
+ checkSpace(request);
297
403
  checkAddress(request.from, "from");
298
404
  checkAddress(request.to, "to");
299
405
  checkOneOf(REFERENCE_KINDS, request.kind, "kind");
300
- return write(db, context, "remove-reference", (change) => {
301
- if (!referencesFrom(db, request.from).some((reference) => reference.to === request.to && reference.kind === request.kind)) {
302
- throw new KnowledgeError("not-found", `There is no ${request.kind} reference from ${hex(request.from)} to ${hex(request.to)}.`);
406
+ }
407
+
408
+ function referenceStep(db: DatabaseSync, change: Change, origin: Origin, request: ReferenceRequest): Step<ReferenceRow> {
409
+ const space = spaceOf(request);
410
+ const find = () => referencesFrom(db, request.from, space).find((reference) => reference.to === request.to && reference.kind === request.kind);
411
+ const existing = find();
412
+ if (existing !== undefined) return { current: existing };
413
+ change.insertReference({ space, from: request.from, to: request.to, kind: request.kind, origin });
414
+ change.close("suppressions", "space = ? AND entity = 'reference' AND start_address = ? AND to_address = ? AND kind = ?", space, request.from, request.to, request.kind);
415
+ return { current: find()! };
416
+ }
417
+
418
+ function unreferenceStep(db: DatabaseSync, change: Change, origin: Origin, request: ReferenceRequest): Step<null> {
419
+ const space = spaceOf(request);
420
+ if (!referencesFrom(db, request.from, space).some((reference) => reference.to === request.to && reference.kind === request.kind)) {
421
+ throw new KnowledgeError("not-found", `There is no ${request.kind} reference from ${hex(request.from)} to ${hex(request.to)}.`);
422
+ }
423
+ change.close('"references"', "space = ? AND from_address = ? AND to_address = ? AND kind = ?", space, request.from, request.to, request.kind);
424
+ change.insertSuppression({ space, entity: "reference", start: request.from, end: request.from, to: request.to, kind: request.kind, origin: origin as SuppressionRow["origin"] });
425
+ return { current: null };
426
+ }
427
+
428
+ type ModuleRequest = InSpace & { start: number; file: string; title?: string };
429
+
430
+ function checkModule(request: ModuleRequest): void {
431
+ checkSpace(request);
432
+ checkAddress(request.start, "start");
433
+ if (!MODULE_FILE.test(request.file)) throw new KnowledgeError("invalid-input", "A module file is a name of letters, digits, underscores or hyphens (at most 60) that ends in .asm, without a directory.");
434
+ if (request.title !== undefined && (request.title.trim().length === 0 || request.title.length > MAX_COMMENT)) throw new KnowledgeError("invalid-input", `A module title has 1 to ${MAX_COMMENT} characters.`);
435
+ }
436
+
437
+ function moduleStep(db: DatabaseSync, change: Change, origin: Origin, request: ModuleRequest): Step<ModuleRow> {
438
+ const space = spaceOf(request);
439
+ const title = request.title?.trim();
440
+ const existing = moduleAt(db, request.start, space);
441
+ if (existing !== undefined && existing.file === request.file && existing.title === title) return { current: existing };
442
+ const owner = moduleWithFile(db, request.file, space);
443
+ if (owner !== undefined && owner.start !== request.start) {
444
+ throw new KnowledgeError("conflict", `The file ${request.file} already belongs to the module at ${hex(owner.start)}. Give this module another file, or remove that module first.`);
445
+ }
446
+ if (existing !== undefined) change.close("modules", "space = ? AND start_address = ?", space, request.start);
447
+ change.insertModule({ space, start: request.start, file: request.file, ...(title === undefined ? {} : { title }), origin: origin as ModuleRow["origin"] });
448
+ return { current: moduleAt(db, request.start, space)! };
449
+ }
450
+
451
+ function unmoduleStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { start: number }): Step<null> {
452
+ const space = spaceOf(request);
453
+ if (moduleAt(db, request.start, space) === undefined) throw new KnowledgeError("not-found", `No module starts at ${hex(request.start)}.`);
454
+ change.close("modules", "space = ? AND start_address = ?", space, request.start);
455
+ return { current: null };
456
+ }
457
+
458
+ /** Names the symbol at an address (one current symbol per address, unique names). */
459
+ export function renameSymbol(db: DatabaseSync, context: WriteContext, request: SymbolRequest): WriteResult<SymbolRow, SymbolRow> {
460
+ checkSymbol(request);
461
+ return result(write(db, context, "rename-symbol", (change) => renameStep(db, change, context.origin, request)));
462
+ }
463
+
464
+ export function removeSymbol(db: DatabaseSync, context: WriteContext, request: AddressRequest): WriteResult<null> {
465
+ checkSpace(request);
466
+ checkAddress(request.address);
467
+ return result(write(db, context, "remove-symbol", (change) => removeSymbolStep(db, change, context.origin, request)));
468
+ }
469
+
470
+ /** Classifies [start, end]; overlapping regions are split so current regions never overlap. */
471
+ export function classifyRegion(db: DatabaseSync, context: WriteContext, request: RegionRequest): WriteResult<RegionRow[], RegionRow[]> {
472
+ checkRegion(request);
473
+ return result(write(db, context, "classify-region", (change) => classifyStep(db, change, context.origin, request)));
474
+ }
475
+
476
+ /** Makes [start, end] unclassified again; parts of regions outside it stay. */
477
+ export function unclassifyRegion(db: DatabaseSync, context: WriteContext, request: RangeRequest): WriteResult<null> {
478
+ checkRange(request);
479
+ return result(write(db, context, "unclassify-region", (change) => unclassifyStep(db, change, context.origin, request)));
480
+ }
481
+
482
+ export function setComment(db: DatabaseSync, context: WriteContext, request: CommentRequest): WriteResult<CommentRow> {
483
+ checkComment(request);
484
+ return result(write(db, context, "set-comment", (change) => commentStep(db, change, context.origin, request)));
485
+ }
486
+
487
+ export function removeComment(db: DatabaseSync, context: WriteContext, request: PlacementRequest): WriteResult<null> {
488
+ checkPlacement(request);
489
+ return result(write(db, context, "remove-comment", (change) => uncommentStep(db, change, context.origin, request)));
490
+ }
491
+
492
+ export function addReference(db: DatabaseSync, context: WriteContext, request: ReferenceRequest): WriteResult<ReferenceRow> {
493
+ checkReference(request);
494
+ return result(write(db, context, "add-reference", (change) => referenceStep(db, change, context.origin, request)));
495
+ }
496
+
497
+ export function removeReference(db: DatabaseSync, context: WriteContext, request: ReferenceRequest): WriteResult<null> {
498
+ checkReference(request);
499
+ return result(write(db, context, "remove-reference", (change) => unreferenceStep(db, change, context.origin, request)));
500
+ }
501
+
502
+ /** Starts a file of the generated source at an address (D48); one module per address and per file. */
503
+ export function setModule(db: DatabaseSync, context: WriteContext, request: ModuleRequest): WriteResult<ModuleRow> {
504
+ checkModule(request);
505
+ return result(write(db, context, "set-module", (change) => moduleStep(db, change, context.origin, request)));
506
+ }
507
+
508
+ export function removeModule(db: DatabaseSync, context: WriteContext, request: InSpace & { start: number }): WriteResult<null> {
509
+ checkSpace(request);
510
+ checkAddress(request.start, "start");
511
+ return result(write(db, context, "remove-module", (change) => unmoduleStep(db, change, context.origin, request)));
512
+ }
513
+
514
+ /** One write of a batch (D42), named as the command that does it alone. */
515
+ export type BatchOperation =
516
+ | ({ op: "rename" } & SymbolRequest)
517
+ | ({ op: "remove-symbol" } & AddressRequest)
518
+ | ({ op: "classify" } & RegionRequest)
519
+ | ({ op: "unclassify" } & RangeRequest)
520
+ | ({ op: "comment" } & CommentRequest)
521
+ | ({ op: "uncomment" } & PlacementRequest)
522
+ | ({ op: "reference" } & ReferenceRequest)
523
+ | ({ op: "unreference" } & ReferenceRequest)
524
+ | ({ op: "module" } & ModuleRequest)
525
+ | ({ op: "unmodule" } & InSpace & { start: number });
526
+
527
+ export const MAX_BATCH = 10_000;
528
+
529
+ /** The result of each write of a batch, in order. */
530
+ export type BatchStep = Step<SymbolRow | RegionRow[] | CommentRow | ReferenceRow | ModuleRow | null, SymbolRow | RegionRow[]>;
531
+
532
+ /**
533
+ * Applies many writes as one revision in one transaction (D42): all of them,
534
+ * or none when one is refused. Every write is checked before anything opens.
535
+ */
536
+ export function applyBatch(db: DatabaseSync, context: WriteContext, operations: BatchOperation[]): { revision: number | null; steps: BatchStep[] } {
537
+ if (operations.length === 0 || operations.length > MAX_BATCH) throw new KnowledgeError("invalid-input", `A batch has 1 to ${MAX_BATCH} writes.`);
538
+ operations.forEach((operation, index) => {
539
+ try {
540
+ switch (operation.op) {
541
+ case "rename":
542
+ return checkSymbol(operation);
543
+ case "remove-symbol":
544
+ checkSpace(operation);
545
+ return checkAddress(operation.address);
546
+ case "classify":
547
+ return checkRegion(operation);
548
+ case "unclassify":
549
+ return checkRange(operation);
550
+ case "comment":
551
+ return void checkComment(operation);
552
+ case "uncomment":
553
+ return checkPlacement(operation);
554
+ case "reference":
555
+ case "unreference":
556
+ return checkReference(operation);
557
+ case "module":
558
+ return checkModule(operation);
559
+ case "unmodule":
560
+ checkSpace(operation);
561
+ return checkAddress(operation.start, "start");
562
+ default:
563
+ throw new KnowledgeError("invalid-input", `op must be one of rename, remove-symbol, classify, unclassify, comment, uncomment, reference, unreference, module, unmodule.`);
564
+ }
565
+ } catch (error) {
566
+ if (error instanceof KnowledgeError) throw new KnowledgeError(error.code, `Write ${index + 1}: ${error.message} Nothing was written.`);
567
+ throw error;
303
568
  }
304
- change.close('"references"', "from_address = ? AND to_address = ? AND kind = ?", request.from, request.to, request.kind);
305
- return null;
306
569
  });
570
+ const written = write(db, context, "apply", (change) =>
571
+ operations.map((operation, index): BatchStep => {
572
+ try {
573
+ switch (operation.op) {
574
+ case "rename":
575
+ return renameStep(db, change, context.origin, operation);
576
+ case "remove-symbol":
577
+ return removeSymbolStep(db, change, context.origin, operation);
578
+ case "classify":
579
+ return classifyStep(db, change, context.origin, operation);
580
+ case "unclassify":
581
+ return unclassifyStep(db, change, context.origin, operation);
582
+ case "comment":
583
+ return commentStep(db, change, context.origin, operation);
584
+ case "uncomment":
585
+ return uncommentStep(db, change, context.origin, operation);
586
+ case "reference":
587
+ return referenceStep(db, change, context.origin, operation);
588
+ case "unreference":
589
+ return unreferenceStep(db, change, context.origin, operation);
590
+ case "module":
591
+ return moduleStep(db, change, context.origin, operation);
592
+ case "unmodule":
593
+ return unmoduleStep(db, change, context.origin, operation);
594
+ }
595
+ } catch (error) {
596
+ if (error instanceof KnowledgeError) throw new KnowledgeError(error.code, `Write ${index + 1}: ${error.message} Nothing was written.`);
597
+ throw error;
598
+ }
599
+ }),
600
+ );
601
+ return { revision: written.revision, steps: written.current };
307
602
  }
308
603
 
309
604
  /** Row content without its revision, as a comparable key. */
310
605
  const KEYS = {
311
- symbols: { columns: SYMBOL_COLUMNS, map: toSymbol, key: (row: SymbolRow) => `${row.address}|${row.name}|${row.kind}|${row.origin}` },
312
- regions: { columns: REGION_COLUMNS, map: toRegion, key: (row: RegionRow) => `${row.start}|${row.end}|${row.type}|${row.origin}` },
313
- comments: { columns: COMMENT_COLUMNS, map: toComment, key: (row: CommentRow) => `${row.address}|${row.placement}|${row.text}|${row.origin}` },
314
- references: { columns: REFERENCE_COLUMNS, map: toReference, key: (row: ReferenceRow) => `${row.from}|${row.to}|${row.kind}|${row.origin}` },
606
+ symbols: { columns: SYMBOL_COLUMNS, map: toSymbol, key: (row: SymbolRow) => `${row.space}|${row.address}|${row.name}|${row.kind}|${row.origin}` },
607
+ regions: { columns: REGION_COLUMNS, map: toRegion, key: (row: RegionRow) => `${row.space}|${row.start}|${row.end}|${row.type}|${row.element ?? ""}|${row.origin}` },
608
+ comments: { columns: COMMENT_COLUMNS, map: toComment, key: (row: CommentRow) => `${row.space}|${row.address}|${row.placement}|${row.text}|${row.origin}` },
609
+ references: { columns: REFERENCE_COLUMNS, map: toReference, key: (row: ReferenceRow) => `${row.space}|${row.from}|${row.to}|${row.kind}|${row.origin}` },
610
+ modules: { columns: MODULE_COLUMNS, map: toModule, key: (row: ModuleRow) => `${row.space}|${row.start}|${row.file}|${row.title ?? ""}|${row.origin}` },
611
+ suppressions: {
612
+ columns: SUPPRESSION_COLUMNS,
613
+ map: toSuppression,
614
+ key: (row: SuppressionRow) => `${row.space}|${row.entity}|${row.start}|${row.end}|${row.to ?? ""}|${row.kind ?? ""}|${row.origin}`,
615
+ },
315
616
  } as const;
316
617
 
317
618
  /**
@@ -336,6 +637,14 @@ export function revert(db: DatabaseSync, context: WriteContext, request: { revis
336
637
  for (const row of now) if (!thenKeys.has(key(row))) change.close(name, "id = ?", row.id as number);
337
638
  for (const row of then) {
338
639
  if (nowKeys.has(key(row))) continue;
640
+ if (table === "suppressions") {
641
+ change.insertSuppression(spec.map(row as never) as SuppressionRow);
642
+ continue;
643
+ }
644
+ if (table === "modules") {
645
+ change.insertModule(spec.map(row as never) as ModuleRow);
646
+ continue;
647
+ }
339
648
  const mapped = spec.map(row as never) as SymbolRow & RegionRow & CommentRow & ReferenceRow;
340
649
  if (table === "symbols") change.insertSymbol(mapped);
341
650
  else if (table === "regions") change.insertRegion(mapped);