@penpair/core 0.1.2 → 0.2.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.
Files changed (123) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +8 -12
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +99 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +28 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +177 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +28 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +114 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +52 -3
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +132 -52
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +35 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +770 -139
  21. package/dist/client.d.ts +26 -5
  22. package/dist/client.js +46 -20
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +179 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +122 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +61 -0
  29. package/dist/deep-check.js +9 -5
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +135 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +48 -4
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +42 -7
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +32 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +40 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +28 -0
  44. package/dist/index.d.ts +33 -1
  45. package/dist/index.js +33 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +344 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +76 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +102 -11
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +53 -12
  54. package/dist/instance/connection.d.ts +14 -0
  55. package/dist/instance/connection.js +13 -2
  56. package/dist/instance/init.js +1 -1
  57. package/dist/instance/setup.js +1 -1
  58. package/dist/instance/tty.js +3 -1
  59. package/dist/journal.d.ts +77 -0
  60. package/dist/journal.js +260 -0
  61. package/dist/kit-smoke.d.ts +31 -0
  62. package/dist/kit-smoke.js +83 -0
  63. package/dist/kit-source.d.ts +2 -0
  64. package/dist/kit-source.js +309 -0
  65. package/dist/kit.d.ts +25 -0
  66. package/dist/kit.js +90 -0
  67. package/dist/layout.d.ts +15 -0
  68. package/dist/layout.js +79 -0
  69. package/dist/live-smoke.d.ts +8 -0
  70. package/dist/live-smoke.js +14 -0
  71. package/dist/mcp-server.d.ts +12 -0
  72. package/dist/mcp-server.js +102 -0
  73. package/dist/native-api.d.ts +15 -0
  74. package/dist/native-api.js +42 -0
  75. package/dist/onboard.d.ts +10 -0
  76. package/dist/onboard.js +15 -0
  77. package/dist/operation.d.ts +20 -1
  78. package/dist/operation.js +113 -13
  79. package/dist/png.d.ts +5 -0
  80. package/dist/png.js +7 -0
  81. package/dist/portable-tokens.d.ts +22 -0
  82. package/dist/portable-tokens.js +134 -0
  83. package/dist/probe.d.ts +6 -2
  84. package/dist/probe.js +19 -3
  85. package/dist/project-input.d.ts +6 -0
  86. package/dist/project-input.js +51 -0
  87. package/dist/project-snippets.js +12 -7
  88. package/dist/project-status.d.ts +24 -0
  89. package/dist/project-status.js +79 -0
  90. package/dist/registry.d.ts +2 -1
  91. package/dist/registry.js +17 -3
  92. package/dist/run-file.d.ts +26 -0
  93. package/dist/run-file.js +100 -0
  94. package/dist/shots.d.ts +43 -0
  95. package/dist/shots.js +90 -0
  96. package/dist/skill-instructions.d.ts +13 -0
  97. package/dist/skill-instructions.js +75 -0
  98. package/dist/skills.d.ts +11 -0
  99. package/dist/skills.js +71 -33
  100. package/dist/snippet.d.ts +3 -2
  101. package/dist/snippet.js +12 -8
  102. package/dist/snippets/archive.d.ts +9 -0
  103. package/dist/snippets/archive.js +10 -4
  104. package/dist/snippets/read.d.ts +1 -1
  105. package/dist/snippets/read.js +71 -40
  106. package/dist/structure-snapshot.d.ts +73 -0
  107. package/dist/structure-snapshot.js +171 -0
  108. package/dist/token-edit.d.ts +53 -0
  109. package/dist/token-edit.js +197 -0
  110. package/dist/tokens-diff.d.ts +19 -6
  111. package/dist/tokens-diff.js +89 -18
  112. package/dist/version.d.ts +1 -1
  113. package/dist/version.js +1 -1
  114. package/package.json +1 -1
  115. package/skills/penpair-design/SKILL.md +12 -40
  116. package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
  117. package/skills/penpair-design/references/project-facts.md +1 -1
  118. package/skills/penpair-design/references/write-protocol.md +15 -3
  119. package/skills/penpair-handoff/SKILL.md +14 -0
  120. package/skills/penpair-handoff/references/handoff-flow.md +11 -0
  121. package/skills/penpair-recipes/SKILL.md +16 -0
  122. package/skills/penpair-recipes/references/build-flow.md +11 -0
  123. package/skills/penpair-setup/SKILL.md +6 -2
@@ -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,21 +1,23 @@
1
- import { readFile } from "node:fs/promises";
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 { configFileName } from "./instance/config.js";
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
- "probe", "pages", "tree", "audit", "tokens", "export", "archive", "snippets", "run", "skills", "init", "setup", "connect",
11
- "up", "down", "status", "doctor", "backup", "restore", "baselines",
10
+ "formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect",
11
+ "up", "down", "open", "status", "doctor", "backup", "restore", "baselines", "config", "runs", "onboard", "help",
12
12
  ];
13
13
  /** Resolves an adapter the way the project would: a relative path from the project's folder, or a package installed in the project. */
14
14
  export const importFromProject = async (specifier, root) => {
15
15
  let file;
16
16
  if (specifier.startsWith(".") || path.isAbsolute(specifier))
17
- file = path.resolve(root, specifier);
17
+ file = await resolveProjectInput(root, specifier);
18
18
  else {
19
+ if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?:\/[a-z0-9._-]+)*$/i.test(specifier) || specifier.split("/").includes(".."))
20
+ throw new Error("Adapter must name an installed package or a relative project module");
19
21
  try {
20
22
  file = createRequire(path.join(root, "package.json")).resolve(specifier);
21
23
  }
@@ -61,11 +63,5 @@ export async function loadAdapters(root, specifiers, importer = importFromProjec
61
63
  }
62
64
  /** The whole penpair.config.json as written (an adapter's own settings live in it). Empty when there is no file. */
63
65
  export async function readRawConfig(root) {
64
- try {
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
- }
66
+ return await readProjectConfig(root) ?? {};
71
67
  }
@@ -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,99 @@
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 || typeof capture.fileId !== "string" || !capture.fileId || typeof capture.pageId !== "string" || !capture.pageId || typeof capture.frameId !== "string" || !capture.frameId || !Number.isInteger(capture.revision) || !Number.isFinite(capture.scale) || capture.scale <= 0 || !/^[a-f0-9]{64}$/.test(sha256))
13
+ throw new Error("Approval requires exact capture identity, revision, scale and SHA-256");
14
+ if (intent)
15
+ validateBaselineCase(intent, message => { throw new Error(message); });
16
+ return observationHash({ capture, sha256, case: intent ?? null });
17
+ }
18
+ export function validateApprovals(raw) {
19
+ const v = raw;
20
+ if (!v || v.schemaVersion !== 1 || v.kind !== "design-approvals" || !Array.isArray(v.records) || v.records.length > 10000)
21
+ throw new Error("Unsupported approval document");
22
+ const ids = new Set();
23
+ for (const r of v.records) {
24
+ if (!r || r.schemaVersion !== 1 || r.kind !== "owner-words" || typeof r.id !== "string" || !r.id || ids.has(r.id) || typeof r.note !== "string" || !r.note.trim() || r.note.length > 4000 || typeof r.by !== "string" || !r.by.trim() || r.by.length > 200 || !Number.isFinite(Date.parse(r.recordedAt)) || typeof r.image !== "string" || !r.image)
25
+ throw new Error("Malformed owner approval record");
26
+ ids.add(r.id);
27
+ const { hash, ...body } = r;
28
+ if (hash !== observationHash(body) || r.fingerprint !== approvalFingerprint(r.capture, r.sha256, r.case))
29
+ throw new Error("Approval evidence hash mismatch");
30
+ }
31
+ return v;
32
+ }
33
+ export async function readApprovals(root) {
34
+ try {
35
+ const file = await resolveProjectInput(root, approvalFile);
36
+ if ((await stat(file)).size > 16 * 1024 * 1024)
37
+ throw new Error("Approval file exceeds 16 MiB");
38
+ return validateApprovals(JSON.parse(await readFile(file, "utf8")));
39
+ }
40
+ catch (error) {
41
+ if (error.code === "ENOENT")
42
+ return { schemaVersion: 1, kind: "design-approvals", records: [] };
43
+ throw error;
44
+ }
45
+ }
46
+ /** Records supplied owner words; hashes detect accidental changes, not identity or consent. */
47
+ export async function recordApproval(root, exported, words, intent, redact) {
48
+ exported = { ...exported, capture: structuredClone(exported.capture), png: Buffer.from(exported.png) };
49
+ intent = intent ? structuredClone(intent) : undefined;
50
+ words = { ...words };
51
+ if (!exported.stable || !pngSize(exported.png))
52
+ throw new Error("Approval requires a stable independently exported PNG");
53
+ if (typeof words.note !== "string" || !words.note.trim() || words.note.length > 4000 || typeof words.by !== "string" || !words.by.trim() || words.by.length > 200)
54
+ throw new Error("Supply the owner's exact nonempty words and identity; an agent cannot invent approval");
55
+ const sha256 = artifactSha(exported.png), fingerprint = approvalFingerprint(exported.capture, sha256, intent);
56
+ if (intent && (!exported.capture.designSize || exported.capture.designSize.width !== intent.viewport.width || exported.capture.designSize.height !== intent.viewport.height || intent.activeThemeIds && observationHash([...(exported.capture.activeThemeIds ?? [])].sort()) !== observationHash([...intent.activeThemeIds].sort())))
57
+ throw new Error("Approval case assertions do not match observed capture");
58
+ const body = { schemaVersion: 1, id: randomUUID(), kind: "owner-words", recordedAt: new Date().toISOString(), by: words.by, note: words.note, capture: exported.capture, ...(intent ? { case: intent } : {}), sha256, image: `design/approvals/${sha256}.png`, fingerprint };
59
+ const record = { ...body, hash: observationHash(body) };
60
+ const serialized = JSON.stringify(record);
61
+ if (redact && redact(serialized) !== serialized)
62
+ throw new Error("Approval requires credential redaction; no record was written");
63
+ const file = await resolveProjectOutput(root, approvalFile);
64
+ await mkdir(path.dirname(file), { recursive: true });
65
+ const lock = await open(file + ".lock", "wx", 0o600);
66
+ try {
67
+ const previous = await readApprovals(root);
68
+ if (previous.records.length >= 10000)
69
+ throw new Error("Approval document record limit reached");
70
+ const image = await resolveProjectOutput(root, record.image);
71
+ await mkdir(path.dirname(image), { recursive: true });
72
+ try {
73
+ await atomicWriteFile(image, exported.png);
74
+ }
75
+ catch (error) {
76
+ if (error.code !== "EEXIST" || artifactSha(await readFile(image)) !== sha256)
77
+ throw error;
78
+ }
79
+ const next = JSON.stringify({ ...previous, records: [...previous.records, record] }, null, 2) + "\n";
80
+ if (Buffer.byteLength(next) > 16 * 1024 * 1024)
81
+ throw new Error("Approval document byte limit reached");
82
+ await atomicWriteFile(file, next, { replace: true });
83
+ return record;
84
+ }
85
+ finally {
86
+ await lock.close();
87
+ await unlink(file + ".lock");
88
+ }
89
+ }
90
+ export async function matchingApproval(root, capture, sha256, intent) {
91
+ const fingerprint = approvalFingerprint(capture, sha256, intent);
92
+ const record = [...(await readApprovals(root)).records].reverse().find(r => r.fingerprint === fingerprint);
93
+ if (!record)
94
+ return undefined;
95
+ const image = await resolveProjectInput(root, record.image);
96
+ if ((await stat(image)).size > 64 * 1024 * 1024 || artifactSha(await readFile(image)) !== sha256)
97
+ throw new Error("Approved image bytes changed");
98
+ return record;
99
+ }
@@ -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,28 @@
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
+ else
22
+ await link(temporary, destination);
23
+ }
24
+ finally {
25
+ await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
26
+ throw error; });
27
+ }
28
+ }
@@ -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
+ };
@@ -0,0 +1,177 @@
1
+ import { validateDesignInspection } from "./design-spec.js";
2
+ import { observationHash } from "./journal.js";
3
+ export function validateAuditRules(raw) {
4
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
5
+ throw new Error("audit must be an object");
6
+ const v = raw;
7
+ const keys = ["contrast", "library", "touchTargets", "names", "blankText", "interactiveIds", "allowBlank", "nameSegments", "suppressions"];
8
+ if (Object.keys(v).some(k => !keys.includes(k)))
9
+ throw new Error("Unknown audit setting");
10
+ for (const key of ["contrast", "library", "touchTargets", "names", "blankText"])
11
+ if (v[key] !== undefined && typeof v[key] !== "boolean")
12
+ throw new Error("Audit rule toggles must be booleans");
13
+ for (const entries of [v.interactiveIds, v.allowBlank])
14
+ if (entries !== undefined && (!Array.isArray(entries) || entries.length > 1000 || entries.some(s => typeof s !== "string" || !s || s.length > 200)))
15
+ throw new Error("Audit ID lists must have at most 1000 explicit IDs");
16
+ if (v.nameSegments !== undefined && (!Number.isInteger(v.nameSegments) || v.nameSegments < 1 || v.nameSegments > 10))
17
+ throw new Error("Audit nameSegments must be 1–10");
18
+ if (v.suppressions !== undefined && (!Array.isArray(v.suppressions) || v.suppressions.length > 1000 || v.suppressions.some(s => !s || !["contrast", "library", "touch-target", "names", "blank-text"].includes(s.rule) || typeof s.nodeId !== "string" || !s.nodeId || typeof s.reason !== "string" || !s.reason.trim() || s.reason.length > 1000)))
19
+ throw new Error("Audit suppressions require exact rule/node ID and reason");
20
+ return structuredClone(v);
21
+ }
22
+ /** Opaque sRGB subset only. Alpha/multiple paints/images require a richer compositing model. */
23
+ export function opaqueRgb(raw) {
24
+ if (typeof raw !== "string")
25
+ return;
26
+ const match = /^#([a-f0-9]{6})(ff)?$/i.exec(raw);
27
+ if (!match)
28
+ return;
29
+ return [0, 2, 4].map(i => parseInt(match[1].slice(i, i + 2), 16) / 255);
30
+ }
31
+ export function contrastRatio(a, b) {
32
+ const luminance = (rgb) => rgb.map(c => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4).reduce((n, c, i) => n + c * [0.2126, 0.7152, 0.0722][i], 0);
33
+ const x = luminance(a), y = luminance(b);
34
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
35
+ }
36
+ function resolvedColors(tokens, shapes) {
37
+ const result = new Map();
38
+ if (!tokens || tokens.schemaVersion !== 1 || tokens.kind !== "token-inspection" || tokens.complete !== true || tokens.redacted === true || tokens.fileId !== shapes.fileId || tokens.revision !== shapes.revision || !Array.isArray(tokens.sets))
39
+ return result;
40
+ for (const set of [...tokens.sets].sort((a, b) => Number(a.order) - Number(b.order))) {
41
+ if (set.active !== true || !Array.isArray(set.tokens))
42
+ continue;
43
+ for (const token of set.tokens) {
44
+ if (typeof token.name !== "string")
45
+ continue;
46
+ result.delete(token.name);
47
+ if (token.type === "color" && (!Array.isArray(token.unknowns) || !token.unknowns.some(k => k === "resolvedValue" || k === "type"))) {
48
+ const rgb = opaqueRgb(token.resolvedValue);
49
+ if (rgb)
50
+ result.set(token.name, rgb);
51
+ }
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+ export function auditDesignRules(raw, rawRules, tokenInspection, limit = 1000) {
57
+ const source = validateDesignInspection(raw), rules = validateAuditRules(rawRules);
58
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10000)
59
+ throw new Error("Audit finding limit must be 1–10000");
60
+ const findings = [];
61
+ let totalFindings = 0, suppressed = 0, contrastMeasured = 0, contrastUnknown = 0, copies = 0, knownComponents = 0, unknownComponents = 0;
62
+ const rows = new Map(source.nodes.map(n => [String(n.id), n])), colors = resolvedColors(tokenInspection, source);
63
+ const add = (finding) => {
64
+ totalFindings++;
65
+ const suppression = rules.suppressions?.find(s => s.rule === finding.rule && s.nodeId === finding.nodeId);
66
+ if (suppression)
67
+ suppressed++;
68
+ if (findings.length < limit)
69
+ findings.push({ ...finding, ...(suppression ? { suppression: suppression.reason } : {}) });
70
+ };
71
+ const paint = (node) => {
72
+ const style = node.style, bindings = node.tokens;
73
+ if (!style || style.opacity !== 1 && style.opacity !== undefined || style.blendMode !== "normal" && style.blendMode !== undefined)
74
+ return { reason: "Unknown/transparent/blended node style" };
75
+ const fills = style.fills;
76
+ if (!Array.isArray(fills))
77
+ return { reason: "Fill observation unavailable" };
78
+ if (!fills.length)
79
+ return { absent: true };
80
+ if (fills.length !== 1 || !fills[0] || typeof fills[0] !== "object")
81
+ return { reason: "Multiple or unsupported paints" };
82
+ const fill = fills[0];
83
+ if (fill.fillOpacity !== undefined && fill.fillOpacity !== 1 || fill.fillColorGradient || fill.fillImage || fill.fillType && fill.fillType !== "solid")
84
+ return { reason: "Gradient/image/alpha paint" };
85
+ const name = bindings?.fill;
86
+ if (typeof name !== "string" || !colors.has(name))
87
+ return { reason: "No independently resolved opaque bound color" };
88
+ const actual = opaqueRgb(fill.fillColor);
89
+ if (!actual || observationHash(actual) !== observationHash(colors.get(name)))
90
+ return { reason: "Observed paint differs from resolved bound color" };
91
+ return { rgb: colors.get(name) };
92
+ };
93
+ const backgroundCache = new Map();
94
+ const backgroundFor = (parent) => {
95
+ const visited = [];
96
+ let result = { reason: "No opaque ancestor background" };
97
+ while (typeof parent === "string") {
98
+ if (backgroundCache.has(parent)) {
99
+ result = backgroundCache.get(parent);
100
+ break;
101
+ }
102
+ const ancestor = rows.get(parent);
103
+ result = paint(ancestor);
104
+ visited.push(parent);
105
+ if (!result.absent)
106
+ break;
107
+ parent = ancestor.parentId;
108
+ }
109
+ if (result.absent)
110
+ result = { reason: "No opaque ancestor background" };
111
+ for (const id of visited)
112
+ backgroundCache.set(id, result);
113
+ return result;
114
+ };
115
+ const rootId = source.nodes.find(n => n.parentId === null)?.id;
116
+ const largest = [];
117
+ const considered = source.nodes.filter(n => n.parentId !== null && n.geometry?.hidden !== true);
118
+ for (const node of considered) {
119
+ const id = String(node.id), geometry = node.geometry;
120
+ if (rules.names) {
121
+ if (["Board", "Rectangle", "Group"].includes(String(node.name)))
122
+ add({ rule: "names", nodeId: id, severity: "info", state: "finding", detail: "Default layer name" });
123
+ if (node.type === "board" && node.parentId === rootId && (String(node.name).split("/").length !== (rules.nameSegments ?? 3) || String(node.name).split("/").some(s => !s.trim())))
124
+ add({ rule: "names", nodeId: id, severity: "info", state: "finding", detail: "Frame name differs from declared slash-separated page/state/device convention" });
125
+ }
126
+ if (rules.blankText && node.type === "text" && !rules.allowBlank?.includes(id)) {
127
+ const text = node.text?.characters;
128
+ if (typeof text !== "string")
129
+ add({ rule: "blank-text", nodeId: id, severity: "info", state: "unknown", detail: "Text not observed" });
130
+ else if (!text.trim() || /^(?:lorem ipsum|placeholder|todo)(?:\b|$)/i.test(text.trim()))
131
+ add({ rule: "blank-text", nodeId: id, severity: "info", state: "finding", detail: "Blank or placeholder text" });
132
+ }
133
+ if (rules.library) {
134
+ const component = node.component;
135
+ if (typeof component?.isComponentCopyInstance !== "boolean")
136
+ unknownComponents++;
137
+ else {
138
+ knownComponents++;
139
+ if (component.isComponentCopyInstance)
140
+ copies++;
141
+ else if (["board", "group"].includes(String(node.type)) && typeof geometry?.width === "number" && typeof geometry?.height === "number")
142
+ largest.push({ id, area: geometry.width * geometry.height });
143
+ }
144
+ }
145
+ if (rules.contrast && node.type === "text") {
146
+ let foreground = paint(node);
147
+ const background = backgroundFor(node.parentId);
148
+ const unknowns = node.unknowns;
149
+ if (unknowns?.includes("text.mixedRangeStyles"))
150
+ foreground = { reason: "Mixed text range styles" };
151
+ if (!foreground.rgb || !background.rgb) {
152
+ contrastUnknown++;
153
+ add({ rule: "contrast", nodeId: id, severity: "info", state: "unknown", detail: foreground.reason ?? background.reason ?? "Opaque bound colors unavailable" });
154
+ }
155
+ else {
156
+ contrastMeasured++;
157
+ const typography = node.text?.typography;
158
+ const size = Number(typography?.fontSize), weight = Number(typography?.fontWeight), threshold = Number.isFinite(size) && (size >= 24 || size >= 56 / 3 && weight >= 700) ? 3 : 4.5;
159
+ const ratio = contrastRatio(foreground.rgb, background.rgb);
160
+ if (ratio < threshold)
161
+ add({ rule: "contrast", nodeId: id, severity: "warning", state: "finding", detail: "Observed opaque text/background contrast below the review threshold", evidence: { ratio, threshold, assumption: "Design units treated as CSS px; exceptions and implementation rendering unassessed" } });
162
+ }
163
+ }
164
+ }
165
+ if (rules.touchTargets)
166
+ for (const id of rules.interactiveIds ?? []) {
167
+ const node = rows.get(id), g = node?.geometry;
168
+ if (!node || typeof g?.width !== "number" || typeof g.height !== "number" || !Number.isFinite(g.width) || !Number.isFinite(g.height) || g.width < 0 || g.height < 0)
169
+ add({ rule: "touch-target", nodeId: id, severity: "info", state: "unknown", detail: "Explicit interactive target dimensions unavailable" });
170
+ else if (g.width < 44 || g.height < 44)
171
+ add({ rule: "touch-target", nodeId: id, severity: g.width < 24 || g.height < 24 ? "warning" : "info", state: "finding", detail: g.width < 24 || g.height < 24 ? "Below 24-unit size signal" : "Below 44-unit preference", evidence: { width: g.width, height: g.height, assessment: "size-signal-only; spacing, exceptions, input semantics and CSS scale unassessed" } });
172
+ }
173
+ largest.sort((a, b) => b.area - a.area || a.id.localeCompare(b.id));
174
+ return { schemaVersion: 1, kind: "design-audit", source: { fileId: source.fileId, pageId: source.pageId, revision: source.revision, hash: observationHash(source.nodes) }, advisory: true, accessibilityCompliance: "not-assessed", complete: totalFindings <= limit, totalFindings, suppressed, findings,
175
+ contrast: { measured: contrastMeasured, unknown: contrastUnknown }, library: rules.library ? { instances: copies, knownNodes: knownComponents, unknownNodes: unknownComponents, shareOfKnown: knownComponents ? copies / knownComponents : null, largestPrimitiveGroups: largest.slice(0, 10) } : null,
176
+ assumptions: ["Complete captured subtree only; hidden ancestors and overlapping/composited paints need independent review.", "Interactive IDs are explicit declarations, not inferred clickability or WCAG compliance.", "Unavailable resolved colors and mixed styles remain unknown; rules never mutate or block by default."] };
177
+ }
@@ -0,0 +1,4 @@
1
+ import type { ProjectConfig } from "./instance/config.js";
2
+ import type { DoctorCheck } from "./instance/connection.js";
3
+ /** An optional advisory record, not proof that an archive can be restored. Disabled unless configured. */
4
+ export declare function backupNoteCheck(root: string, config: ProjectConfig, now?: Date): Promise<DoctorCheck | undefined>;
@@ -0,0 +1,28 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** An optional advisory record, not proof that an archive can be restored. Disabled unless configured. */
4
+ export async function backupNoteCheck(root, config, now = new Date()) {
5
+ if (!config.backup)
6
+ return undefined;
7
+ const warn = (detail) => ({ check: "backup note", ok: true, warning: true, detail, remedy: "Export a current archive, restore it into scratch, then update the backup note; see docs/backup.md." });
8
+ let note;
9
+ try {
10
+ note = JSON.parse(await readFile(path.join(root, config.backup.noteFile), "utf8"));
11
+ }
12
+ catch {
13
+ return warn("No readable backup note; archive/restore status is unknown");
14
+ }
15
+ if (!note || note.schemaVersion !== 1 || typeof note.fileId !== "string" || typeof note.recordedAt !== "string" || typeof note.archiveSha256 !== "string" || !/^[a-f0-9]{64}$/i.test(note.archiveSha256))
16
+ return warn("Backup note is incomplete or invalid; archive/restore status is unknown");
17
+ if (!config.file || note.fileId !== config.file.id)
18
+ return warn("Backup note does not match a pinned project file");
19
+ const timestamp = Date.parse(note.recordedAt);
20
+ const age = now.getTime() - timestamp;
21
+ if (!Number.isFinite(timestamp) || age < 0)
22
+ return warn("Backup note has an invalid or future date");
23
+ if (age >= config.backup.remindAfterDays * 86_400_000)
24
+ return warn(`Backup note is ${Math.floor(age / 86_400_000)} days old; refresh the archive and restore evidence`);
25
+ if (note.restoreVerified !== true)
26
+ return warn("Archive recorded recently, but its restore is not recorded as verified");
27
+ return { check: "backup note", ok: true, detail: "Recent owner-maintained backup/restore note matches the pinned file (a note is not independent restore proof)" };
28
+ }
@@ -0,0 +1,25 @@
1
+ import { type BaselineCase, type BaselinesConfig, type CoverageRequirement } from "./baselines-config.js";
2
+ import type { BaselineResult } from "./baselines.js";
3
+ export interface CoverageRow {
4
+ requirement: CoverageRequirement;
5
+ status: "missing-case" | "missing-capture" | "unapproved-metadata" | "changed-unapproved" | "approved-design-intent" | "unsupported";
6
+ frameIds: string[];
7
+ reason?: string;
8
+ }
9
+ export interface BaselineCoverage {
10
+ schemaVersion: 1;
11
+ kind: "baseline-coverage";
12
+ configured: boolean;
13
+ designCasesComplete: boolean;
14
+ rows: CoverageRow[];
15
+ unknown: {
16
+ id: string;
17
+ fields: string[];
18
+ }[];
19
+ browserVerification: "not-run";
20
+ accessibility: "not-assessed";
21
+ }
22
+ export declare const designCaseKey: (route: string, value: BaselineCase) => string;
23
+ export declare function baselineCoverage(config: BaselinesConfig, results?: BaselineResult[]): BaselineCoverage;
24
+ /** Offline coverage validates the approved image bytes, never only a manifest's claims. */
25
+ export declare function readBaselineCoverage(root: string, config: BaselinesConfig, expectedFileId?: string): Promise<BaselineCoverage>;