@penpair/marwes-ui 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.
- package/dist/build-kit.d.ts +3 -0
- package/dist/build-kit.js +75 -0
- package/dist/commands.js +187 -52
- package/dist/components.d.ts +95 -0
- package/dist/components.js +196 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +36 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -1
- package/dist/mode-trial.d.ts +95 -0
- package/dist/mode-trial.js +229 -0
- package/dist/ownership.d.ts +26 -0
- package/dist/ownership.js +97 -0
- package/dist/penpot.d.ts +21 -3
- package/dist/penpot.js +119 -20
- package/dist/reconciliation.d.ts +6 -0
- package/dist/reconciliation.js +104 -0
- package/dist/resolved-values.d.ts +16 -0
- package/dist/resolved-values.js +128 -0
- package/dist/snapshot-file.d.ts +45 -0
- package/dist/snapshot-file.js +41 -4
- package/dist/theme-input.js +7 -4
- package/dist/theme-lint.d.ts +31 -0
- package/dist/theme-lint.js +59 -0
- package/dist/tokens.d.ts +7 -3
- package/dist/tokens.js +26 -2
- package/package.json +2 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Action, LiveTokens } from "./penpot.js";
|
|
2
|
+
import { type TokenSnapshot } from "./tokens.js";
|
|
3
|
+
export interface OwnershipChange {
|
|
4
|
+
set: string;
|
|
5
|
+
name: string;
|
|
6
|
+
property: string;
|
|
7
|
+
kind: "upstream" | "designer" | "conflict" | "untracked";
|
|
8
|
+
ancestor: unknown;
|
|
9
|
+
desired: unknown;
|
|
10
|
+
live: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface OwnershipReport {
|
|
13
|
+
changes: OwnershipChange[];
|
|
14
|
+
blockers: OwnershipChange[];
|
|
15
|
+
unmanagedSets: string[];
|
|
16
|
+
unmanagedTokens: {
|
|
17
|
+
set: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}[];
|
|
20
|
+
}
|
|
21
|
+
/** Report each changed property against a verified ancestor; never guess a rename. */
|
|
22
|
+
export declare function ownershipReport(desired: TokenSnapshot, live: LiveTokens, ancestor?: TokenSnapshot): OwnershipReport;
|
|
23
|
+
/** Destruction is conservative across all sets, including those outside adapter ownership. */
|
|
24
|
+
export declare function assertSafeDestruction(actions: Action[], live: LiveTokens, bindingCounts?: Record<string, number>): void;
|
|
25
|
+
export declare function readSyncAncestor(root: string, fileId: string): Promise<TokenSnapshot | undefined>;
|
|
26
|
+
export declare function saveSyncAncestor(root: string, fileId: string, snapshot: TokenSnapshot, expectedAncestor?: TokenSnapshot): Promise<void>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { atomicWriteFile, ensureGitignoreEntry, resolveProjectInput, resolveProjectOutput } from "@penpair/core";
|
|
5
|
+
import { canonical, normalizeValue } from "./tokens.js";
|
|
6
|
+
const tokenMap = (sets) => new Map(sets.map(set => [set.name, new Map(set.tokens.map(token => [token.name, token]))]));
|
|
7
|
+
const values = (token) => token ? { type: token.type, value: normalizeValue(token.type, token.value), description: token.description || "" } : { type: null, value: null, description: null };
|
|
8
|
+
/** Report each changed property against a verified ancestor; never guess a rename. */
|
|
9
|
+
export function ownershipReport(desired, live, ancestor) {
|
|
10
|
+
const wanted = tokenMap(desired.sets), actual = tokenMap(live.sets), old = tokenMap(ancestor?.sets ?? []);
|
|
11
|
+
const changes = [];
|
|
12
|
+
const unmanagedSets = live.sets.filter(set => !wanted.has(set.name) && !old.has(set.name)).map(set => set.name);
|
|
13
|
+
const unmanagedTokens = [];
|
|
14
|
+
for (const setName of new Set([...wanted.keys(), ...old.keys()])) {
|
|
15
|
+
for (const name of new Set([...wanted.get(setName)?.keys() ?? [], ...old.get(setName)?.keys() ?? []])) {
|
|
16
|
+
const a = values(old.get(setName)?.get(name)), d = values(wanted.get(setName)?.get(name)), l = values(actual.get(setName)?.get(name));
|
|
17
|
+
for (const property of ["type", "value", "description"]) {
|
|
18
|
+
const same = (x, y) => canonical(x) === canonical(y);
|
|
19
|
+
if (same(d[property], l[property]))
|
|
20
|
+
continue;
|
|
21
|
+
let kind;
|
|
22
|
+
if (!ancestor || !old.get(setName)?.has(name)) {
|
|
23
|
+
kind = !actual.get(setName)?.has(name) ? "upstream" : "untracked";
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const upstream = !same(d[property], a[property]), designer = !same(l[property], a[property]);
|
|
27
|
+
kind = upstream && designer ? "conflict" : designer ? "designer" : "upstream";
|
|
28
|
+
}
|
|
29
|
+
changes.push({ set: setName, name, property, kind, ancestor: a[property], desired: d[property], live: l[property] });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const name of actual.get(setName)?.keys() ?? [])
|
|
33
|
+
if (!wanted.get(setName)?.has(name) && !old.get(setName)?.has(name))
|
|
34
|
+
unmanagedTokens.push({ set: setName, name });
|
|
35
|
+
}
|
|
36
|
+
const key = (theme) => canonical([theme.group, theme.name]);
|
|
37
|
+
const previousThemes = new Map((ancestor?.themes ?? []).map(theme => [key(theme), theme]));
|
|
38
|
+
for (const theme of desired.themes) {
|
|
39
|
+
const current = live.themes.find(t => key(t) === key(theme)), previous = previousThemes.get(key(theme));
|
|
40
|
+
const managed = new Set([...desired.sets, ...ancestor?.sets ?? []].map(set => set.name));
|
|
41
|
+
const orderedMembership = (sets) => sets === undefined ? null : sets.filter(name => managed.has(name)).sort();
|
|
42
|
+
const a = orderedMembership(previous?.sets), d = orderedMembership(theme.sets), l = orderedMembership(current?.sets);
|
|
43
|
+
if (canonical(d) === canonical(l))
|
|
44
|
+
continue;
|
|
45
|
+
const kind = !previous ? !current ? "upstream" : "untracked" : canonical(l) === canonical(a) ? "upstream" : canonical(d) === canonical(a) ? "designer" : "conflict";
|
|
46
|
+
changes.push({ set: "@themes", name: key(theme), property: "membership", kind, ancestor: a, desired: d, live: l });
|
|
47
|
+
}
|
|
48
|
+
return { changes, blockers: changes.filter(change => change.kind !== "upstream"), unmanagedSets, unmanagedTokens };
|
|
49
|
+
}
|
|
50
|
+
/** Destruction is conservative across all sets, including those outside adapter ownership. */
|
|
51
|
+
export function assertSafeDestruction(actions, live, bindingCounts = {}) {
|
|
52
|
+
for (const action of actions) {
|
|
53
|
+
const names = action.kind === "removeTokens" ? action.names : action.kind === "replaceTokens" ? action.tokens.filter(token => live.sets.find(set => set.name === action.set)?.tokens.find(old => old.name === token.name)?.type !== token.type).map(token => token.name) : [];
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
if ((bindingCounts[name] ?? 0) > 0)
|
|
56
|
+
throw new Error(`Cannot remove or change type of ${name}: bound to ${bindingCounts[name]} retained shape(s)`);
|
|
57
|
+
for (const set of live.sets)
|
|
58
|
+
for (const token of set.tokens) {
|
|
59
|
+
if (set.name === action.set && token.name === name)
|
|
60
|
+
continue;
|
|
61
|
+
if (JSON.stringify(token.value).includes("{" + name + "}"))
|
|
62
|
+
throw new Error(`Cannot remove or change type of ${name}: referenced by ${set.name}/${token.name}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const ledgerPath = (fileId) => `.penpair/token-sync/${createHash("sha256").update(fileId).digest("hex")}.json`;
|
|
68
|
+
export async function readSyncAncestor(root, fileId) {
|
|
69
|
+
try {
|
|
70
|
+
const record = JSON.parse(await readFile(await resolveProjectInput(root, ledgerPath(fileId)), "utf8"));
|
|
71
|
+
if (record.schemaVersion !== 1 || record.fileId !== fileId || record.sha256 !== createHash("sha256").update(canonical(record.snapshot)).digest("hex") || ![1, 2].includes(record.snapshot?.schemaVersion) || !Array.isArray(record.snapshot?.sets) || !Array.isArray(record.snapshot?.themes))
|
|
72
|
+
throw new Error("Invalid token-sync ancestor; inspect it before replacing it");
|
|
73
|
+
return record.snapshot;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === "ENOENT")
|
|
77
|
+
return undefined;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function saveSyncAncestor(root, fileId, snapshot, expectedAncestor) {
|
|
82
|
+
await ensureGitignoreEntry(root, ".penpair", { directory: true, comment: "penpair: private token synchronization evidence" });
|
|
83
|
+
const destination = await resolveProjectOutput(root, ledgerPath(fileId));
|
|
84
|
+
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
85
|
+
const lockPath = destination + ".lock";
|
|
86
|
+
const lock = await open(lockPath, "wx", 0o600).catch(() => { throw new Error("Token ancestor is locked; inspect any interrupted local sync before retrying"); });
|
|
87
|
+
try {
|
|
88
|
+
const current = await readSyncAncestor(root, fileId);
|
|
89
|
+
if (current && canonical(current) !== canonical(snapshot) && canonical(current) !== canonical(expectedAncestor))
|
|
90
|
+
throw new Error("Token ancestor advanced concurrently; newer sync evidence was preserved");
|
|
91
|
+
await atomicWriteFile(destination, JSON.stringify({ schemaVersion: 1, fileId, savedAt: new Date().toISOString(), sha256: createHash("sha256").update(canonical(snapshot)).digest("hex"), snapshot }, null, 2) + "\n", { replace: true, mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await lock.close();
|
|
95
|
+
await unlink(lockPath);
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/penpot.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PenpotToolClient, type Snippet } from "@penpair/core";
|
|
1
|
+
import { type LayoutOptions, type LayoutResult, type JournalOptions, type PenpotToolClient, type Snippet } from "@penpair/core";
|
|
2
2
|
import { canonical, type PenpotToken, type TokenSnapshot } from "./tokens.js";
|
|
3
3
|
/** What the Penpot file holds today. */
|
|
4
4
|
export interface LiveTokens {
|
|
@@ -66,7 +66,9 @@ export interface Drift {
|
|
|
66
66
|
unexpectedSets: string[];
|
|
67
67
|
}
|
|
68
68
|
/** Compares the committed expectation with what the file holds. Only the sets the expectation names are judged; other sets are the project's own. */
|
|
69
|
-
export declare function compareToPenpot(expected: TokenSnapshot, live: LiveTokens
|
|
69
|
+
export declare function compareToPenpot(expected: TokenSnapshot, live: LiveTokens, options?: {
|
|
70
|
+
ancestor?: TokenSnapshot;
|
|
71
|
+
}): Drift;
|
|
70
72
|
export declare function formatDrift(drift: Drift, impact?: Record<string, number>, limit?: number): string[];
|
|
71
73
|
export type Action = {
|
|
72
74
|
kind: "createSet";
|
|
@@ -106,12 +108,21 @@ export type Action = {
|
|
|
106
108
|
export interface PlanOptions {
|
|
107
109
|
/** Also remove what Penpot has but the expectation does not (tokens added by hand, sets no longer expected, extra sets in a theme). */
|
|
108
110
|
prune?: boolean;
|
|
111
|
+
/** Only tokens in a verified ancestor can be pruned. */
|
|
112
|
+
ancestor?: TokenSnapshot;
|
|
109
113
|
}
|
|
110
114
|
/** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
|
|
111
115
|
export declare function planPush(expected: TokenSnapshot, live: LiveTokens, options?: PlanOptions): Action[];
|
|
112
116
|
/** Splits a long token list into batches that stay well under the request limit. */
|
|
113
117
|
export declare function inBatches(tokens: PenpotToken[]): PenpotToken[][];
|
|
114
118
|
export interface ApplyReport {
|
|
119
|
+
redacted?: boolean;
|
|
120
|
+
complete?: boolean;
|
|
121
|
+
layout?: LayoutResult;
|
|
122
|
+
journal?: {
|
|
123
|
+
runId: string;
|
|
124
|
+
state: string;
|
|
125
|
+
};
|
|
115
126
|
steps: string[];
|
|
116
127
|
structureBefore: {
|
|
117
128
|
count: number;
|
|
@@ -131,7 +142,14 @@ export declare function readLive(client: PenpotToolClient): Promise<LiveTokens>;
|
|
|
131
142
|
* Carries out the plan one write at a time, stopping at the first anomaly, then reads everything back: the file must now match the
|
|
132
143
|
* expectation, and no shape may have moved or been rebound.
|
|
133
144
|
*/
|
|
134
|
-
export declare function applyPlan(client: PenpotToolClient, expected: TokenSnapshot, actions: Action[], onStep?: (message: string) => void
|
|
145
|
+
export declare function applyPlan(client: PenpotToolClient, expected: TokenSnapshot, actions: Action[], onStep?: (message: string) => void, options?: {
|
|
146
|
+
layout?: false | LayoutOptions;
|
|
147
|
+
expectedFileId?: string;
|
|
148
|
+
ancestor?: TokenSnapshot;
|
|
149
|
+
prune?: boolean;
|
|
150
|
+
expectedLive?: LiveTokens;
|
|
151
|
+
journal?: Omit<JournalOptions, "input" | "operation" | "payload" | "target">;
|
|
152
|
+
}): Promise<ApplyReport>;
|
|
135
153
|
/** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
|
|
136
154
|
export declare function impactOf(client: PenpotToolClient): Promise<Record<string, number>>;
|
|
137
155
|
export { canonical };
|
package/dist/penpot.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertSafeDestruction } from "./ownership.js";
|
|
2
|
+
import { waitForStableLayout, guardRevision, writeCoordinator, runSnippet, snippet, guardFile, observationHash, readJournal, readJournalTarget, runJournaledSteps } from "@penpair/core";
|
|
2
3
|
import { canonical, normalizeValue } from "./tokens.js";
|
|
4
|
+
import { actionSatisfied, reconcileAction, remainingAction } from "./reconciliation.js";
|
|
3
5
|
export function readTokens() {
|
|
4
6
|
return snippet("marwes-tokens-read", `const tokens = penpot.library.local.tokens;
|
|
5
7
|
const set = (s, i) => ({ order: i, name: s.name, active: !!s.active, tokens: s.tokens.map((t) => ({ name: t.name, type: t.type, value: t.value, description: t.description || "" })) });
|
|
@@ -8,9 +10,10 @@ return { ok: true, readOnly: true, sets: tokens.sets.map(set),
|
|
|
8
10
|
}
|
|
9
11
|
/** How many shapes use each token, over every page: what a change of a token would reach. */
|
|
10
12
|
export function bindingCounts() {
|
|
11
|
-
return snippet("marwes-tokens-bindings", `const counts =
|
|
12
|
-
const
|
|
13
|
-
for
|
|
13
|
+
return snippet("marwes-tokens-bindings", `const counts = Object.create(null); let shapes = 0;
|
|
14
|
+
const stack=[],seen=new Set();
|
|
15
|
+
for(const page of penpot.currentFile.pages)for(const shape of page.root.children||[])stack.push(shape);
|
|
16
|
+
while(stack.length){if(++shapes>50000)throw new Error("Binding inventory exceeds 50000 shapes");const s=stack.pop();if(!s.id||seen.has(s.id))throw new Error("Invalid binding inventory identity");seen.add(s.id);const b=s.tokens||{};for(const name of Object.values(b)){if(typeof name!=="string")throw new Error("Unknown token binding");counts[name]=(counts[name]||0)+1;}for(const child of s.children||[])stack.push(child);}
|
|
14
17
|
return { ok: true, readOnly: true, shapes, counts };`);
|
|
15
18
|
}
|
|
16
19
|
/** A hash of everything structural on every page (ids, kinds, geometry, which tokens are bound), not of colours: pushing tokens must not move or rebind a shape. */
|
|
@@ -26,7 +29,7 @@ const display = (type, value) => {
|
|
|
26
29
|
return normal.length > 90 ? `${normal.slice(0, 87)}...` : normal;
|
|
27
30
|
};
|
|
28
31
|
/** Compares the committed expectation with what the file holds. Only the sets the expectation names are judged; other sets are the project's own. */
|
|
29
|
-
export function compareToPenpot(expected, live) {
|
|
32
|
+
export function compareToPenpot(expected, live, options = {}) {
|
|
30
33
|
const sets = [];
|
|
31
34
|
for (const wanted of expected.sets) {
|
|
32
35
|
const found = live.sets.find((s) => s.name === wanted.name);
|
|
@@ -62,14 +65,15 @@ export function compareToPenpot(expected, live) {
|
|
|
62
65
|
continue;
|
|
63
66
|
}
|
|
64
67
|
const missingSets = wanted.sets.filter((s) => !found.sets.includes(s));
|
|
65
|
-
const
|
|
68
|
+
const owned = new Set([...expected.sets, ...(options.ancestor?.sets ?? [])].map(set => set.name));
|
|
69
|
+
const extraSets = found.sets.filter((s) => !wanted.sets.includes(s) && owned.has(s));
|
|
66
70
|
if (missingSets.length || extraSets.length)
|
|
67
71
|
themes.push({ theme: `${wanted.group}/${wanted.name}`, missingSets, extraSets });
|
|
68
72
|
}
|
|
69
73
|
const managed = new Set(expected.sets.map((s) => s.name));
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
return { inSync:
|
|
74
|
+
const unexpectedSets = live.sets.map(set => set.name).filter(name => options.ancestor?.sets.some(set => set.name === name) && !managed.has(name));
|
|
75
|
+
const actionable = sets.some(set => set.missing || set.tokens.some(token => token.kind !== "extra" || options.ancestor?.sets.find(old => old.name === set.set)?.tokens.some(old => old.name === token.name)));
|
|
76
|
+
return { inSync: !actionable && themes.length === 0, sets, themes, unexpectedSets };
|
|
73
77
|
}
|
|
74
78
|
export function formatDrift(drift, impact, limit = 6) {
|
|
75
79
|
const lines = [];
|
|
@@ -105,7 +109,7 @@ export function formatDrift(drift, impact, limit = 6) {
|
|
|
105
109
|
}
|
|
106
110
|
/** The writes that bring the file to the expectation. Nothing in a set the expectation does not name is ever touched. */
|
|
107
111
|
export function planPush(expected, live, options = {}) {
|
|
108
|
-
const drift = compareToPenpot(expected, live);
|
|
112
|
+
const drift = compareToPenpot(expected, live, options);
|
|
109
113
|
const actions = [];
|
|
110
114
|
const wantedSet = new Map(expected.sets.map((s) => [s.name, s]));
|
|
111
115
|
for (const s of drift.sets) {
|
|
@@ -128,8 +132,9 @@ export function planPush(expected, live, options = {}) {
|
|
|
128
132
|
if (replace.length)
|
|
129
133
|
actions.push({ kind: "replaceTokens", set: s.set, tokens: replace });
|
|
130
134
|
const extra = s.tokens.filter((t) => t.kind === "extra").map((t) => t.name);
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
const ownedExtra = extra.filter(name => options.ancestor?.sets.find(old => old.name === s.set)?.tokens.some(token => token.name === name));
|
|
136
|
+
if (options.prune && ownedExtra.length)
|
|
137
|
+
actions.push({ kind: "removeTokens", set: s.set, names: ownedExtra });
|
|
133
138
|
}
|
|
134
139
|
const createdThemes = new Set();
|
|
135
140
|
for (const t of drift.themes) {
|
|
@@ -257,39 +262,133 @@ const describeAction = (action, count) => {
|
|
|
257
262
|
};
|
|
258
263
|
export const describePlan = (actions) => actions.map((a) => describeAction(a));
|
|
259
264
|
export async function readLive(client) {
|
|
260
|
-
const record = await runSnippet(client, readTokens());
|
|
261
|
-
|
|
265
|
+
const record = await runSnippet(client, readTokens(), { effect: "read" });
|
|
266
|
+
if (record.ok !== true || !Array.isArray(record.sets) || !Array.isArray(record.themes))
|
|
267
|
+
throw new Error("Token readback did not provide explicit sets and themes; synchronization is not verified");
|
|
268
|
+
const sets = record.sets, themes = record.themes;
|
|
269
|
+
const setNames = new Set();
|
|
270
|
+
let count = 0;
|
|
271
|
+
for (const set of sets) {
|
|
272
|
+
if (typeof set.name !== "string" || setNames.has(set.name) || !Array.isArray(set.tokens))
|
|
273
|
+
throw new Error("Invalid or ambiguous token set observation");
|
|
274
|
+
setNames.add(set.name);
|
|
275
|
+
const names = new Set();
|
|
276
|
+
for (const token of set.tokens) {
|
|
277
|
+
if (++count > 50000 || typeof token.name !== "string" || names.has(token.name) || typeof token.type !== "string" || token.value === undefined || typeof token.description !== "string")
|
|
278
|
+
throw new Error("Incomplete, ambiguous or over-budget token observation");
|
|
279
|
+
names.add(token.name);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const themeNames = new Set();
|
|
283
|
+
for (const theme of themes) {
|
|
284
|
+
const key = JSON.stringify([theme.group, theme.name]);
|
|
285
|
+
if (typeof theme.group !== "string" || typeof theme.name !== "string" || themeNames.has(key) || !Array.isArray(theme.sets) || theme.sets.some(name => typeof name !== "string" || !setNames.has(name)) || new Set(theme.sets).size !== theme.sets.length)
|
|
286
|
+
throw new Error("Invalid theme membership observation");
|
|
287
|
+
themeNames.add(key);
|
|
288
|
+
}
|
|
289
|
+
return { sets, themes };
|
|
262
290
|
}
|
|
263
291
|
/**
|
|
264
292
|
* Carries out the plan one write at a time, stopping at the first anomaly, then reads everything back: the file must now match the
|
|
265
293
|
* expectation, and no shape may have moved or been rebound.
|
|
266
294
|
*/
|
|
267
|
-
export async function applyPlan(client, expected, actions, onStep) {
|
|
295
|
+
export async function applyPlan(client, expected, actions, onStep, options = {}) {
|
|
296
|
+
if (options.expectedFileId)
|
|
297
|
+
client = guardFile(client, { id: options.expectedFileId });
|
|
298
|
+
if (options.journal)
|
|
299
|
+
client = guardFile(client, { id: (await readJournalTarget(client)).fileId });
|
|
300
|
+
let layout;
|
|
301
|
+
const settle = async () => {
|
|
302
|
+
if (options.layout === false)
|
|
303
|
+
return;
|
|
304
|
+
layout = await waitForStableLayout(client, options.layout);
|
|
305
|
+
if (!layout.stable)
|
|
306
|
+
throw new Error(`Layout did not settle during token verification: ${layout.reason ?? "unknown reason"}`);
|
|
307
|
+
};
|
|
308
|
+
await settle();
|
|
268
309
|
const fingerprint = async () => {
|
|
269
|
-
const r = await runSnippet(client, structureFingerprint());
|
|
310
|
+
const r = await runSnippet(client, structureFingerprint(), { effect: "read" });
|
|
311
|
+
if (r.ok !== true || !Number.isInteger(r.count) || Number(r.count) < 0 || typeof r.hash !== "string" || !r.hash)
|
|
312
|
+
throw new Error("Structure readback did not provide a count and hash; nothing can be verified");
|
|
270
313
|
return { count: Number(r.count), hash: String(r.hash) };
|
|
271
314
|
};
|
|
272
315
|
const structureBefore = await fingerprint();
|
|
316
|
+
const initialLive = await readLive(client);
|
|
317
|
+
for (const action of actions)
|
|
318
|
+
if (action.kind === "removeTokens" && action.names.some(name => !options.ancestor?.sets.find(set => set.name === action.set)?.tokens.some(token => token.name === name)))
|
|
319
|
+
throw new Error("Prune refuses tokens absent from the verified ownership ancestor");
|
|
320
|
+
assertSafeDestruction(actions, initialLive, await impactOf(client));
|
|
321
|
+
if (options.journal) {
|
|
322
|
+
const target = await readJournalTarget(client);
|
|
323
|
+
const guarded = guardFile(client, { id: target.fileId });
|
|
324
|
+
let originalStructure = structureBefore;
|
|
325
|
+
if (options.journal.resume) {
|
|
326
|
+
if (!options.journal.runId)
|
|
327
|
+
throw new Error("Resume requires a run ID");
|
|
328
|
+
const prior = await readJournal(options.journal.root, options.journal.runId);
|
|
329
|
+
const payload = prior.payload;
|
|
330
|
+
if (!payload || !Array.isArray(payload.actions) || !payload.structure || typeof payload.structure.hash !== "string")
|
|
331
|
+
throw new Error("Token journal has no original action/structure plan");
|
|
332
|
+
actions = payload.actions;
|
|
333
|
+
originalStructure = payload.structure;
|
|
334
|
+
}
|
|
335
|
+
if (structureBefore.hash !== originalStructure.hash)
|
|
336
|
+
throw new Error("Shapes changed since the token run began; reconcile the design before resuming");
|
|
337
|
+
const baseline = { live: await readLive(guarded), structure: structureBefore };
|
|
338
|
+
if (!options.journal.resume && options.expectedLive && observationHash(options.expectedLive) !== observationHash(baseline.live))
|
|
339
|
+
throw new Error("Token source changed since the CLI plan; inspect again before writing");
|
|
340
|
+
const work = actions.flatMap((action) => "tokens" in action ? inBatches(action.tokens).map((tokens) => ({ ...action, tokens })) : [action]);
|
|
341
|
+
const record = await runJournaledSteps(work.map((action, index) => ({
|
|
342
|
+
key: `step-${index}-${observationHash(action).slice(0, 12)}`, label: describeAction(action), signature: [actionSnippet(action, "tokens" in action ? action.tokens : undefined).code, actionSatisfied.toString(), reconcileAction.toString(), remainingAction.toString(), waitForStableLayout.toString()].join("\n"),
|
|
343
|
+
initial: baseline,
|
|
344
|
+
inspect: async () => { await settle(); return { live: await readLive(guarded), structure: await fingerprint() }; },
|
|
345
|
+
apply: async (current, target) => {
|
|
346
|
+
const residual = remainingAction(action, current.live);
|
|
347
|
+
if (!residual)
|
|
348
|
+
return { ok: true, reconciled: true };
|
|
349
|
+
const mutation = actionSnippet(residual, "tokens" in residual ? residual.tokens : undefined);
|
|
350
|
+
const sourceGuard = `${destructionGuard(residual)}\n{ const observed = await (async () => { ${readTokens().code} })();
|
|
351
|
+
if (JSON.stringify({sets:observed.sets, themes:observed.themes}) !== ${JSON.stringify(JSON.stringify(current.live))}) return {ok:false, message:"Token source changed before mutation"}; }\n`;
|
|
352
|
+
return runSnippet(guardRevision(guarded, target), { ...mutation, code: sourceGuard + mutation.code }, { effect: "write" });
|
|
353
|
+
},
|
|
354
|
+
verify: (current) => current.structure.hash === originalStructure.hash && actionSatisfied(action, current.live),
|
|
355
|
+
reconcile: (before, current) => current.structure.hash !== originalStructure.hash ? "conflict" : reconcileAction(action, before.live, current.live),
|
|
356
|
+
})), { ...options.journal, coordinate: writeCoordinator(guarded, { root: options.journal.root, operation: "tokens-push", forceLock: options.journal.forceLock, warn: onStep }), operation: "tokens-push", input: { snapshot: observationHash(expected), ancestor: options.ancestor ? observationHash(options.ancestor) : null, prune: options.prune === true, layout: options.layout ?? {} }, payload: { actions, structure: originalStructure }, target: () => readJournalTarget(guarded), onStep,
|
|
357
|
+
finalVerify: async () => { await settle(); return compareToPenpot(expected, await readLive(guarded), options).inSync && (await fingerprint()).hash === originalStructure.hash; },
|
|
358
|
+
});
|
|
359
|
+
await settle();
|
|
360
|
+
const structureAfter = await fingerprint();
|
|
361
|
+
const drift = compareToPenpot(expected, await readLive(guarded), options);
|
|
362
|
+
return { layout, steps: record.steps.map((step) => step.label), structureBefore: originalStructure, structureAfter, structureUnchanged: originalStructure.hash === structureAfter.hash, drift, ...(record.redacted ? { redacted: true, complete: false } : {}), verified: !record.redacted && drift.inSync && originalStructure.hash === structureAfter.hash, journal: { runId: record.runId, state: record.state } };
|
|
363
|
+
}
|
|
273
364
|
const steps = [];
|
|
274
365
|
for (const action of actions) {
|
|
275
366
|
const work = "tokens" in action ? inBatches(action.tokens) : [undefined];
|
|
276
367
|
for (const batch of work) {
|
|
277
368
|
const message = describeAction(action, batch?.length);
|
|
278
369
|
onStep?.(message);
|
|
279
|
-
const
|
|
370
|
+
const mutation = actionSnippet(action, batch);
|
|
371
|
+
const result = await runSnippet(client, { ...mutation, code: destructionGuard(action) + mutation.code }, { effect: "write" });
|
|
280
372
|
const failed = result.failed ?? [];
|
|
281
373
|
if (failed.length > 0)
|
|
282
374
|
throw new Error(`${message}: ${failed.join(" | ")}. Nothing after this was written.`);
|
|
283
375
|
steps.push(message);
|
|
284
376
|
}
|
|
285
377
|
}
|
|
378
|
+
await settle();
|
|
286
379
|
const structureAfter = await fingerprint();
|
|
287
|
-
const drift = compareToPenpot(expected, await readLive(client));
|
|
288
|
-
return { steps, structureBefore, structureAfter, structureUnchanged: structureBefore.hash === structureAfter.hash, drift, verified: drift.inSync && structureBefore.hash === structureAfter.hash };
|
|
380
|
+
const drift = compareToPenpot(expected, await readLive(client), options);
|
|
381
|
+
return { layout, steps, structureBefore, structureAfter, structureUnchanged: structureBefore.hash === structureAfter.hash, drift, verified: drift.inSync && structureBefore.hash === structureAfter.hash };
|
|
382
|
+
}
|
|
383
|
+
function destructionGuard(action) {
|
|
384
|
+
if (action.kind !== "removeTokens" && action.kind !== "replaceTokens")
|
|
385
|
+
return "";
|
|
386
|
+
return `{ const live=await(async()=>{${readTokens().code}})(); const bindings=await(async()=>{${bindingCounts().code}})();
|
|
387
|
+
(${assertSafeDestruction.toString()})([${JSON.stringify(action)}],live,bindings.counts); }\n`;
|
|
289
388
|
}
|
|
290
389
|
/** The use count of every token a drift would change, so a bump can be judged by what it reaches. */
|
|
291
390
|
export async function impactOf(client) {
|
|
292
|
-
const record = await runSnippet(client, bindingCounts());
|
|
391
|
+
const record = await runSnippet(client, bindingCounts(), { effect: "read" });
|
|
293
392
|
return record.counts;
|
|
294
393
|
}
|
|
295
394
|
export { canonical };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Action, LiveTokens } from "./penpot.js";
|
|
2
|
+
export declare function actionSatisfied(action: Action, live: LiveTokens): boolean;
|
|
3
|
+
/** Only old or explicitly desired states can be our partial write; a third value needs owner reconciliation. */
|
|
4
|
+
export declare function reconcileAction(action: Action, before: LiveTokens, current: LiveTokens): "complete" | "pending" | "conflict";
|
|
5
|
+
/** Recompute residual work from actual state so completed token replacements are never removed/recreated again. */
|
|
6
|
+
export declare function remainingAction(action: Action, current: LiveTokens): Action | undefined;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { normalizeValue } from "./tokens.js";
|
|
2
|
+
const set = (live, name) => live.sets.filter((entry) => entry.name === name);
|
|
3
|
+
const theme = (live, group, name) => live.themes.filter((entry) => entry.group === group && entry.name === name);
|
|
4
|
+
const tokenState = (token) => token ? JSON.stringify([token.type, normalizeValue(token.type, token.value), token.description || ""]) : null;
|
|
5
|
+
export function actionSatisfied(action, live) {
|
|
6
|
+
switch (action.kind) {
|
|
7
|
+
case "createSet": return set(live, action.set).length === 1;
|
|
8
|
+
case "addTokens":
|
|
9
|
+
case "updateTokens":
|
|
10
|
+
case "replaceTokens": {
|
|
11
|
+
const sets = set(live, action.set);
|
|
12
|
+
return sets.length === 1 && action.tokens.every((wanted) => {
|
|
13
|
+
const actual = sets[0].tokens.filter((token) => token.name === wanted.name);
|
|
14
|
+
return actual.length === 1 && tokenState(actual[0]) === tokenState(wanted);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
case "removeTokens": return set(live, action.set).length === 1 && action.names.every((name) => !set(live, action.set)[0].tokens.some((token) => token.name === name));
|
|
18
|
+
case "createTheme": {
|
|
19
|
+
const found = theme(live, action.group, action.name);
|
|
20
|
+
return found.length === 1 && found[0].sets.length === action.sets.length && action.sets.every((name) => found[0].sets.includes(name));
|
|
21
|
+
}
|
|
22
|
+
case "syncThemeSets": {
|
|
23
|
+
const found = theme(live, action.group, action.name);
|
|
24
|
+
return found.length === 1 && action.add.every((name) => found[0].sets.includes(name)) && action.remove.every((name) => !found[0].sets.includes(name));
|
|
25
|
+
}
|
|
26
|
+
case "activateTheme": return theme(live, action.group, action.name).some((entry) => entry.active) && live.themes.every((entry) => !entry.active || entry.group === action.group && entry.name === action.name);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Only old or explicitly desired states can be our partial write; a third value needs owner reconciliation. */
|
|
30
|
+
export function reconcileAction(action, before, current) {
|
|
31
|
+
if (actionSatisfied(action, current))
|
|
32
|
+
return "complete";
|
|
33
|
+
switch (action.kind) {
|
|
34
|
+
case "createSet": return set(current, action.set).length === 0 ? "pending" : "conflict";
|
|
35
|
+
case "addTokens":
|
|
36
|
+
case "updateTokens":
|
|
37
|
+
case "replaceTokens": {
|
|
38
|
+
const oldSets = set(before, action.set), nowSets = set(current, action.set);
|
|
39
|
+
if (nowSets.length !== 1 || oldSets.length > 1)
|
|
40
|
+
return "conflict";
|
|
41
|
+
for (const wanted of action.tokens) {
|
|
42
|
+
const oldTokens = oldSets[0]?.tokens.filter((token) => token.name === wanted.name) ?? [];
|
|
43
|
+
const nowTokens = nowSets[0].tokens.filter((token) => token.name === wanted.name);
|
|
44
|
+
if (oldTokens.length > 1 || nowTokens.length > 1)
|
|
45
|
+
return "conflict";
|
|
46
|
+
const old = tokenState(oldTokens[0]), actual = tokenState(nowTokens[0]), desired = tokenState(wanted);
|
|
47
|
+
if (action.kind === "addTokens" && old !== null && old !== desired)
|
|
48
|
+
return "conflict";
|
|
49
|
+
if (action.kind !== "addTokens" && old === null)
|
|
50
|
+
return "conflict";
|
|
51
|
+
if (actual !== old && actual !== desired && !(action.kind === "replaceTokens" && actual === null))
|
|
52
|
+
return "conflict";
|
|
53
|
+
}
|
|
54
|
+
return "pending";
|
|
55
|
+
}
|
|
56
|
+
case "removeTokens": {
|
|
57
|
+
const oldSets = set(before, action.set), nowSets = set(current, action.set);
|
|
58
|
+
if (oldSets.length !== 1 || nowSets.length !== 1)
|
|
59
|
+
return "conflict";
|
|
60
|
+
return action.names.every((name) => {
|
|
61
|
+
const old = oldSets[0].tokens.filter((token) => token.name === name);
|
|
62
|
+
const actual = nowSets[0].tokens.filter((token) => token.name === name);
|
|
63
|
+
return actual.length <= 1 && (actual.length === 0 || old.length === 1 && tokenState(old[0]) === tokenState(actual[0]));
|
|
64
|
+
}) ? "pending" : "conflict";
|
|
65
|
+
}
|
|
66
|
+
case "createTheme": {
|
|
67
|
+
const old = theme(before, action.group, action.name), actual = theme(current, action.group, action.name);
|
|
68
|
+
return old.length === 0 && actual.length <= 1 && (actual.length === 0 || actual[0].sets.every((name) => action.sets.includes(name))) ? "pending" : "conflict";
|
|
69
|
+
}
|
|
70
|
+
case "syncThemeSets": {
|
|
71
|
+
const old = theme(before, action.group, action.name), actual = theme(current, action.group, action.name);
|
|
72
|
+
if (old.length !== 1 || actual.length !== 1)
|
|
73
|
+
return "conflict";
|
|
74
|
+
const touched = new Set([...action.add, ...action.remove]);
|
|
75
|
+
const unchanged = (sets) => sets.filter((name) => !touched.has(name)).sort().join("\n");
|
|
76
|
+
return unchanged(old[0].sets) === unchanged(actual[0].sets) ? "pending" : "conflict";
|
|
77
|
+
}
|
|
78
|
+
case "activateTheme": {
|
|
79
|
+
const active = (live) => live.themes.filter((entry) => entry.active).map((entry) => JSON.stringify([entry.group, entry.name])).sort().join("\n");
|
|
80
|
+
return active(current) === active(before) || active(current) === "" ? "pending" : "conflict";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Recompute residual work from actual state so completed token replacements are never removed/recreated again. */
|
|
85
|
+
export function remainingAction(action, current) {
|
|
86
|
+
if (actionSatisfied(action, current))
|
|
87
|
+
return undefined;
|
|
88
|
+
if ("tokens" in action) {
|
|
89
|
+
const actual = set(current, action.set)[0];
|
|
90
|
+
const tokens = action.tokens.filter((wanted) => tokenState(actual?.tokens.find((token) => token.name === wanted.name)) !== tokenState(wanted));
|
|
91
|
+
return tokens.length ? { ...action, tokens } : undefined;
|
|
92
|
+
}
|
|
93
|
+
if (action.kind === "removeTokens")
|
|
94
|
+
return { ...action, names: action.names.filter((name) => set(current, action.set)[0]?.tokens.some((token) => token.name === name)) };
|
|
95
|
+
if (action.kind === "createTheme" && theme(current, action.group, action.name).length === 1) {
|
|
96
|
+
const actual = theme(current, action.group, action.name)[0];
|
|
97
|
+
return { kind: "syncThemeSets", group: action.group, name: action.name, add: action.sets.filter((name) => !actual.sets.includes(name)), remove: [] };
|
|
98
|
+
}
|
|
99
|
+
if (action.kind === "syncThemeSets") {
|
|
100
|
+
const actual = theme(current, action.group, action.name)[0];
|
|
101
|
+
return { ...action, add: action.add.filter((name) => !actual.sets.includes(name)), remove: action.remove.filter((name) => actual.sets.includes(name)) };
|
|
102
|
+
}
|
|
103
|
+
return action;
|
|
104
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type TokenSnapshot } from "./tokens.js";
|
|
2
|
+
export interface ResolvedExpectation {
|
|
3
|
+
name: string;
|
|
4
|
+
type: string;
|
|
5
|
+
value?: unknown;
|
|
6
|
+
unknown?: string;
|
|
7
|
+
}
|
|
8
|
+
/** A small arithmetic grammar, never JavaScript evaluation. Unsupported units/functions are explicit unknowns. */
|
|
9
|
+
export declare function numericExpression(source: string): number;
|
|
10
|
+
export declare function expectedResolved(snapshot: TokenSnapshot, theme: {
|
|
11
|
+
sets: string[];
|
|
12
|
+
}): ResolvedExpectation[];
|
|
13
|
+
export declare function compareResolved(wanted: ResolvedExpectation, actual: unknown): {
|
|
14
|
+
equal?: boolean;
|
|
15
|
+
unknown?: string;
|
|
16
|
+
};
|