@mcuste/pi-diagram 0.0.0
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/LICENSE +21 -0
- package/README.md +240 -0
- package/dist/artifacts.d.ts +59 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +274 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/cache.d.ts +43 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +0 -0
- package/dist/cache.js.map +1 -0
- package/dist/d2/diagnostics.d.ts +25 -0
- package/dist/d2/diagnostics.d.ts.map +1 -0
- package/dist/d2/diagnostics.js +77 -0
- package/dist/d2/diagnostics.js.map +1 -0
- package/dist/d2/fonts.d.ts +23 -0
- package/dist/d2/fonts.d.ts.map +1 -0
- package/dist/d2/fonts.js +255 -0
- package/dist/d2/fonts.js.map +1 -0
- package/dist/d2/preflight.d.ts +20 -0
- package/dist/d2/preflight.d.ts.map +1 -0
- package/dist/d2/preflight.js +217 -0
- package/dist/d2/preflight.js.map +1 -0
- package/dist/d2/profiles.d.ts +34 -0
- package/dist/d2/profiles.d.ts.map +1 -0
- package/dist/d2/profiles.js +118 -0
- package/dist/d2/profiles.js.map +1 -0
- package/dist/d2/runner.d.ts +95 -0
- package/dist/d2/runner.d.ts.map +1 -0
- package/dist/d2/runner.js +350 -0
- package/dist/d2/runner.js.map +1 -0
- package/dist/display.d.ts +48 -0
- package/dist/display.d.ts.map +1 -0
- package/dist/display.js +120 -0
- package/dist/display.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +23 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +83 -0
- package/dist/normalize.js.map +1 -0
- package/dist/process.d.ts +38 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +87 -0
- package/dist/process.js.map +1 -0
- package/dist/raster.d.ts +40 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js +193 -0
- package/dist/raster.js.map +1 -0
- package/dist/render.d.ts +55 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +229 -0
- package/dist/render.js.map +1 -0
- package/dist/tools.d.ts +58 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +284 -0
- package/dist/tools.js.map +1 -0
- package/package.json +101 -0
- package/src/artifacts.ts +418 -0
- package/src/cache.ts +0 -0
- package/src/d2/diagnostics.ts +114 -0
- package/src/d2/fonts.ts +289 -0
- package/src/d2/preflight.ts +270 -0
- package/src/d2/profiles.ts +157 -0
- package/src/d2/runner.ts +513 -0
- package/src/display.ts +201 -0
- package/src/index.ts +5 -0
- package/src/normalize.ts +119 -0
- package/src/process.ts +134 -0
- package/src/raster.ts +258 -0
- package/src/render.ts +338 -0
- package/src/tools.ts +455 -0
package/package.json
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mcuste/pi-diagram",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Terminal-native diagram rendering as one safe, model-callable tool for the Pi and Oh My Pi coding agents",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"oh-my-pi",
|
|
9
|
+
"omp",
|
|
10
|
+
"extension",
|
|
11
|
+
"diagram",
|
|
12
|
+
"d2",
|
|
13
|
+
"architecture-diagram",
|
|
14
|
+
"sequence-diagram",
|
|
15
|
+
"svg",
|
|
16
|
+
"ascii"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "mcuste",
|
|
20
|
+
"homepage": "https://github.com/mcuste/pi-diagram#readme",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/mcuste/pi-diagram.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/mcuste/pi-diagram/issues"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"exports": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"typebox": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@biomejs/biome": "2.5.9",
|
|
49
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
50
|
+
"@types/node": "^26.2.0",
|
|
51
|
+
"knip": "6.32.2",
|
|
52
|
+
"publint": "0.3.23",
|
|
53
|
+
"typebox": "^1.3.15",
|
|
54
|
+
"typescript": "^7.0.2"
|
|
55
|
+
},
|
|
56
|
+
"knip": {
|
|
57
|
+
"entry": [
|
|
58
|
+
"test/**/*.test.mjs",
|
|
59
|
+
"test/**/*.e2e.mjs",
|
|
60
|
+
"scripts/*.mjs"
|
|
61
|
+
],
|
|
62
|
+
"project": [
|
|
63
|
+
"src/**/*.ts",
|
|
64
|
+
"test/**/*.mjs",
|
|
65
|
+
"scripts/*.mjs"
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"pi": {
|
|
69
|
+
"extensions": [
|
|
70
|
+
"./src/index.ts"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"omp": {
|
|
74
|
+
"extensions": [
|
|
75
|
+
"./src/index.ts"
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"@resvg/resvg-js": "^2.6.2"
|
|
80
|
+
},
|
|
81
|
+
"peerDependenciesMeta": {
|
|
82
|
+
"@earendil-works/pi-tui": {
|
|
83
|
+
"optional": true
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"scripts": {
|
|
87
|
+
"build": "tsc -p tsconfig.json",
|
|
88
|
+
"check": "pnpm quality && pnpm package:check && pnpm test && pnpm test:integration && pnpm deadcode && pnpm security",
|
|
89
|
+
"deadcode": "knip",
|
|
90
|
+
"fix": "biome check --write .",
|
|
91
|
+
"format": "biome format --write .",
|
|
92
|
+
"lint": "biome lint .",
|
|
93
|
+
"package:check": "pnpm build && publint",
|
|
94
|
+
"preview": "pnpm build && node scripts/preview.mjs",
|
|
95
|
+
"quality": "biome check .",
|
|
96
|
+
"release": "node scripts/release.mjs",
|
|
97
|
+
"security": "pnpm audit --audit-level high",
|
|
98
|
+
"test": "pnpm build && node --test test/*.test.mjs",
|
|
99
|
+
"test:integration": "pnpm build && node --test test/*.e2e.mjs"
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
readdir,
|
|
7
|
+
realpath,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { DiagramSourceError } from "./d2/diagnostics.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Writes diagram artifacts. Files land in a private temporary directory unless a call names a
|
|
19
|
+
* repository destination, because most diagrams explain something in passing and have no
|
|
20
|
+
* business being committed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const MAX_BASENAME_LENGTH = 60;
|
|
24
|
+
const MAX_DIRECTORY_LENGTH = 255;
|
|
25
|
+
/** Keeps a long session from filling the temp directory with diagrams nobody opened. */
|
|
26
|
+
const MAX_TEMP_FILES = 64;
|
|
27
|
+
|
|
28
|
+
const EXTENSIONS = {
|
|
29
|
+
source: ".d2",
|
|
30
|
+
svg: ".svg",
|
|
31
|
+
png: ".png",
|
|
32
|
+
txt: ".txt",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export type ArtifactFormat = keyof typeof EXTENSIONS;
|
|
36
|
+
|
|
37
|
+
/** Editable source plus a viewable rendering. */
|
|
38
|
+
const DEFAULT_FORMATS: readonly ArtifactFormat[] = ["source", "svg"];
|
|
39
|
+
|
|
40
|
+
/** Names Windows treats as devices rather than files, whatever extension follows. */
|
|
41
|
+
const RESERVED_NAMES: ReadonlySet<string> = new Set([
|
|
42
|
+
"con",
|
|
43
|
+
"prn",
|
|
44
|
+
"aux",
|
|
45
|
+
"nul",
|
|
46
|
+
...Array.from({ length: 9 }, (_, index) => `com${index + 1}`),
|
|
47
|
+
...Array.from({ length: 9 }, (_, index) => `lpt${index + 1}`),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
declare const safeBasenameBrand: unique symbol;
|
|
51
|
+
declare const artifactDirectoryBrand: unique symbol;
|
|
52
|
+
|
|
53
|
+
/** A file name stem with no path separators, extension, or reserved meaning. */
|
|
54
|
+
type SafeBasename = string & { readonly [safeBasenameBrand]: true };
|
|
55
|
+
|
|
56
|
+
/** An absolute directory that is either the session temp store or inside the workspace root. */
|
|
57
|
+
type ArtifactDirectory = string & { readonly [artifactDirectoryBrand]: true };
|
|
58
|
+
|
|
59
|
+
type ArtifactLocation = "temp" | "workspace";
|
|
60
|
+
|
|
61
|
+
export interface ArtifactNames {
|
|
62
|
+
/** Workspace-relative destination, or `undefined` to keep the files out of the repository. */
|
|
63
|
+
readonly directory: string | undefined;
|
|
64
|
+
readonly basename: SafeBasename;
|
|
65
|
+
readonly formats: readonly ArtifactFormat[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ArtifactTarget {
|
|
69
|
+
readonly location: ArtifactLocation;
|
|
70
|
+
readonly directory: ArtifactDirectory;
|
|
71
|
+
readonly names: ArtifactNames;
|
|
72
|
+
readonly root: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface WrittenArtifact {
|
|
76
|
+
readonly format: ArtifactFormat;
|
|
77
|
+
readonly location: ArtifactLocation;
|
|
78
|
+
/** Workspace-relative for a repository file, absolute for a temporary one. */
|
|
79
|
+
readonly path: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function refuse(summary: string, message: string, hint?: string): never {
|
|
83
|
+
throw new DiagramSourceError(summary, [
|
|
84
|
+
{ code: "D2_SOURCE", message, ...(hint === undefined ? {} : { hint }) },
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseFormats(requested: unknown): readonly ArtifactFormat[] {
|
|
89
|
+
if (requested === undefined) {
|
|
90
|
+
return DEFAULT_FORMATS;
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(requested) || requested.length === 0) {
|
|
93
|
+
refuse(
|
|
94
|
+
"Diagram save formats are not usable.",
|
|
95
|
+
`Expected a non-empty list, got ${typeof requested}.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const formats: ArtifactFormat[] = [];
|
|
100
|
+
for (const format of requested) {
|
|
101
|
+
if (!Object.hasOwn(EXTENSIONS, format)) {
|
|
102
|
+
refuse(
|
|
103
|
+
"Diagram save formats are not usable.",
|
|
104
|
+
`${JSON.stringify(format)} is not a format.`,
|
|
105
|
+
`Use ${Object.keys(EXTENSIONS).join(", ")}.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (!formats.includes(format)) {
|
|
109
|
+
formats.push(format);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return formats;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A repository destination has to be named. There is no directory convention worth assuming. */
|
|
116
|
+
function parseDirectory(requested: unknown): string {
|
|
117
|
+
if (typeof requested !== "string" || requested.trim().length === 0) {
|
|
118
|
+
refuse(
|
|
119
|
+
"Saving a diagram needs a directory.",
|
|
120
|
+
requested === undefined
|
|
121
|
+
? "`save.dir` was not given."
|
|
122
|
+
: `Expected a path, got ${typeof requested}.`,
|
|
123
|
+
"Name the directory to write into, such as docs/diagrams.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const directory = requested.trim().replaceAll("\\", "/");
|
|
128
|
+
if (directory.length > MAX_DIRECTORY_LENGTH) {
|
|
129
|
+
refuse(
|
|
130
|
+
"Diagram save directory is not usable.",
|
|
131
|
+
`It is longer than ${MAX_DIRECTORY_LENGTH} characters.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (isAbsolute(requested) || directory.startsWith("/")) {
|
|
135
|
+
refuse(
|
|
136
|
+
"Diagram save directory is not usable.",
|
|
137
|
+
`${JSON.stringify(requested)} is an absolute path.`,
|
|
138
|
+
"Give a path relative to the workspace, such as docs/diagrams.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const segments = directory.split("/").filter((segment) => segment.length > 0 && segment !== ".");
|
|
142
|
+
if (segments.includes("..")) {
|
|
143
|
+
refuse(
|
|
144
|
+
"Diagram save directory is not usable.",
|
|
145
|
+
`${JSON.stringify(requested)} climbs out of the workspace.`,
|
|
146
|
+
"Give a path inside the workspace.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return segments.length === 0 ? "." : segments.join("/");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseSave(requested: unknown): { readonly directory: string; readonly basename: unknown } {
|
|
153
|
+
if (typeof requested !== "object" || requested === null || Array.isArray(requested)) {
|
|
154
|
+
refuse("Diagram save options are not usable.", `Expected an object, got ${typeof requested}.`);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
directory: parseDirectory(Reflect.get(requested, "dir")),
|
|
158
|
+
basename: Reflect.get(requested, "basename"),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function slugify(value: string): string {
|
|
163
|
+
return value
|
|
164
|
+
.normalize("NFKD")
|
|
165
|
+
.toLowerCase()
|
|
166
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
167
|
+
.replace(/^-+/u, "")
|
|
168
|
+
.slice(0, MAX_BASENAME_LENGTH)
|
|
169
|
+
.replace(/-+$/u, "");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A repository file needs a name someone chose, so regenerating the diagram lands on the same
|
|
174
|
+
* path. A temporary file falls back to the source hash, so an ad-hoc diagram needs no title.
|
|
175
|
+
*/
|
|
176
|
+
function parseBasename(
|
|
177
|
+
requested: unknown,
|
|
178
|
+
title: string | undefined,
|
|
179
|
+
fallback: string | undefined,
|
|
180
|
+
): SafeBasename {
|
|
181
|
+
const chosen = typeof requested === "string" && requested.trim().length > 0 ? requested : title;
|
|
182
|
+
if (chosen === undefined) {
|
|
183
|
+
if (fallback !== undefined) {
|
|
184
|
+
return fallback as SafeBasename;
|
|
185
|
+
}
|
|
186
|
+
refuse(
|
|
187
|
+
"A diagram saved into the repository needs a name.",
|
|
188
|
+
"Neither `save.basename` nor `title` was given.",
|
|
189
|
+
"Add a title so the file keeps the same name when the diagram is regenerated.",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const basename = slugify(chosen);
|
|
194
|
+
if (basename.length === 0) {
|
|
195
|
+
refuse(
|
|
196
|
+
"A saved diagram needs a usable name.",
|
|
197
|
+
`${JSON.stringify(chosen)} has no letters or digits to build a file name from.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (RESERVED_NAMES.has(basename)) {
|
|
201
|
+
refuse("A saved diagram needs a different name.", `${basename} is a reserved device name.`);
|
|
202
|
+
}
|
|
203
|
+
return basename as SafeBasename;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface ArtifactIdentity {
|
|
207
|
+
readonly title: string | undefined;
|
|
208
|
+
/** Hash of the normalized source, used to name a temporary file with no title. */
|
|
209
|
+
readonly hash: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ArtifactAsk {
|
|
213
|
+
/** Which artifacts to produce. They land in the temp store either way. */
|
|
214
|
+
readonly formats?: unknown;
|
|
215
|
+
/** Present only when the caller also wants them in the repository. */
|
|
216
|
+
readonly save?: unknown;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Resolves nothing on disk, so a bad request is refused before any rendering starts. */
|
|
220
|
+
export function parseArtifactNames(
|
|
221
|
+
request: ArtifactAsk,
|
|
222
|
+
identity: ArtifactIdentity,
|
|
223
|
+
): ArtifactNames {
|
|
224
|
+
const save = request.save === undefined ? undefined : parseSave(request.save);
|
|
225
|
+
const fallback = save === undefined ? `diagram-${identity.hash.slice(0, 12)}` : undefined;
|
|
226
|
+
return {
|
|
227
|
+
directory: save?.directory,
|
|
228
|
+
basename: parseBasename(save?.basename, identity.title, fallback),
|
|
229
|
+
formats: parseFormats(request.formats),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The workspace-relative paths a repository write would touch, for an approval prompt. */
|
|
234
|
+
export function workspacePaths(names: ArtifactNames): readonly string[] {
|
|
235
|
+
if (names.directory === undefined) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
const prefix = names.directory === "." ? "" : `${names.directory}/`;
|
|
239
|
+
return names.formats.map((format) => `${prefix}${names.basename}${EXTENSIONS[format]}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function contains(root: string, candidate: string): boolean {
|
|
243
|
+
return candidate === root || candidate.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Checks the deepest existing ancestor, before any directory is created: a `docs` symlink
|
|
248
|
+
* pointing outside would otherwise have `mkdir` build the rest of the path beyond it.
|
|
249
|
+
*/
|
|
250
|
+
async function assertInsideWorkspace(realRoot: string, target: string): Promise<void> {
|
|
251
|
+
if (!contains(realRoot, target)) {
|
|
252
|
+
refuse(
|
|
253
|
+
"Diagram save directory is outside the workspace.",
|
|
254
|
+
`${target} is not inside ${realRoot}.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let probe = target;
|
|
259
|
+
for (;;) {
|
|
260
|
+
try {
|
|
261
|
+
const real = await realpath(probe);
|
|
262
|
+
if (!contains(realRoot, real)) {
|
|
263
|
+
refuse(
|
|
264
|
+
"Diagram save directory leaves the workspace.",
|
|
265
|
+
`${probe} resolves to ${real}, outside ${realRoot}.`,
|
|
266
|
+
"A symbolic link on that path points outside the workspace.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
const parent = dirname(probe);
|
|
275
|
+
if (parent === probe) {
|
|
276
|
+
refuse("Diagram save directory is not usable.", `No part of ${target} exists.`);
|
|
277
|
+
}
|
|
278
|
+
probe = parent;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let sessionStore: Promise<string> | undefined;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* `mkdtemp` creates the directory owner-only, so diagrams that quote repository content are not
|
|
287
|
+
* readable by other users of a shared machine.
|
|
288
|
+
*/
|
|
289
|
+
function sessionDirectory(): Promise<string> {
|
|
290
|
+
sessionStore ??= mkdtemp(join(tmpdir(), "pi-diagram-store-"));
|
|
291
|
+
return sessionStore;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function parseArtifactTarget(
|
|
295
|
+
cwd: unknown,
|
|
296
|
+
names: ArtifactNames,
|
|
297
|
+
): Promise<ArtifactTarget> {
|
|
298
|
+
if (names.directory === undefined) {
|
|
299
|
+
const directory = await sessionDirectory();
|
|
300
|
+
return { location: "temp", directory: directory as ArtifactDirectory, names, root: directory };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (typeof cwd !== "string" || cwd.length === 0 || !isAbsolute(cwd)) {
|
|
304
|
+
refuse(
|
|
305
|
+
"Diagrams cannot be saved into a repository without a workspace directory.",
|
|
306
|
+
`The host gave ${JSON.stringify(cwd)}.`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const root = resolve(cwd);
|
|
311
|
+
let realRoot: string;
|
|
312
|
+
try {
|
|
313
|
+
realRoot = await realpath(root);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
refuse(
|
|
316
|
+
"The workspace directory cannot be read.",
|
|
317
|
+
`${root} could not be resolved: ${(error as Error).message}.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const directory = resolve(realRoot, names.directory);
|
|
322
|
+
await assertInsideWorkspace(realRoot, directory);
|
|
323
|
+
return {
|
|
324
|
+
location: "workspace",
|
|
325
|
+
directory: directory as ArtifactDirectory,
|
|
326
|
+
names,
|
|
327
|
+
root: realRoot,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Writes each artifact through a temporary file and a rename, so a reader never sees a half
|
|
333
|
+
* written diagram and a failed render leaves the previous version in place.
|
|
334
|
+
*/
|
|
335
|
+
export async function writeArtifacts(
|
|
336
|
+
target: ArtifactTarget,
|
|
337
|
+
contents: ReadonlyMap<ArtifactFormat, string | Uint8Array>,
|
|
338
|
+
): Promise<readonly WrittenArtifact[]> {
|
|
339
|
+
await mkdir(target.directory, { recursive: true });
|
|
340
|
+
if (target.location === "workspace") {
|
|
341
|
+
// The directory exists now, so this catches a link created between the check and the write.
|
|
342
|
+
await assertInsideWorkspace(target.root, target.directory);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const written: WrittenArtifact[] = [];
|
|
346
|
+
for (const format of target.names.formats) {
|
|
347
|
+
const content = contents.get(format);
|
|
348
|
+
if (content === undefined) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const destination = join(target.directory, `${target.names.basename}${EXTENSIONS[format]}`);
|
|
353
|
+
await assertWritable(destination);
|
|
354
|
+
const temporary = join(target.directory, `.${target.names.basename}.${randomUUID()}.tmp`);
|
|
355
|
+
try {
|
|
356
|
+
await writeFile(temporary, content, {
|
|
357
|
+
...(typeof content === "string" ? { encoding: "utf8" as const } : {}),
|
|
358
|
+
mode: 0o644,
|
|
359
|
+
});
|
|
360
|
+
await rename(temporary, destination);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
await rm(temporary, { force: true });
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
written.push({
|
|
366
|
+
format,
|
|
367
|
+
location: target.location,
|
|
368
|
+
path:
|
|
369
|
+
target.location === "workspace"
|
|
370
|
+
? relative(target.root, destination).split(sep).join("/")
|
|
371
|
+
: destination,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (target.location === "temp") {
|
|
376
|
+
await evictOldest(target.directory);
|
|
377
|
+
}
|
|
378
|
+
return written;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Regenerating a diagram replaces its own files, but never anything that is not a plain file. */
|
|
382
|
+
async function assertWritable(destination: string): Promise<void> {
|
|
383
|
+
try {
|
|
384
|
+
const existing = await lstat(destination);
|
|
385
|
+
if (!existing.isFile()) {
|
|
386
|
+
refuse(
|
|
387
|
+
"That diagram path cannot be written.",
|
|
388
|
+
`${destination} already exists and is not a regular file.`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function evictOldest(directory: string): Promise<void> {
|
|
399
|
+
const names = await readdir(directory);
|
|
400
|
+
if (names.length <= MAX_TEMP_FILES) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const aged = await Promise.all(
|
|
405
|
+
names.map(async (name) => {
|
|
406
|
+
const path = join(directory, name);
|
|
407
|
+
try {
|
|
408
|
+
return { path, at: (await stat(path)).mtimeMs };
|
|
409
|
+
} catch {
|
|
410
|
+
return { path, at: Number.POSITIVE_INFINITY };
|
|
411
|
+
}
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
aged.sort((left, right) => left.at - right.at);
|
|
415
|
+
for (const { path } of aged.slice(0, aged.length - MAX_TEMP_FILES)) {
|
|
416
|
+
await rm(path, { force: true, recursive: true });
|
|
417
|
+
}
|
|
418
|
+
}
|
package/src/cache.ts
ADDED
|
Binary file
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One vocabulary for every refusal, including D2's own errors, so a policy rejection and a
|
|
3
|
+
* syntax error read the same way and the model has one thing to correct.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MAX_DIAGNOSTICS = 10;
|
|
7
|
+
const MAX_MESSAGE_LENGTH = 200;
|
|
8
|
+
|
|
9
|
+
export type DiagnosticCode =
|
|
10
|
+
| "D2_SOURCE"
|
|
11
|
+
| "D2_TOO_LARGE"
|
|
12
|
+
| "D2_TIMEOUT"
|
|
13
|
+
| "D2_IMPORT"
|
|
14
|
+
| "D2_ICON"
|
|
15
|
+
| "D2_LINK"
|
|
16
|
+
| "D2_IMAGE_SHAPE"
|
|
17
|
+
| "D2_UNKNOWN_SHAPE"
|
|
18
|
+
| "D2_BLOCK_STRING"
|
|
19
|
+
| "D2_CONFIG"
|
|
20
|
+
| "D2_UNTERMINATED"
|
|
21
|
+
| "D2_SYNTAX"
|
|
22
|
+
| "D2_RENDER";
|
|
23
|
+
|
|
24
|
+
export interface Diagnostic {
|
|
25
|
+
readonly code: DiagnosticCode;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly line?: number;
|
|
28
|
+
readonly column?: number;
|
|
29
|
+
readonly hint?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A refusal the model can fix by editing its source. What it cannot fix gets another type. */
|
|
33
|
+
export class DiagramSourceError extends Error {
|
|
34
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
35
|
+
|
|
36
|
+
constructor(summary: string, diagnostics: readonly Diagnostic[]) {
|
|
37
|
+
const capped = diagnostics.slice(0, MAX_DIAGNOSTICS);
|
|
38
|
+
super(capped.length > 0 ? `${summary}\n${formatDiagnostics(capped)}` : summary);
|
|
39
|
+
this.name = "DiagramSourceError";
|
|
40
|
+
this.diagnostics = capped;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatDiagnostics(diagnostics: readonly Diagnostic[]): string {
|
|
45
|
+
return diagnostics.map(formatDiagnostic).join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One line, indented, the way it reads inside a refusal message. */
|
|
49
|
+
export function formatDiagnostic(diagnostic: Diagnostic): string {
|
|
50
|
+
const at =
|
|
51
|
+
diagnostic.line === undefined
|
|
52
|
+
? ""
|
|
53
|
+
: diagnostic.column === undefined
|
|
54
|
+
? `line ${diagnostic.line}: `
|
|
55
|
+
: `line ${diagnostic.line}, column ${diagnostic.column}: `;
|
|
56
|
+
const hint = diagnostic.hint === undefined ? "" : ` ${diagnostic.hint}`;
|
|
57
|
+
return ` ${at}${diagnostic.message}${hint} [${diagnostic.code}]`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Prefixes D2 adds that do not help the model fix its source. */
|
|
61
|
+
const NOISE_PREFIXES = [/^err:\s*/u, /^failed to compile [^:]*:\s*/u, /^github\.com\/\S+:\s*/u];
|
|
62
|
+
|
|
63
|
+
const LOCATION = /(\d+):(\d+):\s*(.+)$/u;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `secretPaths` are absolute paths from the isolated render directory. D2 puts them in render
|
|
67
|
+
* errors, and they must not reach the model or the transcript.
|
|
68
|
+
*/
|
|
69
|
+
export function parseD2Diagnostics(
|
|
70
|
+
stderr: string,
|
|
71
|
+
code: DiagnosticCode,
|
|
72
|
+
secretPaths: readonly string[],
|
|
73
|
+
): readonly Diagnostic[] {
|
|
74
|
+
const diagnostics: Diagnostic[] = [];
|
|
75
|
+
for (const rawLine of stderr.split("\n")) {
|
|
76
|
+
if (diagnostics.length >= MAX_DIAGNOSTICS) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
const scrubbed = scrub(rawLine, secretPaths);
|
|
80
|
+
if (scrubbed.length === 0) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const location = LOCATION.exec(scrubbed);
|
|
84
|
+
if (location) {
|
|
85
|
+
const [, line, column, message] = location;
|
|
86
|
+
diagnostics.push({
|
|
87
|
+
code,
|
|
88
|
+
message: cap(message ?? scrubbed),
|
|
89
|
+
line: Number(line),
|
|
90
|
+
column: Number(column),
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
diagnostics.push({ code, message: cap(scrubbed) });
|
|
95
|
+
}
|
|
96
|
+
return diagnostics;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scrub(line: string, secretPaths: readonly string[]): string {
|
|
100
|
+
let text = line.trim();
|
|
101
|
+
for (const prefix of NOISE_PREFIXES) {
|
|
102
|
+
text = text.replace(prefix, "").trim();
|
|
103
|
+
}
|
|
104
|
+
for (const secret of secretPaths) {
|
|
105
|
+
// Strip the directory before the file name so `<dir>/input.d2:2:6:` leaves `2:6:`.
|
|
106
|
+
text = text.split(`${secret}/`).join("").split(secret).join("");
|
|
107
|
+
}
|
|
108
|
+
return text.replace(/^input\.d2:/u, "").trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cap(message: string): string {
|
|
112
|
+
const text = message.trim();
|
|
113
|
+
return text.length > MAX_MESSAGE_LENGTH ? `${text.slice(0, MAX_MESSAGE_LENGTH - 1)}...` : text;
|
|
114
|
+
}
|