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.
- package/dist/commands/doctor.js +68 -0
- package/dist/commands/hook.js +13 -0
- package/dist/doctor/coherence.js +117 -0
- package/dist/doctor/ledger.js +105 -0
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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.
|
package/dist/commands/hook.js
CHANGED
|
@@ -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