@assure-one/design-system 1.31.0 → 1.33.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.
Files changed (42) hide show
  1. package/README.md +61 -6
  2. package/codemods/0.2.0-radix-migration.mjs +315 -0
  3. package/codemods/README.md +365 -0
  4. package/codemods/lib/css-selectors.mjs +33 -0
  5. package/codemods/lib/css-values.mjs +223 -0
  6. package/codemods/lib/ds-stylesheet.mjs +168 -0
  7. package/codemods/lib/environment.mjs +72 -0
  8. package/codemods/lib/files.mjs +100 -0
  9. package/codemods/lib/forms.mjs +253 -0
  10. package/codemods/lib/jsx.mjs +0 -0
  11. package/codemods/lib/ledger.mjs +84 -0
  12. package/codemods/lib/registry.mjs +32 -0
  13. package/codemods/lib/report.mjs +119 -0
  14. package/codemods/lib/runner.mjs +164 -0
  15. package/codemods/run.mjs +161 -0
  16. package/codemods/transforms/cm-14-hidden-mirrors.mjs +275 -0
  17. package/codemods/transforms/cm-15-dom-selectors.mjs +573 -0
  18. package/codemods/transforms/cm-16-globals-css.mjs +487 -0
  19. package/codemods/transforms/cm-20-select-sentinels.mjs +442 -0
  20. package/dist/css/base.css +60 -0
  21. package/dist/css/components.css +7 -0
  22. package/dist/css/legacy-aliases.css +665 -0
  23. package/dist/css/shadcn.css +155 -0
  24. package/dist/css/tailwind.css +296 -0
  25. package/dist/css/tokens.css +630 -0
  26. package/dist/index.d.ts +807 -27
  27. package/dist/index.js +2038 -683
  28. package/dist/index.js.map +1 -1
  29. package/dist/styles.css +1 -1
  30. package/dist/system-BDU18fVg.d.ts +559 -0
  31. package/dist/testing/index.cjs +458 -0
  32. package/dist/testing/index.d.cts +253 -0
  33. package/dist/testing/index.d.ts +253 -0
  34. package/dist/testing/index.js +452 -0
  35. package/dist/testing/setup.cjs +123 -0
  36. package/dist/testing/setup.js +121 -0
  37. package/dist/testing/style-stub.cjs +7 -0
  38. package/dist/testing/style-stub.js +5 -0
  39. package/dist/tokens/index.d.ts +50 -439
  40. package/dist/tokens/index.js +557 -50
  41. package/dist/tokens/index.js.map +1 -1
  42. package/package.json +74 -5
@@ -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
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Form-shaped source facts, shared by the two report-only finders of the
3
+ * forms wave (plan §16): CM-14 (hidden-input mirrors) and CM-20 (Select
4
+ * sentinels).
5
+ *
6
+ * `lib/jsx.mjs` answers "which class tokens sit on which design-system
7
+ * component" and therefore only records elements that carry a class
8
+ * attribute. A hidden `<input type="hidden" name="x" value={x} />` carries
9
+ * none, and a sentinel lives in a prop value, not in a class token — so the
10
+ * forms codemods need a different view of the same tree:
11
+ *
12
+ * - **every** JSX element, design-system and raw alike, in document order;
13
+ * - each element's attributes, with the string literals and the identifiers
14
+ * of the expression behind them (the identifiers are what tie a mirror to
15
+ * the control it mirrors);
16
+ * - the nearest `<form>` ancestor, because the applications do not put a
17
+ * mirror next to its control — they collect the mirrors at the top of the
18
+ * form ([CU §18]);
19
+ * - top-level `const NAME = "literal"` declarations, because the sentinels
20
+ * are named constants (`const NONE_VALUE = "__none__"`).
21
+ *
22
+ * As everywhere in `codemods/lib`, the TypeScript module is passed in: a
23
+ * codemod never imports a parser itself (`lib/environment.mjs`).
24
+ */
25
+ import { DS_PACKAGE, createSourceFile } from "./jsx.mjs";
26
+
27
+ export { DS_PACKAGE };
28
+
29
+ const isDsSpecifier = (s) => s === DS_PACKAGE || s.startsWith(`${DS_PACKAGE}/`);
30
+
31
+ /** Attributes whose value is the control's current value, in binding order. */
32
+ export const VALUE_PROPS = ["value", "defaultValue", "checked", "defaultChecked", "selected"];
33
+
34
+ /** Attributes through which a control writes its value back. */
35
+ export const CHANGE_PROPS = [
36
+ "onValueChange",
37
+ "onCheckedChange",
38
+ "onChange",
39
+ "onSelect",
40
+ "onRemove",
41
+ "onSelectionChange",
42
+ ];
43
+
44
+ /**
45
+ * Parses one file into form facts.
46
+ *
47
+ * Returns:
48
+ * - `sf`, `lineOf` — the source file and a node → line helper
49
+ * - `imports` — design-system local name → exported name
50
+ * - `namespaces` — local names of `import * as X from the package`
51
+ * - `elements` — every JSX element, in document order, as described below
52
+ * - `constants` — top-level `const` name → `{ text, line }` for string consts
53
+ * - `componentsUsed` — the design-system base names used as JSX
54
+ * - `parseErrors`
55
+ *
56
+ * An element is
57
+ * `{ index, parent, tag, base, component, isDs, line, props, spread, form }`
58
+ * where `props` is a Map of attribute name → `{ literals, identifiers, text,
59
+ * line, expression }` and `form` is the index of the nearest `<form>`
60
+ * ancestor (or `null`).
61
+ */
62
+ export function analyseForms(ts, text, rel) {
63
+ const sf = createSourceFile(ts, text, rel);
64
+ const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
65
+
66
+ const imports = new Map();
67
+ const namespaces = new Set();
68
+ for (const stmt of sf.statements) {
69
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
70
+ if (!isDsSpecifier(stmt.moduleSpecifier.text)) continue;
71
+ const clause = stmt.importClause;
72
+ if (!clause || clause.isTypeOnly) continue;
73
+ const bindings = clause.namedBindings;
74
+ if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
75
+ else if (bindings && ts.isNamedImports(bindings)) {
76
+ for (const el of bindings.elements) {
77
+ if (!el.isTypeOnly) imports.set(el.name.text, (el.propertyName ?? el.name).text);
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Top-level `const NAME = "text"`, the shape the sentinels are declared in. */
83
+ const constants = new Map();
84
+ for (const stmt of sf.statements) {
85
+ const list = ts.isVariableStatement(stmt) ? stmt.declarationList : null;
86
+ if (!list || !(list.flags & ts.NodeFlags.Const)) continue;
87
+ for (const decl of list.declarations) {
88
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
89
+ const init = decl.initializer;
90
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
91
+ constants.set(decl.name.text, { text: init.text, line: lineOf(init) });
92
+ }
93
+ }
94
+ }
95
+
96
+ /** String literals inside an expression, at any depth below JSX. */
97
+ const literalsIn = (node, out = []) => {
98
+ if (!node) return out;
99
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
100
+ out.push(node.text);
101
+ return out;
102
+ }
103
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
104
+ return out;
105
+ }
106
+ // A block body: `forEachChild` stops at the first truthy return, and the
107
+ // accumulator is always truthy.
108
+ ts.forEachChild(node, (child) => {
109
+ literalsIn(child, out);
110
+ });
111
+ return out;
112
+ };
113
+
114
+ /**
115
+ * Identifier names an expression reads. Member names (`a.b`), object keys
116
+ * and JSX subtrees are excluded: what matters is which bindings the value
117
+ * depends on, so that two attributes reading `entityType` can be recognised
118
+ * as two views of the same state.
119
+ */
120
+ const identifiersIn = (node, out = new Set()) => {
121
+ if (!node) return out;
122
+ if (ts.isIdentifier(node)) {
123
+ const parent = node.parent;
124
+ const isMemberName = parent && ts.isPropertyAccessExpression(parent) && parent.name === node;
125
+ const isKey =
126
+ parent &&
127
+ (ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent)) &&
128
+ parent.name === node;
129
+ if (!isMemberName && !isKey) out.add(node.text);
130
+ return out;
131
+ }
132
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
133
+ return out;
134
+ }
135
+ ts.forEachChild(node, (child) => {
136
+ identifiersIn(child, out);
137
+ });
138
+ return out;
139
+ };
140
+
141
+ const resolveTag = (tag) => {
142
+ const members = [];
143
+ let current = tag;
144
+ while (ts.isPropertyAccessExpression(current)) {
145
+ members.unshift(current.name.text);
146
+ current = current.expression;
147
+ }
148
+ if (!ts.isIdentifier(current)) return null;
149
+ const root = current.text;
150
+ if (namespaces.has(root) && members.length) {
151
+ return { base: members[0], component: members.join(".") };
152
+ }
153
+ const base = imports.get(root);
154
+ if (!base) return null;
155
+ return { base, component: [base, ...members].join(".") };
156
+ };
157
+
158
+ const elements = [];
159
+ const openingOf = (node) => (ts.isJsxElement(node) ? node.openingElement : node);
160
+
161
+ const record = (node, parent, form) => {
162
+ const opening = openingOf(node);
163
+ const info = resolveTag(opening.tagName);
164
+ const tag = opening.tagName.getText(sf);
165
+ const props = new Map();
166
+ let spread = false;
167
+ for (const attr of opening.attributes.properties) {
168
+ if (ts.isJsxSpreadAttribute(attr)) {
169
+ spread = true;
170
+ continue;
171
+ }
172
+ if (!ts.isJsxAttribute(attr)) continue;
173
+ const name = attr.name.getText(sf);
174
+ const init = attr.initializer;
175
+ if (init === undefined) {
176
+ // A bare attribute (`required`) is the boolean `true`.
177
+ props.set(name, {
178
+ literals: [],
179
+ identifiers: [],
180
+ text: "true",
181
+ line: lineOf(attr),
182
+ expression: false,
183
+ });
184
+ continue;
185
+ }
186
+ const inner = ts.isJsxExpression(init) ? (init.expression ?? null) : init;
187
+ const isLiteral =
188
+ inner !== null && (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner));
189
+ props.set(name, {
190
+ literals: inner ? literalsIn(inner) : [],
191
+ identifiers: inner ? [...identifiersIn(inner)] : [],
192
+ text: inner ? inner.getText(sf) : null,
193
+ line: lineOf(attr),
194
+ expression: Boolean(inner) && !isLiteral,
195
+ });
196
+ }
197
+ const entry = {
198
+ index: elements.length,
199
+ parent,
200
+ tag,
201
+ base: info?.base ?? null,
202
+ component: info?.component ?? null,
203
+ isDs: Boolean(info),
204
+ line: lineOf(opening),
205
+ props,
206
+ spread,
207
+ form,
208
+ node,
209
+ };
210
+ elements.push(entry);
211
+ return entry;
212
+ };
213
+
214
+ const walk = (node, parent, form) => {
215
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
216
+ const entry = record(node, parent, form);
217
+ const nextForm = /^(?:form|Form)$/.test(entry.tag) ? entry.index : form;
218
+ ts.forEachChild(node, (child) => walk(child, entry.index, nextForm));
219
+ return;
220
+ }
221
+ ts.forEachChild(node, (child) => walk(child, parent, form));
222
+ };
223
+ walk(sf, null, null);
224
+
225
+ const parseErrors = (sf.parseDiagnostics ?? []).map((d) => ({
226
+ line: d.start === undefined ? null : sf.getLineAndCharacterOfPosition(d.start).line + 1,
227
+ message: ts.flattenDiagnosticMessageText(d.messageText, " "),
228
+ }));
229
+
230
+ return {
231
+ sf,
232
+ lineOf,
233
+ literalsIn,
234
+ identifiersIn: (node) => [...identifiersIn(node)],
235
+ imports,
236
+ namespaces,
237
+ elements,
238
+ constants,
239
+ componentsUsed: new Set(elements.filter((e) => e.isDs).map((e) => e.base)),
240
+ parseErrors,
241
+ };
242
+ }
243
+
244
+ /** The literal value of an attribute when it is a single string, else `null`. */
245
+ export const literalValue = (prop) =>
246
+ prop && !prop.expression && prop.literals.length === 1 ? prop.literals[0] : null;
247
+
248
+ /** Whether `inner` is `outer` or sits below it in the element tree. */
249
+ export function isWithin(elements, inner, outer) {
250
+ if (outer === null || inner === null) return false;
251
+ for (let i = inner; i !== null; i = elements[i].parent) if (i === outer) return true;
252
+ return false;
253
+ }
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
+ }