synthesisui 0.16.19 → 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.
@@ -2,6 +2,7 @@ import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
3
  import { findDivergences } from "../doctor/coherence.js";
4
4
  import { findFrozenBindings } from "../doctor/frozen.js";
5
+ import { appendEvent, readEvents, summarize } from "../doctor/ledger.js";
5
6
  import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
6
7
  import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
7
8
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
@@ -522,6 +523,46 @@ export async function doctor(opts) {
522
523
  console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
523
524
  console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}`));
524
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
+ }
525
566
  /**
526
567
  * Its own section, above drift, because it is a worse problem wearing a
527
568
  * better disguise.
@@ -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,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.19",
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": {