@penpair/reflection-check 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niklas Westman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @penpair/reflection-check
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
4
+
5
+ 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
+
7
+ ```sh
8
+ penpair reflection plan --map design/implementation-cases.json
9
+ penpair reflection check --map design/implementation-cases.json --provider design/reflection-provider.mjs
10
+ ```
11
+
12
+ Mapping: `{"schemaVersion":1,"cases":[{"id":"dashboard-empty-dark","setup":"dark-empty"}]}`. A case must have current exact approved provenance, matching file pin and an explicit setup name. Missing mappings, legacy approvals and changed case metadata remain pending. No setup is silently replaced by the default route state. Baselines remain owned by the core; this adapter neither captures nor approves design references.
13
+
14
+ The provider module exports `async runCase(test)` and receives the immutable case ID, route, setup key, declared intent, baseline relative path/hash/scale and threshold. It must activate the route/theme/state/data fixture, verify actual browser state and fonts independently, capture a new PNG at the requested scale, and use the installed checker to compare it with the approved image. It returns:
15
+
16
+ ```json
17
+ {
18
+ "schemaVersion":1,"id":"dashboard-empty-dark","route":"/dashboard","setup":"dark-empty",
19
+ "observed":{"viewport":{"width":1280,"height":900},"theme":"dark","state":"empty","fixture":"no-items"},
20
+ "fonts":["Expected Font"],"fontsReady":true,"baselineHash":"EXACT_SHA256",
21
+ "screenshot":".penpair/comparison/dashboard.png","diffPixelRatio":0.01,
22
+ "provider":{"name":"PROJECT_PINNED_CHECKER","version":"ACTUAL_VERSION"}
23
+ }
24
+ ```
25
+
26
+ Observed context must come from browser readback, not copied test inputs. The adapter checks context/font evidence, independent screenshot path, PNG dimensions, byte hashes and unchanged baseline bytes. A trusted provider can lie: this interface is an evidence contract, not a sandbox or cryptographic attestation of browser work. Modules and transitive imports execute project code. Pixel ratios are checker evidence, never a visual verdict, accessibility assessment or owner approval. Exit 0 means complete evidence within thresholds; 2 means measured drift; 3 means missing/incomplete evidence. Every result still says `owner-review-required`.
27
+
28
+ No real consumer was modified and no live implementation route was run here. The offline integration test uses a named stub; replace it with a real provider and record the I12 human trial before claiming adoption. Release/publishing is the owner's act and all six packages remain in lockstep.
@@ -0,0 +1,82 @@
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>;
73
+ declare const _default: {
74
+ name: string;
75
+ commands: {
76
+ name: string;
77
+ summary: string;
78
+ usage: string[];
79
+ run: typeof command;
80
+ }[];
81
+ };
82
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,97 @@
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 }] });
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@penpair/reflection-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Explicit design case planning and verified implementation evidence for penpair baselines.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/niklas-westman/penpair.git",
9
+ "directory": "packages/reflection-check"
10
+ },
11
+ "homepage": "https://github.com/niklas-westman/penpair#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/niklas-westman/penpair/issues"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "keywords": [
35
+ "penpot",
36
+ "reflection",
37
+ "design-verification"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "jiti": "^2.4.2",
44
+ "@penpair/core": "0.2.0"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit",
49
+ "test": "vitest run"
50
+ }
6
51
  }