synthesisui 0.16.18 → 0.16.20

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.
@@ -1,6 +1,8 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
+ import { findDivergences } from "../doctor/coherence.js";
3
4
  import { findFrozenBindings } from "../doctor/frozen.js";
5
+ import { appendEvent, readEvents, summarize } from "../doctor/ledger.js";
4
6
  import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
5
7
  import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
6
8
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
@@ -521,6 +523,46 @@ export async function doctor(opts) {
521
523
  console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
522
524
  console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}`));
523
525
  }
526
+ /**
527
+ * THE RECORD - the layer that remembers, printed where the moment is shown.
528
+ *
529
+ * A snapshot is appended only for FULL runs: a scoped run measures a corner,
530
+ * and a corner's coverage on the project's trend line would read as the
531
+ * project getting better or worse when somebody just pointed the tool at a
532
+ * different folder.
533
+ *
534
+ * Every line printed here is something no single run can know: whether a
535
+ * finding was acted on (its file checked clean afterwards - the fix is a
536
+ * write, so it logged itself), and which way coverage is moving. That is the
537
+ * question a design-system lead asks before paying for anything, and until
538
+ * today the honest answer was that nobody knew.
539
+ */
540
+ if (hasSystem && measurable && scopes.length === 0) {
541
+ await appendEvent(root, {
542
+ kind: "doctor",
543
+ at: new Date().toISOString(),
544
+ coverage: d.coverage,
545
+ });
546
+ }
547
+ if (hasSystem && scopes.length === 0) {
548
+ const record = summarize(await readEvents(root));
549
+ if (record.checks >= 2) {
550
+ console.log("");
551
+ console.log(section("The record"));
552
+ const acted = record.resolved > 0
553
+ ? ` ${record.resolved} ${record.resolved === 1 ? "was" : "were"} fixed in a later write.`
554
+ : "";
555
+ console.log(body(`${record.checks} checks logged.${acted}${record.open.length === 0 && record.resolved > 0
556
+ ? " Nothing is still open."
557
+ : ""}`));
558
+ for (const o of record.open.slice(0, 5)) {
559
+ console.log(body(` still open: ${o.file} (${o.named} named, ${o.phantoms} phantom)`));
560
+ }
561
+ if (record.coverage && record.coverage.from !== record.coverage.to) {
562
+ console.log(body(` coverage ${record.coverage.from}% → ${record.coverage.to}% since the first run.`));
563
+ }
564
+ }
565
+ }
524
566
  /**
525
567
  * Its own section, above drift, because it is a worse problem wearing a
526
568
  * better disguise.
@@ -633,6 +675,32 @@ export async function doctor(opts) {
633
675
  say(body("Whoever wrote the law and whoever wrote the recipe disagree."));
634
676
  say(body("Until they do not, no code here can be correct."));
635
677
  }
678
+ /**
679
+ * THE FAILURE THAT SURVIVES A PERFECT SCORE.
680
+ *
681
+ * Coverage asks whether the vocabulary was used. It cannot ask whether the
682
+ * system says ONE thing, because every component here can reference the right
683
+ * token and still answer the same question differently. Measured on 28/07 in
684
+ * a system this tool had just graded 100%: the input shows focus by moving its
685
+ * border, the button by drawing an outline.
686
+ *
687
+ * Reported quietly and only against a clear house style. Hover is legitimately
688
+ * different per component, and a checker that flagged variety would be
689
+ * uninstalled the same day it shipped.
690
+ */
691
+ const divergences = findDivergences(documents);
692
+ if (divergences.length > 0) {
693
+ say(section("The system answers the same question two ways"));
694
+ for (const d of divergences) {
695
+ say(body(`${d.concern}: ${d.houseCount} components use ${d.house.join(" + ")}.`));
696
+ for (const o of d.outliers) {
697
+ say(` ${o.component} uses ${o.props.join(" + ")} instead`);
698
+ }
699
+ say("");
700
+ }
701
+ say(body("Every one of these uses your tokens, so coverage says nothing"));
702
+ say(body("about them. A person meets the difference on screen."));
703
+ }
636
704
  // The other way a system fails itself: a recipe that names a SHELF where the
637
705
  // system has a ROLE. The value is legitimate, the reference resolves, the CSS
638
706
  // compiles - and the binding sits still while everything around it flips.
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
+ import { appendEvent } from "../doctor/ledger.js";
3
4
  import { diagnose, scanSource } from "../doctor/scan.js";
4
5
  import { loadSystem } from "./doctor.js";
5
6
  const pass = () => ({ continue: true });
@@ -57,6 +58,18 @@ async function report(root, filePath) {
57
58
  const d = diagnose([scanSource(rel, src, table)]);
58
59
  const named = d.findings.filter((f) => f.token);
59
60
  const phantoms = d.files.flatMap((f) => f.phantoms ?? []);
61
+ // Every check leaves one line in the ledger - INCLUDING the clean ones,
62
+ // because a fix is itself a write, so the clean re-check of a file that was
63
+ // just flagged is the evidence the agent read the report and acted. Without
64
+ // the clean lines the record could only ever say "found", never "fixed",
65
+ // which is the half we could not answer when asked (28/07).
66
+ await appendEvent(root, {
67
+ kind: "hook",
68
+ at: new Date().toISOString(),
69
+ file: rel,
70
+ named: named.length,
71
+ phantoms: phantoms.length,
72
+ });
60
73
  // Unnamed drift alone is deliberately NOT worth interrupting for. There is
61
74
  // no token to move to, so the only honest advice is "ask a person" - and
62
75
  // saying that after every edit trains the reader to skip the block.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * DOES THE SYSTEM AGREE WITH ITSELF?
3
+ *
4
+ * Token coverage answers a different question than everybody assumes. It asks
5
+ * whether the vocabulary was used. It cannot ask whether the system says one
6
+ * thing, because every component can reference the correct token and still
7
+ * express the same idea in a different way.
8
+ *
9
+ * Measured on 28/07, in a system this tool had just graded at 100%: the input
10
+ * shows focus by moving its border, the button by drawing an outline. Same
11
+ * token, same toolbar, two answers to one question. Every deterministic
12
+ * assertion we had passed, and a person looking at the screen sees a system
13
+ * that has not decided what focus looks like.
14
+ *
15
+ * WHY IT LIVES IN CODE AND NOT IN THE DESIGN TOOL. A designer in the Design
16
+ * Systems Slack built a lint plugin that does this inside Figma. Asked whether
17
+ * it can compare across components rather than one at a time: "it can check one
18
+ * at a time or all, but this crashes figma most of the time". A plugin walks a
19
+ * document graph inside a sandbox; this reads recipes off disk, and the whole
20
+ * catalogue costs a few milliseconds. The check is feasible on this side and not
21
+ * on that one.
22
+ *
23
+ * WHAT MAKES IT HONEST RATHER THAN NOISY. Variety is not drift. Hover is
24
+ * legitimately different per component - a row tints, a link recolours, a chip
25
+ * moves its border - and a checker that flagged all of it would be uninstalled
26
+ * the same day. So it reports a MINORITY against a DOMINANT majority and says
27
+ * nothing at all when the catalogue has no majority to disagree with.
28
+ *
29
+ * Measured against the shipped catalogue, that rule reports the two real ones
30
+ * and neither of the false ones:
31
+ *
32
+ * focus 9 use outline, 3 use border-color → reported (75% dominant)
33
+ * disabled 12 dim and change the cursor, 1 only dims → reported (92%)
34
+ * hover 5 shapes across 11, none dominant → silent
35
+ * active 1 and 1 → silent
36
+ */
37
+ /**
38
+ * State names that answer the SAME question, folded together.
39
+ *
40
+ * Without this the check finds nothing here, because each name is unanimous on
41
+ * its own: `focusVisible` is nine-for-nine outline, `focus` is three-for-three
42
+ * border. The disagreement only exists once you know they are the same concern,
43
+ * which is exactly why it survived every per-name check anyone could write.
44
+ */
45
+ const CONCERNS = {
46
+ focus: "focus",
47
+ focusvisible: "focus",
48
+ focuswithin: "focus",
49
+ active: "pressed",
50
+ pressed: "pressed",
51
+ checked: "selected",
52
+ selected: "selected",
53
+ disabled: "disabled",
54
+ hover: "hover",
55
+ };
56
+ /** Below this share, the catalogue has no house style to disagree with, and
57
+ * every "outlier" is just the variety the concern is supposed to have. */
58
+ const DOMINANT = 0.7;
59
+ function collect(documents) {
60
+ const byConcern = new Map();
61
+ const visit = (label, node) => {
62
+ for (const [state, block] of Object.entries(node.states ?? {})) {
63
+ const concern = CONCERNS[state.toLowerCase()];
64
+ // An unrecognised state is not folded into anything. Guessing that two
65
+ // names mean the same thing is how a checker starts inventing findings.
66
+ if (!concern)
67
+ continue;
68
+ const props = Object.keys(block ?? {}).sort();
69
+ if (props.length === 0)
70
+ continue;
71
+ const list = byConcern.get(concern) ?? [];
72
+ list.push({ component: label, props });
73
+ byConcern.set(concern, list);
74
+ }
75
+ };
76
+ for (const raw of documents) {
77
+ const doc = raw;
78
+ for (const [name, recipe] of Object.entries(doc.components ?? {})) {
79
+ visit(name, recipe);
80
+ for (const [part, block] of Object.entries(recipe.parts ?? {})) {
81
+ visit(`${name}.${part}`, block);
82
+ }
83
+ }
84
+ }
85
+ return byConcern;
86
+ }
87
+ /** Every concern the system answers more than one way, with a clear majority. */
88
+ export function findDivergences(documents) {
89
+ const out = [];
90
+ for (const [concern, entries] of collect(documents)) {
91
+ // One component cannot disagree with itself, and two cannot form a house
92
+ // style - the smallest catalogue this can speak about is three.
93
+ if (entries.length < 3)
94
+ continue;
95
+ const shapes = new Map();
96
+ for (const e of entries) {
97
+ const key = e.props.join("+");
98
+ shapes.set(key, [...(shapes.get(key) ?? []), e]);
99
+ }
100
+ if (shapes.size < 2)
101
+ continue;
102
+ const ranked = [...shapes.entries()].sort((a, b) => b[1].length - a[1].length);
103
+ const [houseKey, houseList] = ranked[0];
104
+ if (houseList.length / entries.length < DOMINANT)
105
+ continue;
106
+ out.push({
107
+ concern,
108
+ house: houseKey.split("+"),
109
+ houseCount: houseList.length,
110
+ outliers: ranked
111
+ .slice(1)
112
+ .flatMap(([, list]) => list)
113
+ .sort((a, b) => a.component.localeCompare(b.component)),
114
+ });
115
+ }
116
+ return out.sort((a, b) => b.outliers.length - a.outliers.length);
117
+ }
@@ -0,0 +1,105 @@
1
+ import { appendFile, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * THE LAYER THAT REMEMBERS - governance that cannot answer "is it getting
5
+ * better?" is a sequence of moments, not a system.
6
+ *
7
+ * The stack up to here acts and forgets. The hook checks every write and
8
+ * reports into the agent's context, and we had no idea whether the agent acted
9
+ * on it - the owner admitted exactly that, in public, to the person who asked
10
+ * (28/07). The doctor measures a moment and prints it. Nothing anywhere could
11
+ * answer the one question a design-system lead actually asks before paying for
12
+ * a tool: is my team's drift going up or down?
13
+ *
14
+ * THE TRICK THAT MAKES THIS CHEAP. The hook already runs on every write, and a
15
+ * FIX is also a write. So if every check appends one line here, the loop closes
16
+ * by itself: a finding at 10:02 followed by a clean check of the same file at
17
+ * 10:04 IS the evidence the agent read the report and acted. Nobody logs
18
+ * "fixed"; the fix logs itself.
19
+ *
20
+ * LOCAL BY DESIGN. One JSONL per project, inside `_synthesisui/`, next to the
21
+ * system it describes. Nothing leaves the machine - "we send telemetry to our
22
+ * server" is how a tool that installs an editor hook loses the room. Committing
23
+ * it is the project's choice: do, and the ledger becomes the team's shared
24
+ * memory; don't, and it is a personal instrument. Both are legitimate.
25
+ *
26
+ * Append-only, one JSON object per line, unknown lines skipped on read - the
27
+ * same contract every log file that survives a decade has.
28
+ */
29
+ export const LEDGER_FILE = "ledger.jsonl";
30
+ const ledgerPath = (root) => join(root, "_synthesisui", LEDGER_FILE);
31
+ /** Keep the file from growing forever: past ~1MB, keep the newest half. A
32
+ * trend needs recent history, not an archive. */
33
+ const MAX_BYTES = 1_000_000;
34
+ export async function appendEvent(root, event) {
35
+ const path = ledgerPath(root);
36
+ try {
37
+ await appendFile(path, `${JSON.stringify(event)}\n`, "utf8");
38
+ const size = (await stat(path)).size;
39
+ if (size > MAX_BYTES) {
40
+ const lines = (await readFile(path, "utf8")).split("\n").filter(Boolean);
41
+ await writeFile(path, `${lines.slice(Math.floor(lines.length / 2)).join("\n")}\n`, "utf8");
42
+ }
43
+ }
44
+ catch {
45
+ // The record must never cost the check. A ledger that can break the hook
46
+ // is a ledger that gets the hook uninstalled.
47
+ }
48
+ }
49
+ export async function readEvents(root) {
50
+ const raw = await readFile(ledgerPath(root), "utf8").catch(() => "");
51
+ if (!raw)
52
+ return [];
53
+ const out = [];
54
+ for (const line of raw.split("\n")) {
55
+ if (!line.trim())
56
+ continue;
57
+ try {
58
+ const e = JSON.parse(line);
59
+ if (e && (e.kind === "hook" || e.kind === "doctor") && e.at)
60
+ out.push(e);
61
+ }
62
+ catch {
63
+ // A corrupt line loses itself, not the file.
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ export function summarize(events) {
69
+ const hooks = events.filter((e) => e.kind === "hook" && e.file);
70
+ const byFile = new Map();
71
+ for (const e of hooks) {
72
+ const key = e.file;
73
+ byFile.set(key, [...(byFile.get(key) ?? []), e]);
74
+ }
75
+ let resolved = 0;
76
+ const open = [];
77
+ for (const [file, list] of byFile) {
78
+ let dirty = false;
79
+ for (const e of list) {
80
+ const bad = (e.named ?? 0) + (e.phantoms ?? 0) > 0;
81
+ if (bad && !dirty)
82
+ dirty = true;
83
+ else if (!bad && dirty) {
84
+ dirty = false;
85
+ resolved++;
86
+ }
87
+ }
88
+ if (dirty) {
89
+ const last = list[list.length - 1];
90
+ open.push({
91
+ file,
92
+ named: last.named ?? 0,
93
+ phantoms: last.phantoms ?? 0,
94
+ });
95
+ }
96
+ }
97
+ const snaps = events.filter((e) => e.kind === "doctor" && typeof e.coverage === "number");
98
+ const coverage = snaps.length >= 2
99
+ ? {
100
+ from: snaps[0].coverage,
101
+ to: snaps[snaps.length - 1].coverage,
102
+ }
103
+ : null;
104
+ return { checks: hooks.length, resolved, open, coverage };
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.18",
3
+ "version": "0.16.20",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {