@assure-one/design-system 1.30.0 → 1.32.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Rule selectors of a CSS file with their line numbers, without a CSS
3
+ * parser dependency. Comments and strings are blanked (keeping offsets), then
4
+ * every `prelude {` whose prelude is not an at-rule is a selector. Good
5
+ * enough for finding selectors; not a validator.
6
+ */
7
+ export function cssSelectors(text) {
8
+ const blank = (s) => s.replace(/[^\n]/g, " ");
9
+ const clean = text
10
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
11
+ .replace(
12
+ /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/g,
13
+ (m) => m[0] + blank(m.slice(1, -1)) + m[0],
14
+ );
15
+ const out = [];
16
+ let start = 0;
17
+ for (let i = 0; i < clean.length; i += 1) {
18
+ const ch = clean[i];
19
+ if (ch === "{" || ch === "}" || ch === ";") {
20
+ if (ch === "{") {
21
+ const prelude = clean.slice(start, i);
22
+ const offset = start + (prelude.length - prelude.trimStart().length);
23
+ const selector = text.slice(offset, i).trim();
24
+ if (selector && !selector.startsWith("@") && !/^[\d.%,\s]+$|^(?:from|to)$/.test(selector)) {
25
+ const line = text.slice(0, offset).split("\n").length;
26
+ out.push({ selector: selector.replace(/\s+/g, " "), line });
27
+ }
28
+ }
29
+ start = i + 1;
30
+ }
31
+ }
32
+ return out;
33
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Custom-property values: normalisation for comparison, and `var()`
3
+ * resolution. Dependency-free and pure — no file system, no CSS parser — so
4
+ * it can ship inside the package and run from a consumer project.
5
+ *
6
+ * It is the single implementation shared by
7
+ * `scripts/derive-consumer-preset` (W1-18) and CM-16: the two tools must
8
+ * agree on when two declarations of the same token "differ", or their counts
9
+ * cannot be compared. `scripts/derive-consumer-preset/lib/normalize.mjs` and
10
+ * `…/lib/cascade.mjs` re-export from here.
11
+ *
12
+ * Two values are "the same" when their normalised text is equal. The
13
+ * normalisation is conservative: it only removes differences that cannot
14
+ * change rendering (case of hex colours and keywords, short hex, whitespace,
15
+ * quotes around font names, `calc()` over px/rem only, trailing zeros). It
16
+ * does not convert between colour spaces: `#6c42f8` and an `oklab(...)`
17
+ * spelling of the same colour stay different, and the report shows both.
18
+ */
19
+
20
+ const ROOT_FONT_SIZE_PX = 16;
21
+
22
+ export function normalizeValue(value) {
23
+ if (value == null) return value;
24
+ let v = String(value).trim().replace(/\s+/g, " ");
25
+ v = v
26
+ .replace(/\s*,\s*/g, ",")
27
+ .replace(/\(\s+/g, "(")
28
+ .replace(/\s+\)/g, ")");
29
+ // Minifiers drop optional whitespace: `…) infinite` -> `…)infinite`, `16 / 9` -> `16/9`.
30
+ v = v.replace(/\)\s*(?=[\w#.-])/g, ") ").replace(/\s*\/\s*/g, "/");
31
+ v = v.replace(/#([0-9a-f]{3,8})\b/gi, (_, hex) => `#${expandHex(hex.toLowerCase())}`);
32
+ v = v.replace(/["']([^"',]+)["']/g, "$1");
33
+ v = evaluateCalcs(v);
34
+ v = v.replace(/(\d*\.\d*?)0+(?=[a-z%]|\b)/gi, (m, num) =>
35
+ num.endsWith(".") ? num.slice(0, -1) : num,
36
+ );
37
+ v = v.replace(/(^|[\s(,])0\.(\d)/g, "$1.$2");
38
+ if (/^[A-Za-z-]+$/.test(v)) v = v.toLowerCase();
39
+ if (/^-?\d*\.?\d+rem$/.test(v)) v = `${lengthToPx(v)}px`;
40
+ return v;
41
+ }
42
+
43
+ function expandHex(hex) {
44
+ if (hex.length === 3 || hex.length === 4) return [...hex].map((c) => c + c).join("");
45
+ return hex;
46
+ }
47
+
48
+ /** Evaluate innermost calc() expressions made only of px/rem/unitless terms. */
49
+ export function evaluateCalcs(value) {
50
+ let prev;
51
+ let v = value;
52
+ do {
53
+ prev = v;
54
+ v = v.replace(/calc\(([^()]*)\)/g, (whole, expr) => {
55
+ const result = evaluateLength(expr);
56
+ return result === null ? whole : result;
57
+ });
58
+ } while (v !== prev);
59
+ return v;
60
+ }
61
+
62
+ /**
63
+ * Evaluate `a op b op c` where terms are px, rem or unitless numbers.
64
+ * Returns a px string ("8px") or a unitless number string, or null.
65
+ */
66
+ export function evaluateLength(expr) {
67
+ const tokens = expr.match(/-?\d*\.?\d+(?:px|rem)?|[+\-*/]/g);
68
+ if (!tokens || tokens.join("").replace(/\s/g, "") !== expr.replace(/\s/g, "")) return null;
69
+ // Terms: { n: number, unit: "px" | "" }
70
+ const terms = [];
71
+ const ops = [];
72
+ let expectTerm = true;
73
+ for (const t of tokens) {
74
+ if (expectTerm) {
75
+ const m = /^(-?\d*\.?\d+)(px|rem)?$/.exec(t);
76
+ if (!m) return null;
77
+ const n = Number(m[1]) * (m[2] === "rem" ? ROOT_FONT_SIZE_PX : 1);
78
+ terms.push({ n, unit: m[2] ? "px" : "" });
79
+ expectTerm = false;
80
+ } else {
81
+ if (!/^[+\-*/]$/.test(t)) return null;
82
+ ops.push(t);
83
+ expectTerm = true;
84
+ }
85
+ }
86
+ if (expectTerm) return null;
87
+ // * and / first
88
+ for (let i = 0; i < ops.length; ) {
89
+ if (ops[i] === "*" || ops[i] === "/") {
90
+ const a = terms[i];
91
+ const b = terms[i + 1];
92
+ let r;
93
+ if (ops[i] === "*") {
94
+ if (a.unit && b.unit) return null;
95
+ r = { n: a.n * b.n, unit: a.unit || b.unit };
96
+ } else {
97
+ if (b.unit || b.n === 0) return null;
98
+ r = { n: a.n / b.n, unit: a.unit };
99
+ }
100
+ terms.splice(i, 2, r);
101
+ ops.splice(i, 1);
102
+ } else i++;
103
+ }
104
+ let acc = terms[0];
105
+ for (let i = 0; i < ops.length; i++) {
106
+ const b = terms[i + 1];
107
+ if (acc.unit !== b.unit) return null;
108
+ acc = { n: ops[i] === "+" ? acc.n + b.n : acc.n - b.n, unit: acc.unit };
109
+ }
110
+ const n = Math.round(acc.n * 10000) / 10000;
111
+ return `${n}${acc.unit}`;
112
+ }
113
+
114
+ /** Normalise a length-ish value to px when it is a plain rem/px value. */
115
+ export function lengthToPx(value) {
116
+ const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(String(value).trim());
117
+ if (!m) return null;
118
+ return Number(m[1]) * (m[2] === "rem" ? ROOT_FONT_SIZE_PX : 1);
119
+ }
120
+
121
+ export function sameValue(a, b) {
122
+ if (a === undefined || b === undefined) return a === b;
123
+ const na = normalizeValue(a);
124
+ const nb = normalizeValue(b);
125
+ if (na === nb) return true;
126
+ const pa = lengthToPx(na);
127
+ const pb = lengthToPx(nb);
128
+ return pa !== null && pa === pb;
129
+ }
130
+
131
+ /** Names referenced through `var(--name…)` in a value, in order. */
132
+ export function referencedVars(value) {
133
+ return [...String(value).matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]);
134
+ }
135
+
136
+ const INVALID = Symbol("invalid");
137
+
138
+ /**
139
+ * Compute custom-property values for one element.
140
+ * @param {Map<string, {value: string}>} winners own declarations
141
+ * @param {Map<string, string>} inherited parent's (or, for CM-16, the design
142
+ * system sheet's) computed values
143
+ * @returns {Map<string, string>} computed values (invalid ones omitted)
144
+ */
145
+ export function computeCustomProperties(winners, inherited = new Map()) {
146
+ const computed = new Map();
147
+ const state = new Map(); // prop -> "visiting" | "done"
148
+ const visit = (prop) => {
149
+ if (!winners.has(prop)) return inherited.has(prop) ? inherited.get(prop) : undefined;
150
+ if (state.get(prop) === "done") return computed.has(prop) ? computed.get(prop) : INVALID;
151
+ if (state.get(prop) === "visiting") return INVALID;
152
+ state.set(prop, "visiting");
153
+ const value = substitute(winners.get(prop).value, visit);
154
+ state.set(prop, "done");
155
+ if (value === INVALID) computed.delete(prop);
156
+ else computed.set(prop, value);
157
+ return value;
158
+ };
159
+ for (const prop of winners.keys()) visit(prop);
160
+ const out = new Map(inherited);
161
+ for (const prop of winners.keys()) {
162
+ if (computed.has(prop)) out.set(prop, computed.get(prop));
163
+ else out.delete(prop); // guaranteed-invalid: behaves as unset for custom props
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** Replace every var() in `value`; returns INVALID when a cycle is hit. */
169
+ function substitute(value, lookup) {
170
+ let out = "";
171
+ let i = 0;
172
+ while (i < value.length) {
173
+ const start = value.indexOf("var(", i);
174
+ if (start === -1 || (start > 0 && /[\w-]/.test(value[start - 1]))) {
175
+ if (start === -1) {
176
+ out += value.slice(i);
177
+ break;
178
+ }
179
+ out += value.slice(i, start + 4);
180
+ i = start + 4;
181
+ continue;
182
+ }
183
+ out += value.slice(i, start);
184
+ const end = matchParen(value, start + 3);
185
+ if (end === -1) {
186
+ out += value.slice(start);
187
+ break;
188
+ }
189
+ const inner = value.slice(start + 4, end);
190
+ const comma = topLevelComma(inner);
191
+ const name = (comma === -1 ? inner : inner.slice(0, comma)).trim();
192
+ const fallback = comma === -1 ? null : inner.slice(comma + 1).trim();
193
+ const resolved = lookup(name);
194
+ if (resolved === INVALID) return INVALID;
195
+ if (resolved !== undefined) out += resolved;
196
+ else if (fallback !== null) {
197
+ const fb = substitute(fallback, lookup);
198
+ if (fb === INVALID) return INVALID;
199
+ out += fb;
200
+ } else out += `var(${name})`; // external: keep it visible
201
+ i = end + 1;
202
+ }
203
+ return out;
204
+ }
205
+
206
+ function matchParen(s, open) {
207
+ let depth = 0;
208
+ for (let i = open; i < s.length; i++) {
209
+ if (s[i] === "(") depth++;
210
+ else if (s[i] === ")" && --depth === 0) return i;
211
+ }
212
+ return -1;
213
+ }
214
+
215
+ function topLevelComma(s) {
216
+ let depth = 0;
217
+ for (let i = 0; i < s.length; i++) {
218
+ if (s[i] === "(") depth++;
219
+ else if (s[i] === ")") depth--;
220
+ else if (s[i] === "," && depth === 0) return i;
221
+ }
222
+ return -1;
223
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * What the design system's own stylesheet declares — the reference side of
3
+ * every comparison CM-16 makes.
4
+ *
5
+ * Read from the installed package, so a consumer always compares its
6
+ * `globals.css` against the sheet it actually loads:
7
+ *
8
+ * dist/styles.css the shipped sheet (always present in
9
+ * an installed package)
10
+ * src/tokens/generated/theme.css fallback for an unbuilt checkout of
11
+ * this repository, where the contract
12
+ * tests run before `pnpm build`
13
+ *
14
+ * Only the **default root scope** is read: `@theme`, `:root`, `:host` and
15
+ * `html`. The `.dark` block and the `[data-brand="…"]` blocks are deliberately
16
+ * left out — they are unlayered and beat app variables, so a consumer that
17
+ * activates one (TAX sets `data-brand="tax"`) sees different values than this
18
+ * model reports. `derive-consumer-preset` (W1-18) takes the element model as
19
+ * input and does apply them; the difference is recorded in CM-16's report.
20
+ */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import { computeCustomProperties } from "./css-values.mjs";
26
+
27
+ const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
28
+
29
+ export const DS_SHEET_CANDIDATES = ["dist/styles.css", "src/tokens/generated/theme.css"];
30
+
31
+ /** Split a selector list on top-level commas. */
32
+ export function selectorParts(selector) {
33
+ const parts = [];
34
+ let depth = 0;
35
+ let start = 0;
36
+ for (let i = 0; i < selector.length; i += 1) {
37
+ const ch = selector[i];
38
+ if (ch === "(" || ch === "[") depth += 1;
39
+ else if (ch === ")" || ch === "]") depth -= 1;
40
+ else if (ch === "," && depth === 0) {
41
+ parts.push(selector.slice(start, i).trim());
42
+ start = i + 1;
43
+ }
44
+ }
45
+ parts.push(selector.slice(start).trim());
46
+ return parts.filter(Boolean);
47
+ }
48
+
49
+ const ROOT_PART = /^(?::root|:host|html)$/;
50
+
51
+ /** Whether a selector list declares on the default document root. */
52
+ export const isRootSelector = (selector) =>
53
+ selectorParts(selector).some((p) => ROOT_PART.test(p.replace(/\s+/g, "")));
54
+
55
+ /** The cascade layer a node sits in (dot-joined for nested layers), or null. */
56
+ export function layerOf(node) {
57
+ const path = [];
58
+ for (let p = node; p && p.type !== "root"; p = p.parent) {
59
+ if (p.type === "atrule" && p.name === "layer") path.unshift(p.params.trim());
60
+ }
61
+ return path.join(".") || null;
62
+ }
63
+
64
+ export function insideAtRule(node, name) {
65
+ for (let p = node.parent; p && p.type !== "root"; p = p.parent) {
66
+ if (p.type === "atrule" && p.name === name) return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ export function insideKeyframes(node) {
72
+ for (let p = node.parent; p && p.type !== "root"; p = p.parent) {
73
+ if (p.type === "atrule" && /keyframes$/.test(p.name)) return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /** Class names a selector list names, without duplicates. */
79
+ export const classNamesOf = (selector) => [
80
+ ...new Set([...selector.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1])),
81
+ ];
82
+
83
+ /**
84
+ * Tailwind's preflight, recognised by the reset it puts in `@layer base` on
85
+ * `*, ::before, ::after, ::backdrop`. Stable across Tailwind 4 minor versions
86
+ * and survives minification.
87
+ */
88
+ function hasPreflight(postcss, root) {
89
+ let found = false;
90
+ root.walkRules((rule) => {
91
+ if (found) return;
92
+ if (!/::backdrop/.test(rule.selector)) return;
93
+ if (!selectorParts(rule.selector).includes("*")) return;
94
+ rule.walkDecls("box-sizing", () => {
95
+ found = true;
96
+ });
97
+ });
98
+ return found;
99
+ }
100
+
101
+ let cached = null;
102
+ let overrideRoot = null;
103
+
104
+ /**
105
+ * The design system's declared surface.
106
+ * @param {object} postcss the parser the runner borrowed from the project
107
+ * @param {{ root?: string }} [options] package root, for tests
108
+ * @returns {{ from: string, tokens: Map<string,string>, declared: Map<string,string>,
109
+ * classes: Set<string>, preflight: boolean, scopes: string[] }}
110
+ */
111
+ export function dsStylesheet(postcss, { root = overrideRoot ?? packageRoot } = {}) {
112
+ if (cached && cached.root === root) return cached.value;
113
+ const rel = DS_SHEET_CANDIDATES.find((c) => existsSync(join(root, c)));
114
+ if (!rel) {
115
+ throw new Error(
116
+ `CM-16 compares the application's stylesheet against the design system's own, but none of ` +
117
+ `${DS_SHEET_CANDIDATES.join(", ")} exists under ${root}. Run \`pnpm build\` (in this ` +
118
+ `repository) or reinstall the package.`,
119
+ );
120
+ }
121
+ const css = readFileSync(join(root, rel), "utf8");
122
+ const parsed = postcss.parse(css, { from: rel });
123
+ const declared = new Map(); // name -> winning declaration text (source order)
124
+ const classes = new Set();
125
+ const scopes = new Set();
126
+ parsed.walkDecls((decl) => {
127
+ if (!decl.prop.startsWith("--") || decl.prop.startsWith("--tw-")) return;
128
+ if (insideKeyframes(decl)) return;
129
+ const parent = decl.parent;
130
+ const inTheme = parent?.type === "atrule" && parent.name === "theme";
131
+ if (inTheme || insideAtRule(decl, "theme")) {
132
+ declared.set(decl.prop, decl.value.trim());
133
+ scopes.add("@theme");
134
+ return;
135
+ }
136
+ if (parent?.type !== "rule") return;
137
+ if (isRootSelector(parent.selector)) {
138
+ declared.set(decl.prop, decl.value.trim());
139
+ scopes.add(selectorParts(parent.selector).join(", "));
140
+ }
141
+ });
142
+ parsed.walkRules((rule) => {
143
+ for (const name of classNamesOf(rule.selector)) classes.add(name);
144
+ });
145
+ const winners = new Map([...declared].map(([name, value]) => [name, { value }]));
146
+ const value = {
147
+ from: rel,
148
+ tokens: computeCustomProperties(winners),
149
+ declared,
150
+ classes,
151
+ preflight: hasPreflight(postcss, parsed),
152
+ scopes: [...scopes].sort(),
153
+ };
154
+ cached = { root, value };
155
+ return value;
156
+ }
157
+
158
+ /**
159
+ * Test hook: read the reference sheet from `root` instead of the installed
160
+ * package, and forget what was cached. The fixtures use it to compare against
161
+ * a small stylesheet of invented names, so that they neither depend on
162
+ * `pnpm build` having run nor on the value of a real token
163
+ * (`tests/codemods/cm-16.test.mjs`). `null` restores the installed package.
164
+ */
165
+ export const setDsStylesheetRoot = (root) => {
166
+ overrideRoot = root;
167
+ cached = null;
168
+ };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * What a codemod run needs from its surroundings: the installed design-system
3
+ * version and, for codemods that parse code, the TypeScript compiler.
4
+ */
5
+ import { readFileSync } from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+
10
+ const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
11
+
12
+ /**
13
+ * The version of the design-system package these codemods ship in. The
14
+ * runner is executed from the installed package, so this is the version the
15
+ * consumer has installed.
16
+ */
17
+ export function installedDsVersion() {
18
+ return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
19
+ }
20
+
21
+ /** Compares `major.minor.patch[-pre]` strings; prereleases sort before the release. */
22
+ export function compareVersions(a, b) {
23
+ const parse = (v) => {
24
+ const [core, pre = ""] = String(v).replace(/^v/, "").split("-", 2);
25
+ return { parts: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
26
+ };
27
+ const x = parse(a);
28
+ const y = parse(b);
29
+ for (let i = 0; i < 3; i += 1) {
30
+ const d = (x.parts[i] ?? 0) - (y.parts[i] ?? 0);
31
+ if (d) return Math.sign(d);
32
+ }
33
+ if (x.pre === y.pre) return 0;
34
+ if (!x.pre) return 1;
35
+ if (!y.pre) return -1;
36
+ return x.pre < y.pre ? -1 : 1;
37
+ }
38
+
39
+ /**
40
+ * A module resolved from the consumer project first and from this package's
41
+ * own location second. Neither is a dependency of the design system (see
42
+ * codemods/README.md, "Packaging"): every consumer app already has them, and
43
+ * codemods are a development-time tool.
44
+ */
45
+ async function borrow(root, name, hint) {
46
+ const attempts = [join(root, "package.json"), join(packageRoot, "package.json")];
47
+ for (const from of attempts) {
48
+ try {
49
+ const path = createRequire(from).resolve(name);
50
+ const mod = await import(pathToFileURL(path).href);
51
+ return mod.default ?? mod;
52
+ } catch {
53
+ // try the next location
54
+ }
55
+ }
56
+ throw new Error(
57
+ `This codemod ${hint}, but \`${name}\` could not be resolved from ${root}. ` +
58
+ `Install it in the project (\`pnpm add -D ${name}\`) and run again.`,
59
+ );
60
+ }
61
+
62
+ /** The `typescript` module, for codemods that parse code. */
63
+ export const loadTypeScript = (root) =>
64
+ borrow(root, "typescript", "parses code with the TypeScript compiler");
65
+
66
+ /**
67
+ * The `postcss` module, for codemods that parse stylesheets. Every consumer
68
+ * is a Tailwind 4 application and therefore already has it; using the same
69
+ * parser as the consumer scanner (W0-28) and `derive-consumer-preset`
70
+ * (W1-18) is also what keeps the three tools' CSS facts comparable.
71
+ */
72
+ export const loadPostcss = (root) => borrow(root, "postcss", "parses stylesheets with PostCSS");
@@ -0,0 +1,100 @@
1
+ /**
2
+ * File discovery for codemods. Runs inside consumer projects, so it only uses
3
+ * Node built-ins. The skip lists and the test-file rule match the consumer
4
+ * scanner (scripts/scan-consumers/lib/files.mjs) so both tools count the same
5
+ * files.
6
+ */
7
+ import { existsSync, readdirSync, statSync } from "node:fs";
8
+ import { dirname, join, relative, resolve, sep } from "node:path";
9
+
10
+ export const EXCLUDED_DIRS = new Set([
11
+ ".git",
12
+ ".next",
13
+ ".turbo",
14
+ ".vercel",
15
+ ".swc",
16
+ "build",
17
+ "coverage",
18
+ "dist",
19
+ "node_modules",
20
+ "out",
21
+ "playwright-report",
22
+ "storybook-static",
23
+ "test-results",
24
+ ]);
25
+
26
+ /** Root-relative directories served as-is (Next.js `public/`), never scanned. */
27
+ export const STATIC_DIRS = ["public"];
28
+
29
+ const CODE_EXT = /\.(?:[cm]?[jt]sx?)$/;
30
+ const CSS_EXT = /\.css$/;
31
+ const TEST_DIRS = new Set(["__tests__", "__mocks__", "__fixtures__", "e2e", "test", "tests"]);
32
+ const TEST_FILE =
33
+ /(?:\.(?:test|spec|e2e)\.[cm]?[jt]sx?$)|(?:^|\/)(?:jest|vitest|playwright)[.-][^/]*$/;
34
+
35
+ export const toPosix = (path) => path.split(sep).join("/");
36
+
37
+ /** `{ kind: "code" | "css" | "other", test }` for a root-relative posix path. */
38
+ export function classifyFile(rel) {
39
+ const kind = rel.endsWith(".d.ts")
40
+ ? "other"
41
+ : CODE_EXT.test(rel)
42
+ ? "code"
43
+ : CSS_EXT.test(rel)
44
+ ? "css"
45
+ : "other";
46
+ const segments = rel.split("/");
47
+ const test =
48
+ segments.slice(0, -1).some((s) => TEST_DIRS.has(s)) ||
49
+ TEST_FILE.test(rel) ||
50
+ /\.stories\.[cm]?[jt]sx?$/.test(rel);
51
+ return { kind, test };
52
+ }
53
+
54
+ /** The nearest directory at or above `start` that holds a package.json. */
55
+ export function findProjectRoot(start) {
56
+ let dir = resolve(start);
57
+ for (;;) {
58
+ if (existsSync(join(dir, "package.json"))) return dir;
59
+ const parent = dirname(dir);
60
+ if (parent === dir) return resolve(start);
61
+ dir = parent;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Code and CSS files under `paths` (files or directories), as sorted
67
+ * root-relative posix paths. Paths outside `root` are refused.
68
+ */
69
+ export function collectFiles(root, paths) {
70
+ const out = new Set();
71
+ const skipPrefixes = STATIC_DIRS;
72
+ const consider = (abs) => {
73
+ const rel = toPosix(relative(root, abs));
74
+ if (rel.startsWith("..")) throw new Error(`${abs} is outside the project root ${root}`);
75
+ return rel;
76
+ };
77
+ const visit = (abs) => {
78
+ const rel = consider(abs);
79
+ if (rel && skipPrefixes.some((p) => rel === p || rel.startsWith(`${p}/`))) return;
80
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
81
+ const child = join(abs, entry.name);
82
+ if (entry.isDirectory()) {
83
+ if (!EXCLUDED_DIRS.has(entry.name)) visit(child);
84
+ } else if (entry.isFile()) {
85
+ const childRel = consider(child);
86
+ if (classifyFile(childRel).kind !== "other") out.add(childRel);
87
+ }
88
+ }
89
+ };
90
+ for (const path of paths.length ? paths : [root]) {
91
+ const abs = resolve(path);
92
+ if (!existsSync(abs)) throw new Error(`${path} does not exist`);
93
+ if (statSync(abs).isDirectory()) visit(abs);
94
+ else {
95
+ const rel = consider(abs);
96
+ if (classifyFile(rel).kind !== "other") out.add(rel);
97
+ }
98
+ }
99
+ return [...out].sort();
100
+ }
Binary file
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Consumer-side codemod ledger (`.ds-migrations.json`, plan §29).
3
+ *
4
+ * { "schema": 1, "applied": [{ "id", "appliedAt", "dsVersion", "filesChanged" }] }
5
+ *
6
+ * The ledger lives in the consumer project root and is committed there. It
7
+ * records every transforming codemod that was applied (class A and R), so
8
+ * that one-shot codemods refuse to run twice and `upgrade` can run exactly
9
+ * the codemods a project is missing. Report-only codemods (class X) and dry
10
+ * runs never write it.
11
+ */
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ export const LEDGER_FILE = ".ds-migrations.json";
16
+ export const LEDGER_SCHEMA = 1;
17
+
18
+ const COMMENT =
19
+ "Codemods from @assure-one/design-system applied to this project. Written by the codemod runner; commit it with the codemod's changes.";
20
+
21
+ export const ledgerPath = (root) => join(root, LEDGER_FILE);
22
+
23
+ /**
24
+ * `{ schema, applied }`. A missing file is an empty ledger. Entries written
25
+ * as bare ids (`["CM-05"]`) are accepted and read as `{ id }`.
26
+ */
27
+ export function readLedger(root) {
28
+ const path = ledgerPath(root);
29
+ if (!existsSync(path)) return { schema: LEDGER_SCHEMA, applied: [] };
30
+ let data;
31
+ try {
32
+ data = JSON.parse(readFileSync(path, "utf8"));
33
+ } catch (error) {
34
+ throw new Error(`${path} is not valid JSON: ${error.message}`);
35
+ }
36
+ const list = Array.isArray(data) ? data : data?.applied;
37
+ if (!Array.isArray(list)) throw new Error(`${path} has no "applied" list`);
38
+ const applied = list.map((entry) => {
39
+ const normalized = typeof entry === "string" ? { id: entry } : entry;
40
+ if (!normalized || typeof normalized.id !== "string") {
41
+ throw new Error(`${path} has an entry without an id: ${JSON.stringify(entry)}`);
42
+ }
43
+ return normalized;
44
+ });
45
+ return { schema: data?.schema ?? LEDGER_SCHEMA, applied };
46
+ }
47
+
48
+ export const appliedIds = (ledger) => new Set(ledger.applied.map((e) => e.id));
49
+
50
+ /** Appends one entry and writes the file (stable key order, trailing newline). */
51
+ export function recordApplied(root, { id, appliedAt, dsVersion, filesChanged }) {
52
+ const ledger = readLedger(root);
53
+ const applied = [...ledger.applied, { id, appliedAt, dsVersion, filesChanged }];
54
+ const body = { $comment: COMMENT, schema: LEDGER_SCHEMA, applied };
55
+ writeFileSync(ledgerPath(root), `${JSON.stringify(body, null, 2)}\n`);
56
+ return { schema: LEDGER_SCHEMA, applied };
57
+ }
58
+
59
+ /**
60
+ * Why `meta` may not be applied to a project with this ledger and installed
61
+ * DS version, or `null`. Checked before any file is read.
62
+ */
63
+ export function guardProblems(meta, ledger, dsVersion, compareVersions) {
64
+ const problems = [];
65
+ const ids = appliedIds(ledger);
66
+ if (meta.oneShot && ids.has(meta.id)) {
67
+ const when = ledger.applied.find((e) => e.id === meta.id)?.appliedAt;
68
+ problems.push(
69
+ `${meta.id} is a one-shot codemod and the ledger shows it was already applied${when ? ` (${when})` : ""}.`,
70
+ );
71
+ }
72
+ for (const required of meta.requires?.codemods ?? []) {
73
+ if (!ids.has(required)) {
74
+ problems.push(`${meta.id} needs ${required} to be applied first (not in ${LEDGER_FILE}).`);
75
+ }
76
+ }
77
+ const minimum = meta.requires?.dsVersion;
78
+ if (minimum && compareVersions(dsVersion, minimum) < 0) {
79
+ problems.push(
80
+ `${meta.id} needs @assure-one/design-system ${minimum} or later; ${dsVersion} is installed.`,
81
+ );
82
+ }
83
+ return problems;
84
+ }