@tonycodes-ui/mortar 0.0.0-stage → 0.12.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 (107) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +268 -0
  3. package/CONTRIBUTING.md +60 -0
  4. package/DESIGN.md +51 -0
  5. package/LICENSE +21 -0
  6. package/README.md +155 -2
  7. package/TESTING.md +83 -0
  8. package/baseline-policy.json +16 -0
  9. package/bin/mortar.mjs +258 -0
  10. package/catalog.json +9900 -0
  11. package/cli/audit.mjs +943 -0
  12. package/cli/data.mjs +176 -0
  13. package/cli/mcp.mjs +216 -0
  14. package/cli/migrations.mjs +128 -0
  15. package/cli/packet.mjs +267 -0
  16. package/cli/search.mjs +246 -0
  17. package/cli/tools.mjs +229 -0
  18. package/design-principles.json +30 -0
  19. package/dist/agent-data.json +1 -0
  20. package/dist/application.d.ts +340 -0
  21. package/dist/application.js +240 -0
  22. package/dist/auth.d.ts +121 -0
  23. package/dist/auth.js +97 -0
  24. package/dist/brand.d.ts +69 -0
  25. package/dist/brand.js +266 -0
  26. package/dist/calendar.d.ts +75 -0
  27. package/dist/calendar.js +310 -0
  28. package/dist/choice.d.ts +36 -0
  29. package/dist/choice.js +36 -0
  30. package/dist/composer.d.ts +28 -0
  31. package/dist/composer.js +47 -0
  32. package/dist/display.d.ts +136 -0
  33. package/dist/display.js +75 -0
  34. package/dist/docs.d.ts +145 -0
  35. package/dist/docs.js +141 -0
  36. package/dist/ecommerce.d.ts +261 -0
  37. package/dist/ecommerce.js +177 -0
  38. package/dist/feedback.d.ts +80 -0
  39. package/dist/feedback.js +98 -0
  40. package/dist/foundation.d.ts +42 -0
  41. package/dist/foundation.js +121 -0
  42. package/dist/heading.d.ts +5 -0
  43. package/dist/heading.js +9 -0
  44. package/dist/icons.d.ts +50 -0
  45. package/dist/icons.js +70 -0
  46. package/dist/illustration.d.ts +47 -0
  47. package/dist/illustration.js +47 -0
  48. package/dist/index.d.ts +36 -0
  49. package/dist/index.js +18 -0
  50. package/dist/inspection.d.ts +24 -0
  51. package/dist/inspection.js +14 -0
  52. package/dist/internal.d.ts +514 -0
  53. package/dist/internal.js +332 -0
  54. package/dist/layout.d.ts +107 -0
  55. package/dist/layout.js +66 -0
  56. package/dist/marketing-sections.d.ts +161 -0
  57. package/dist/marketing-sections.js +58 -0
  58. package/dist/marketing.d.ts +277 -0
  59. package/dist/marketing.js +163 -0
  60. package/dist/navigation.d.ts +35 -0
  61. package/dist/navigation.js +44 -0
  62. package/dist/primitives.d.ts +258 -0
  63. package/dist/primitives.js +244 -0
  64. package/dist/showcase.d.ts +29 -0
  65. package/dist/showcase.js +10 -0
  66. package/dist/workspace.d.ts +80 -0
  67. package/dist/workspace.js +48 -0
  68. package/docs/agents.md +127 -0
  69. package/docs/auth-flows.md +71 -0
  70. package/docs/migrating-to-0.6.md +94 -0
  71. package/docs/theming.md +170 -0
  72. package/package.json +94 -4
  73. package/skills/mortar/SKILL.md +36 -0
  74. package/src/application.css +1487 -0
  75. package/src/application.tsx +1413 -0
  76. package/src/auth.tsx +452 -0
  77. package/src/brand.ts +335 -0
  78. package/src/calendar.css +468 -0
  79. package/src/calendar.tsx +868 -0
  80. package/src/choice.tsx +240 -0
  81. package/src/composer.tsx +158 -0
  82. package/src/data.css +690 -0
  83. package/src/display.tsx +452 -0
  84. package/src/docs.css +704 -0
  85. package/src/docs.tsx +737 -0
  86. package/src/ecommerce.css +810 -0
  87. package/src/ecommerce.tsx +1033 -0
  88. package/src/feedback.tsx +388 -0
  89. package/src/foundation.css +1867 -0
  90. package/src/foundation.tsx +227 -0
  91. package/src/heading.ts +13 -0
  92. package/src/icons.tsx +237 -0
  93. package/src/illustration.tsx +169 -0
  94. package/src/index.ts +233 -0
  95. package/src/inspection.tsx +91 -0
  96. package/src/internal.tsx +700 -0
  97. package/src/layout.tsx +342 -0
  98. package/src/marketing-sections.tsx +621 -0
  99. package/src/marketing.css +2579 -0
  100. package/src/marketing.tsx +1235 -0
  101. package/src/navigation.tsx +118 -0
  102. package/src/overlays.css +350 -0
  103. package/src/primitives.tsx +1135 -0
  104. package/src/showcase.tsx +120 -0
  105. package/src/tokens.css +449 -0
  106. package/src/workspace.tsx +333 -0
  107. package/styles.css +56 -0
package/cli/audit.mjs ADDED
@@ -0,0 +1,943 @@
1
+ // A local, read-only static audit of an application that uses Mortar. It never
2
+ // modifies files and never touches the network.
3
+ //
4
+ // The scanner is deliberately lightweight (no TypeScript or Babel at runtime):
5
+ // comments are blanked, imports from the package are read with patterns, and
6
+ // JSX opening tags of imported Mortar components are tokenised to read their
7
+ // attribute names and literal values. Known limits: props passed through a
8
+ // spread ({...props}) are not checked; components re-exported or wrapped under
9
+ // another name are not followed; JSX in .vue/.svelte/.astro/.html files is not
10
+ // read; dynamic class names built at runtime are not resolved.
11
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
12
+ import { resolve, join, relative, dirname, extname, sep } from "node:path";
13
+ import { createHash } from "node:crypto";
14
+ import { loadData, PACKAGE_ROOT } from "./data.mjs";
15
+ import {
16
+ REMOVED_EXPORTS,
17
+ RENAMED_PROPS,
18
+ GENERAL_PROPS,
19
+ RENAMED_VALUES,
20
+ } from "./migrations.mjs";
21
+
22
+ export const RULES = {
23
+ "not-installed": "@tonycodes-ui/mortar is imported but not installed.",
24
+ "not-declared": "@tonycodes-ui/mortar is imported but not in package.json.",
25
+ "version-mismatch":
26
+ "The installed version does not satisfy the package.json range.",
27
+ "unknown-export":
28
+ "A named import from @tonycodes-ui/mortar that the installed version does not export.",
29
+ "deep-import":
30
+ "An import or alias that reaches into the package's src or dist instead of its public entry points.",
31
+ "unknown-prop": "A prop that the component's contract does not declare.",
32
+ "invalid-value": "A literal value outside the prop's declared union.",
33
+ "css-override":
34
+ "Consumer CSS that selects Mortar's .m-* classes or [data-m-component].",
35
+ "class-override":
36
+ "A Tailwind arbitrary variant in a className that targets Mortar's .m-* classes.",
37
+ "kit-class":
38
+ "A Mortar-internal .m-* class used on the application's own markup.",
39
+ "raw-colour":
40
+ "A raw colour or Tailwind palette class in a file that renders Mortar components.",
41
+ "missing-styles": "No import of @tonycodes-ui/mortar/styles.css.",
42
+ "styles-order":
43
+ "@tonycodes-ui/mortar/styles.css is imported before Tailwind in the same file.",
44
+ "missing-theme-provider":
45
+ "Mortar components are rendered but ThemeProvider is never rendered.",
46
+ "local-copy": "A local copy of Mortar source.",
47
+ "limited-data": "The audit could not use the installed version's own data.",
48
+ };
49
+
50
+ const SKIP_DIRS = new Set([
51
+ "node_modules",
52
+ ".git",
53
+ "dist",
54
+ "build",
55
+ "out",
56
+ ".next",
57
+ ".nuxt",
58
+ ".output",
59
+ ".svelte-kit",
60
+ ".turbo",
61
+ ".vercel",
62
+ ".netlify",
63
+ ".cache",
64
+ ".parcel-cache",
65
+ "coverage",
66
+ "playwright-report",
67
+ "test-results",
68
+ "storybook-static",
69
+ ]);
70
+ const CODE = new Set([
71
+ ".js",
72
+ ".jsx",
73
+ ".ts",
74
+ ".tsx",
75
+ ".mjs",
76
+ ".cjs",
77
+ ".mts",
78
+ ".cts",
79
+ ]);
80
+ const JSX = new Set([".js", ".jsx", ".tsx", ".mjs"]);
81
+ const STYLE = new Set([".css", ".scss", ".sass", ".less", ".pcss", ".postcss"]);
82
+ const PKG = "@tonycodes-ui/mortar";
83
+ const MAX_FILES = 20000;
84
+ const MAX_BYTES = 1_000_000;
85
+
86
+ function walk(root) {
87
+ const files = [];
88
+ const visit = (dir) => {
89
+ let entries;
90
+ try {
91
+ entries = readdirSync(dir, { withFileTypes: true });
92
+ } catch {
93
+ return;
94
+ }
95
+ for (const e of entries) {
96
+ if (files.length >= MAX_FILES) return;
97
+ if (e.isSymbolicLink()) continue;
98
+ const path = join(dir, e.name);
99
+ if (e.isDirectory()) {
100
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) visit(path);
101
+ } else if (e.isFile()) {
102
+ const ext = extname(e.name);
103
+ if (CODE.has(ext) || STYLE.has(ext)) files.push(path);
104
+ }
105
+ }
106
+ };
107
+ visit(root);
108
+ return files;
109
+ }
110
+
111
+ /** Index of the bracket that closes the one at `at`, skipping strings. */
112
+ function closing(src, at) {
113
+ let depth = 0;
114
+ for (let i = at; i < src.length; i++) {
115
+ const c = src[i];
116
+ if (c === '"' || c === "'" || c === "`") {
117
+ // Quoted strings end at a newline, as in blankComments.
118
+ for (i++; i < src.length && src[i] !== c; i++) {
119
+ if (src[i] === "\\") i++;
120
+ else if (c !== "`" && src[i] === "\n") break;
121
+ }
122
+ } else if (c === "(" || c === "{" || c === "[") depth++;
123
+ else if (c === ")" || c === "}" || c === "]") {
124
+ depth--;
125
+ if (depth === 0) return i;
126
+ }
127
+ }
128
+ return src.length;
129
+ }
130
+
131
+ const BRAND_TYPE = /\bBrand(?:Definition|AppearanceTokens|Fonts)\b/;
132
+ /**
133
+ * The [start, end] ranges of `src` that define a brand, where colour literals
134
+ * belong: the value of a `brandTokens=` prop, the arguments of brandToCss,
135
+ * brandToStyle and registerBrand, objects typed as or satisfying
136
+ * BrandDefinition (or BrandAppearanceTokens), the declarations in this file
137
+ * of names those reference, and `--m-brand-*` custom property values.
138
+ */
139
+ export function brandRanges(src) {
140
+ const ranges = [];
141
+ // Object, array and call declarations in this file, by name.
142
+ const declarations = new Map();
143
+ for (const m of src.matchAll(
144
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(:[^=;]*)?=\s*(?=[{[(])/g,
145
+ )) {
146
+ const start = m.index + m[0].length;
147
+ const end = closing(src, start);
148
+ declarations.set(m[1], [start, end]);
149
+ const after = src.slice(end + 1, end + 80);
150
+ if (
151
+ BRAND_TYPE.test(m[2] ?? "") ||
152
+ new RegExp(`^\\s*(?:satisfies|as)\\s+[^;\\n]*${BRAND_TYPE.source}`).test(
153
+ after,
154
+ )
155
+ )
156
+ ranges.push([start, end]);
157
+ }
158
+ const withReferences = (start, end) => {
159
+ ranges.push([start, end]);
160
+ for (const name of src.slice(start, end + 1).match(/[A-Za-z_$][\w$]*/g) ??
161
+ [])
162
+ if (declarations.has(name)) ranges.push(declarations.get(name));
163
+ };
164
+ for (const m of src.matchAll(/\bbrandTokens\s*=\s*\{/g)) {
165
+ const start = m.index + m[0].length - 1;
166
+ withReferences(start, closing(src, start));
167
+ }
168
+ for (const m of src.matchAll(
169
+ /\b(?:brandToCss|brandToStyle|registerBrand)\s*\(/g,
170
+ )) {
171
+ const start = m.index + m[0].length - 1;
172
+ withReferences(start, closing(src, start));
173
+ }
174
+ // --m-brand-* values: a quoted string after a quoted key, or a CSS value up
175
+ // to the end of the declaration.
176
+ for (const m of src.matchAll(/--m-brand-[\w-]+(["'`]?)\s*:\s*/g)) {
177
+ const start = m.index + m[0].length;
178
+ const quote = src[start];
179
+ let end;
180
+ if (m[1] && (quote === '"' || quote === "'" || quote === "`"))
181
+ end = src.indexOf(quote, start + 1);
182
+ else end = src.slice(start).search(/[;}\n]/) + start;
183
+ ranges.push([start, end < start ? src.length : end]);
184
+ }
185
+ return ranges;
186
+ }
187
+
188
+ /**
189
+ * Comments replaced by spaces (line numbers and offsets kept). Strings and
190
+ * template literals are respected; single and double quoted strings end at a
191
+ * newline, so an apostrophe in JSX text cannot swallow the rest of the file.
192
+ */
193
+ export function blankComments(text, { css = false } = {}) {
194
+ const out = text.split("");
195
+ let i = 0;
196
+ const n = text.length;
197
+ while (i < n) {
198
+ const c = text[i];
199
+ const next = text[i + 1];
200
+ if (c === "/" && next === "*") {
201
+ const end = text.indexOf("*/", i + 2);
202
+ const stop = end < 0 ? n : end + 2;
203
+ for (let j = i; j < stop; j++) if (out[j] !== "\n") out[j] = " ";
204
+ i = stop;
205
+ } else if (!css && c === "/" && next === "/") {
206
+ while (i < n && text[i] !== "\n") out[i++] = " ";
207
+ } else if (c === '"' || c === "'" || (!css && c === "`")) {
208
+ i++;
209
+ while (i < n && text[i] !== c) {
210
+ if (text[i] === "\\") i++;
211
+ else if (c !== "`" && text[i] === "\n") break;
212
+ i++;
213
+ }
214
+ i++;
215
+ } else i++;
216
+ }
217
+ return out.join("");
218
+ }
219
+
220
+ /** Index of the character after the `{…}` group starting at `i`. */
221
+ function skipBraces(src, i) {
222
+ let depth = 0;
223
+ const n = src.length;
224
+ while (i < n) {
225
+ const c = src[i];
226
+ if (c === "{") depth++;
227
+ else if (c === "}") {
228
+ depth--;
229
+ if (depth === 0) return i + 1;
230
+ } else if (c === '"' || c === "'") {
231
+ i++;
232
+ while (i < n && src[i] !== c && src[i] !== "\n") {
233
+ if (src[i] === "\\") i++;
234
+ i++;
235
+ }
236
+ } else if (c === "`") {
237
+ i++;
238
+ while (i < n && src[i] !== "`") {
239
+ if (src[i] === "\\") i++;
240
+ else if (src[i] === "$" && src[i + 1] === "{") {
241
+ i = skipBraces(src, i + 1) - 1;
242
+ }
243
+ i++;
244
+ }
245
+ }
246
+ i++;
247
+ }
248
+ return n;
249
+ }
250
+
251
+ const NAME = /[A-Za-z_$][\w$]*(?:[-:.][\w$]+)*/y;
252
+
253
+ /**
254
+ * The attributes of a JSX opening tag whose name ends at `i`: name, offset and,
255
+ * for string literals (`a="x"` or `a={"x"}`), the value.
256
+ */
257
+ export function jsxAttributes(src, i) {
258
+ const attrs = [];
259
+ const n = src.length;
260
+ while (i < n) {
261
+ while (i < n && /\s/.test(src[i])) i++;
262
+ if (src[i] === ">" || (src[i] === "/" && src[i + 1] === ">")) break;
263
+ if (src[i] === "{") {
264
+ i = skipBraces(src, i);
265
+ continue;
266
+ }
267
+ NAME.lastIndex = i;
268
+ const m = NAME.exec(src);
269
+ if (!m) break;
270
+ const attr = { name: m[0], index: i };
271
+ i += m[0].length;
272
+ while (i < n && /\s/.test(src[i])) i++;
273
+ if (src[i] === "=") {
274
+ i++;
275
+ while (i < n && /\s/.test(src[i])) i++;
276
+ const q = src[i];
277
+ if (q === '"' || q === "'") {
278
+ const end = src.indexOf(q, i + 1);
279
+ if (end < 0) break;
280
+ attr.value = src.slice(i + 1, end);
281
+ i = end + 1;
282
+ } else if (q === "{") {
283
+ const end = skipBraces(src, i);
284
+ const inner = src.slice(i + 1, end - 1).trim();
285
+ const literal = inner.match(/^(["'`])([^"'`$\\]*)\1$/);
286
+ if (literal) attr.value = literal[2];
287
+ i = end;
288
+ } else break;
289
+ }
290
+ attrs.push(attr);
291
+ }
292
+ return attrs;
293
+ }
294
+
295
+ function lineAt(text) {
296
+ const starts = [0];
297
+ for (let i = 0; i < text.length; i++)
298
+ if (text[i] === "\n") starts.push(i + 1);
299
+ return (index) => {
300
+ let lo = 0;
301
+ let hi = starts.length - 1;
302
+ while (lo < hi) {
303
+ const mid = (lo + hi + 1) >> 1;
304
+ if (starts[mid] <= index) lo = mid;
305
+ else hi = mid - 1;
306
+ }
307
+ return lo + 1;
308
+ };
309
+ }
310
+
311
+ function distance(a, b) {
312
+ a = a.toLowerCase();
313
+ b = b.toLowerCase();
314
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
315
+ for (let i = 1; i <= a.length; i++) {
316
+ let prev = row[0];
317
+ row[0] = i;
318
+ for (let j = 1; j <= b.length; j++) {
319
+ const tmp = row[j];
320
+ row[j] = Math.min(
321
+ row[j] + 1,
322
+ row[j - 1] + 1,
323
+ prev + (a[i - 1] === b[j - 1] ? 0 : 1),
324
+ );
325
+ prev = tmp;
326
+ }
327
+ }
328
+ return row[b.length];
329
+ }
330
+
331
+ function closest(name, candidates, max = 3) {
332
+ let best;
333
+ let bestD = Infinity;
334
+ for (const c of candidates) {
335
+ const d = distance(name, c);
336
+ if (d < bestD) {
337
+ best = c;
338
+ bestD = d;
339
+ }
340
+ }
341
+ return bestD <= Math.min(max, Math.max(1, Math.floor(name.length / 2)))
342
+ ? best
343
+ : undefined;
344
+ }
345
+
346
+ // ---- semver (the subset package.json ranges use) ----
347
+
348
+ function parseVersion(v) {
349
+ const m = String(v)
350
+ .trim()
351
+ .replace(/^[=v]+/, "")
352
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?/);
353
+ return m ? { v: [+m[1], +m[2], +m[3]], pre: m[4] ?? "" } : undefined;
354
+ }
355
+
356
+ function compare(a, b) {
357
+ for (let i = 0; i < 3; i++) if (a.v[i] !== b.v[i]) return a.v[i] - b.v[i];
358
+ if (a.pre === b.pre) return 0;
359
+ if (!a.pre) return 1;
360
+ if (!b.pre) return -1;
361
+ return a.pre < b.pre ? -1 : 1;
362
+ }
363
+
364
+ /** Comparators for one partial version: "1", "1.2", "1.x", "*". */
365
+ function partial(text) {
366
+ const parts = text.replace(/^[=v]+/, "").split(/[.-]/);
367
+ const nums = [];
368
+ for (const p of parts.slice(0, 3)) {
369
+ if (/^[x*X]?$/.test(p)) break;
370
+ if (!/^\d+$/.test(p)) return undefined;
371
+ nums.push(+p);
372
+ }
373
+ return nums;
374
+ }
375
+
376
+ function comparators(range) {
377
+ const out = [];
378
+ const tokens = range
379
+ .trim()
380
+ .replace(/([<>=~^]+)\s+/g, "$1")
381
+ .split(/\s+/)
382
+ .filter(Boolean);
383
+ for (let k = 0; k < tokens.length; k++) {
384
+ const t = tokens[k];
385
+ if (tokens[k + 1] === "-" && tokens[k + 2]) {
386
+ const lo = partial(t);
387
+ const hi = partial(tokens[k + 2]);
388
+ if (!lo || !hi) return undefined;
389
+ out.push([">=", [...lo, 0, 0, 0].slice(0, 3)]);
390
+ if (hi.length === 3) out.push(["<=", hi]);
391
+ else if (hi.length) {
392
+ const up = [...hi];
393
+ up[up.length - 1]++;
394
+ out.push(["<", [...up, 0, 0].slice(0, 3)]);
395
+ }
396
+ k += 2;
397
+ continue;
398
+ }
399
+ const m = t.match(/^(\^|~|>=|<=|>|<|=)?(.*)$/);
400
+ const op = m[1] ?? "";
401
+ const exact = parseVersion(m[2]);
402
+ const nums = exact ? exact.v : partial(m[2]);
403
+ if (!nums) return undefined;
404
+ const pad = (a) => [...a, 0, 0, 0].slice(0, 3);
405
+ if (op === "^") {
406
+ const lo = pad(nums);
407
+ const i = lo.findIndex((x, idx) => x !== 0 || idx === nums.length - 1);
408
+ const at = i < 0 ? Math.max(0, nums.length - 1) : Math.min(i, 2);
409
+ const hi = lo.slice(0, at + 1);
410
+ hi[at]++;
411
+ out.push([">=", lo, exact?.pre], ["<", pad(hi)]);
412
+ } else if (op === "~") {
413
+ const lo = pad(nums);
414
+ const at = nums.length >= 2 ? 1 : 0;
415
+ const hi = lo.slice(0, at + 1);
416
+ hi[at]++;
417
+ out.push([">=", lo, exact?.pre], ["<", pad(hi)]);
418
+ } else if (op === "" || op === "=") {
419
+ if (nums.length === 3) out.push(["=", nums, exact?.pre]);
420
+ else if (nums.length) {
421
+ const hi = [...nums];
422
+ hi[hi.length - 1]++;
423
+ out.push([">=", pad(nums)], ["<", pad(hi)]);
424
+ }
425
+ } else out.push([op, pad(nums), exact?.pre]);
426
+ }
427
+ return out;
428
+ }
429
+
430
+ /** true/false, or undefined when the range is not a semver range (tags, urls …). */
431
+ export function satisfies(version, range) {
432
+ const v = parseVersion(version);
433
+ if (!v) return undefined;
434
+ if (/^(npm|file|link|workspace|git|github|https?):|\//.test(range.trim()))
435
+ return undefined;
436
+ if (range.trim() === "" || range.trim() === "*") return true;
437
+ let known = false;
438
+ for (const part of range.split("||")) {
439
+ const set = comparators(part);
440
+ if (!set) continue;
441
+ known = true;
442
+ // A prerelease only satisfies a range that names the same major.minor.patch.
443
+ if (
444
+ v.pre &&
445
+ !set.some(([, nums, pre]) => pre && nums.join(".") === v.v.join("."))
446
+ )
447
+ continue;
448
+ if (
449
+ set.every(([op, nums, pre]) => {
450
+ const c = compare(v, { v: nums, pre: pre ?? "" });
451
+ return op === ">="
452
+ ? c >= 0
453
+ : op === "<="
454
+ ? c <= 0
455
+ : op === ">"
456
+ ? c > 0
457
+ : op === "<"
458
+ ? c < 0 && !(v.pre && nums.join(".") === v.v.join("."))
459
+ : c === 0;
460
+ })
461
+ )
462
+ return true;
463
+ }
464
+ return known ? false : undefined;
465
+ }
466
+
467
+ // ---- the audit ----
468
+
469
+ /** The nearest installed copy of the package, walking up from the app. */
470
+ function installedPackage(appRoot) {
471
+ for (let dir = appRoot; ; dir = dirname(dir)) {
472
+ const file = join(dir, "node_modules", ...PKG.split("/"), "package.json");
473
+ if (existsSync(file)) return dirname(file);
474
+ if (dirname(dir) === dir) return undefined;
475
+ }
476
+ }
477
+
478
+ /** Value and type export names from a built index.d.ts (older releases). */
479
+ function exportsFromDts(root) {
480
+ const file = join(root, "dist", "index.d.ts");
481
+ if (!existsSync(file)) return undefined;
482
+ const names = new Set();
483
+ for (const [, list] of readFileSync(file, "utf8").matchAll(
484
+ /export\s+(?:type\s+)?\{([^}]*)\}/g,
485
+ ))
486
+ for (const item of list.split(",")) {
487
+ const name = item
488
+ .trim()
489
+ .replace(/^type\s+/, "")
490
+ .split(/\s+as\s+/)
491
+ .pop()
492
+ .trim();
493
+ if (name) names.add(name);
494
+ }
495
+ return names;
496
+ }
497
+
498
+ const IMPORT =
499
+ /\b(import|export)\s+(type\s+)?((?:(?!\bfrom\b)[^;])*?)\s*\bfrom\s*(["'])(@tonycodes-ui\/mortar(?:\/[^"']*)?)\4/g;
500
+ const SIDE_EFFECT = /\bimport\s*(["'])(@tonycodes-ui\/mortar\/[^"']*)\1/g;
501
+ const DEEP =
502
+ /@tonycodes-ui\/mortar\/(src|dist)\b|node_modules\/@tonycodes-ui\/mortar\/(src|dist)\b/g;
503
+ const PALETTE =
504
+ "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
505
+ const UTILITY =
506
+ "bg|text|border|ring|outline|fill|stroke|from|via|to|divide|decoration|shadow|accent|caret|placeholder";
507
+ const PALETTE_CLASS = new RegExp(
508
+ `(?<![\\w-])(?:[\\w-]+:)*(?:${UTILITY})-(?:(?:${PALETTE})-\\d{2,3}(?:\\/\\d+)?|white|black|\\[#[0-9a-fA-F]{3,8}\\])(?![\\w-])`,
509
+ "g",
510
+ );
511
+ const HEX =
512
+ /(?<=["'`\s:(,])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/g;
513
+ const COLOUR_FN = /(?<![\w-])(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch)\(/g;
514
+ const COLOURISH =
515
+ /colou?r|background|fill|stroke|border|shadow|style|bg-|text-/i;
516
+
517
+ /**
518
+ * Audit an application directory. Returns { root, mortar, findings, summary }.
519
+ * Findings: { rule, severity, file, line, message, fix }.
520
+ */
521
+ export async function auditApp(path = ".", { data: bundled } = {}) {
522
+ const root = resolve(path);
523
+ if (!existsSync(root) || !statSync(root).isDirectory())
524
+ throw Error(`Not a directory: ${root}`);
525
+ bundled ??= await loadData();
526
+ const findings = [];
527
+ const add = (rule, severity, file, line, message, fix) =>
528
+ findings.push({
529
+ rule,
530
+ severity,
531
+ file: file ? relative(root, file).split(sep).join("/") || "." : ".",
532
+ line,
533
+ message,
534
+ ...(fix ? { fix } : {}),
535
+ });
536
+
537
+ // Which catalogue to check against: the installed copy when it carries its
538
+ // own data, otherwise this tool's.
539
+ const installedRoot = installedPackage(root);
540
+ let installed;
541
+ let data = bundled;
542
+ if (installedRoot) {
543
+ installed = JSON.parse(
544
+ readFileSync(join(installedRoot, "package.json"), "utf8"),
545
+ ).version;
546
+ if (
547
+ installedRoot !== PACKAGE_ROOT &&
548
+ existsSync(join(installedRoot, "catalog.json"))
549
+ ) {
550
+ const own = await loadData(installedRoot);
551
+ if (!own.agent.missing) data = own;
552
+ }
553
+ }
554
+ let exportNames = new Set([
555
+ ...data.agent.exports.values,
556
+ ...data.agent.exports.types,
557
+ ]);
558
+ if (data === bundled && installedRoot && installed !== bundled.version) {
559
+ const dts = exportsFromDts(installedRoot);
560
+ if (dts) exportNames = dts;
561
+ add(
562
+ "limited-data",
563
+ "info",
564
+ undefined,
565
+ undefined,
566
+ `The installed ${PKG} ${installed} has no agent data; props and values were checked against this tool's catalogue (${bundled.version}).`,
567
+ `Upgrade ${PKG} to ${bundled.version} or later for an exact check of the version you use.`,
568
+ );
569
+ }
570
+ const classes = new Set(data.agent.classes);
571
+
572
+ const files = walk(root);
573
+ const hashes = data.agent.sourceHashes ?? {};
574
+ let usesMortar = false;
575
+ let stylesImported = false;
576
+ let themeProvider = false;
577
+ let firstMortarFile;
578
+ let tailwindEntry;
579
+
580
+ for (const file of files) {
581
+ let text;
582
+ try {
583
+ if (statSync(file).size > MAX_BYTES) continue;
584
+ text = readFileSync(file, "utf8");
585
+ } catch {
586
+ continue;
587
+ }
588
+ const ext = extname(file);
589
+ const line = lineAt(text);
590
+ const hash = createHash("sha256").update(text).digest("hex");
591
+ if (hashes[hash])
592
+ add(
593
+ "local-copy",
594
+ "error",
595
+ file,
596
+ 1,
597
+ `This file is a copy of Mortar's ${hashes[hash]}.`,
598
+ `Delete it and import from "${PKG}". Contribute changes to the kit through a pull request.`,
599
+ );
600
+
601
+ if (STYLE.has(ext)) {
602
+ const css = blankComments(text, { css: true });
603
+ const tw = css.search(/@import\s+["']tailwindcss["']/);
604
+ const mortar = css.search(
605
+ /@import\s+["']@tonycodes-ui\/mortar\/styles\.css["']/,
606
+ );
607
+ if (tw >= 0) tailwindEntry ??= file;
608
+ if (mortar >= 0) {
609
+ stylesImported = true;
610
+ if (tw >= 0 && mortar < tw)
611
+ add(
612
+ "styles-order",
613
+ "warning",
614
+ file,
615
+ line(mortar),
616
+ "Mortar's styles are imported before Tailwind.",
617
+ '@import "tailwindcss"; first, then @import "@tonycodes-ui/mortar/styles.css";',
618
+ );
619
+ }
620
+ // Selectors: the text before each `{` that is not an at-rule.
621
+ for (const m of css.matchAll(/([^{};]+)\{/g)) {
622
+ const selector = m[1].trim();
623
+ if (!selector || selector.startsWith("@")) continue;
624
+ const hits = [
625
+ ...[...selector.matchAll(/\.(m-[a-z][a-z0-9-]*)/g)]
626
+ .map((x) => x[1])
627
+ .filter((c) => classes.has(c))
628
+ .map((c) => "." + c),
629
+ ...(/\[data-m-component\b/.test(selector)
630
+ ? ["[data-m-component]"]
631
+ : []),
632
+ ];
633
+ if (hits.length)
634
+ add(
635
+ "css-override",
636
+ "error",
637
+ file,
638
+ line(m.index + m[0].length - m[1].trimStart().length - 1),
639
+ `Overrides Mortar markup (${[...new Set(hits)].join(", ")}).`,
640
+ "Remove the override. Use component props, className for layout only, or theme tokens (brandTokens, --m-brand-*). If the kit is wrong, fix it centrally with a pull request.",
641
+ );
642
+ }
643
+ continue;
644
+ }
645
+
646
+ const src = blankComments(text);
647
+ for (const m of src.matchAll(DEEP)) {
648
+ usesMortar = true;
649
+ add(
650
+ "deep-import",
651
+ "error",
652
+ file,
653
+ line(m.index),
654
+ `Reaches into ${m[0]}; only "${PKG}", "${PKG}/styles.css" and "${PKG}/brand" are public.`,
655
+ `Import from "${PKG}" instead, and remove any alias to the package's source.`,
656
+ );
657
+ }
658
+ if (
659
+ /\bcomponentRoot\s*\(/.test(src) ||
660
+ /<[\w.]+[^>]*\sdata-m-component\s*=/.test(src)
661
+ ) {
662
+ const at = src.search(/\bcomponentRoot\s*\(|\sdata-m-component\s*=/);
663
+ add(
664
+ "local-copy",
665
+ "error",
666
+ file,
667
+ line(at),
668
+ "This file renders Mortar's component marker, so it looks like a copy of Mortar source.",
669
+ `Import the component from "${PKG}" instead. Contribute changes to the kit through a pull request.`,
670
+ );
671
+ }
672
+ for (const m of src.matchAll(SIDE_EFFECT))
673
+ if (m[2] === `${PKG}/styles.css`) stylesImported = true;
674
+
675
+ // Local name → export name for this file.
676
+ const locals = new Map();
677
+ const namespaces = new Set();
678
+ let imported = false;
679
+ for (const m of src.matchAll(IMPORT)) {
680
+ const [, kind, typeOnly, clause, , spec] = m;
681
+ if (spec !== PKG) {
682
+ if (spec !== `${PKG}/brand` && spec !== `${PKG}/styles.css`)
683
+ if (!/\/(src|dist)\b/.test(spec))
684
+ add(
685
+ "deep-import",
686
+ "error",
687
+ file,
688
+ line(m.index),
689
+ `"${spec}" is not a public entry point.`,
690
+ `Import from "${PKG}".`,
691
+ );
692
+ continue;
693
+ }
694
+ imported = usesMortar = true;
695
+ firstMortarFile ??= file;
696
+ const ns = clause.match(/\*\s*as\s+([\w$]+)/);
697
+ if (ns) namespaces.add(ns[1]);
698
+ const named = clause.match(/\{([\s\S]*)\}/);
699
+ if (!named) continue;
700
+ const offset = m.index + m[0].indexOf("{");
701
+ for (const raw of named[1].split(",")) {
702
+ const item = raw.trim();
703
+ if (!item) continue;
704
+ const [name, alias] = item
705
+ .replace(/^type\s+/, "")
706
+ .split(/\s+as\s+/)
707
+ .map((s) => s.trim());
708
+ const isType = !!typeOnly || /^type\s/.test(item);
709
+ const at = offset + named[0].indexOf(raw.trimStart() || raw);
710
+ if (!exportNames.has(name)) {
711
+ const removed = REMOVED_EXPORTS[name];
712
+ const guess = removed?.replacement ?? closest(name, exportNames, 3);
713
+ add(
714
+ "unknown-export",
715
+ "error",
716
+ file,
717
+ line(at),
718
+ `${PKG} ${data.version} has no export "${name}".${removed ? " " + removed.note : ""}`,
719
+ guess
720
+ ? `Import ${guess} instead.`
721
+ : "Search the catalogue (mortar search) for the current component.",
722
+ );
723
+ continue;
724
+ }
725
+ if (!isType && kind === "import") locals.set(alias ?? name, name);
726
+ }
727
+ }
728
+ if (!imported) continue;
729
+
730
+ // className tokens on the app's own markup and arbitrary variants.
731
+ for (const m of src.matchAll(
732
+ /\bclass(?:Name)?\s*=\s*(?:\{\s*)?(["'`])([^"'`]*)\1/g,
733
+ )) {
734
+ const value = m[2];
735
+ const at = m.index;
736
+ for (const v of value.matchAll(/\[[^\]]*\.(m-[a-z][a-z0-9-]*)[^\]]*\]/g))
737
+ if (classes.has(v[1]))
738
+ add(
739
+ "class-override",
740
+ "error",
741
+ file,
742
+ line(at),
743
+ `The variant ${v[0]} restyles Mortar's .${v[1]}.`,
744
+ "Remove it. Use the component's props or theme tokens; className is for layout only.",
745
+ );
746
+ const own = value.split(/\s+/).filter((c) => classes.has(c));
747
+ if (own.length)
748
+ add(
749
+ "kit-class",
750
+ "warning",
751
+ file,
752
+ line(at),
753
+ `Uses Mortar-internal class${own.length > 1 ? "es" : ""} ${own.join(", ")} on the app's markup.`,
754
+ "Render the Mortar component instead of copying its classes; the class names are not a public API.",
755
+ );
756
+ }
757
+ // Colour literals that define a brand are where colours belong.
758
+ const brand = brandRanges(src);
759
+ const inBrand = (at) =>
760
+ brand.some(([start, end]) => at >= start && at <= end);
761
+ let lineStart = 0;
762
+ src.split("\n").forEach((l, i) => {
763
+ const offset = lineStart;
764
+ lineStart += l.length + 1;
765
+ for (const p of l.matchAll(PALETTE_CLASS))
766
+ add(
767
+ "raw-colour",
768
+ "warning",
769
+ file,
770
+ i + 1,
771
+ `Palette class ${p[0]} next to Mortar components.`,
772
+ "Use Mortar's semantic props (tone, color, variant) or tokens (var(--m-…)) so brand, dark mode and contrast stay correct.",
773
+ );
774
+ const hex = [...l.matchAll(HEX)].filter(
775
+ (h) => /\d/.test(h[0]) || COLOURISH.test(l),
776
+ );
777
+ const fns = COLOURISH.test(l) ? [...l.matchAll(COLOUR_FN)] : [];
778
+ for (const h of [...hex, ...fns].filter(
779
+ (h) => !inBrand(offset + h.index),
780
+ ))
781
+ add(
782
+ "raw-colour",
783
+ "warning",
784
+ file,
785
+ i + 1,
786
+ `Raw colour ${h[0].replace(/\($/, "()")} in a file that renders Mortar components.`,
787
+ "Use semantic props or tokens (var(--m-…)); define brand colours once through ThemeProvider brandTokens or a [data-mortar-brand] rule.",
788
+ );
789
+ });
790
+
791
+ if (!JSX.has(ext)) continue;
792
+ // JSX opening tags of Mortar components.
793
+ const tag = /<([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)(?=[\s/>])/g;
794
+ for (const m of src.matchAll(tag)) {
795
+ let exportName;
796
+ const [ns, member] = m[1].split(".");
797
+ if (member && namespaces.has(ns)) exportName = member;
798
+ else if (!member) exportName = locals.get(m[1]);
799
+ if (!exportName) continue;
800
+ if (exportName === "ThemeProvider") themeProvider = true;
801
+ const component = data.find(exportName);
802
+ if (!component) {
803
+ if (member && !exportNames.has(member))
804
+ add(
805
+ "unknown-export",
806
+ "error",
807
+ file,
808
+ line(m.index),
809
+ `${PKG} ${data.version} has no export "${member}".`,
810
+ );
811
+ continue;
812
+ }
813
+ const propTypes = component.contract.propTypes;
814
+ if (!propTypes) continue;
815
+ const accepts = data.accepts(component);
816
+ for (const attr of jsxAttributes(src, m.index + m[0].length)) {
817
+ const name = attr.name;
818
+ if (name === "key" || /[-:]/.test(name)) continue;
819
+ if (!accepts.has(name)) {
820
+ const rename =
821
+ RENAMED_PROPS[exportName]?.[name] ??
822
+ (GENERAL_PROPS[name] && accepts.has(GENERAL_PROPS[name])
823
+ ? { to: GENERAL_PROPS[name] }
824
+ : undefined);
825
+ const guess = rename?.to ?? closest(name, Object.keys(propTypes), 2);
826
+ add(
827
+ "unknown-prop",
828
+ "error",
829
+ file,
830
+ line(attr.index),
831
+ `<${exportName}> has no prop "${name}".${rename?.note ? " " + rename.note : ""}`,
832
+ guess
833
+ ? `Use \`${guess}\` (${propTypes[guess]?.type ?? "see the contract"}).`
834
+ : `See the contract: mortar component ${exportName}.`,
835
+ );
836
+ continue;
837
+ }
838
+ const type = propTypes[name]?.type;
839
+ if (attr.value === undefined || !type) continue;
840
+ const members = type.split(" | ").map((s) => s.trim());
841
+ if (!members.every((t) => /^"[^"]*"$|^null$|^boolean$|^\d+$/.test(t)))
842
+ continue;
843
+ const allowed = members
844
+ .filter((t) => t.startsWith('"'))
845
+ .map((t) => JSON.parse(t));
846
+ if (!allowed.length || allowed.includes(attr.value)) continue;
847
+ const renamed = RENAMED_VALUES[attr.value];
848
+ const guess = allowed.includes(renamed)
849
+ ? renamed
850
+ : closest(attr.value, allowed, 3);
851
+ add(
852
+ "invalid-value",
853
+ "error",
854
+ file,
855
+ line(attr.index),
856
+ `<${exportName} ${name}="${attr.value}"> is not one of ${allowed.map((a) => `"${a}"`).join(", ")}.`,
857
+ guess ? `Use ${name}="${guess}".` : undefined,
858
+ );
859
+ }
860
+ }
861
+ }
862
+
863
+ // Package-level rules.
864
+ let pkgJson;
865
+ const pkgFile = join(root, "package.json");
866
+ try {
867
+ pkgJson = JSON.parse(readFileSync(pkgFile, "utf8"));
868
+ } catch {}
869
+ const range =
870
+ pkgJson?.dependencies?.[PKG] ??
871
+ pkgJson?.devDependencies?.[PKG] ??
872
+ pkgJson?.peerDependencies?.[PKG];
873
+ if (usesMortar || range) {
874
+ if (!range && pkgJson)
875
+ add(
876
+ "not-declared",
877
+ "error",
878
+ pkgFile,
879
+ 1,
880
+ `${PKG} is imported but not listed in package.json.`,
881
+ `npm install ${PKG}`,
882
+ );
883
+ if (!installed)
884
+ add(
885
+ "not-installed",
886
+ "error",
887
+ pkgJson ? pkgFile : undefined,
888
+ 1,
889
+ `${PKG} is not installed (no node_modules/${PKG}).`,
890
+ "Run npm install.",
891
+ );
892
+ else if (range) {
893
+ const ok = satisfies(installed, range);
894
+ if (ok === false)
895
+ add(
896
+ "version-mismatch",
897
+ "error",
898
+ pkgFile,
899
+ 1,
900
+ `Installed ${PKG} ${installed} does not satisfy "${range}".`,
901
+ "Run npm install so the lockfile and node_modules match package.json.",
902
+ );
903
+ }
904
+ if (usesMortar && !stylesImported)
905
+ add(
906
+ "missing-styles",
907
+ "error",
908
+ tailwindEntry ?? firstMortarFile,
909
+ 1,
910
+ `No file imports "${PKG}/styles.css", so components render unstyled.`,
911
+ `In the Tailwind CSS entry, after @import "tailwindcss";, add @import "${PKG}/styles.css";`,
912
+ );
913
+ if (usesMortar && !themeProvider && firstMortarFile)
914
+ add(
915
+ "missing-theme-provider",
916
+ "warning",
917
+ firstMortarFile,
918
+ 1,
919
+ "Mortar components are used but ThemeProvider is never rendered.",
920
+ `Wrap the app root once: <ThemeProvider brand="neutral" density="comfortable" initialAppearance="system">…</ThemeProvider>`,
921
+ );
922
+ }
923
+
924
+ const order = { error: 0, warning: 1, info: 2 };
925
+ findings.sort(
926
+ (a, b) =>
927
+ order[a.severity] - order[b.severity] ||
928
+ a.file.localeCompare(b.file) ||
929
+ (a.line ?? 0) - (b.line ?? 0),
930
+ );
931
+ const summary = { error: 0, warning: 0, info: 0, files: files.length };
932
+ for (const f of findings) summary[f.severity]++;
933
+ return {
934
+ root,
935
+ mortar: {
936
+ range: range ?? null,
937
+ installed: installed ?? null,
938
+ checkedAgainst: data.version,
939
+ },
940
+ summary,
941
+ findings,
942
+ };
943
+ }