@penpair/reflection-check 0.2.0 → 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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # @penpair/reflection-check
2
2
 
3
- An optional adapter for explicit implementation-case evidence against approved penpair baselines. Add it to `adapters` and install it alongside one command package at the same reviewed version. It depends on the neutral core; the core does not load or depend on this adapter.
3
+ The recommended companion when you implement a frontend from Penpot and want repeatable visual checks against approved designs. It is an optional package installed alongside one command package at the same reviewed version and registered in `adapters`.
4
+
5
+ **Penpot design → approved reference and handoff → frontend code → browser capture → comparison → owner review.** Start with the [companion setup guide](https://github.com/niklas-westman/penpair/blob/main/docs/adapters.md#check-the-frontend-against-the-design) for installation, registration and the readiness checklist. Installing the package does not configure application state or supply a browser provider.
6
+
7
+ It depends on the neutral core; the core does not load or depend on this adapter.
4
8
 
5
9
  The existing generated Reflection `visualSmoke` configuration remains the simple route for legacy cases. This adapter addresses the missing setup hook for rich theme/state/fixture/font cases. It does not bundle a browser or assume a version-specific Reflection API. A trusted project provider owns actual application activation, capture and comparison using the project's pinned browser/checker stack.
6
10
 
@@ -0,0 +1,2 @@
1
+ import type { BrowserCaptureMetadata, ImplementationCase } from "./types.js";
2
+ export declare function validateCaptureMetadata(capture: BrowserCaptureMetadata, implementationCase: ImplementationCase): void;
@@ -0,0 +1,65 @@
1
+ function hasValidGeometry(capture, implementationCase) {
2
+ const rootBox = capture.rootBox;
3
+ const clip = capture.clip;
4
+ if (!rootBox || !clip) {
5
+ return false;
6
+ }
7
+ if (![
8
+ rootBox.x,
9
+ rootBox.y,
10
+ rootBox.width,
11
+ rootBox.height,
12
+ clip.x,
13
+ clip.y,
14
+ clip.width,
15
+ clip.height,
16
+ ].every(Number.isFinite)) {
17
+ return false;
18
+ }
19
+ return (rootBox.x >= 0 &&
20
+ rootBox.y >= 0 &&
21
+ rootBox.width > 0 &&
22
+ rootBox.height > 0 &&
23
+ clip.x === Math.floor(rootBox.x) &&
24
+ clip.y === Math.floor(rootBox.y) &&
25
+ clip.width === Math.ceil(rootBox.x + rootBox.width) - clip.x &&
26
+ clip.height === Math.ceil(rootBox.y + rootBox.height) - clip.y &&
27
+ clip.width === implementationCase.intent.viewport.width &&
28
+ clip.height === implementationCase.intent.viewport.height);
29
+ }
30
+ function hasStableUnobstructedLayout(capture) {
31
+ const pointer = capture.pointer;
32
+ const scroll = capture.scroll;
33
+ const occlusion = capture.occlusion;
34
+ return (pointer?.outsideRoot === true &&
35
+ pointer.hoveredControls === 0 &&
36
+ Number.isFinite(pointer.x) &&
37
+ Number.isFinite(pointer.y) &&
38
+ Boolean(scroll) &&
39
+ [
40
+ scroll.x,
41
+ scroll.y,
42
+ ].every(Number.isFinite) &&
43
+ Number.isInteger(capture.layoutSamples) &&
44
+ capture.layoutSamples >= 3 &&
45
+ (!occlusion ||
46
+ (Number.isInteger(occlusion.samples) && occlusion.samples >= 1 && occlusion.blocked === 0)));
47
+ }
48
+ function hasLoadedFonts(capture, expectedFonts) {
49
+ return (Array.isArray(capture.fonts) &&
50
+ capture.fonts.every((font) => Boolean(font.family && font.weight && font.style && font.status === "loaded")) &&
51
+ expectedFonts.every((expectedFont) => capture.fonts.some((font) => font.family === expectedFont)));
52
+ }
53
+ export function validateCaptureMetadata(capture, implementationCase) {
54
+ if (capture.schemaVersion !== 1 ||
55
+ !capture.root ||
56
+ capture.rounding !== "outward-css-pixels" ||
57
+ capture.deviceScaleFactor !== implementationCase.baseline.scale ||
58
+ !capture.browser?.name ||
59
+ !capture.browser.version ||
60
+ !hasValidGeometry(capture, implementationCase) ||
61
+ !hasStableUnobstructedLayout(capture) ||
62
+ !hasLoadedFonts(capture, implementationCase.intent.fonts ?? [])) {
63
+ throw new Error("Invalid browser capture geometry, font or pointer evidence");
64
+ }
65
+ }
@@ -0,0 +1,3 @@
1
+ import { type BaselineResult, type BaselinesConfig } from "@penpair/core";
2
+ import type { CasePlan, ImplementationMapping } from "./types.js";
3
+ export declare function planImplementationCases(config: BaselinesConfig, results: BaselineResult[], mapping: ImplementationMapping): CasePlan;
@@ -0,0 +1,97 @@
1
+ import path from "node:path";
2
+ import { observationHash, validateBaselineCase, } from "@penpair/core";
3
+ function validateMapping(mapping) {
4
+ if (!mapping ||
5
+ mapping.schemaVersion !== 1 ||
6
+ !Array.isArray(mapping.cases) ||
7
+ mapping.cases.length > 1000) {
8
+ throw new Error("Unsupported implementation mapping");
9
+ }
10
+ const mappedIds = new Set();
11
+ for (const entry of mapping.cases) {
12
+ if (!entry ||
13
+ typeof entry.id !== "string" ||
14
+ !entry.id ||
15
+ mappedIds.has(entry.id) ||
16
+ typeof entry.setup !== "string" ||
17
+ !/^[a-zA-Z][\w.-]{0,100}$/.test(entry.setup)) {
18
+ throw new Error("Duplicate/invalid explicit setup mapping");
19
+ }
20
+ mappedIds.add(entry.id);
21
+ }
22
+ }
23
+ function matchingCaseApproval(result, intent) {
24
+ const approved = result?.approved && !("legacy" in result.approved) ? result.approved : undefined;
25
+ if (!result ||
26
+ !approved?.case ||
27
+ !result.captured ||
28
+ approved.approval?.kind === "forced-reference") {
29
+ return undefined;
30
+ }
31
+ if (![
32
+ "same",
33
+ "new",
34
+ "changed",
35
+ ].includes(result.status)) {
36
+ return undefined;
37
+ }
38
+ const matchesCase = observationHash(approved.case) === observationHash(intent);
39
+ const matchesCapture = approved.sha256 === result.captured.sha256 &&
40
+ approved.frameId === result.frameId &&
41
+ approved.fileId === result.captured.fileId &&
42
+ approved.pageId === result.captured.pageId &&
43
+ approved.revision === result.captured.revision &&
44
+ approved.file === result.file;
45
+ return matchesCase && matchesCapture ? approved : undefined;
46
+ }
47
+ export function planImplementationCases(config, results, mapping) {
48
+ validateMapping(mapping);
49
+ const cases = [];
50
+ const pending = [];
51
+ for (const frame of config.frames) {
52
+ if (!frame.route) {
53
+ continue;
54
+ }
55
+ const result = results.find((baselineResult) => baselineResult.id === frame.id);
56
+ const setup = mapping.cases.find((entry) => entry.id === frame.id)?.setup;
57
+ const approved = frame.case && result && setup ? matchingCaseApproval(result, frame.case) : undefined;
58
+ if (!frame.case || !result || !approved || !setup) {
59
+ pending.push({
60
+ id: frame.id,
61
+ reason: "Requires current exact approved case provenance and an explicit implementation setup; legacy cases keep the existing generated Reflection path",
62
+ });
63
+ continue;
64
+ }
65
+ validateBaselineCase(frame.case, (message) => {
66
+ throw new Error(message);
67
+ });
68
+ cases.push({
69
+ id: frame.id,
70
+ route: frame.route,
71
+ intent: structuredClone(frame.case),
72
+ setup,
73
+ baseline: {
74
+ path: path.join(config.root, result.file),
75
+ sha256: approved.sha256,
76
+ scale: approved.scale,
77
+ },
78
+ threshold: config.threshold ?? 0.05,
79
+ });
80
+ }
81
+ for (const entry of mapping.cases) {
82
+ if (!config.frames.some((frame) => frame.id === entry.id)) {
83
+ pending.push({
84
+ id: entry.id,
85
+ reason: "Mapping refers to no configured baseline",
86
+ });
87
+ }
88
+ }
89
+ return {
90
+ schemaVersion: 1,
91
+ kind: "implementation-case-plan",
92
+ cases,
93
+ pending,
94
+ complete: pending.length === 0 && cases.length > 0,
95
+ visualApproval: false,
96
+ };
97
+ }
@@ -0,0 +1,2 @@
1
+ import { type AdapterContext } from "@penpair/core";
2
+ export declare function runReflectionCommand(context: AdapterContext, commandArguments: string[]): Promise<number>;
@@ -0,0 +1,72 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createJiti } from "jiti";
4
+ import { parseCommandArgs, readDesignJson, resolveProjectInput, } from "@penpair/core";
5
+ import { planImplementationCases } from "./case-plan.js";
6
+ import { runImplementationCases } from "./run-cases.js";
7
+ export async function runReflectionCommand(context, commandArguments) {
8
+ const [subcommand, ...subcommandArguments] = commandArguments;
9
+ const { values, positionals } = parseCommandArgs({
10
+ args: subcommandArguments,
11
+ allowPositionals: true,
12
+ options: {
13
+ map: {
14
+ type: "string",
15
+ },
16
+ provider: {
17
+ type: "string",
18
+ },
19
+ json: {
20
+ type: "boolean",
21
+ },
22
+ },
23
+ });
24
+ if (![
25
+ "plan",
26
+ "check",
27
+ ].includes(subcommand ?? "") ||
28
+ positionals.length ||
29
+ !values.map ||
30
+ (subcommand === "plan" && values.provider) ||
31
+ (subcommand === "check" && !values.provider)) {
32
+ throw new Error("Usage: reflection plan --map FILE | reflection check --map FILE --provider FILE");
33
+ }
34
+ const baselinesConfig = context.config.baselines;
35
+ if (!baselinesConfig) {
36
+ throw new Error("Configure baselines first");
37
+ }
38
+ const manifest = (await readDesignJson(context.root, path.join(baselinesConfig.root, "penpair-baselines.json")));
39
+ if (manifest.schemaVersion !== 2 ||
40
+ !Array.isArray(manifest.frames) ||
41
+ !context.config.file ||
42
+ manifest.fileId !== context.config.file.id) {
43
+ throw new Error("A schema-2 baseline manifest matching the configured file pin is required");
44
+ }
45
+ const plan = planImplementationCases(baselinesConfig, manifest.frames, (await readDesignJson(context.root, values.map, 1024 * 1024)));
46
+ let report = plan;
47
+ let exitCode = plan.complete ? 0 : 3;
48
+ if (subcommand === "check") {
49
+ const providerFile = await resolveProjectInput(context.root, values.provider);
50
+ const provider = (await createJiti(providerFile, {
51
+ moduleCache: false,
52
+ tryNative: false,
53
+ }).evalModule(await readFile(providerFile, "utf8"), {
54
+ filename: providerFile,
55
+ async: true,
56
+ forceTranspile: true,
57
+ }));
58
+ if (typeof provider.runCase !== "function") {
59
+ throw new Error("Trusted project provider must export async runCase(test)");
60
+ }
61
+ const result = await runImplementationCases(context.root, plan, provider);
62
+ report = result;
63
+ exitCode = !result.complete
64
+ ? 3
65
+ : result.cases.some((caseReport) => caseReport.withinThreshold === false)
66
+ ? 2
67
+ : 0;
68
+ }
69
+ context.output?.result(report);
70
+ (context.output?.log ?? console.log)(JSON.stringify(report, null, 2));
71
+ return exitCode;
72
+ }
@@ -0,0 +1,2 @@
1
+ import type { ImplementationCase, ProviderEvidence } from "./types.js";
2
+ export declare function validateProviderEvidence(evidence: ProviderEvidence, implementationCase: ImplementationCase): void;
@@ -0,0 +1,34 @@
1
+ import { observationHash } from "@penpair/core";
2
+ import { validateCaptureMetadata } from "./capture-validation.js";
3
+ export function validateProviderEvidence(evidence, implementationCase) {
4
+ const requestedObservation = {
5
+ viewport: implementationCase.intent.viewport,
6
+ theme: implementationCase.intent.theme,
7
+ state: implementationCase.intent.state,
8
+ fixture: implementationCase.intent.fixture,
9
+ };
10
+ if (!evidence ||
11
+ evidence.schemaVersion !== 1 ||
12
+ evidence.id !== implementationCase.id ||
13
+ evidence.route !== implementationCase.route ||
14
+ evidence.setup !== implementationCase.setup ||
15
+ observationHash(evidence.observed) !== observationHash(requestedObservation) ||
16
+ evidence.baselineHash !== implementationCase.baseline.sha256 ||
17
+ evidence.fontsReady !== true ||
18
+ !Array.isArray(evidence.fonts) ||
19
+ (implementationCase.intent.fonts ?? []).some((font) => !evidence.fonts.includes(font)) ||
20
+ !evidence.provider?.name ||
21
+ !evidence.provider.version ||
22
+ !Number.isFinite(evidence.diffPixelRatio) ||
23
+ evidence.diffPixelRatio < 0 ||
24
+ evidence.diffPixelRatio > 1) {
25
+ throw new Error("Provider did not independently observe the exact requested state, fixture, viewport, fonts and baseline");
26
+ }
27
+ if (evidence.provider.name === "penpair-browser-starter-rgba-exact" &&
28
+ (!evidence.capture || !evidence.capture.occlusion)) {
29
+ throw new Error("Browser starter requires complete capture metadata");
30
+ }
31
+ if (evidence.capture) {
32
+ validateCaptureMetadata(evidence.capture, implementationCase);
33
+ }
34
+ }
package/dist/index.d.ts CHANGED
@@ -1,82 +1,14 @@
1
- import { type AdapterContext, type BaselineCase, type BaselineResult, type BaselinesConfig } from "@penpair/core";
2
- export interface ImplementationMapping {
3
- schemaVersion: 1;
4
- cases: Array<{
5
- id: string;
6
- setup: string;
7
- }>;
8
- }
9
- export interface ImplementationCase {
10
- id: string;
11
- route: string;
12
- intent: BaselineCase;
13
- setup: string;
14
- baseline: {
15
- path: string;
16
- sha256: string;
17
- scale: number;
18
- };
19
- threshold: number;
20
- }
21
- export interface CasePlan {
22
- schemaVersion: 1;
23
- kind: "implementation-case-plan";
24
- cases: ImplementationCase[];
25
- pending: Array<{
26
- id: string;
27
- reason: string;
28
- }>;
29
- complete: boolean;
30
- visualApproval: false;
31
- }
32
- export interface ProviderEvidence {
33
- schemaVersion: 1;
34
- id: string;
35
- route: string;
36
- setup: string;
37
- observed: Pick<BaselineCase, "viewport" | "theme" | "state" | "fixture">;
38
- fonts: string[];
39
- fontsReady: boolean;
40
- baselineHash: string;
41
- screenshot: string;
42
- diffPixelRatio: number;
43
- provider: {
44
- name: string;
45
- version: string;
46
- };
47
- }
48
- export interface ImplementationProvider {
49
- runCase(test: ImplementationCase): Promise<ProviderEvidence>;
50
- }
51
- export declare function planImplementationCases(config: BaselinesConfig, results: BaselineResult[], mapping: ImplementationMapping): CasePlan;
52
- export declare function runImplementationCases(root: string, plan: CasePlan, provider: ImplementationProvider): Promise<{
53
- schemaVersion: number;
54
- kind: string;
55
- complete: boolean;
56
- cases: {
57
- id: string;
58
- complete: boolean;
59
- withinThreshold?: boolean;
60
- evidence?: ProviderEvidence;
61
- screenshotHash?: string;
62
- reason?: string;
63
- }[];
64
- pending: {
65
- id: string;
66
- reason: string;
67
- }[];
68
- visualApproval: boolean;
69
- verdict: string;
70
- accessibility: string;
71
- }>;
72
- declare function command(context: AdapterContext, args: string[]): Promise<number>;
1
+ import { runReflectionCommand } from "./command.js";
2
+ export { planImplementationCases } from "./case-plan.js";
3
+ export { runImplementationCases } from "./run-cases.js";
4
+ export type { ImplementationMapping, ImplementationCase, CasePlan, BrowserCaptureMetadata, ProviderEvidence, ImplementationProvider, ImplementationCaseReport, ImplementationReport, } from "./types.js";
73
5
  declare const _default: {
74
6
  name: string;
75
7
  commands: {
76
8
  name: string;
77
9
  summary: string;
78
10
  usage: string[];
79
- run: typeof command;
11
+ run: typeof runReflectionCommand;
80
12
  }[];
81
13
  };
82
14
  export default _default;
package/dist/index.js CHANGED
@@ -1,97 +1,18 @@
1
- import { createHash } from "node:crypto";
2
- import { readFile, stat } from "node:fs/promises";
3
- import path from "node:path";
4
- import { createJiti } from "jiti";
5
- import { defineAdapter, observationHash, parseCommandArgs, readDesignJson, resolveProjectInput, validateBaselineCase } from "@penpair/core";
6
- const sha = (b) => createHash("sha256").update(b).digest("hex");
7
- export function planImplementationCases(config, results, mapping) {
8
- if (!mapping || mapping.schemaVersion !== 1 || !Array.isArray(mapping.cases) || mapping.cases.length > 1000)
9
- throw new Error("Unsupported implementation mapping");
10
- const names = new Set();
11
- for (const entry of mapping.cases)
12
- if (!entry || typeof entry.id !== "string" || !entry.id || names.has(entry.id) || typeof entry.setup !== "string" || !/^[a-zA-Z][\w.-]{0,100}$/.test(entry.setup))
13
- throw new Error("Duplicate/invalid explicit setup mapping");
14
- else
15
- names.add(entry.id);
16
- const cases = [], pending = [];
17
- for (const wanted of config.frames) {
18
- if (!wanted.route)
19
- continue;
20
- const result = results.find(r => r.id === wanted.id), setup = mapping.cases.find(c => c.id === wanted.id)?.setup;
21
- const approved = result?.approved && !("legacy" in result.approved) ? result.approved : undefined;
22
- if (approved?.approval?.kind === "forced-reference" || !wanted.case || !result || !approved?.case || !setup || !["same", "new", "changed"].includes(result.status) || !result.captured || approved.sha256 !== result.captured.sha256 || observationHash(approved.case) !== observationHash(wanted.case) || approved.frameId !== result.frameId || approved.fileId !== result.captured.fileId || approved.pageId !== result.captured.pageId || approved.revision !== result.captured.revision || approved.file !== result.file) {
23
- pending.push({ id: wanted.id, reason: "Requires current exact approved case provenance and an explicit implementation setup; legacy cases keep the existing generated Reflection path" });
24
- continue;
25
- }
26
- validateBaselineCase(wanted.case, message => { throw new Error(message); });
27
- cases.push({ id: wanted.id, route: wanted.route, intent: structuredClone(wanted.case), setup, baseline: { path: path.join(config.root, result.file), sha256: approved.sha256, scale: approved.scale }, threshold: config.threshold ?? 0.05 });
28
- }
29
- for (const entry of mapping.cases)
30
- if (!config.frames.some(f => f.id === entry.id))
31
- pending.push({ id: entry.id, reason: "Mapping refers to no configured baseline" });
32
- return { schemaVersion: 1, kind: "implementation-case-plan", cases, pending, complete: pending.length === 0 && cases.length > 0, visualApproval: false };
33
- }
34
- async function png(root, file) {
35
- const input = await resolveProjectInput(root, file);
36
- if ((await stat(input)).size > 64 * 1024 * 1024)
37
- throw new Error("Image exceeds 64 MiB");
38
- const bytes = await readFile(input);
39
- if (bytes.length < 24 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.subarray(12, 16).toString() !== "IHDR")
40
- throw new Error("Provider evidence must reference PNG bytes");
41
- return { bytes, width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
42
- }
43
- export async function runImplementationCases(root, plan, provider) {
44
- const reports = [];
45
- for (const c of plan.cases) {
46
- try {
47
- if (!(Number.isFinite(c.baseline.scale) && c.baseline.scale > 0))
48
- throw new Error("Missing capture scale");
49
- const before = await png(root, c.baseline.path);
50
- if (sha(before.bytes) !== c.baseline.sha256)
51
- throw new Error("Approved baseline bytes changed");
52
- const evidence = await provider.runCase(structuredClone(c));
53
- const desired = { viewport: c.intent.viewport, theme: c.intent.theme, state: c.intent.state, fixture: c.intent.fixture };
54
- if (!evidence || evidence.schemaVersion !== 1 || evidence.id !== c.id || evidence.route !== c.route || evidence.setup !== c.setup || observationHash(evidence.observed) !== observationHash(desired) || evidence.baselineHash !== c.baseline.sha256 || evidence.fontsReady !== true || !Array.isArray(evidence.fonts) || (c.intent.fonts ?? []).some(font => !evidence.fonts.includes(font)) || !evidence.provider?.name || !evidence.provider.version || !Number.isFinite(evidence.diffPixelRatio) || evidence.diffPixelRatio < 0 || evidence.diffPixelRatio > 1)
55
- throw new Error("Provider did not independently observe the exact requested state, fixture, viewport, fonts and baseline");
56
- const shot = await png(root, evidence.screenshot);
57
- if (await resolveProjectInput(root, evidence.screenshot) === await resolveProjectInput(root, c.baseline.path))
58
- throw new Error("Screenshot must be independently captured, not the baseline path");
59
- if (shot.width !== Math.round(c.intent.viewport.width * c.baseline.scale) || shot.height !== Math.round(c.intent.viewport.height * c.baseline.scale) || before.width !== shot.width || before.height !== shot.height)
60
- throw new Error("Provider PNG scale/viewport differs from the design case");
61
- if (sha((await png(root, c.baseline.path)).bytes) !== c.baseline.sha256)
62
- throw new Error("Baseline changed during provider execution");
63
- reports.push({ id: c.id, complete: true, withinThreshold: evidence.diffPixelRatio <= c.threshold, evidence, screenshotHash: sha(shot.bytes) });
64
- }
65
- catch (error) {
66
- reports.push({ id: c.id, complete: false, reason: String(error.message) });
67
- }
68
- }
69
- return { schemaVersion: 1, kind: "implementation-case-evidence", complete: plan.complete && reports.length > 0 && reports.every(r => r.complete), cases: reports, pending: plan.pending, visualApproval: false, verdict: "owner-review-required", accessibility: "not-assessed" };
70
- }
71
- async function command(context, args) {
72
- const [sub, ...rest] = args;
73
- const { values, positionals } = parseCommandArgs({ args: rest, allowPositionals: true, options: { map: { type: "string" }, provider: { type: "string" }, json: { type: "boolean" } } });
74
- if (!["plan", "check"].includes(sub ?? "") || positionals.length || !values.map || sub === "plan" && values.provider || sub === "check" && !values.provider)
75
- throw new Error("Usage: reflection plan --map FILE | reflection check --map FILE --provider FILE");
76
- const config = context.config.baselines;
77
- if (!config)
78
- throw new Error("Configure baselines first");
79
- const manifest = await readDesignJson(context.root, path.join(config.root, "penpair-baselines.json"));
80
- if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.frames) || !context.config.file || manifest.fileId !== context.config.file.id)
81
- throw new Error("A schema-2 baseline manifest matching the configured file pin is required");
82
- const plan = planImplementationCases(config, manifest.frames, await readDesignJson(context.root, values.map, 1024 * 1024));
83
- let report = plan, code = plan.complete ? 0 : 3;
84
- if (sub === "check") {
85
- const file = await resolveProjectInput(context.root, values.provider);
86
- const provider = await createJiti(file, { moduleCache: false, tryNative: false }).evalModule(await readFile(file, "utf8"), { filename: file, async: true, forceTranspile: true });
87
- if (typeof provider.runCase !== "function")
88
- throw new Error("Trusted project provider must export async runCase(test)");
89
- const result = await runImplementationCases(context.root, plan, provider);
90
- report = result;
91
- code = !result.complete ? 3 : result.cases.some(c => c.withinThreshold === false) ? 2 : 0;
92
- }
93
- context.output?.result(report);
94
- (context.output?.log ?? console.log)(JSON.stringify(report, null, 2));
95
- return code;
96
- }
97
- export default defineAdapter({ name: "@penpair/reflection-check", commands: [{ name: "reflection", summary: "Plan explicit implementation cases and collect provider evidence", usage: ["penpair reflection plan --map FILE", "penpair reflection check --map FILE --provider FILE"], run: command }] });
1
+ import { defineAdapter } from "@penpair/core";
2
+ import { runReflectionCommand } from "./command.js";
3
+ export { planImplementationCases } from "./case-plan.js";
4
+ export { runImplementationCases } from "./run-cases.js";
5
+ export default defineAdapter({
6
+ name: "@penpair/reflection-check",
7
+ commands: [
8
+ {
9
+ name: "reflection",
10
+ summary: "Plan explicit implementation cases and collect provider evidence",
11
+ usage: [
12
+ "penpair reflection plan --map FILE",
13
+ "penpair reflection check --map FILE --provider FILE",
14
+ ],
15
+ run: runReflectionCommand,
16
+ },
17
+ ],
18
+ });
@@ -0,0 +1,6 @@
1
+ export declare function hashPngBytes(bytes: Buffer): string;
2
+ export declare function readPngEvidence(root: string, file: string): Promise<{
3
+ bytes: Buffer;
4
+ width: number;
5
+ height: number;
6
+ }>;
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { resolveProjectInput } from "@penpair/core";
4
+ export function hashPngBytes(bytes) {
5
+ return createHash("sha256").update(bytes).digest("hex");
6
+ }
7
+ export async function readPngEvidence(root, file) {
8
+ const input = await resolveProjectInput(root, file);
9
+ if ((await stat(input)).size > 64 * 1024 * 1024) {
10
+ throw new Error("Image exceeds 64 MiB");
11
+ }
12
+ const bytes = await readFile(input);
13
+ if (bytes.length < 24 ||
14
+ bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" ||
15
+ bytes.subarray(12, 16).toString() !== "IHDR") {
16
+ throw new Error("Provider evidence must reference PNG bytes");
17
+ }
18
+ return {
19
+ bytes,
20
+ width: bytes.readUInt32BE(16),
21
+ height: bytes.readUInt32BE(20),
22
+ };
23
+ }
@@ -0,0 +1,2 @@
1
+ import type { CasePlan, ImplementationProvider, ImplementationReport } from "./types.js";
2
+ export declare function runImplementationCases(root: string, plan: CasePlan, provider: ImplementationProvider): Promise<ImplementationReport>;
@@ -0,0 +1,64 @@
1
+ import { resolveProjectInput } from "@penpair/core";
2
+ import { validateProviderEvidence } from "./evidence-validation.js";
3
+ import { hashPngBytes, readPngEvidence } from "./png-evidence.js";
4
+ async function collectCaseEvidence(root, implementationCase, provider) {
5
+ if (!(Number.isFinite(implementationCase.baseline.scale) && implementationCase.baseline.scale > 0)) {
6
+ throw new Error("Missing capture scale");
7
+ }
8
+ const baselineImage = await readPngEvidence(root, implementationCase.baseline.path);
9
+ if (hashPngBytes(baselineImage.bytes) !== implementationCase.baseline.sha256) {
10
+ throw new Error("Approved baseline bytes changed");
11
+ }
12
+ const evidence = await provider.runCase(structuredClone(implementationCase));
13
+ validateProviderEvidence(evidence, implementationCase);
14
+ const implementationImage = await readPngEvidence(root, evidence.screenshot);
15
+ const screenshotPath = await resolveProjectInput(root, evidence.screenshot);
16
+ const baselinePath = await resolveProjectInput(root, implementationCase.baseline.path);
17
+ if (screenshotPath === baselinePath) {
18
+ throw new Error("Screenshot must be independently captured, not the baseline path");
19
+ }
20
+ const viewport = implementationCase.intent.viewport;
21
+ const scale = implementationCase.baseline.scale;
22
+ if (implementationImage.width !== Math.round(viewport.width * scale) ||
23
+ implementationImage.height !== Math.round(viewport.height * scale) ||
24
+ baselineImage.width !== implementationImage.width ||
25
+ baselineImage.height !== implementationImage.height) {
26
+ throw new Error("Provider PNG scale/viewport differs from the design case");
27
+ }
28
+ const baselineAfterCapture = await readPngEvidence(root, implementationCase.baseline.path);
29
+ if (hashPngBytes(baselineAfterCapture.bytes) !== implementationCase.baseline.sha256) {
30
+ throw new Error("Baseline changed during provider execution");
31
+ }
32
+ return {
33
+ id: implementationCase.id,
34
+ complete: true,
35
+ withinThreshold: evidence.diffPixelRatio <= implementationCase.threshold,
36
+ evidence,
37
+ screenshotHash: hashPngBytes(implementationImage.bytes),
38
+ };
39
+ }
40
+ export async function runImplementationCases(root, plan, provider) {
41
+ const reports = [];
42
+ for (const implementationCase of plan.cases) {
43
+ try {
44
+ reports.push(await collectCaseEvidence(root, implementationCase, provider));
45
+ }
46
+ catch (error) {
47
+ reports.push({
48
+ id: implementationCase.id,
49
+ complete: false,
50
+ reason: String(error.message),
51
+ });
52
+ }
53
+ }
54
+ return {
55
+ schemaVersion: 1,
56
+ kind: "implementation-case-evidence",
57
+ complete: plan.complete && reports.length > 0 && reports.every((report) => report.complete),
58
+ cases: reports,
59
+ pending: plan.pending,
60
+ visualApproval: false,
61
+ verdict: "owner-review-required",
62
+ accessibility: "not-assessed",
63
+ };
64
+ }
@@ -0,0 +1,112 @@
1
+ import type { BaselineCase } from "@penpair/core";
2
+ export interface ImplementationMapping {
3
+ schemaVersion: 1;
4
+ cases: Array<{
5
+ id: string;
6
+ setup: string;
7
+ }>;
8
+ }
9
+ export interface ImplementationCase {
10
+ id: string;
11
+ route: string;
12
+ intent: BaselineCase;
13
+ setup: string;
14
+ baseline: {
15
+ path: string;
16
+ sha256: string;
17
+ scale: number;
18
+ };
19
+ threshold: number;
20
+ }
21
+ export interface CasePlan {
22
+ schemaVersion: 1;
23
+ kind: "implementation-case-plan";
24
+ cases: ImplementationCase[];
25
+ pending: Array<{
26
+ id: string;
27
+ reason: string;
28
+ }>;
29
+ complete: boolean;
30
+ visualApproval: false;
31
+ }
32
+ export interface BrowserCaptureMetadata {
33
+ schemaVersion: 1;
34
+ root: string;
35
+ rootBox: {
36
+ x: number;
37
+ y: number;
38
+ width: number;
39
+ height: number;
40
+ };
41
+ clip: {
42
+ x: number;
43
+ y: number;
44
+ width: number;
45
+ height: number;
46
+ };
47
+ rounding: "outward-css-pixels";
48
+ deviceScaleFactor: number;
49
+ browser: {
50
+ name: string;
51
+ version: string;
52
+ };
53
+ fonts: Array<{
54
+ family: string;
55
+ weight: string;
56
+ style: string;
57
+ status: "loaded";
58
+ }>;
59
+ pointer: {
60
+ x: number;
61
+ y: number;
62
+ outsideRoot: boolean;
63
+ hoveredControls: number;
64
+ };
65
+ occlusion?: {
66
+ samples: number;
67
+ blocked: number;
68
+ };
69
+ scroll: {
70
+ x: number;
71
+ y: number;
72
+ };
73
+ layoutSamples: number;
74
+ }
75
+ export interface ProviderEvidence {
76
+ schemaVersion: 1;
77
+ id: string;
78
+ route: string;
79
+ setup: string;
80
+ observed: Pick<BaselineCase, "viewport" | "theme" | "state" | "fixture">;
81
+ fonts: string[];
82
+ fontsReady: boolean;
83
+ baselineHash: string;
84
+ capture?: BrowserCaptureMetadata;
85
+ screenshot: string;
86
+ diffPixelRatio: number;
87
+ provider: {
88
+ name: string;
89
+ version: string;
90
+ };
91
+ }
92
+ export interface ImplementationProvider {
93
+ runCase(implementationCase: ImplementationCase): Promise<ProviderEvidence>;
94
+ }
95
+ export interface ImplementationCaseReport {
96
+ id: string;
97
+ complete: boolean;
98
+ withinThreshold?: boolean;
99
+ evidence?: ProviderEvidence;
100
+ screenshotHash?: string;
101
+ reason?: string;
102
+ }
103
+ export interface ImplementationReport {
104
+ schemaVersion: 1;
105
+ kind: "implementation-case-evidence";
106
+ complete: boolean;
107
+ cases: ImplementationCaseReport[];
108
+ pending: CasePlan["pending"];
109
+ visualApproval: false;
110
+ verdict: "owner-review-required";
111
+ accessibility: "not-assessed";
112
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/reflection-check",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Explicit design case planning and verified implementation evidence for penpair baselines.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "jiti": "^2.4.2",
44
- "@penpair/core": "0.2.0"
44
+ "@penpair/core": "0.3.0"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "tsc -p tsconfig.json",