@penpair/core 0.1.3 → 0.3.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/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +62 -19
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +213 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +33 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +443 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +49 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +248 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +150 -11
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +362 -59
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +47 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +2107 -247
- package/dist/client.d.ts +30 -6
- package/dist/client.js +160 -35
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +468 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +184 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +105 -0
- package/dist/deep-check.js +57 -9
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +275 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +133 -21
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +113 -10
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +58 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +58 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +229 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +34 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +657 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +435 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +191 -29
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +178 -30
- package/dist/instance/connection.d.ts +39 -4
- package/dist/instance/connection.js +198 -14
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +28 -8
- package/dist/journal.d.ts +87 -0
- package/dist/journal.js +423 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +145 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +279 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +158 -0
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +21 -0
- package/dist/layout.js +36 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +23 -0
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +415 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +78 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +41 -0
- package/dist/operation.d.ts +22 -3
- package/dist/operation.js +341 -31
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.d.ts +5 -0
- package/dist/png.js +22 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +331 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +64 -11
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +65 -0
- package/dist/project-snippets.js +29 -12
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +188 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +224 -53
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +199 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +172 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +144 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +163 -46
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +33 -9
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +78 -41
- package/dist/structure-snapshot.d.ts +85 -0
- package/dist/structure-snapshot.js +525 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +352 -0
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +182 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +20 -45
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
- package/skills/penpair-design/references/project-facts.md +3 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +16 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +13 -0
- package/skills/penpair-recipes/SKILL.md +21 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +8 -2
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
package/dist/adapters.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { BuildKit } from "./kit.js";
|
|
2
|
+
import type { CommandOutput } from "./command-result.js";
|
|
1
3
|
import type { PenpotToolClient } from "./client.js";
|
|
2
4
|
import { type ProjectConfig } from "./instance/config.js";
|
|
3
5
|
/**
|
|
@@ -6,6 +8,9 @@ import { type ProjectConfig } from "./instance/config.js";
|
|
|
6
8
|
* project and knows nothing about any of them. An adapter keeps its own settings in its own block of the same file.
|
|
7
9
|
*/
|
|
8
10
|
export interface AdapterContext {
|
|
11
|
+
output?: CommandOutput;
|
|
12
|
+
/** Removes this connection's credentials before recovery evidence is persisted or reported. */
|
|
13
|
+
redact?(text: string): string;
|
|
9
14
|
/** The project's folder. */
|
|
10
15
|
root: string;
|
|
11
16
|
/** The project's validated configuration. */
|
|
@@ -29,14 +34,24 @@ export interface AdapterCommand {
|
|
|
29
34
|
/** Runs with the arguments after the command's name; returns the exit code. The adapter parses its own flags. */
|
|
30
35
|
run(context: AdapterContext, args: string[]): Promise<number>;
|
|
31
36
|
}
|
|
37
|
+
export interface AdapterStatusLine {
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
state: "ok" | "warning" | "failed" | "unknown";
|
|
41
|
+
detail: string;
|
|
42
|
+
}
|
|
32
43
|
export interface Adapter {
|
|
33
44
|
name: string;
|
|
34
45
|
commands: AdapterCommand[];
|
|
46
|
+
/** Executed only by an explicit status --adapters request; trusted project code. */
|
|
47
|
+
status?(context: AdapterContext): Promise<AdapterStatusLine[]>;
|
|
48
|
+
/** Explicit opt-in plugin-source includes. Built-in commands do not load these unless requested. */
|
|
49
|
+
kits?: BuildKit[];
|
|
35
50
|
}
|
|
36
51
|
/** Declares an adapter. Returns the definition unchanged, so a module can `export default defineAdapter({...})` with types checked. */
|
|
37
52
|
export declare const defineAdapter: <T extends Adapter>(adapter: T) => T;
|
|
38
53
|
/** The commands of the core itself; an adapter cannot take one of these names. */
|
|
39
|
-
export declare const reservedCommands: readonly ["probe", "pages", "tree", "audit", "tokens", "export", "archive", "snippets", "run", "skills", "init", "setup", "connect", "up", "down", "status", "doctor", "backup", "restore", "baselines"];
|
|
54
|
+
export declare const reservedCommands: readonly ["formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
|
|
40
55
|
export interface LoadedAdapters {
|
|
41
56
|
adapters: Adapter[];
|
|
42
57
|
problems: string[];
|
package/dist/adapters.js
CHANGED
|
@@ -1,26 +1,66 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveProjectInput } from "./project-input.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
-
import {
|
|
5
|
+
import { readProjectConfig } from "./instance/config.js";
|
|
6
6
|
/** Declares an adapter. Returns the definition unchanged, so a module can `export default defineAdapter({...})` with types checked. */
|
|
7
7
|
export const defineAdapter = (adapter) => adapter;
|
|
8
8
|
/** The commands of the core itself; an adapter cannot take one of these names. */
|
|
9
9
|
export const reservedCommands = [
|
|
10
|
-
"
|
|
11
|
-
"
|
|
10
|
+
"formats",
|
|
11
|
+
"snapshot",
|
|
12
|
+
"approve",
|
|
13
|
+
"mcp",
|
|
14
|
+
"spec",
|
|
15
|
+
"inspect",
|
|
16
|
+
"probe",
|
|
17
|
+
"pages",
|
|
18
|
+
"tree",
|
|
19
|
+
"audit",
|
|
20
|
+
"tokens",
|
|
21
|
+
"export",
|
|
22
|
+
"shots",
|
|
23
|
+
"archive",
|
|
24
|
+
"snippets",
|
|
25
|
+
"run",
|
|
26
|
+
"run-file",
|
|
27
|
+
"kit",
|
|
28
|
+
"recipes",
|
|
29
|
+
"skills",
|
|
30
|
+
"init",
|
|
31
|
+
"setup",
|
|
32
|
+
"connect",
|
|
33
|
+
"up",
|
|
34
|
+
"down",
|
|
35
|
+
"open",
|
|
36
|
+
"status",
|
|
37
|
+
"doctor",
|
|
38
|
+
"backup",
|
|
39
|
+
"restore",
|
|
40
|
+
"baselines",
|
|
41
|
+
"config",
|
|
42
|
+
"runs",
|
|
43
|
+
"onboard",
|
|
44
|
+
"help",
|
|
12
45
|
];
|
|
13
46
|
/** Resolves an adapter the way the project would: a relative path from the project's folder, or a package installed in the project. */
|
|
14
47
|
export const importFromProject = async (specifier, root) => {
|
|
15
48
|
let file;
|
|
16
|
-
if (specifier.startsWith(".") || path.isAbsolute(specifier))
|
|
17
|
-
file =
|
|
49
|
+
if (specifier.startsWith(".") || path.isAbsolute(specifier)) {
|
|
50
|
+
file = await resolveProjectInput(root, specifier);
|
|
51
|
+
}
|
|
18
52
|
else {
|
|
53
|
+
if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?:\/[a-z0-9._-]+)*$/i.test(specifier) ||
|
|
54
|
+
specifier.split("/").includes("..")) {
|
|
55
|
+
throw new Error("Adapter must name an installed package or a relative project module");
|
|
56
|
+
}
|
|
19
57
|
try {
|
|
20
58
|
file = createRequire(path.join(root, "package.json")).resolve(specifier);
|
|
21
59
|
}
|
|
22
60
|
catch {
|
|
23
|
-
const error = Object.assign(new Error(`${specifier} is not installed in this project`), {
|
|
61
|
+
const error = Object.assign(new Error(`${specifier} is not installed in this project`), {
|
|
62
|
+
code: "ERR_MODULE_NOT_FOUND",
|
|
63
|
+
});
|
|
24
64
|
throw error;
|
|
25
65
|
}
|
|
26
66
|
}
|
|
@@ -48,24 +88,27 @@ export async function loadAdapters(root, specifiers, importer = importFromProjec
|
|
|
48
88
|
continue;
|
|
49
89
|
}
|
|
50
90
|
for (const command of adapter.commands) {
|
|
51
|
-
if (reservedCommands.includes(command.name))
|
|
91
|
+
if (reservedCommands.includes(command.name)) {
|
|
52
92
|
problems.push(`adapter ${specifier}: the command "${command.name}" is one the core already has`);
|
|
53
|
-
|
|
93
|
+
}
|
|
94
|
+
else if (taken.has(command.name)) {
|
|
54
95
|
problems.push(`adapter ${specifier}: the command "${command.name}" is already provided by ${taken.get(command.name)}`);
|
|
55
|
-
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
56
98
|
taken.set(command.name, adapter.name);
|
|
99
|
+
}
|
|
57
100
|
}
|
|
58
|
-
adapters.push({
|
|
101
|
+
adapters.push({
|
|
102
|
+
...adapter,
|
|
103
|
+
commands: adapter.commands.filter((c) => taken.get(c.name) === adapter.name),
|
|
104
|
+
});
|
|
59
105
|
}
|
|
60
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
adapters,
|
|
108
|
+
problems,
|
|
109
|
+
};
|
|
61
110
|
}
|
|
62
111
|
/** The whole penpair.config.json as written (an adapter's own settings live in it). Empty when there is no file. */
|
|
63
112
|
export async function readRawConfig(root) {
|
|
64
|
-
|
|
65
|
-
const parsed = JSON.parse(await readFile(path.join(root, configFileName), "utf8"));
|
|
66
|
-
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return {};
|
|
70
|
-
}
|
|
113
|
+
return (await readProjectConfig(root)) ?? {};
|
|
71
114
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CaptureIdentity, ExportResult } from "./export.js";
|
|
2
|
+
import { type BaselineCase } from "./baselines-config.js";
|
|
3
|
+
export interface ApprovalRecord {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
id: string;
|
|
6
|
+
kind: "owner-words";
|
|
7
|
+
recordedAt: string;
|
|
8
|
+
by: string;
|
|
9
|
+
note: string;
|
|
10
|
+
capture: CaptureIdentity;
|
|
11
|
+
case?: BaselineCase;
|
|
12
|
+
sha256: string;
|
|
13
|
+
image: string;
|
|
14
|
+
fingerprint: string;
|
|
15
|
+
hash: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ApprovalFile {
|
|
18
|
+
schemaVersion: 1;
|
|
19
|
+
kind: "design-approvals";
|
|
20
|
+
records: ApprovalRecord[];
|
|
21
|
+
}
|
|
22
|
+
export declare const approvalFile = "design/approvals.json";
|
|
23
|
+
export declare const artifactSha: (bytes: Buffer) => string;
|
|
24
|
+
export declare function approvalFingerprint(capture: CaptureIdentity, sha256: string, intent?: BaselineCase): string;
|
|
25
|
+
export declare function validateApprovals(raw: unknown): ApprovalFile;
|
|
26
|
+
export declare function readApprovals(root: string): Promise<ApprovalFile>;
|
|
27
|
+
/** Records supplied owner words; hashes detect accidental changes, not identity or consent. */
|
|
28
|
+
export declare function recordApproval(root: string, exported: ExportResult, words: {
|
|
29
|
+
note: string;
|
|
30
|
+
by: string;
|
|
31
|
+
}, intent?: BaselineCase, redact?: (text: string) => string): Promise<ApprovalRecord>;
|
|
32
|
+
export declare function matchingApproval(root: string, capture: CaptureIdentity, sha256: string, intent?: BaselineCase): Promise<ApprovalRecord | undefined>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { atomicWriteFile } from "./atomic-output.js";
|
|
5
|
+
import { observationHash } from "./journal.js";
|
|
6
|
+
import { resolveProjectInput, resolveProjectOutput } from "./project-input.js";
|
|
7
|
+
import { validateBaselineCase } from "./baselines-config.js";
|
|
8
|
+
import { pngSize } from "./png.js";
|
|
9
|
+
export const approvalFile = "design/approvals.json";
|
|
10
|
+
export const artifactSha = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
11
|
+
export function approvalFingerprint(capture, sha256, intent) {
|
|
12
|
+
if (!capture ||
|
|
13
|
+
typeof capture.fileId !== "string" ||
|
|
14
|
+
!capture.fileId ||
|
|
15
|
+
typeof capture.pageId !== "string" ||
|
|
16
|
+
!capture.pageId ||
|
|
17
|
+
typeof capture.frameId !== "string" ||
|
|
18
|
+
!capture.frameId ||
|
|
19
|
+
!Number.isInteger(capture.revision) ||
|
|
20
|
+
!Number.isFinite(capture.scale) ||
|
|
21
|
+
capture.scale <= 0 ||
|
|
22
|
+
!/^[a-f0-9]{64}$/.test(sha256)) {
|
|
23
|
+
throw new Error("Approval requires exact capture identity, revision, scale and SHA-256");
|
|
24
|
+
}
|
|
25
|
+
if (intent) {
|
|
26
|
+
validateBaselineCase(intent, (message) => {
|
|
27
|
+
throw new Error(message);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return observationHash({
|
|
31
|
+
capture,
|
|
32
|
+
sha256,
|
|
33
|
+
case: intent ?? null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function validateApprovals(raw) {
|
|
37
|
+
const v = raw;
|
|
38
|
+
if (!v ||
|
|
39
|
+
v.schemaVersion !== 1 ||
|
|
40
|
+
v.kind !== "design-approvals" ||
|
|
41
|
+
!Array.isArray(v.records) ||
|
|
42
|
+
v.records.length > 10000) {
|
|
43
|
+
throw new Error("Unsupported approval document");
|
|
44
|
+
}
|
|
45
|
+
const ids = new Set();
|
|
46
|
+
for (const r of v.records) {
|
|
47
|
+
if (!r ||
|
|
48
|
+
r.schemaVersion !== 1 ||
|
|
49
|
+
r.kind !== "owner-words" ||
|
|
50
|
+
typeof r.id !== "string" ||
|
|
51
|
+
!r.id ||
|
|
52
|
+
ids.has(r.id) ||
|
|
53
|
+
typeof r.note !== "string" ||
|
|
54
|
+
!r.note.trim() ||
|
|
55
|
+
r.note.length > 4000 ||
|
|
56
|
+
typeof r.by !== "string" ||
|
|
57
|
+
!r.by.trim() ||
|
|
58
|
+
r.by.length > 200 ||
|
|
59
|
+
!Number.isFinite(Date.parse(r.recordedAt)) ||
|
|
60
|
+
typeof r.image !== "string" ||
|
|
61
|
+
!r.image) {
|
|
62
|
+
throw new Error("Malformed owner approval record");
|
|
63
|
+
}
|
|
64
|
+
ids.add(r.id);
|
|
65
|
+
const { hash, ...body } = r;
|
|
66
|
+
if (hash !== observationHash(body) ||
|
|
67
|
+
r.fingerprint !== approvalFingerprint(r.capture, r.sha256, r.case)) {
|
|
68
|
+
throw new Error("Approval evidence hash mismatch");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return v;
|
|
72
|
+
}
|
|
73
|
+
export async function readApprovals(root) {
|
|
74
|
+
try {
|
|
75
|
+
const file = await resolveProjectInput(root, approvalFile);
|
|
76
|
+
if ((await stat(file)).size > 16 * 1024 * 1024) {
|
|
77
|
+
throw new Error("Approval file exceeds 16 MiB");
|
|
78
|
+
}
|
|
79
|
+
return validateApprovals(JSON.parse(await readFile(file, "utf8")));
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code === "ENOENT") {
|
|
83
|
+
return {
|
|
84
|
+
schemaVersion: 1,
|
|
85
|
+
kind: "design-approvals",
|
|
86
|
+
records: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Records supplied owner words; hashes detect accidental changes, not identity or consent. */
|
|
93
|
+
export async function recordApproval(root, exported, words, intent, redact) {
|
|
94
|
+
exported = {
|
|
95
|
+
...exported,
|
|
96
|
+
capture: structuredClone(exported.capture),
|
|
97
|
+
png: Buffer.from(exported.png),
|
|
98
|
+
};
|
|
99
|
+
intent = intent ? structuredClone(intent) : undefined;
|
|
100
|
+
words = {
|
|
101
|
+
...words,
|
|
102
|
+
};
|
|
103
|
+
if (!exported.stable || !pngSize(exported.png)) {
|
|
104
|
+
throw new Error("Approval requires a stable independently exported PNG");
|
|
105
|
+
}
|
|
106
|
+
if (typeof words.note !== "string" ||
|
|
107
|
+
!words.note.trim() ||
|
|
108
|
+
words.note.length > 4000 ||
|
|
109
|
+
typeof words.by !== "string" ||
|
|
110
|
+
!words.by.trim() ||
|
|
111
|
+
words.by.length > 200) {
|
|
112
|
+
throw new Error("Supply the owner's exact nonempty words and identity; an agent cannot invent approval");
|
|
113
|
+
}
|
|
114
|
+
const sha256 = artifactSha(exported.png);
|
|
115
|
+
const fingerprint = approvalFingerprint(exported.capture, sha256, intent);
|
|
116
|
+
if (intent &&
|
|
117
|
+
(!exported.capture.designSize ||
|
|
118
|
+
exported.capture.designSize.width !== intent.viewport.width ||
|
|
119
|
+
exported.capture.designSize.height !== intent.viewport.height ||
|
|
120
|
+
(intent.activeThemeIds &&
|
|
121
|
+
observationHash([
|
|
122
|
+
...(exported.capture.activeThemeIds ?? []),
|
|
123
|
+
].sort()) !==
|
|
124
|
+
observationHash([
|
|
125
|
+
...intent.activeThemeIds,
|
|
126
|
+
].sort())))) {
|
|
127
|
+
throw new Error("Approval case assertions do not match observed capture");
|
|
128
|
+
}
|
|
129
|
+
const body = {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
id: randomUUID(),
|
|
132
|
+
kind: "owner-words",
|
|
133
|
+
recordedAt: new Date().toISOString(),
|
|
134
|
+
by: words.by,
|
|
135
|
+
note: words.note,
|
|
136
|
+
capture: exported.capture,
|
|
137
|
+
...(intent
|
|
138
|
+
? {
|
|
139
|
+
case: intent,
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
sha256,
|
|
143
|
+
image: `design/approvals/${sha256}.png`,
|
|
144
|
+
fingerprint,
|
|
145
|
+
};
|
|
146
|
+
const record = {
|
|
147
|
+
...body,
|
|
148
|
+
hash: observationHash(body),
|
|
149
|
+
};
|
|
150
|
+
const serialized = JSON.stringify(record);
|
|
151
|
+
if (redact && redact(serialized) !== serialized) {
|
|
152
|
+
throw new Error("Approval requires credential redaction; no record was written");
|
|
153
|
+
}
|
|
154
|
+
const file = await resolveProjectOutput(root, approvalFile);
|
|
155
|
+
await mkdir(path.dirname(file), {
|
|
156
|
+
recursive: true,
|
|
157
|
+
});
|
|
158
|
+
const lock = await open(file + ".lock", "wx", 0o600);
|
|
159
|
+
try {
|
|
160
|
+
const previous = await readApprovals(root);
|
|
161
|
+
if (previous.records.length >= 10000) {
|
|
162
|
+
throw new Error("Approval document record limit reached");
|
|
163
|
+
}
|
|
164
|
+
const image = await resolveProjectOutput(root, record.image);
|
|
165
|
+
await mkdir(path.dirname(image), {
|
|
166
|
+
recursive: true,
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
await atomicWriteFile(image, exported.png);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== "EEXIST" ||
|
|
173
|
+
artifactSha(await readFile(image)) !== sha256) {
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const next = JSON.stringify({
|
|
178
|
+
...previous,
|
|
179
|
+
records: [
|
|
180
|
+
...previous.records,
|
|
181
|
+
record,
|
|
182
|
+
],
|
|
183
|
+
}, null, 2) + "\n";
|
|
184
|
+
if (Buffer.byteLength(next) > 16 * 1024 * 1024) {
|
|
185
|
+
throw new Error("Approval document byte limit reached");
|
|
186
|
+
}
|
|
187
|
+
await atomicWriteFile(file, next, {
|
|
188
|
+
replace: true,
|
|
189
|
+
});
|
|
190
|
+
return record;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await lock.close();
|
|
194
|
+
await unlink(file + ".lock");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export async function matchingApproval(root, capture, sha256, intent) {
|
|
198
|
+
const fingerprint = approvalFingerprint(capture, sha256, intent);
|
|
199
|
+
const record = [
|
|
200
|
+
...(await readApprovals(root)).records,
|
|
201
|
+
]
|
|
202
|
+
.reverse()
|
|
203
|
+
.find((r) => r.fingerprint === fingerprint);
|
|
204
|
+
if (!record) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const image = await resolveProjectInput(root, record.image);
|
|
208
|
+
if ((await stat(image)).size > 64 * 1024 * 1024 ||
|
|
209
|
+
artifactSha(await readFile(image)) !== sha256) {
|
|
210
|
+
throw new Error("Approved image bytes changed");
|
|
211
|
+
}
|
|
212
|
+
return record;
|
|
213
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Stage complete bytes beside the destination; publish once. Exclusive mode never replaces an existing file. */
|
|
2
|
+
export declare function atomicWriteFile(destination: string, data: string | Uint8Array, options?: {
|
|
3
|
+
replace?: boolean;
|
|
4
|
+
mode?: number;
|
|
5
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, open, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Stage complete bytes beside the destination; publish once. Exclusive mode never replaces an existing file. */
|
|
5
|
+
export async function atomicWriteFile(destination, data, options = {}) {
|
|
6
|
+
const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}-${randomUUID()}.tmp`);
|
|
7
|
+
const handle = await open(temporary, "wx", options.mode ?? 0o600);
|
|
8
|
+
try {
|
|
9
|
+
await handle.writeFile(data);
|
|
10
|
+
await handle.sync();
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
await handle.close();
|
|
14
|
+
await unlink(temporary).catch(() => undefined);
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
await handle.close();
|
|
18
|
+
try {
|
|
19
|
+
if (options.replace) {
|
|
20
|
+
await rename(temporary, destination);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
await link(temporary, destination);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await unlink(temporary).catch((error) => {
|
|
28
|
+
if (error.code !== "ENOENT") {
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { InspectionDocument } from "./inspection.js";
|
|
2
|
+
export interface AuditRules {
|
|
3
|
+
contrast?: boolean;
|
|
4
|
+
library?: boolean;
|
|
5
|
+
touchTargets?: boolean;
|
|
6
|
+
names?: boolean;
|
|
7
|
+
blankText?: boolean;
|
|
8
|
+
interactiveIds?: string[];
|
|
9
|
+
allowBlank?: string[];
|
|
10
|
+
nameSegments?: number;
|
|
11
|
+
suppressions?: Array<{
|
|
12
|
+
rule: string;
|
|
13
|
+
nodeId: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
export interface AuditFinding {
|
|
18
|
+
rule: string;
|
|
19
|
+
nodeId: string;
|
|
20
|
+
severity: "warning" | "info";
|
|
21
|
+
state: "finding" | "unknown";
|
|
22
|
+
detail: string;
|
|
23
|
+
evidence?: unknown;
|
|
24
|
+
suppression?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function validateAuditRules(raw: unknown): AuditRules;
|
|
27
|
+
/** Opaque sRGB subset only. Alpha/multiple paints/images require a richer compositing model. */
|
|
28
|
+
export declare function opaqueRgb(raw: unknown): number[] | undefined;
|
|
29
|
+
export declare function contrastRatio(a: number[], b: number[]): number;
|
|
30
|
+
export declare function auditDesignRules(raw: InspectionDocument, rawRules: AuditRules, tokenInspection?: InspectionDocument, limit?: number): {
|
|
31
|
+
schemaVersion: number;
|
|
32
|
+
kind: string;
|
|
33
|
+
source: {
|
|
34
|
+
fileId: string;
|
|
35
|
+
pageId: string | null;
|
|
36
|
+
revision: number;
|
|
37
|
+
hash: string;
|
|
38
|
+
};
|
|
39
|
+
advisory: boolean;
|
|
40
|
+
accessibilityCompliance: string;
|
|
41
|
+
complete: boolean;
|
|
42
|
+
totalFindings: number;
|
|
43
|
+
suppressed: number;
|
|
44
|
+
findings: AuditFinding[];
|
|
45
|
+
contrast: {
|
|
46
|
+
measured: number;
|
|
47
|
+
unknown: number;
|
|
48
|
+
};
|
|
49
|
+
library: {
|
|
50
|
+
instances: number;
|
|
51
|
+
knownNodes: number;
|
|
52
|
+
unknownNodes: number;
|
|
53
|
+
shareOfKnown: number | null;
|
|
54
|
+
largestPrimitiveGroups: {
|
|
55
|
+
id: string;
|
|
56
|
+
area: number;
|
|
57
|
+
}[];
|
|
58
|
+
} | null;
|
|
59
|
+
assumptions: string[];
|
|
60
|
+
};
|