@heyramzi/lint 0.0.0-stage → 1.32.2

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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -2
  3. package/bin/heyramzi-comment-length.js +99 -0
  4. package/bin/heyramzi-commit-msg.js +31 -0
  5. package/bin/heyramzi-file-tokens.js +66 -0
  6. package/bin/heyramzi-inline-path.js +79 -0
  7. package/bin/heyramzi-jev-code.js +107 -0
  8. package/bin/heyramzi-push-diff.js +93 -0
  9. package/bin/heyramzi-repo-rules.js +70 -0
  10. package/bin/heyramzi-slop.js +314 -0
  11. package/bin/heyramzi-svelte-markup.js +50 -0
  12. package/bin/heyramzi-ui-rules.js +56 -0
  13. package/comment-length.js +368 -0
  14. package/commit-msg.js +57 -0
  15. package/config/oxlintrc.json +87 -0
  16. package/data/invisibles.js +218 -0
  17. package/data/jev-bench.js +115 -0
  18. package/data/jev-code-bench.js +313 -0
  19. package/data/slop-words.d.ts +64 -0
  20. package/data/slop-words.js +2681 -0
  21. package/file-tokens.js +122 -0
  22. package/gate-files.js +14 -0
  23. package/index.js +116 -0
  24. package/inline-path.js +90 -0
  25. package/jev-code.js +194 -0
  26. package/jev.js +341 -0
  27. package/package.json +80 -4
  28. package/processors/markdown.js +22 -0
  29. package/push-diff.js +55 -0
  30. package/register.d.ts +43 -0
  31. package/register.js +255 -0
  32. package/repo-rules/packages.js +164 -0
  33. package/repo-rules/paths.js +101 -0
  34. package/repo-rules/toolchain.js +172 -0
  35. package/repo-rules/tree.js +51 -0
  36. package/repo-rules/workflows.js +116 -0
  37. package/repo-rules.js +54 -0
  38. package/rules/convex-ast.js +66 -0
  39. package/rules/convex-index-name.js +40 -0
  40. package/rules/convex-internal-only.js +59 -0
  41. package/rules/convex-no-date-now-in-query.js +48 -0
  42. package/rules/convex-no-deprecated-crons.js +40 -0
  43. package/rules/convex-no-filter.js +41 -0
  44. package/rules/convex-no-get-metadata.js +26 -0
  45. package/rules/convex-no-v-any.js +24 -0
  46. package/rules/convex-react-client-module-scope.js +37 -0
  47. package/rules/convex-require-args.js +39 -0
  48. package/rules/convex-require-returns.js +31 -0
  49. package/rules/convex-runtime-imports.js +48 -0
  50. package/rules/max-function-lines.js +105 -0
  51. package/rules/max-object-methods.js +86 -0
  52. package/rules/no-ai-words.js +78 -0
  53. package/rules/no-app-stores.js +25 -0
  54. package/rules/no-chained-type-assertions.js +91 -0
  55. package/rules/no-class.js +47 -0
  56. package/rules/no-conditional-empty-object-spread.js +64 -0
  57. package/rules/no-deep-phosphor-imports.js +29 -0
  58. package/rules/no-default-export.js +70 -0
  59. package/rules/no-em-dash.js +194 -0
  60. package/rules/no-emoji.js +56 -0
  61. package/rules/no-hardcoded-models.js +63 -0
  62. package/rules/no-inline-path.js +46 -0
  63. package/rules/no-inline-svg.js +67 -0
  64. package/rules/no-known-value-widening.js +289 -0
  65. package/rules/no-legacy-compat.js +35 -0
  66. package/rules/no-overkill-fallback.js +77 -0
  67. package/rules/no-raw-try-catch.js +26 -0
  68. package/rules/no-redundant-prop-fallback.js +153 -0
  69. package/rules/no-relative-imports.js +213 -0
  70. package/rules/no-relative-type-imports.js +40 -0
  71. package/rules/no-rune-outside-svelte-file.js +56 -0
  72. package/rules/no-rune-shadowing-binding.js +85 -0
  73. package/rules/no-skipped-or-focused-tests.js +74 -0
  74. package/rules/no-snake-case-props.js +45 -0
  75. package/rules/no-svelte4-syntax.js +33 -0
  76. package/rules/no-vitest-import-in-vite-plus.js +64 -0
  77. package/rules/props-interface.js +44 -0
  78. package/rules/redirect-external-needs-option.js +45 -0
  79. package/rules/require-safety-comment-for-type-assertion.js +110 -0
  80. package/rules/svelte-section-order.js +115 -0
  81. package/rules/test-file.js +8 -0
  82. package/scripts/count-hits.mjs +213 -0
  83. package/scripts/harvest-voice.mjs +161 -0
  84. package/scripts/jev-bench.mjs +41 -0
  85. package/scripts/jev-code-bench.mjs +61 -0
  86. package/scripts/measure-register.mjs +61 -0
  87. package/slop.d.ts +53 -0
  88. package/slop.js +644 -0
  89. package/svelte-markup.js +107 -0
  90. package/svelte-template.js +258 -0
  91. package/ui-rules.js +324 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The Svelte markup gate: the template checks oxlint can't make.
3
+ */
4
+
5
+ /*
6
+ * oxlint only visits a .svelte file that has a `<script>`, and a markup-only component (a `<slot />` wrapper) has none. Pure here, git and process in `bin/heyramzi-svelte-markup.js`.
7
+ */
8
+ import {
9
+ isRegularElement,
10
+ MATHML_ELEMENTS,
11
+ scanTemplate,
12
+ SVG_ELEMENTS,
13
+ VOID_ELEMENTS,
14
+ } from "#lint/svelte-template.js";
15
+
16
+ /** Generated or vendored trees: nobody can fix code they don't write. */
17
+ export const DEFAULT_IGNORED = [
18
+ "node_modules",
19
+ "dist",
20
+ "build",
21
+ "out",
22
+ "coverage",
23
+ "vendor",
24
+ ".upstream",
25
+ "generated",
26
+ "_generated",
27
+ ".svelte-kit",
28
+ ".wrangler",
29
+ ".agents",
30
+ ".claude",
31
+ ".opencode",
32
+ ".gemini",
33
+ "playground",
34
+ ];
35
+
36
+ /** Every violation in one .svelte source: `{ line, rule, message }`. */
37
+ export function findMarkupViolations(source) {
38
+ const { tags } = scanTemplate(source);
39
+ const customElement = tags.some(
40
+ (tag) => tag.name === "svelte:options" && tag.attrs.some((a) => a.name === "customElement"),
41
+ );
42
+ const found = [];
43
+ const add = (line, rule, message) => found.push({ line, rule, message });
44
+
45
+ for (const tag of tags) {
46
+ if (tag.closing) continue;
47
+
48
+ if (tag.name === "slot" && !customElement) {
49
+ add(
50
+ tag.line,
51
+ "svelte4-slot",
52
+ "`<slot>` is Svelte 4. Take `children` or a named snippet prop and render it with `{@render children?.()}`.",
53
+ );
54
+ }
55
+ if (tag.name === "svelte:component") {
56
+ add(
57
+ tag.line,
58
+ "svelte4-component",
59
+ "`<svelte:component>` is Svelte 4. Put the component in a variable and render `<Component />`.",
60
+ );
61
+ }
62
+ for (const attr of tag.attrs) {
63
+ if (attr.name.startsWith("on:")) {
64
+ const event = attr.name.slice(3).split("|")[0];
65
+ add(
66
+ attr.line,
67
+ "svelte4-event",
68
+ `\`${attr.name.split("|")[0]}\` is Svelte 4. Use the \`on${event}\` attribute, and a callback prop on a component.`,
69
+ );
70
+ }
71
+ if (attr.name.startsWith("class:") && /\[[^\]]+\]/.test(attr.name)) {
72
+ add(
73
+ attr.line,
74
+ "class-arbitrary-value",
75
+ `\`${attr.name}\` never applies: \`class:\` can't carry a Tailwind arbitrary value. Use \`style:property={...}\` (for example \`style:min-height={compact ? '300px' : '460px'}\`).`,
76
+ );
77
+ }
78
+ }
79
+ if (
80
+ tag.selfClosing &&
81
+ tag.name !== "slot" &&
82
+ isRegularElement(tag.name) &&
83
+ !VOID_ELEMENTS.has(tag.name) &&
84
+ !SVG_ELEMENTS.has(tag.name) &&
85
+ !MATHML_ELEMENTS.has(tag.name)
86
+ ) {
87
+ add(
88
+ tag.line,
89
+ "self-closing",
90
+ `\`<${tag.name} />\` is ambiguous HTML. Write \`<${tag.name}></${tag.name}>\`.`,
91
+ );
92
+ }
93
+ }
94
+ return found.toSorted((a, b) => a.line - b.line);
95
+ }
96
+
97
+ /** Filter tracked files down to the .svelte files the gate reads. */
98
+ export function selectFiles(tracked, { ignored = DEFAULT_IGNORED, targets = [] } = {}) {
99
+ return tracked.filter((file) => {
100
+ if (!file.endsWith(".svelte")) return false;
101
+ if (file.split("/").some((segment) => ignored.includes(segment))) return false;
102
+ if (targets.length === 0) return true;
103
+ return targets.some(
104
+ (target) => file === target || file.startsWith(`${target.replace(/\/$/, "")}/`),
105
+ );
106
+ });
107
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * A small reader for a Svelte template, for the rules that oxlint can't reach.
3
+ */
4
+
5
+ /*
6
+ * oxlint gives a rule only a .svelte file's script. This scans the markup from disk: tags with attributes, `{...}` expressions and block headers, each with its line.
7
+ */
8
+
9
+ /*
10
+ * It isn't a parser. It knows quotes, comments, `<script>`, `<style>` and balanced braces, which is all the rules need to never read prose or a string as markup.
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+
15
+ export const VOID_ELEMENTS = new Set([
16
+ "area",
17
+ "base",
18
+ "br",
19
+ "col",
20
+ "command",
21
+ "embed",
22
+ "hr",
23
+ "img",
24
+ "input",
25
+ "keygen",
26
+ "link",
27
+ "meta",
28
+ "param",
29
+ "source",
30
+ "track",
31
+ "wbr",
32
+ ]);
33
+
34
+ // Copied from svelte/src/utils.js, which warns on a self-closing tag by name.
35
+ export const SVG_ELEMENTS = new Set(
36
+ "altGlyph altGlyphDef altGlyphItem animate animateColor animateMotion animateTransform circle clipPath color-profile cursor defs desc discard ellipse feBlend feColorMatrix feComponentTransfer feComposite feConvolveMatrix feDiffuseLighting feDisplacementMap feDistantLight feDropShadow feFlood feFuncA feFuncB feFuncG feFuncR feGaussianBlur feImage feMerge feMergeNode feMorphology feOffset fePointLight feSpecularLighting feSpotLight feTile feTurbulence filter font font-face font-face-format font-face-name font-face-src font-face-uri foreignObject g glyph glyphRef hatch hatchpath hkern image line linearGradient marker mask mesh meshgradient meshpatch meshrow metadata missing-glyph mpath path pattern polygon polyline radialGradient rect set solidcolor stop svg switch symbol text textPath tref tspan unknown use view vkern".split(
37
+ " ",
38
+ ),
39
+ );
40
+
41
+ export const MATHML_ELEMENTS = new Set(
42
+ "annotation annotation-xml maction math merror mfrac mi mmultiscripts mn mo mover mpadded mphantom mprescripts mroot mrow ms mspace msqrt mstyle msub msubsup msup mtable mtd mtext mtr munder munderover semantics".split(
43
+ " ",
44
+ ),
45
+ );
46
+
47
+ const RAW_TEXT = new Set(["script", "style"]);
48
+
49
+ /** The index just past the `}` that closes the `{` at `start`, honouring strings and comments. */
50
+ function skipBraces(source, start) {
51
+ let depth = 0;
52
+ for (let i = start; i < source.length; i++) {
53
+ const c = source[i];
54
+ if (c === "'" || c === '"') {
55
+ i = skipString(source, i, c);
56
+ } else if (c === "`") {
57
+ i = skipTemplate(source, i);
58
+ } else if (c === "/" && source[i + 1] === "*") {
59
+ const end = source.indexOf("*/", i + 2);
60
+ if (end === -1) return source.length;
61
+ i = end + 1;
62
+ } else if (c === "/" && source[i + 1] === "/" && source[i - 1] !== ":") {
63
+ const end = source.indexOf("\n", i);
64
+ if (end === -1) return source.length;
65
+ i = end;
66
+ } else if (c === "{") {
67
+ depth++;
68
+ } else if (c === "}") {
69
+ depth--;
70
+ if (depth === 0) return i + 1;
71
+ }
72
+ }
73
+ return source.length;
74
+ }
75
+
76
+ function skipString(source, start, quote) {
77
+ for (let i = start + 1; i < source.length; i++) {
78
+ if (source[i] === "\\") i++;
79
+ else if (source[i] === quote || source[i] === "\n") return i;
80
+ }
81
+ return source.length;
82
+ }
83
+
84
+ function skipTemplate(source, start) {
85
+ for (let i = start + 1; i < source.length; i++) {
86
+ if (source[i] === "\\") i++;
87
+ else if (source[i] === "`") return i;
88
+ else if (source[i] === "$" && source[i + 1] === "{") i = skipBraces(source, i + 1) - 1;
89
+ }
90
+ return source.length;
91
+ }
92
+
93
+ /**
94
+ * Scan a whole .svelte source.
95
+ * @returns {{ tags: Tag[], expressions: Expression[], blocks: Block[] }}
96
+ */
97
+ export function scanTemplate(source) {
98
+ const tags = [];
99
+ const expressions = [];
100
+ const blocks = [];
101
+ const lineStarts = [0];
102
+ for (let i = 0; i < source.length; i++) if (source[i] === "\n") lineStarts.push(i + 1);
103
+ const lineAt = (index) => {
104
+ let lo = 0;
105
+ let hi = lineStarts.length - 1;
106
+ while (lo < hi) {
107
+ const mid = (lo + hi + 1) >> 1;
108
+ if (lineStarts[mid] <= index) lo = mid;
109
+ else hi = mid - 1;
110
+ }
111
+ return lo + 1;
112
+ };
113
+
114
+ /** Read `{...}` at `start`; records it and returns the index after it. */
115
+ function readBraces(start, into) {
116
+ const end = skipBraces(source, start);
117
+ const code = source.slice(start + 1, end - 1);
118
+ const kind = code[0];
119
+ const record = { code, line: lineAt(start) };
120
+ if (kind === "#" || kind === ":" || kind === "/" || kind === "@") blocks.push(record);
121
+ else if (into) into.push(record);
122
+ else expressions.push(record);
123
+ return end;
124
+ }
125
+
126
+ function readTag(start, closing) {
127
+ let i = start + (closing ? 2 : 1);
128
+ const nameStart = i;
129
+ while (i < source.length && /[\w:.-]/.test(source[i])) i++;
130
+ const tag = {
131
+ name: source.slice(nameStart, i),
132
+ closing,
133
+ selfClosing: false,
134
+ line: lineAt(start),
135
+ attrs: [],
136
+ end: i,
137
+ };
138
+ while (i < source.length) {
139
+ while (i < source.length && /\s/.test(source[i])) i++;
140
+ const c = source[i];
141
+ if (c === undefined) break;
142
+ if (c === ">") {
143
+ i++;
144
+ break;
145
+ }
146
+ if (c === "/" && source[i + 1] === ">") {
147
+ tag.selfClosing = true;
148
+ i += 2;
149
+ break;
150
+ }
151
+ if (c === "/") {
152
+ i++;
153
+ continue;
154
+ }
155
+ if (c === "{") {
156
+ const attrExprs = [];
157
+ const end = readBraces(i, attrExprs);
158
+ tag.attrs.push({ name: source.slice(i, end), value: null, line: lineAt(i) });
159
+ expressions.push(...attrExprs);
160
+ i = end;
161
+ continue;
162
+ }
163
+ const attrStart = i;
164
+ while (
165
+ i < source.length &&
166
+ !/[\s=>]/.test(source[i]) &&
167
+ !(source[i] === "/" && source[i + 1] === ">")
168
+ ) {
169
+ if (source[i] === "{") i = skipBraces(source, i);
170
+ else i++;
171
+ }
172
+ const attr = { name: source.slice(attrStart, i), value: null, line: lineAt(attrStart) };
173
+ let j = i;
174
+ while (j < source.length && /\s/.test(source[j])) j++;
175
+ if (source[j] === "=") {
176
+ j++;
177
+ while (j < source.length && /\s/.test(source[j])) j++;
178
+ const q = source[j];
179
+ const valueStart = j;
180
+ if (q === '"' || q === "'") {
181
+ j++;
182
+ while (j < source.length && source[j] !== q) {
183
+ if (source[j] === "{") j = readBraces(j, null);
184
+ else j++;
185
+ }
186
+ attr.value = source.slice(valueStart + 1, j);
187
+ j++;
188
+ } else {
189
+ while (j < source.length && !/[\s>]/.test(source[j])) {
190
+ if (source[j] === "{") j = readBraces(j, null);
191
+ else if (source[j] === "/" && source[j + 1] === ">") break;
192
+ else j++;
193
+ }
194
+ attr.value = source.slice(valueStart, j);
195
+ }
196
+ i = j;
197
+ }
198
+ tag.attrs.push(attr);
199
+ }
200
+ tag.end = i;
201
+ return tag;
202
+ }
203
+
204
+ let i = 0;
205
+ while (i < source.length) {
206
+ const c = source[i];
207
+ if (c === "<" && source.startsWith("<!--", i)) {
208
+ const end = source.indexOf("-->", i + 4);
209
+ i = end === -1 ? source.length : end + 3;
210
+ } else if (c === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) {
211
+ const tag = readTag(i, false);
212
+ tags.push(tag);
213
+ i = tag.end;
214
+ if (RAW_TEXT.has(tag.name) && !tag.selfClosing) {
215
+ const close = new RegExp(`</${tag.name}\\s*>`, "i");
216
+ const rest = source.slice(i);
217
+ const found = close.exec(rest);
218
+ i = found ? i + found.index + found[0].length : source.length;
219
+ }
220
+ } else if (c === "<" && source[i + 1] === "/" && /[A-Za-z]/.test(source[i + 2] ?? "")) {
221
+ const tag = readTag(i, true);
222
+ tags.push(tag);
223
+ i = tag.end;
224
+ } else if (c === "{") {
225
+ i = readBraces(i, null);
226
+ } else {
227
+ i++;
228
+ }
229
+ }
230
+ return { tags, expressions, blocks };
231
+ }
232
+
233
+ /** True for `<div>`, false for `<Card>`, `<a.b>` and `<svelte:head>`. */
234
+ export function isRegularElement(name) {
235
+ return /^[a-z][^\s.:]*$/.test(name);
236
+ }
237
+
238
+ /** @typedef {{ name: string, closing: boolean, selfClosing: boolean, line: number, attrs: { name: string, value: string | null, line: number }[], end: number }} Tag */
239
+
240
+ /** @typedef {{ code: string, line: number }} Expression */
241
+
242
+ /** @typedef {{ code: string, line: number }} Block */
243
+
244
+ let cached = null;
245
+
246
+ /** The scan of a .svelte file on disk, or null when it can't be read. One file is kept, since every rule asks for the same one in turn. */
247
+ export function readTemplate(filename) {
248
+ let source;
249
+ try {
250
+ source = readFileSync(filename, "utf8");
251
+ } catch {
252
+ return null;
253
+ }
254
+ if (cached?.filename !== filename || cached.source !== source) {
255
+ cached = { filename, source, scan: scanTemplate(source) };
256
+ }
257
+ return { source, ...cached.scan };
258
+ }
package/ui-rules.js ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The UI-rules gate: CSS, markup and config checks the impeccable detector misses.
3
+ * Pure here, git and process in `bin/heyramzi-ui-rules.js`.
4
+ */
5
+
6
+ export const STYLE_EXTENSIONS = [".svelte", ".css", ".html", ".ts", ".tsx"];
7
+
8
+ /** Generated, vendored or projected trees, plus the playground boards and fixture-style test dirs. */
9
+ export const DEFAULT_IGNORED = [
10
+ "node_modules",
11
+ "dist",
12
+ "build",
13
+ "out",
14
+ "coverage",
15
+ "vendor",
16
+ ".upstream",
17
+ "generated",
18
+ "_generated",
19
+ "paraglide",
20
+ ".svelte-kit",
21
+ ".wrangler",
22
+ ".vercel",
23
+ ".next",
24
+ ".agents",
25
+ ".claude",
26
+ ".codex",
27
+ ".gemini",
28
+ ".opencode",
29
+ "playground",
30
+ ".obsidian",
31
+ ".reference",
32
+ "builds",
33
+ ];
34
+
35
+ const T_FAMILIES =
36
+ "avatar|check|clear|digit|dropdown|enter|error|exit|fade|fly|follow|glare|hold|input|learn|lift|like|mask|modal|morph|page|pop|press|reel|resize|return|scrollbar|shake|shimmer|stagger|success|swap|text|think|tilt|acc|tabs|toast|tooltip|number|badge|panel|toggle|banner|matrix|stream|reason|skeleton|accordion";
37
+
38
+ const TOKEN_NAMES = "stagger|micro|quick|fast|medium|slow|very-slow";
39
+
40
+ const MESSAGES = {
41
+ "transition-all":
42
+ "Name the properties: `transition: opacity var(--duration-fast), transform var(--duration-fast)`, or Tailwind `transition-[opacity,transform]`. `all` animates layout properties you never meant to.",
43
+ "scale-zero":
44
+ "Start from `scale(0.9)` to `scale(0.97)` plus `opacity: 0`. Nothing in the real world appears from nothing.",
45
+ "ease-in":
46
+ "Use `var(--ease-smooth-out)` (or `ease-out`). `ease-in` starts slow, delaying the exact moment the user is watching. `ease-in-out` is fine for on-screen movement.",
47
+ "duration-literal":
48
+ "Write `duration-[var(--duration-fast)]` and `delay-[var(--duration-micro)]`, never a literal. Tokens: stagger, micro, quick, fast, medium, slow, very-slow.",
49
+ "duration-bare":
50
+ "Write `duration-[var(--duration-fast)]`, never `duration-fast`. Tailwind's `duration-*` resolves against `--transition-duration-*`, so a bare token class generates nothing and sits at 0s.",
51
+ "acc-panel-padding":
52
+ "Put the padding on `.t-acc-panel-inner`. A padded `0fr` grid track never fully closes.",
53
+ "swift-t-class":
54
+ "`t-*` classes are CSS. In Swift, take the pattern and tokens from `transitions-dev` and write the native call (see `ios-design`, Transitions branch).",
55
+ "viewport-zoom":
56
+ "Never disable zoom, it's an accessibility failure. Remove `user-scalable=no` and `maximum-scale=1`; set input font size to 16px instead, since that caused the zoom.",
57
+ "body-user-select":
58
+ "Never `user-select: none` on body or html: users copy addresses and order numbers. Put it on controls only (`button`, `[role=\"button\"]`, `.tab`, `.chip`).",
59
+ "outline-neutral":
60
+ "Image outlines are pure black or white at 10%: `outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10`. Never `outline-slate-*`, `-zinc-*` or `-neutral-*`.",
61
+ "raw-hover":
62
+ "Wrap raw CSS `:hover` in `@media (hover: hover) and (pointer: fine) { }`, or it sticks after a tap on touch. Tailwind's `hover:` is already gated.",
63
+ "local-shadcn":
64
+ "Never vendor shadcn. Import from `@heyramzi/ui/shadcn/<name>`; a fix or new component lands in the `@heyramzi/ui` source and arrives as a version bump. Delete the local copy and `components.json`.",
65
+ "ui-source":
66
+ "app.css needs `@source \"../node_modules/@heyramzi/ui/src\";` (path relative to app.css), or Tailwind generates none of the kit's classes.",
67
+ "cloudflare-relative":
68
+ "Set `paths: { relative: false }` in svelte.config. adapter-cloudflare with relative paths took upsys-consulting.com down with intermittent 404s.",
69
+ "static-shadow":
70
+ "Delete the static file. On Cloudflare a file in `static/` beats a route of the same path, so a robots or sitemap route never has a static twin.",
71
+ };
72
+
73
+ /** Blank out comments, keeping every newline so line numbers hold. */
74
+ export function stripComments(source, file) {
75
+ const blank = (match) => match.replace(/[^\n]/g, " ");
76
+ let out = source.replace(/\/\*[\s\S]*?\*\//g, blank);
77
+ if (file.endsWith(".svelte") || file.endsWith(".html")) out = out.replace(/<!--[\s\S]*?-->/g, blank);
78
+ if (/\.(svelte|tsx?)$/.test(file)) out = out.replace(/(^|[\s;{(,])\/\/[^\n]*/g, (m, lead) => lead + blank(m.slice(lead.length)));
79
+ return out;
80
+ }
81
+
82
+ function lineOf(source, index) {
83
+ let line = 1;
84
+ for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
85
+ return line;
86
+ }
87
+
88
+ function scan(source, pattern, rule) {
89
+ const hits = [];
90
+ for (const match of source.matchAll(pattern)) {
91
+ hits.push({ rule, line: lineOf(source, match.index), snippet: match[0].trim().slice(0, 80) });
92
+ }
93
+ return hits;
94
+ }
95
+
96
+ /** A `t-*` class from `@heyramzi/ui/animations` or `design`, inside a Swift string. */
97
+ const SWIFT_T_CLASS = new RegExp(
98
+ `"[^"\\n]*(?<![\\w/.-])t-(?:${T_FAMILIES})(?![\\w])[^"\\n]*"`,
99
+ "g",
100
+ );
101
+
102
+ const LINE_RULES = [
103
+ ["transition-all", /(?<![\w-])transition(?:-property)?\s*:\s*[^;}"'`]*\ball\b|(?<![\w-])transition-(?:all|\[all\])(?![\w-])/g],
104
+ ["scale-zero", /(?<![\w-])scale\(\s*0(?:\.0+)?\s*(?:,\s*0(?:\.0+)?\s*)?\)|(?<![\w-])scale-0(?![\w.-])/g],
105
+ ["ease-in", /(?<![\w-])ease-in(?![\w-])/g],
106
+ ["duration-literal", /(?<![\w-])(?:duration|delay)-\[\s*[\d.]+m?s\s*\]/g],
107
+ ["duration-bare", new RegExp(`(?<![\\w-])(?:duration|delay)-(?:${TOKEN_NAMES})(?![\\w-])`, "g")],
108
+ ["viewport-zoom", /user-scalable\s*=\s*(?:no|0)|maximum-scale\s*=\s*1(?:\.0+)?(?![\d.])/g],
109
+ ["outline-neutral", /(?<![\w-])outline-(?:slate|zinc|neutral)-\d/g],
110
+ ];
111
+
112
+ /** Every `<style>` body in a .svelte or .html file, or the whole .css, with its first line number. */
113
+ function styleBlocks(source, file) {
114
+ if (file.endsWith(".css")) return [{ css: source, offset: 0 }];
115
+ const blocks = [];
116
+ for (const match of source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/g)) {
117
+ const start = match.index + match[0].indexOf(">") + 1;
118
+ blocks.push({ css: match[1], offset: lineOf(source, start) - 1 });
119
+ }
120
+ return blocks;
121
+ }
122
+
123
+ /** Rules as `{ selector, body, ancestors, line }`, walking the brace stack. */
124
+ export function parseRules(css) {
125
+ const rules = [];
126
+ const stack = [];
127
+ let start = 0;
128
+ for (let i = 0; i < css.length; i++) {
129
+ const char = css[i];
130
+ if (char === "{") {
131
+ const prelude = css.slice(start, i).trim();
132
+ stack.push({ prelude, from: i + 1, line: lineOf(css, start + (css.slice(start).length - css.slice(start).trimStart().length)) });
133
+ start = i + 1;
134
+ } else if (char === "}") {
135
+ const open = stack.pop();
136
+ if (open) {
137
+ const body = css.slice(open.from, i);
138
+ rules.push({ selector: open.prelude, body, ancestors: stack.map((s) => s.prelude), line: open.line });
139
+ }
140
+ start = i + 1;
141
+ } else if (char === ";") {
142
+ start = i + 1;
143
+ }
144
+ }
145
+ return rules;
146
+ }
147
+
148
+ const hoverGated = (ancestors) =>
149
+ ancestors.some((p) => /^@media\b[^{]*hover\s*:\s*hover/i.test(p) || /^@(custom-variant|variant|utility|theme)\b/.test(p));
150
+
151
+ function cssRules(source, file) {
152
+ const hits = [];
153
+ for (const { css, offset } of styleBlocks(source, file)) {
154
+ for (const rule of parseRules(css)) {
155
+ if (rule.selector.startsWith("@")) continue;
156
+ const line = rule.line + offset;
157
+ if (/:hover\b/.test(rule.selector) && !hoverGated(rule.ancestors)) {
158
+ hits.push({ rule: "raw-hover", line, snippet: rule.selector.slice(0, 80) });
159
+ }
160
+ const selectors = rule.selector.split(",").map((s) => s.trim());
161
+ const own = selectors.some((s) => /(^|[\s>+~])\.t-acc-panel$/.test(s) || /(^|[\s>+~])\.t-acc-panel(?=[:.\[])/.test(s));
162
+ if (own && /(?<![\w-])padding(?:-[a-z]+)*\s*:/.test(rule.body) && !selectors.some((s) => /t-acc-panel-/.test(s))) {
163
+ hits.push({ rule: "acc-panel-padding", line, snippet: `${rule.selector} { padding }` });
164
+ }
165
+ const root = selectors.some((s) => /^(?:body|html)$/.test(s));
166
+ if (root && /(?<![\w-])(?:-webkit-)?user-select\s*:\s*none|@apply[^;]*\bselect-none\b/.test(rule.body)) {
167
+ hits.push({ rule: "body-user-select", line, snippet: `${rule.selector} { user-select: none }` });
168
+ }
169
+ }
170
+ }
171
+ return hits;
172
+ }
173
+
174
+ function markupRules(source) {
175
+ const hits = [];
176
+ for (const match of source.matchAll(/<body\b[^>]*\bclass\s*=\s*["'{`][^"'`>]*\bselect-none\b/g)) {
177
+ hits.push({ rule: "body-user-select", line: lineOf(source, match.index), snippet: "<body class=\"select-none\">" });
178
+ }
179
+ for (const match of source.matchAll(/class\s*=\s*["'`{][^"'`>]*(?<![\w-])t-acc-panel(?![\w-])[^"'`>]*/g)) {
180
+ if (/(?:^|[\s:"'`{])-?p[xytblrse]?-[\w[\]./]+/.test(match[0].replace(/^class\s*=\s*["'`{]/, " "))) {
181
+ hits.push({ rule: "acc-panel-padding", line: lineOf(source, match.index), snippet: match[0].slice(0, 80) });
182
+ }
183
+ }
184
+ return hits;
185
+ }
186
+
187
+ /** The per-file rules. Returns `{ rule, line, snippet }` hits, each rule's message in `describe`. */
188
+ export function checkFile(file, source) {
189
+ if (file.endsWith(".swift")) {
190
+ return scan(source, SWIFT_T_CLASS, "swift-t-class");
191
+ }
192
+ if (!STYLE_EXTENSIONS.some((ext) => file.endsWith(ext))) return [];
193
+ const clean = stripComments(source, file);
194
+ const plainTs = file.endsWith(".ts") || file.endsWith(".tsx");
195
+ const hits = LINE_RULES.filter(([rule]) => !(plainTs && rule === "ease-in")).flatMap(([rule, pattern]) => scan(clean, pattern, rule));
196
+ if (!plainTs) hits.push(...cssRules(clean, file));
197
+ if (!file.endsWith(".css")) hits.push(...markupRules(clean));
198
+ return hits.sort((a, b) => a.line - b.line);
199
+ }
200
+
201
+ export function describe(rule) {
202
+ return MESSAGES[rule];
203
+ }
204
+
205
+ /** Nearest package.json up from `file`, as a parsed object, using `read(path)` that returns text or null. */
206
+ export function nearestManifest(file, read) {
207
+ const parts = file.split("/");
208
+ for (let depth = parts.length - 1; depth >= 0; depth--) {
209
+ const candidate = [...parts.slice(0, depth), "package.json"].join("/");
210
+ const text = read(candidate);
211
+ if (text) {
212
+ try {
213
+ return JSON.parse(text);
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+
222
+ export function dependsOnKit(manifest) {
223
+ if (!manifest) return false;
224
+ return ["dependencies", "devDependencies", "peerDependencies"].some((key) => {
225
+ const range = manifest[key]?.["@heyramzi/ui"];
226
+ return range && !range.startsWith("workspace:");
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Whole-project checks on the judged files. `read(path)` gives text or null, `tracked` is every file in the repo.
232
+ * `files` are the judged ones: only a violation they touch is reported.
233
+ */
234
+ export function checkProject(files, tracked, read) {
235
+ const hits = [];
236
+ const add = (file, rule, line, snippet) => hits.push({ file, rule, line, snippet });
237
+ const trackedSet = new Set(tracked);
238
+
239
+ for (const file of files) {
240
+ const name = file.split("/").at(-1);
241
+ const isKitApp = () => dependsOnKit(nearestManifest(file, read));
242
+
243
+ if (/(^|\/)src\/lib\/components\/ui\/[^/]+\.svelte$/.test(file) && isKitApp()) {
244
+ add(file, "local-shadcn", 1, file);
245
+ }
246
+ if (name === "components.json" && isKitApp() && /shadcn-svelte/.test(read(file) ?? "")) {
247
+ add(file, "local-shadcn", 1, "components.json (shadcn-svelte)");
248
+ }
249
+ if (/(^|\/)src\/app\.css$/.test(file) && isKitApp()) {
250
+ const css = stripComments(read(file) ?? "", file);
251
+ if (!/@source\s+["'][^"']*(?:@heyramzi\/ui\/src|packages\/ui\/src|\/ui\/src)/.test(css)) {
252
+ add(file, "ui-source", 1, "no @source for @heyramzi/ui/src");
253
+ }
254
+ }
255
+ if (/^(?:svelte|kit)\.config\.(js|ts|mjs)$/.test(name)) {
256
+ const config = stripComments(read(file) ?? "", file);
257
+ if (/adapter-cloudflare/.test(config) && !/relative\s*:\s*false/.test(config)) {
258
+ add(file, "cloudflare-relative", 1, "adapter-cloudflare without paths.relative: false");
259
+ }
260
+ }
261
+ }
262
+
263
+ const prefixes = new Set();
264
+ for (const file of [...files, ...tracked.filter((f) => /\/\+(server|page)\.(ts|js|svelte)$/.test(f) && files.includes(f))]) {
265
+ const staticAt = file.match(/^(.*?)(?:^|\/)static\/(.+)$/) ?? file.match(/^()static\/(.+)$/);
266
+ if (staticAt) prefixes.add(staticAt[1] === undefined ? "" : staticAt[1].replace(/\/$/, ""));
267
+ const routeAt = file.match(/^(.*?)\/?src\/routes\//);
268
+ if (routeAt) prefixes.add(routeAt[1].replace(/\/$/, ""));
269
+ }
270
+ const judged = new Set(files);
271
+ for (const prefix of prefixes) {
272
+ const base = prefix ? `${prefix}/` : "";
273
+ for (const file of tracked) {
274
+ if (!file.startsWith(`${base}src/routes/`)) continue;
275
+ const route = file.slice(`${base}src/routes/`.length);
276
+ const match = route.match(/^(.*?)\/?\+(?:server\.(?:ts|js)|page\.svelte)$/);
277
+ if (!match) continue;
278
+ const url = match[1].split("/").filter((seg) => !/^\(.*\)$/.test(seg)).join("/");
279
+ if (!url || /[[\]]/.test(url)) continue;
280
+ const twin = `${base}static/${url}`;
281
+ if (trackedSet.has(twin) && (judged.has(twin) || judged.has(file))) {
282
+ add(twin, "static-shadow", 1, `static/${url} shadows ${file}`);
283
+ }
284
+ }
285
+ }
286
+ return hits;
287
+ }
288
+
289
+ /** Skip generated and vendored trees. */
290
+ export function ignoredPath(file, extra = []) {
291
+ const parts = file.split("/");
292
+ return [...DEFAULT_IGNORED, ...extra].some((entry) => parts.includes(entry));
293
+ }
294
+
295
+ export function report(hits) {
296
+ const byRule = new Map();
297
+ for (const hit of hits) {
298
+ if (!byRule.has(hit.rule)) byRule.set(hit.rule, []);
299
+ byRule.get(hit.rule).push(hit);
300
+ }
301
+ const blocks = [...byRule].map(
302
+ ([rule, list]) =>
303
+ `[${rule}] ${MESSAGES[rule]}\n${list.map((hit) => ` ${hit.file}:${hit.line} ${hit.snippet}`).join("\n")}`,
304
+ );
305
+ return `ui-rules: ${hits.length} violation(s)\n\n${blocks.join("\n\n")}`;
306
+ }
307
+
308
+ /** Names the repo makes a bare `duration-NAME` class for: the kit's design import, or its own `--transition-duration-NAME`. */
309
+ export function durationNames(tracked, read) {
310
+ const names = new Set();
311
+ for (const file of tracked.filter((f) => f.endsWith(".css") && !ignoredPath(f))) {
312
+ const css = read(file) ?? "";
313
+ if (/@import\s+["']@heyramzi\/ui\/design["']/.test(css)) {
314
+ for (const name of TOKEN_NAMES.split("|").filter((n) => n !== "stagger")) names.add(name);
315
+ }
316
+ for (const match of css.matchAll(/--transition-duration-([\w-]+)\s*:/g)) names.add(match[1]);
317
+ }
318
+ return names;
319
+ }
320
+
321
+ /** Drops `duration-bare` hits the repo resolves. */
322
+ export function resolvedDurations(hits, names) {
323
+ return hits.filter((hit) => !(hit.rule === "duration-bare" && names.has(hit.snippet.replace(/^(?:duration|delay)-/, ""))));
324
+ }