@henols/c64-re-tools 2.0.0-rc.20 → 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/SKILL.md +10 -9
- package/skills/c64-docs/references/page-types.md +38 -10
- 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 +69 -23
- package/skills/c64-knowledge/SKILL.md +2 -2
- package/skills/c64-knowledge/scripts/knowledge.ts +23 -12
- package/skills/c64-reverse-engineering/SKILL.md +3 -2
- package/skills/c64-source/scripts/generate.ts +2 -2
- package/skills/c64-unpacker/scripts/unpack.ts +0 -1
- package/src/host-client/coverage.ts +0 -27
- package/src/host-client/image.ts +11 -11
- 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/mcp/tools/debug.ts +1 -1
- package/src/opcodes.ts +0 -4
- package/src/project.ts +15 -1
- package/src/protocol/tools.ts +0 -14
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",
|
package/skills/c64-docs/SKILL.md
CHANGED
|
@@ -7,7 +7,7 @@ description: Use this skill to write the documentation of a C64 program. The doc
|
|
|
7
7
|
|
|
8
8
|
## Purpose
|
|
9
9
|
|
|
10
|
-
Write documentation that tells how a program works, and that points to the code. The documentation is Markdown in the project
|
|
10
|
+
Write documentation that tells how a program works, and that points to the code. The documentation is Markdown in the project. The entry page is `README.md` in the project folder. The other pages and the reference are in `docs/`. The documentation has two parts:
|
|
11
11
|
|
|
12
12
|
- **The reference.** `generate.ts` generates it from the knowledge, the source map of c64-source and the image. It has a page for each module. The page gives the routines, their callers and callees, the data and a call graph. It also has pages for the names, the variables, the tables, the memory map and the graphics. Do not edit these pages. The next run writes over them.
|
|
13
13
|
- **The pages that you write.** They tell how the program works. They give the flows, the state machines, the data formats and the task of each part. Use the page types in `references/page-types.md` and the rules in `references/style.md`.
|
|
@@ -20,7 +20,7 @@ This skill does not change the knowledge.
|
|
|
20
20
|
|
|
21
21
|
- The source directory of c64-source, with its `source-map.json`, for example `src`. The source must be from the current knowledge revision.
|
|
22
22
|
- The program in the knowledge. Put it in with `image` of the c64-knowledge skill. The reference decodes the tables and shows the graphics from it.
|
|
23
|
-
- The directory of the
|
|
23
|
+
- The entry page `README.md` and the directory of the other pages, for example `docs`. The reference goes into a directory of its own, for example `docs/reference`.
|
|
24
24
|
|
|
25
25
|
## Tools / execution path
|
|
26
26
|
|
|
@@ -28,16 +28,16 @@ The scripts run on this machine. They do not use the emulator or the host runtim
|
|
|
28
28
|
|
|
29
29
|
```
|
|
30
30
|
node <skill>/scripts/generate.ts --source src --out docs/reference
|
|
31
|
-
node <skill>/scripts/link.ts docs --source src
|
|
32
|
-
node <skill>/scripts/link.ts docs --source src --check
|
|
31
|
+
node <skill>/scripts/link.ts README.md docs --source src
|
|
32
|
+
node <skill>/scripts/link.ts README.md docs --source src --check
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
| Option | Meaning |
|
|
36
36
|
|---|---|
|
|
37
37
|
| `--source src` | The source directory of c64-source. Both scripts read `src/source-map.json`. |
|
|
38
38
|
| `--out docs/reference` | The directory of the reference. `generate.ts` writes only there. It refuses to start when the directory holds a page that it did not write. |
|
|
39
|
-
| `docs` | The
|
|
40
|
-
| `--reference <dir>` | The directory of the reference for `link.ts`. The default is `
|
|
39
|
+
| `README.md docs` | The pages of the documentation: `.md` files and directories. `link.ts` reads each file and every `.md` file below each directory. It changes only the pages that you wrote. It checks the pages of the reference. |
|
|
40
|
+
| `--reference <dir>` | The directory of the reference for `link.ts`. The default is the `reference` directory of the first directory that has one, for example `docs/reference`. |
|
|
41
41
|
| `--check` | `link.ts` writes nothing. It fails when a link is not up to date or a page has a problem. |
|
|
42
42
|
|
|
43
43
|
### Project folder
|
|
@@ -50,15 +50,16 @@ node <skill>/scripts/link.ts docs --source src --check
|
|
|
50
50
|
1. Read the knowledge with the c64-knowledge skill. The documentation can only be as good as the knowledge. Each routine needs a name and a comment. Each table needs a region and a name. Each file of the source needs a module with a title. Record what is missing with c64-knowledge.
|
|
51
51
|
2. Generate the source with c64-source. Make sure that `check.identical` is `true`. The script also writes `source-map.json`.
|
|
52
52
|
3. Run `generate.ts`. Read `index.md` of the reference. A module with no routines, or a routine with no comment, shows what the knowledge does not have yet.
|
|
53
|
-
4. Make the plan of the pages. Start from the modules and the call graphs. Find the main loop, the interrupt chain, the dispatch tables, the state machines and the data formats. Each subsystem gets one page
|
|
53
|
+
4. Make the plan of the pages. Start from the modules and the call graphs. Find the main loop, the interrupt chain, the dispatch tables, the state machines and the data formats. Each subsystem gets one page in `docs/`. The entry page `README.md` in the project folder lists the pages. Use the page types in `references/page-types.md`.
|
|
54
54
|
5. Write each page. Read the code and the knowledge for each fact. Run the program with c64-testing or c64-emulator when a fact needs a run. Write each name with its address, for example `` `draw_room` `$09BC` ``. Mark each fact with its evidence. Several subagents can write the pages at the same time, one page each. Give each subagent `references/page-types.md` and `references/style.md`.
|
|
55
55
|
6. Record each new fact in the knowledge first: a name, a comment, a region or a module. Then the source and the reference show it too. A fact only in a page is lost at the next change of the knowledge.
|
|
56
56
|
7. Run `link.ts`. Read `problems` and correct each one. Then run `link.ts --check`. Make sure that `problems.count` is 0 and that `notUpToDate.links` is 0.
|
|
57
|
-
8.
|
|
57
|
+
8. When the project folder is in a workspace with other projects, find `README.md` at the root of the workspace. This page lists each program of the workspace. Add one row for the program, with a link to `<project>/README.md` and one sentence. If the page does not exist, make it. Use the workspace index in `references/page-types.md`. `link.ts` does not read this page, because the page is not in the project folder.
|
|
58
|
+
9. After each change to the knowledge, do steps 2, 3 and 7 again. The links then go to the new lines.
|
|
58
59
|
|
|
59
60
|
## The links
|
|
60
61
|
|
|
61
|
-
`link.ts` changes these forms in the pages that you write. It does not change a code block, a mermaid diagram or an HTML comment.
|
|
62
|
+
`link.ts` changes these forms in the pages that you write. It does not change a code block, a mermaid diagram or an HTML comment. The examples are for a page in `docs/`. In `README.md`, the links start with `src/` and `docs/reference/`.
|
|
62
63
|
|
|
63
64
|
| You write | `link.ts` writes |
|
|
64
65
|
|---|---|
|
|
@@ -4,15 +4,16 @@ Each page that you write is one of these types. The types come from the document
|
|
|
4
4
|
|
|
5
5
|
## 1. The entry page
|
|
6
6
|
|
|
7
|
-
One entry page for the program
|
|
7
|
+
One entry page for the program: `README.md` in the project folder. A large subsystem can have an entry page of its own in `docs/`, for example `docs/rooms.md`. The entry page of the program has these parts, in this order:
|
|
8
8
|
|
|
9
9
|
1. **One paragraph.** What the program is, and which release the documentation is for.
|
|
10
10
|
2. **The table of pages.** One row for each page, with the link and one sentence about its content.
|
|
11
11
|
3. **The layers.** A mermaid diagram of the layers of the program. The layers are the data, the code that runs one time, the main loop and the interrupts. Under the diagram, a numbered list tells how the layers work together.
|
|
12
12
|
4. **The flow of a user.** A mermaid diagram of the steps of the player or user from start to end. An example is the order of the levels.
|
|
13
13
|
5. **Findings.** A table of the facts that a reader does not expect, each with its evidence. Put a bug of the original program here.
|
|
14
|
-
6. **The reference.** A link to `reference/index.md`.
|
|
14
|
+
6. **The reference.** A link to `docs/reference/index.md`.
|
|
15
15
|
7. **Still open.** What is not known yet, and which run can find it.
|
|
16
|
+
8. **The release and the rebuild.** What the release holds, for example a crack with a cracktro and a mover. Which part is the clean program of the knowledge. Which changes of the release to the machine state stay, and how the loader sets them. How the source assembles to the clean program, and which scenarios compare the original with the rebuild.
|
|
16
17
|
|
|
17
18
|
```markdown
|
|
18
19
|
# <Program>: <subject>
|
|
@@ -21,7 +22,7 @@ One entry page for the program, for example `docs/README.md` or `docs/rooms.md`
|
|
|
21
22
|
|
|
22
23
|
| Page | Content |
|
|
23
24
|
|---|---|
|
|
24
|
-
| [parts/main_loop.md](parts/main_loop.md) | **The main loop.** <one sentence> |
|
|
25
|
+
| [parts/main_loop.md](docs/parts/main_loop.md) | **The main loop.** <one sentence> |
|
|
25
26
|
|
|
26
27
|
## 1. The layers
|
|
27
28
|
|
|
@@ -31,23 +32,50 @@ flowchart TB
|
|
|
31
32
|
...
|
|
32
33
|
~~~
|
|
33
34
|
|
|
34
|
-
## 2.
|
|
35
|
+
## 2. The flow of a player
|
|
36
|
+
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
## 3. Findings
|
|
35
40
|
|
|
36
41
|
| Finding | Evidence |
|
|
37
42
|
|---|---|
|
|
38
43
|
| <fact> | Observed: <what run showed it> |
|
|
39
44
|
|
|
40
|
-
##
|
|
45
|
+
## 4. The reference
|
|
46
|
+
|
|
47
|
+
[docs/reference/index.md](docs/reference/index.md)
|
|
48
|
+
|
|
49
|
+
## 5. Still open
|
|
41
50
|
|
|
42
51
|
- <question> — <the run that can answer it>
|
|
52
|
+
|
|
53
|
+
## 6. The release and the rebuild
|
|
54
|
+
|
|
55
|
+
- **The release.** <the file on the disk, and what the release adds to the program>
|
|
56
|
+
- **The rebuild.** <the source, the loader and the scenarios>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 2. The workspace index
|
|
60
|
+
|
|
61
|
+
A workspace with several projects has one `README.md` at its root. It lists each program of the workspace. It has a title, one sentence and a table with one row for each program. Each row has a link to the entry page of the program and one sentence about the program.
|
|
62
|
+
|
|
63
|
+
```markdown
|
|
64
|
+
# C64 games
|
|
65
|
+
|
|
66
|
+
Reverse-engineered C64 games. Each game has its own folder with the original disk, the rebuilt source and the documentation.
|
|
67
|
+
|
|
68
|
+
| Game | |
|
|
69
|
+
|---|---|
|
|
70
|
+
| [Bruce Lee](bruce_lee/README.md) | Datasoft 1984. How the program works, from the cold start to the last room. |
|
|
43
71
|
```
|
|
44
72
|
|
|
45
|
-
##
|
|
73
|
+
## 3. The subsystem page
|
|
46
74
|
|
|
47
75
|
One page for each subsystem, for example the main loop, the interrupt chain, the room generation or the sound player. It has these parts:
|
|
48
76
|
|
|
49
77
|
1. **The scope.** A list of what the page covers, and the sources (the source files, the assets).
|
|
50
|
-
2. **The conventions.** The terms of the page, for example "a pass is one run of `main_loop` `$1BC2`". Also the evidence labels. Use the conventions block of section
|
|
78
|
+
2. **The conventions.** The terms of the page, for example "a pass is one run of `main_loop` `$1BC2`". Also the evidence labels. Use the conventions block of section 6.
|
|
51
79
|
3. **The overview.** A short text diagram or a mermaid flowchart of the calls, from the entry of the subsystem to its end.
|
|
52
80
|
4. **The catalogue.** A table of the data of the subsystem. Each row has the name, the address, the layout (bytes, entries, the meaning of each byte), the readers and the writers.
|
|
53
81
|
5. **The step-by-step walk.** For each main routine: a numbered list of what it does, in the order of the code. Each step gives the names and addresses. Add pseudo-code when a loop or a condition is not clear from the list.
|
|
@@ -71,7 +99,7 @@ stateDiagram-v2
|
|
|
71
99
|
| Waiting `$00` | `move_timer` = 0 | Moving `$01` | `update_slider` `$3F8C` |
|
|
72
100
|
```
|
|
73
101
|
|
|
74
|
-
##
|
|
102
|
+
## 4. The entity sections
|
|
75
103
|
|
|
76
104
|
A program often has many items of one kind: levels, rooms, enemies, tunes. Give each item one section of the same form, in a page for a group of items. Each section has these parts:
|
|
77
105
|
|
|
@@ -83,7 +111,7 @@ A program often has many items of one kind: levels, rooms, enemies, tunes. Give
|
|
|
83
111
|
|
|
84
112
|
End the group page with a table of the dependencies between the items.
|
|
85
113
|
|
|
86
|
-
##
|
|
114
|
+
## 5. The data-format page
|
|
87
115
|
|
|
88
116
|
One page for each format of data, for example a packed map, a music stream or a sprite format. It has these parts:
|
|
89
117
|
|
|
@@ -101,7 +129,7 @@ One page for each format of data, for example a packed map, a music stream or a
|
|
|
101
129
|
| `$01-$7F` | The next byte, `b` times. |
|
|
102
130
|
```
|
|
103
131
|
|
|
104
|
-
##
|
|
132
|
+
## 6. The conventions block
|
|
105
133
|
|
|
106
134
|
Put this block near the top of each subsystem page. Change the terms for the program.
|
|
107
135
|
|
|
@@ -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
|
}
|
|
@@ -16,13 +16,14 @@ import { checkLinks, linkPage, type Files, type Problem } from "./lib/links.ts";
|
|
|
16
16
|
import { GENERATED_MARK } from "./lib/reference.ts";
|
|
17
17
|
import { readSourceMap } from "./lib/source-map.ts";
|
|
18
18
|
|
|
19
|
-
const USAGE = `link.ts <
|
|
19
|
+
const USAGE = `link.ts <pages>... --source <dir> [--reference <dir>] [--check]
|
|
20
20
|
|
|
21
|
-
<
|
|
22
|
-
|
|
21
|
+
<pages>... .md files and directories of the documentation, relative to
|
|
22
|
+
the project folder, for example README.md docs; link.ts reads
|
|
23
|
+
each file and every .md file below each directory
|
|
23
24
|
--source <dir> the source directory of c64-source, with its source-map.json
|
|
24
|
-
--reference <dir> the reference pages of generate.ts; the default is
|
|
25
|
-
|
|
25
|
+
--reference <dir> the reference pages of generate.ts; the default is the
|
|
26
|
+
reference/ folder of the first directory that has one
|
|
26
27
|
--check write nothing; fail when a link is not up to date or a page
|
|
27
28
|
has a problem
|
|
28
29
|
|
|
@@ -34,9 +35,19 @@ const MAX_LISTED = 50;
|
|
|
34
35
|
/** A name outside the image covers the addresses up to the next name, at most this many. */
|
|
35
36
|
const MAX_BODY = 256;
|
|
36
37
|
|
|
38
|
+
const isDirectory = (path: string) => {
|
|
39
|
+
const real = resolveProjectPath(path);
|
|
40
|
+
return existsSync(real) && statSync(real).isDirectory();
|
|
41
|
+
};
|
|
42
|
+
|
|
37
43
|
function markdownFiles(directory: string): string[] {
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}
|
|
40
51
|
const found: string[] = [];
|
|
41
52
|
const walk = (relative: string) => {
|
|
42
53
|
for (const entry of readdirSync(join(root, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -49,6 +60,9 @@ function markdownFiles(directory: string): string[] {
|
|
|
49
60
|
return found;
|
|
50
61
|
}
|
|
51
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
|
+
|
|
52
66
|
const files: Files = {
|
|
53
67
|
inside: (path) => {
|
|
54
68
|
try {
|
|
@@ -67,27 +81,38 @@ const files: Files = {
|
|
|
67
81
|
}
|
|
68
82
|
},
|
|
69
83
|
text: (path) => {
|
|
84
|
+
if (texts.has(path)) return texts.get(path);
|
|
85
|
+
let text: string | undefined;
|
|
70
86
|
try {
|
|
71
87
|
const real = resolveProjectFile(path);
|
|
72
|
-
|
|
88
|
+
text = statSync(real).isFile() ? readFileSync(real, "utf8") : undefined;
|
|
73
89
|
} catch {
|
|
74
|
-
|
|
90
|
+
text = undefined;
|
|
75
91
|
}
|
|
92
|
+
texts.set(path, text);
|
|
93
|
+
return text;
|
|
76
94
|
},
|
|
77
95
|
};
|
|
78
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
|
+
|
|
79
100
|
function run(argv: string[]): { output: Record<string, unknown>; failed: boolean } {
|
|
80
101
|
const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { source: { type: "string" }, reference: { type: "string" }, check: { type: "boolean" }, help: { type: "boolean" } } });
|
|
81
102
|
if (values.help) throw new UsageError("");
|
|
82
|
-
if (positionals.length
|
|
103
|
+
if (positionals.length === 0) throw new UsageError("give the pages: .md files and directories of the documentation, for example README.md docs");
|
|
83
104
|
if (values.source === undefined) throw new UsageError("give --source <dir>: the source directory of c64-source");
|
|
84
|
-
const
|
|
85
|
-
for (const [option, path] of [["<
|
|
105
|
+
const given = [...new Set(positionals.map((path) => posix.normalize(path)))];
|
|
106
|
+
for (const [option, path] of [...given.map((path) => ["<pages>", path] as const), ["--source", values.source], ["--reference", values.reference]] as const) {
|
|
86
107
|
if (path === undefined) continue;
|
|
87
108
|
if (!files.inside(path)) throw new UsageError(`${option} must be a path relative to the project folder that stays inside it, not ${path}`);
|
|
88
109
|
}
|
|
110
|
+
for (const path of given) {
|
|
111
|
+
if (!path.endsWith(".md") && files.exists(path) && !isDirectory(path)) throw new UsageError(`<pages> takes .md files and directories, not ${path}`);
|
|
112
|
+
}
|
|
113
|
+
const directories = given.filter((path) => !path.endsWith(".md") || isDirectory(path));
|
|
89
114
|
const map = readSourceMap(values.source);
|
|
90
|
-
const reference = values.reference === undefined ? (
|
|
115
|
+
const reference = values.reference === undefined ? directories.map((path) => posix.join(path, "reference")).find((path) => isDirectory(path)) : posix.normalize(values.reference);
|
|
91
116
|
const problems: Problem[] = [];
|
|
92
117
|
const db = openForRead();
|
|
93
118
|
let symbols;
|
|
@@ -108,14 +133,21 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
|
|
|
108
133
|
const routines = symbols.filter((symbol) => symbol.kind === "routine").map((symbol) => symbol.address).sort((a, b) => a - b);
|
|
109
134
|
const named = [...new Set(symbols.map((symbol) => symbol.address))].sort((a, b) => a - b);
|
|
110
135
|
const kinds = new Map(symbols.map((symbol) => [symbol.name, symbol.kind]));
|
|
136
|
+
const bodyEnds = new Map<string, number | undefined>();
|
|
111
137
|
const bodyEnd = (name: string) => {
|
|
138
|
+
if (bodyEnds.has(name)) return bodyEnds.get(name);
|
|
112
139
|
const address = addresses.get(name);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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);
|
|
119
151
|
return end;
|
|
120
152
|
};
|
|
121
153
|
const nameAt = new Map(symbols.map((symbol) => [symbol.address, symbol.name]));
|
|
@@ -134,7 +166,11 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
|
|
|
134
166
|
const entries = new Map<string, string>();
|
|
135
167
|
const referencePages = reference === undefined ? [] : markdownFiles(reference);
|
|
136
168
|
for (const page of referencePages) {
|
|
137
|
-
const text = files.text(page)
|
|
169
|
+
const text = files.text(page);
|
|
170
|
+
if (text === undefined) {
|
|
171
|
+
problems.push(unreadable(page));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
138
174
|
const stamp = /^<!-- Generated by c64-docs from knowledge revision (\S+) /.exec(text);
|
|
139
175
|
if (text.startsWith(GENERATED_MARK) && stamp !== null && stamp[1] !== map.revision) {
|
|
140
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.` });
|
|
@@ -158,11 +194,19 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
|
|
|
158
194
|
managed: (path: string) => inside(path, posix.normalize(map.directory)) || inside(path, reference),
|
|
159
195
|
};
|
|
160
196
|
|
|
161
|
-
const pages =
|
|
197
|
+
const pages = [...new Set(given.flatMap((path) => {
|
|
198
|
+
if (directories.includes(path)) return markdownFiles(path);
|
|
199
|
+
if (files.text(path) === undefined) throw new WireFailure("not-found", `There is no file ${path} in the project folder.`);
|
|
200
|
+
return [path];
|
|
201
|
+
}))];
|
|
162
202
|
const changedPages: string[] = [];
|
|
163
203
|
let links = 0;
|
|
164
204
|
for (const page of pages) {
|
|
165
|
-
const text = files.text(page)
|
|
205
|
+
const text = files.text(page);
|
|
206
|
+
if (text === undefined) {
|
|
207
|
+
problems.push(unreadable(page));
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
166
210
|
// A generated page is right by its making: it is only checked.
|
|
167
211
|
const generated = text.startsWith(GENERATED_MARK);
|
|
168
212
|
const linked = generated ? { text, changed: 0, problems: [] } : linkPage(text, { page, ...context });
|
|
@@ -170,7 +214,9 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
|
|
|
170
214
|
if (linked.changed === 0) continue;
|
|
171
215
|
links += linked.changed;
|
|
172
216
|
changedPages.push(page);
|
|
173
|
-
if (
|
|
217
|
+
if (values.check) continue;
|
|
218
|
+
writeProjectFile(page, Buffer.from(linked.text, "utf8"));
|
|
219
|
+
texts.set(page, linked.text);
|
|
174
220
|
}
|
|
175
221
|
const byKind: Record<string, number> = {};
|
|
176
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 };
|
|
@@ -87,7 +87,7 @@ Do the stages in this sequence. Go to the next stage only when its exit check is
|
|
|
87
87
|
| 7. Name | c64-provenance: if the workspace has a library, find its routines with `match` and apply the batch. Then c64-knowledge and c64-memory-map: name each routine in the scope and give it a comment. Record the modules. When the scope has hundreds of routines, ask the user which routines to name. The source gives the other routines an `l_xxxx` label. | Each routine that the user selected has a name and a comment. |
|
|
88
88
|
| 8. Generate | c64-source into `src/`. For an unpacked program, add `--loader`. | `check.identical` is true. For an unpacked program, `loader.verified` is true. |
|
|
89
89
|
| 9. Build and test | c64-assembler with `--compare` into `build/`. c64-testing scenarios in `tests/`. | The criteria of stage 1 are true. |
|
|
90
|
-
| 10. Document | When the criteria ask for documentation: c64-docs into `docs/`. | `link.ts --check` of c64-docs gives no problem. |
|
|
90
|
+
| 10. Document | When the criteria ask for documentation: c64-docs into `README.md` and `docs/`. | `link.ts --check` of c64-docs gives no problem. |
|
|
91
91
|
| 11. Hand over | Find each file that you made outside `.c64-re-tools/local/` and that is not a deliverable. Move it into `.c64-re-tools/local/`. Then give the answer (below). | Each file that you made outside `.c64-re-tools/local/` is a deliverable. |
|
|
92
92
|
|
|
93
93
|
In stage 5, if you cannot get to a part of the program, ask the user how to get to it. Do stages 7 and 8 again until the source is clear. Each run of c64-source writes the source again.
|
|
@@ -100,7 +100,7 @@ After stage 7, save each routine that other programs can have to the library wit
|
|
|
100
100
|
- the generated source
|
|
101
101
|
- the built program and the loader
|
|
102
102
|
- the c64-testing scenarios
|
|
103
|
-
- the documentation in `docs/`, when the criteria ask for it
|
|
103
|
+
- the documentation in `README.md` and `docs/`, when the criteria ask for it
|
|
104
104
|
- the runs in `runs/` that the knowledge gives as evidence
|
|
105
105
|
- the scripts in `tools/` for the formats of this program, when you wrote them.
|
|
106
106
|
|
|
@@ -128,6 +128,7 @@ tests/ c64-testing scenarios
|
|
|
128
128
|
runs/ the runs that the knowledge gives as evidence
|
|
129
129
|
docs/ the documentation from c64-docs, with the reference in docs/reference/
|
|
130
130
|
tools/ scripts for the formats of this program only
|
|
131
|
+
README.md the entry page of the documentation from c64-docs
|
|
131
132
|
```
|
|
132
133
|
|
|
133
134
|
Commit each folder of the layout and `.c64-re-tools/knowledge/`. The knowledge holds the program, the entry state and the coverage, so the project needs no other folder for them. Put all other files that you make in `.c64-re-tools/local/`, also the captures and the coverage maps after you put them into the knowledge. Do not let git ignore a folder of the layout. Do not change the `.gitignore` files of the project.
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// passes, 1 when one fails, 2 for a bad call.
|
|
9
9
|
|
|
10
10
|
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
11
|
-
import { join
|
|
11
|
+
import { join } from "node:path";
|
|
12
12
|
import { parseArgs } from "node:util";
|
|
13
13
|
|
|
14
|
-
import { COVERAGE, coverageSeedKinds, formatC64Address, parseC64Address,
|
|
14
|
+
import { COVERAGE, coverageSeedKinds, formatC64Address, parseC64Address, textToPetscii } from "#src/c64.ts";
|
|
15
15
|
import { autostartLoaded, RUN_LIMIT_FRAMES } from "#src/host-client/autostart.ts";
|
|
16
16
|
import { MAX_MEMORY_READ, WireFailure } from "#src/host-client/tools.ts";
|
|
17
17
|
import { writeProjectFile } from "#src/host-client/transfer.ts";
|
|
@@ -104,7 +104,6 @@ async function runSteps(client: ViceSessionClient, steps: ActionStep[]): Promise
|
|
|
104
104
|
async function trace(program: string, options: { frames: number; entry?: number; steps: ActionStep[] }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
|
|
105
105
|
const { bytes } = readProjectFile(program);
|
|
106
106
|
if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
|
|
107
|
-
const load = bytes[0]! | (bytes[1]! << 8);
|
|
108
107
|
if (options.entry === undefined && basicStart(bytes) === undefined) throw new UsageError(NO_BASIC);
|
|
109
108
|
const client = await ViceSessionClient.open({ videoStandard: "pal" });
|
|
110
109
|
try {
|
|
@@ -3,38 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import { COVERAGE, type CodeRange } from "../c64.ts";
|
|
5
5
|
import { MAX_MEMMAP_RANGES, type MemmapRange } from "../protocol/vice.ts";
|
|
6
|
-
import { readProjectFile } from "./transfer.ts";
|
|
7
6
|
import type { ViceSessionClient } from "./vice-session.ts";
|
|
8
7
|
|
|
9
8
|
/** A full coverage file: offset n is address n (D40). */
|
|
10
9
|
export const FULL_COVERAGE = 0x10000;
|
|
11
10
|
|
|
12
|
-
/** Reads full coverage files from the project, one map per file. `fail` refuses a file of another size. */
|
|
13
|
-
export function readCoverageMaps(paths: string[], fail: (message: string) => never): Uint8Array[] {
|
|
14
|
-
return paths.map((path) => {
|
|
15
|
-
const { bytes } = readProjectFile(path);
|
|
16
|
-
if (bytes.length !== FULL_COVERAGE) {
|
|
17
|
-
fail(`--coverage ${path} has ${bytes.length} bytes; a full coverage file has 65536, one for each address. Write it with c64_memmap action read and out, without start and end.`);
|
|
18
|
-
}
|
|
19
|
-
return bytes;
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Joins coverage maps: an address has every flag that any run gave it. */
|
|
24
|
-
export function joinCoverage(maps: readonly Uint8Array[]): Uint8Array {
|
|
25
|
-
const joined = new Uint8Array(FULL_COVERAGE);
|
|
26
|
-
for (const map of maps) for (let address = 0; address < FULL_COVERAGE; address++) joined[address]! |= map[address]!;
|
|
27
|
-
return joined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Reads full coverage files from the project and joins them: an address has
|
|
32
|
-
* every flag that any run gave it. `fail` refuses a file of another size.
|
|
33
|
-
*/
|
|
34
|
-
export function readCoverageFiles(paths: string[], fail: (message: string) => never): Uint8Array | undefined {
|
|
35
|
-
return paths.length === 0 ? undefined : joinCoverage(readCoverageMaps(paths, fail));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
11
|
/** The coverage bytes of start..end, read from the session in parts. */
|
|
39
12
|
export async function readCoverage(session: Pick<ViceSessionClient, "memmap">, start = 0x0000, end = 0xffff): Promise<Uint8Array> {
|
|
40
13
|
const map = new Uint8Array(end - start + 1);
|
package/src/host-client/image.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// An image.json of the c64-unpacker skill (a 64 KiB memory and its segments)
|
|
2
|
+
// or a PRG, read from the project to put it into the knowledge (D83).
|
|
3
3
|
|
|
4
4
|
import { posix } from "node:path";
|
|
5
5
|
|
|
@@ -12,12 +12,9 @@ export class ImageError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface Image {
|
|
15
|
-
path: string;
|
|
16
15
|
segments: Array<{ start: number; end: number }>;
|
|
17
16
|
/** The byte at an address inside a segment. */
|
|
18
17
|
byteAt: (address: number) => number;
|
|
19
|
-
/** The bytes that Ghidra analyzes, and how. */
|
|
20
|
-
analyzed: { bytes: Buffer; kind: "prg" | "flat64k" };
|
|
21
18
|
entry?: number;
|
|
22
19
|
/** entry_state.json next to image.json. */
|
|
23
20
|
statePath?: string;
|
|
@@ -35,21 +32,24 @@ export function readImage(path: string): Image {
|
|
|
35
32
|
} catch (error) {
|
|
36
33
|
throw new ImageError(`${path}: ${(error as Error).message}`);
|
|
37
34
|
}
|
|
38
|
-
return {
|
|
35
|
+
return { segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]! };
|
|
39
36
|
}
|
|
40
37
|
const fail = (what: string): never => {
|
|
41
38
|
throw new ImageError(`${path} is not an image.json of the c64-unpacker skill: ${what}`);
|
|
42
39
|
};
|
|
43
|
-
|
|
40
|
+
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
41
|
+
let parsed: unknown;
|
|
44
42
|
try {
|
|
45
|
-
|
|
43
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
46
44
|
} catch (error) {
|
|
47
45
|
return fail((error as Error).message);
|
|
48
46
|
}
|
|
47
|
+
const manifest = isObject(parsed) ? parsed : fail("it is not a JSON object");
|
|
49
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`));
|
|
50
49
|
if (typeof manifest.image !== "string") fail("it has no image");
|
|
51
50
|
if (!Array.isArray(manifest.segments) || manifest.segments.length === 0) fail("it has no segments");
|
|
52
|
-
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`)))
|
|
53
53
|
.map((segment, index) => ({ start: address(segment.start, `segment ${index + 1} start`), end: address(segment.end, `segment ${index + 1} end`) }))
|
|
54
54
|
.sort((a, b) => a.start - b.start);
|
|
55
55
|
segments.forEach((segment, index) => {
|
|
@@ -59,8 +59,8 @@ export function readImage(path: string): Image {
|
|
|
59
59
|
const directory = posix.dirname(path);
|
|
60
60
|
const memory = readProjectFile(posix.join(directory, manifest.image as string)).bytes;
|
|
61
61
|
if (memory.length !== 0x10000) fail(`its image has ${memory.length} bytes, not 65536`);
|
|
62
|
-
const image: Image = {
|
|
62
|
+
const image: Image = { segments, byteAt: (at) => memory[at]!, statePath: posix.join(directory, "entry_state.json") };
|
|
63
63
|
if (manifest.entry !== undefined) image.entry = address(manifest.entry, "entry");
|
|
64
|
-
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`)));
|
|
65
65
|
return image;
|
|
66
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/mcp/tools/debug.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { WireFailure } from "../../protocol/messages.ts";
|
|
|
8
8
|
import { CHECKPOINT_ACTIONS, MAX_BACKTRACE, MAX_HISTORY, MAX_TRACE_FRAMES, MAX_TRACE_HITS, MAX_TRACE_POINTS, TRACE_KINDS, TRACE_ENDS, MAX_MEMMAP_RANGES, MAX_MEMORY_READ, MAX_PROFILE, MAX_WATCH_SIZE, SPACES, TIMING_ACTIONS, WATCH_ACCESS, type Breakpoint, type Watchpoint } from "../../protocol/vice.ts";
|
|
9
9
|
import { readCoverage } from "../../host-client/coverage.ts";
|
|
10
10
|
import { OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
|
|
11
|
-
import { AddressInput, AddressOutput, Byte, ConditionInput, ConditionOutput, defineTool, HexData, requireFields, showCondition, SpaceInput, toCondition
|
|
11
|
+
import { AddressInput, AddressOutput, Byte, ConditionInput, ConditionOutput, defineTool, HexData, requireFields, showCondition, SpaceInput, toCondition } from "../server.ts";
|
|
12
12
|
import { spaced } from "./memory.ts";
|
|
13
13
|
|
|
14
14
|
/** No default here: a filled-in default would count as a field the other actions refuse. */
|
package/src/opcodes.ts
CHANGED
|
@@ -56,10 +56,6 @@ export function takesAddress(mode: Mode): boolean {
|
|
|
56
56
|
return mode !== "imp" && mode !== "imm";
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function isZeroPageMode(mode: Mode): boolean {
|
|
60
|
-
return mode === "zp" || mode === "zpx" || mode === "zpy" || mode === "izx" || mode === "izy";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
59
|
/** How an instruction uses the address of its operand, in the order a summary lists them. */
|
|
64
60
|
export const USE_KINDS = ["read", "written", "modified", "called", "jumped to", "jumped through", "branched to", "pointer", "address", "table", "macro argument"] as const;
|
|
65
61
|
export type UseKind = (typeof USE_KINDS)[number];
|
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);
|
package/src/protocol/tools.ts
CHANGED
|
@@ -109,20 +109,6 @@ export function isRelativePath(value: unknown): value is string {
|
|
|
109
109
|
return typeof value === "string" && value.length <= 1024 && RELATIVE_PATH.test(value) && !value.includes("//") && !value.endsWith("/");
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/** Validates a transferred source tree against its attachments. Throws WireFailure(invalid-input). */
|
|
113
|
-
export function validateSourceTree(value: unknown, attachments: readonly Uint8Array[]): SourceTree {
|
|
114
|
-
if (!Array.isArray(value) || value.length > MAX_TREE_FILES) invalid(`files must list at most ${MAX_TREE_FILES} files`);
|
|
115
|
-
if (value.length !== attachments.length) invalid("each listed file needs exactly one attachment");
|
|
116
|
-
const seen = new Set<string>();
|
|
117
|
-
return value.map((entry, index) => {
|
|
118
|
-
if (!isObject(entry) || !isRelativePath(entry.path)) invalid("each file needs a relative path inside the source root");
|
|
119
|
-
if (seen.has(entry.path)) invalid(`the file ${entry.path} is listed twice`);
|
|
120
|
-
seen.add(entry.path);
|
|
121
|
-
if (entry.size !== attachments[index]!.length) invalid(`the size of ${entry.path} does not match its bytes`);
|
|
122
|
-
return { path: entry.path, size: entry.size as number };
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
112
|
/** Refuses anything but exactly one attachment with at least one byte, named by what it must hold. */
|
|
127
113
|
function oneFile(attachments: readonly Uint8Array[], what: string): void {
|
|
128
114
|
if (attachments.length !== 1 || attachments[0]!.length === 0) invalid(`${what} must be the one attachment, and it must not be empty`);
|