synthesisui 0.4.4 → 0.4.5

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, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { generateComponentFiles } from "../component-codegen.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
+ import { diffLocalDocuments, localChangelogMarkdown, } from "../document-diff.js";
5
6
  import { body, section, snippet } from "../output.js";
6
7
  import { fetchChangelog, fetchComponent, fetchDesignSystem, RegistryError, } from "../registry.js";
7
8
  import { add } from "./add.js";
@@ -85,11 +86,29 @@ export async function upgrade(slug, opts) {
85
86
  }
86
87
  }
87
88
  }
88
- // 3. deterministic changelog → UPGRADE.md (the agent's migration brief)
89
- const log = await fetchChangelog(base, slug, installed, latest.version);
89
+ // 3. deterministic changelog → UPGRADE.md (the agent's migration brief).
90
+ // Diffed against the LOCALLY installed snapshot: personal systems mutate
91
+ // their working draft in place under a version number, so the server's
92
+ // vN may have moved since this app installed it ("draft drift") - a
93
+ // server-side history diff can be empty while this app's files differ.
94
+ // Fallback to the server changelog if the local snapshot is unreadable.
95
+ let markdown;
96
+ let breaking;
97
+ try {
98
+ const beforeDoc = JSON.parse(await readFile(join(slugDir, `v${installed}`, "design-system.json"), "utf8"));
99
+ const afterDoc = JSON.parse(await readFile(join(slugDir, `v${latest.version}`, "design-system.json"), "utf8"));
100
+ const local = diffLocalDocuments(beforeDoc, afterDoc);
101
+ markdown = localChangelogMarkdown(slug, installed, latest.version, local);
102
+ breaking = local.breaking;
103
+ }
104
+ catch {
105
+ const log = await fetchChangelog(base, slug, installed, latest.version);
106
+ markdown = log.markdown;
107
+ breaking = log.changelog.breaking;
108
+ }
90
109
  const upgradePath = join(slugDir, "UPGRADE.md");
91
110
  const brief = [
92
- log.markdown,
111
+ markdown,
93
112
  "",
94
113
  "---",
95
114
  "",
@@ -110,10 +129,10 @@ export async function upgrade(slug, opts) {
110
129
  await writeFile(upgradePath, brief, "utf8");
111
130
  // ── report ──
112
131
  console.log(section(`Upgraded ${slug}: v${installed} → v${latest.version}`));
113
- if (log.changelog.breaking.length > 0) {
114
- console.log(body(`Breaking changes (${log.changelog.breaking.length}):`));
132
+ if (breaking.length > 0) {
133
+ console.log(body(`Breaking changes (${breaking.length}):`));
115
134
  console.log("");
116
- console.log(snippet(log.changelog.breaking.map((item) => `- ${item}`)));
135
+ console.log(snippet(breaking.map((item) => `- ${item}`)));
117
136
  }
118
137
  else {
119
138
  console.log(body("No breaking changes detected."));
@@ -0,0 +1,174 @@
1
+ /**
2
+ * LOCAL document diff for `upgrade` - computed against the design-system.json
3
+ * snapshots on disk, not against the server's version history.
4
+ *
5
+ * Why local: personal design systems mutate their WORKING DRAFT in place under
6
+ * a fixed version number; only publishing freezes it. So the server's "v3" may
7
+ * have moved since this app installed it ("draft drift"), and a server-side
8
+ * v3→v4 changelog can be legitimately empty while the app's files differ. The
9
+ * app's truth is what it has installed - so that's what we diff.
10
+ */
11
+ /** Flatten a token subtree into `prefix.path → value` leaves. */
12
+ function flattenTokens(prefix, value, out) {
13
+ if (value == null)
14
+ return;
15
+ if (typeof value === "string" || typeof value === "number") {
16
+ out.set(prefix, String(value));
17
+ return;
18
+ }
19
+ if (Array.isArray(value)) {
20
+ value.forEach((v, i) => flattenTokens(`${prefix}.${i}`, v, out));
21
+ return;
22
+ }
23
+ if (typeof value === "object") {
24
+ for (const [k, v] of Object.entries(value)) {
25
+ flattenTokens(prefix ? `${prefix}.${k}` : k, v, out);
26
+ }
27
+ }
28
+ }
29
+ /** Key-order-insensitive equality (JSON docs round-trip with unstable order). */
30
+ function deepEqual(a, b) {
31
+ if (a === b)
32
+ return true;
33
+ if (typeof a !== typeof b)
34
+ return false;
35
+ if (Array.isArray(a) || Array.isArray(b)) {
36
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
37
+ return false;
38
+ return a.every((v, i) => deepEqual(v, b[i]));
39
+ }
40
+ if (a && b && typeof a === "object") {
41
+ const ka = Object.keys(a);
42
+ const kb = Object.keys(b);
43
+ if (ka.length !== kb.length)
44
+ return false;
45
+ return ka.every((k) => deepEqual(a[k], b[k]));
46
+ }
47
+ return false;
48
+ }
49
+ /** Variant options that existed and disappeared (breaking for the consumer). */
50
+ function removedVariantOptions(before, after) {
51
+ const out = [];
52
+ const prevVariants = (before.variants ?? {});
53
+ const nextVariants = (after.variants ?? {});
54
+ for (const [axis, options] of Object.entries(prevVariants)) {
55
+ const nextAxis = nextVariants[axis];
56
+ if (!nextAxis) {
57
+ out.push(axis);
58
+ continue;
59
+ }
60
+ for (const option of Object.keys(options)) {
61
+ if (!(option in nextAxis))
62
+ out.push(`${axis}="${option}"`);
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ function diffRecipeMaps(before, after, label, breaking) {
68
+ const out = [];
69
+ const names = new Set([...Object.keys(before), ...Object.keys(after)]);
70
+ for (const name of [...names].sort()) {
71
+ const prev = before[name];
72
+ const next = after[name];
73
+ if (!prev) {
74
+ out.push({ name, kind: "added" });
75
+ continue;
76
+ }
77
+ if (!next) {
78
+ out.push({ name, kind: "removed" });
79
+ breaking.push(`${label} "${name}" was removed`);
80
+ continue;
81
+ }
82
+ if (deepEqual(prev, next))
83
+ continue;
84
+ out.push({ name, kind: "changed" });
85
+ for (const gone of removedVariantOptions(prev, next)) {
86
+ breaking.push(`${label} "${name}" no longer supports ${gone}`);
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ /** Diff two installed design-system.json documents (token + recipe level). */
92
+ export function diffLocalDocuments(before, after) {
93
+ const breaking = [];
94
+ const prevTokens = new Map();
95
+ const nextTokens = new Map();
96
+ flattenTokens("", before.foundations, prevTokens);
97
+ flattenTokens("motion", before.motion, prevTokens);
98
+ flattenTokens("", after.foundations, nextTokens);
99
+ flattenTokens("motion", after.motion, nextTokens);
100
+ const tokens = [];
101
+ const paths = new Set([...prevTokens.keys(), ...nextTokens.keys()]);
102
+ for (const path of [...paths].sort()) {
103
+ const prev = prevTokens.get(path);
104
+ const next = nextTokens.get(path);
105
+ if (prev === next)
106
+ continue;
107
+ if (prev === undefined)
108
+ tokens.push({ path, after: next, kind: "added" });
109
+ else if (next === undefined) {
110
+ tokens.push({ path, before: prev, kind: "removed" });
111
+ breaking.push(`token "${path}" was removed`);
112
+ }
113
+ else
114
+ tokens.push({ path, before: prev, after: next, kind: "changed" });
115
+ }
116
+ const components = diffRecipeMaps((before.components ?? {}), (after.components ?? {}), "component", breaking);
117
+ const blocks = diffRecipeMaps((before.blocks ?? {}), (after.blocks ?? {}), "block", breaking);
118
+ return {
119
+ tokens,
120
+ components,
121
+ blocks,
122
+ breaking,
123
+ isEmpty: tokens.length === 0 && components.length === 0 && blocks.length === 0,
124
+ };
125
+ }
126
+ const CAP_TOKENS = 40;
127
+ /** The migration brief body, mirroring the server changelog's format. */
128
+ export function localChangelogMarkdown(slug, from, to, log) {
129
+ const lines = [
130
+ `# ${slug} - v${from} → v${to}`,
131
+ "",
132
+ `_Diffed against this app's installed v${from} snapshot (the app's truth) -`,
133
+ "personal systems can evolve in place under a version, so a server-side",
134
+ "history diff may miss what changed HERE._",
135
+ "",
136
+ ];
137
+ if (log.isEmpty) {
138
+ lines.push("No visual-contract changes for this app.", "");
139
+ return lines.join("\n");
140
+ }
141
+ if (log.breaking.length > 0) {
142
+ lines.push("## Breaking", "");
143
+ for (const item of log.breaking)
144
+ lines.push(`- ${item}`);
145
+ lines.push("");
146
+ }
147
+ if (log.tokens.length > 0) {
148
+ lines.push(`## Tokens (${log.tokens.length} change(s))`, "");
149
+ for (const t of log.tokens.slice(0, CAP_TOKENS)) {
150
+ const val = t.kind === "removed"
151
+ ? `removed (was \`${t.before}\`)`
152
+ : t.kind === "added"
153
+ ? `added: \`${t.after}\``
154
+ : `\`${t.before}\` → \`${t.after}\``;
155
+ lines.push(`- ${t.path}: ${val}`);
156
+ }
157
+ if (log.tokens.length > CAP_TOKENS) {
158
+ lines.push(`- …and ${log.tokens.length - CAP_TOKENS} more`);
159
+ }
160
+ lines.push("");
161
+ }
162
+ for (const [title, entries] of [
163
+ ["Components", log.components],
164
+ ["Blocks", log.blocks],
165
+ ]) {
166
+ if (entries.length === 0)
167
+ continue;
168
+ lines.push(`## ${title}`, "");
169
+ for (const entry of entries)
170
+ lines.push(`- \`ds-${entry.name}\` - ${entry.kind}`);
171
+ lines.push("");
172
+ }
173
+ return lines.join("\n");
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Traz design systems do SynthesisUI para qualquer projeto (materializa em _local/ds/).",
5
5
  "type": "module",
6
6
  "bin": {