@heyramzi/lint 1.33.0 → 2.0.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Shared lint rules for Studio projects: one oxlint plugin, the oxlint base config
6
6
 
7
7
  - **Oxlint plugin**: the rules below, loaded through `jsPlugins`.
8
8
  - **Oxlint base config**: `@heyramzi/lint/oxlint`.
9
- - **Gates**: `heyramzi-commit-msg`, `heyramzi-comment-length`, `heyramzi-file-tokens`, `heyramzi-inline-path`, `heyramzi-jev-code`, `heyramzi-repo-rules`, `heyramzi-slop` and `heyramzi-svelte-markup`.
9
+ - **Gates**: `heyramzi-commit-msg`, `heyramzi-comment-length`, `heyramzi-file-tokens`, `heyramzi-inline-path`, `heyramzi-jev-code`, `heyramzi-old-names`, `heyramzi-repo-rules`, `heyramzi-slop` and `heyramzi-svelte-markup`.
10
10
 
11
11
  There's no ESLint side. The Studio stack doesn't run ESLint, so the flat configs and the
12
12
  `heyramzi-lint` pre-commit CLI are gone.
@@ -31,7 +31,6 @@ deleted on 14 Sep 2026.
31
31
  | `no-class` | Prevents the use of classes in favor of functional composition. |
32
32
  | `no-default-export` | Enforces named exports for better discoverability and refactoring. |
33
33
  | `no-relative-imports` | Error in every preset. Blocks relative specifiers in `import`, `export … from`, `import()`, `require` and `vi.mock`; only `./$types` is exempt. `--fix` rewrites each to the most specific alias covering the target: package.json `imports` first, then tsconfig `paths` for files that tsconfig includes. `.mjs`, `.cjs` and `*.config.*` files get `imports` only, because they run before a bundler applies aliases. A target no alias covers is reported with no fix: add the alias. |
34
- | `no-relative-type-imports` | Deprecated and off: `no-relative-imports` covers type imports. |
35
34
  | `no-em-dash` | Error. Em and en dashes in comments, strings, template text and Svelte text. A spaced hyphen (` - `) fails there too, except where it isn't a dash: a string whose whole value is one em or en dash (an empty-value placeholder), a string or template that is code judged on content (SQL keywords like `SELECT … FROM` or `WHERE`, CSS `calc(` or `var(--`, GLSL `gl_` or `vec2`, JS `function x(`, `=>` or `const x =`), a markdown bullet at the start of a template line or comment line, `<style>`, and `sql`/`css`/`gql`/`html`/`raw` templates. Test and fixture files (`*.test.*`, `*.spec.*`, `__fixtures__/`, `fixtures/`) pass a spaced hyphen because their strings are inputs under test. A real em or en dash fails everywhere except a bare placeholder and a tagged template. |
36
35
  | `no-svelte4-syntax` | Error. `export let` and `$:` in a `.svelte` file. Svelte 4 markup is the markup gate's job. |
37
36
  | `no-app-stores` | Error. An import from `$app/stores`; read `page` from `$app/state`. |
@@ -42,7 +41,7 @@ deleted on 14 Sep 2026.
42
41
  | `no-redundant-prop-fallback` | Error. A `\|\|` or `??` fallback repeating the default a prop already has, in parameters, `$props()` and markup. |
43
42
  | `no-vitest-import-in-vite-plus` | Error. An import from `vitest` in a Vite+ package with no `vitest` dependency of its own; import from `vite-plus/test`. |
44
43
  | `no-skipped-or-focused-tests` | Error. `.only`, `.skip("title", fn)`, `xit`, `fit` and kin. A Playwright conditional skip, `test.skip(cond, "reason")`, passes. |
45
- | `convex-*` | 10 rules, an `overrides` entry on `**/convex/**` in the base config. Error: `require-args` (and the old function syntax), `no-deprecated-crons`, `internal-only` (crons, scheduler, HTTP actions), `no-get-metadata`, `runtime-imports`. Warn while old hits remain: `require-returns`, `no-filter`, `no-v-any`, `no-date-now-in-query`, `index-name`. `convex-react-client-module-scope` is error everywhere. Replaces the `@convex-dev/eslint-plugin` the skill claimed and no repo installed. |
44
+ | `convex-*` | 10 rules, an `overrides` entry on `**/convex/**` in the base config. Error: `require-args` (and the old function syntax), `no-hourly-daily-crons`, `internal-only` (crons, scheduler, HTTP actions), `no-get-metadata`, `runtime-imports`. Warn while old hits remain: `require-returns`, `no-filter`, `no-v-any`, `no-date-now-in-query`, `index-name`. `convex-react-client-module-scope` is error everywhere. Replaces the `@convex-dev/eslint-plugin` the skill claimed and no repo installed. |
46
45
 
47
46
  ## The alias convention `no-relative-imports` enforces
48
47
 
@@ -143,7 +142,7 @@ Test and fixture files need no entry: `no-emoji` skips them already.
143
142
 
144
143
  ### Comment Length Gate
145
144
 
146
- The conduct core caps a comment at 30 words. `heyramzi-comment-length` enforces it over a
145
+ `ai-doc/rules/comments.md` caps a comment at 30 words. `heyramzi-comment-length` enforces it over a
147
146
  whole repo: block comments, HTML comments and runs of consecutive line comments (`//`, `#`, `--`).
148
147
 
149
148
  ```bash
@@ -154,7 +153,7 @@ It's a hard cap. There's no allowlist, no baseline and no `--update`: a file ove
154
153
  until the comment is cut down, whether it's new or years old.
155
154
 
156
155
  The same gate fails a comment that marks a hack left in place: a `TODO`, `FIXME` or `HACK` tag,
157
- "for now", a temporary fix, backward compat, a legacy alias or path, or `@deprecated`. Agents
156
+ "for now", a temporary fix, backward compat or `@deprecated`. Agents
158
157
  copy these along with the hack. Fix what the comment names, then delete it. A word inside
159
158
  backticks is a quote and passes. The list is `MARKERS` in `comment-length.js`.
160
159
 
@@ -195,12 +194,16 @@ Installed into every Studio repo's active pre-push hook by `bash ai-doc/scripts/
195
194
 
196
195
  ### File Token Gate
197
196
 
198
- No staged file may run past 5,000 tokens, counted as bytes / 4. `heyramzi-file-tokens` reads the
199
- staged blobs, not the working copy, so it judges exactly what the commit records.
197
+ No staged file may run past its cap, counted as bytes / 4: 5,000 tokens for code, 8,000 for
198
+ Markdown, because a reference reads top to bottom and splitting it at 5,000 scattered one topic over
199
+ linked files. `heyramzi-file-tokens` reads the staged blobs, not the working copy, so it judges
200
+ exactly what the commit records. `heyramzi-file-tokens --file <path>…` runs the same check on working
201
+ copies; the `file-tokens-warn.sh` hook calls it after every write, so a file is split when it
202
+ crosses the cap, not at someone else's commit.
200
203
 
201
204
  It skips pages, stylesheets and logs (`.html`, `.css`, `.log`), and what no person or agent
202
205
  writes by hand: lockfiles, `CHANGELOG.md`, `*.generated.*`, minified bundles, source maps, snapshots, SVGs, binaries, generated and vendored trees (`dist`,
203
- `_generated`, `vendor`, the `.claude/` projections, …), files whose first 2 KB carry a codegen
206
+ `_generated`, `vendor`, `.obsidian` plugin bundles, the `.claude/` projections, …), files whose first 2 KB carry a codegen
204
207
  header (`@generated`, `Do not edit`, `Generated by`), and paths `.gitattributes` marks
205
208
  `linguist-generated`. A pure rename passes. There's no config and no allowlist: a hand-written
206
209
  file over the cap is split, and the refusal says how.
@@ -219,6 +222,23 @@ classes in Swift, zoom-blocking viewports, `user-select: none` on body, `outline
219
222
  `@heyramzi/ui/src`, `adapter-cloudflare` without `paths.relative: false`, and a `static/` file under a route.
220
223
  No allowlist and no config.
221
224
 
225
+ Interface rules, from Jakub Krehel's checklist (in `ui-rules-css.js` and `ui-rules-markup.js`). They judge
226
+ `.svelte`, `.css`, `.ts` and `.tsx`, never `.html` exports, and skip comments and `<code>`:
227
+
228
+ - **CSS and Tailwind:** `will-change-all`, `will-change-layout` (anything but transform, opacity, filter),
229
+ `press-scale-floor` (an `active` scale under 0.95), `keyframe-toggle` (an `animation` on hover or open
230
+ state; the shared collapsible and accordion height keyframes pass), `light-small-text` (font-thin,
231
+ extralight or light with text-xs, sm or base), `line-height-unit` (px or rem), `tracking-px` (px or rem
232
+ letter-spacing), `raw-font-tags` (`wght`, `opsz`, `tnum`, `zero` in the font-*-settings properties),
233
+ `balance-paragraph` (`text-balance` on a `<p>`), `text-justify`. The `line-height: 24px`-style
234
+ declarations aren't read in plain `.ts`, where they're emails and OG images.
235
+ - **Markup, `.svelte` only:** `div-onclick` (a `<div>` or `<span>` with `onclick` and no role),
236
+ `role-no-keys` (role button or tab with `onclick`, no key handler, no `use:` or spread),
237
+ `img-no-alt`, `aria-hidden-focusable`, `input-autocomplete` (email, password or tel), `block-paste`
238
+ (a handler that only cancels the paste).
239
+ - **Any file:** `positive-tabindex`, `locale-less-date` (`toLocaleDateString()`, or `toLocaleTimeString()`,
240
+ with no locale).
241
+
222
242
  ### Svelte Markup Gate
223
243
 
224
244
  oxlint never reads a `.svelte` template, and skips a file with no `<script>`, so
@@ -234,6 +254,37 @@ pnpm exec heyramzi-svelte-markup --all # every tracked .svelte file
234
254
  Generated and vendored trees are skipped (`DEFAULT_IGNORED` in `svelte-markup.js`). It runs in the
235
255
  managed `push gates` block. No allowlist and no config.
236
256
 
257
+ ### Old Names Gate
258
+
259
+ "None of our repos should have legacy" (Ramzi, 22 Oct 2026). `heyramzi-old-names` fails a repo
260
+ that keeps a name for how things used to work. The house rule it enforces: a refactor leaves no
261
+ compat layer, so no alias export, no `oldField?` kept for a migration and no shim.
262
+
263
+ ```bash
264
+ pnpm exec heyramzi-old-names # the pushed files, or every tracked one outside a push
265
+ pnpm exec heyramzi-old-names --all # every tracked file, for an audit
266
+ ```
267
+
268
+ - **The word `legacy`**, in any case and inside a longer word (`fromLegacy`, `LEGACY_KID`,
269
+ `monthlyValueLegacy`): in identifiers, comments, strings, template text and markup, and in file
270
+ and folder names.
271
+ - **`compat`, `deprecated` and `shim`** (and `shims`), as a word part of a name only: an
272
+ identifier, a JSON, YAML or TOML key, a CSS or HTML name, a file or folder name. `toCompat`,
273
+ `deprecatedField` and `api-shim.ts` fail. `compatible`, `shimmer` and the word in a comment or
274
+ a string value pass, because they are different words or prose.
275
+ - **Files read:** `ts tsx mts cts js jsx mjs cjs svelte swift py sql sh bash zsh css html json
276
+ jsonc yaml yml toml`. Prose (`md`, `mdx`, `txt`) is out of scope.
277
+ - **Skipped:** what the file-token gate already treats as a tool's output (lockfiles,
278
+ `*.generated.*`, minified bundles, `dist`, `_generated`, `vendor`, `.svelte-kit`, `.obsidian`
279
+ plugin bundles, the `.claude` projections, …), a file whose first 2 KB carry a codegen header (`worker-configuration.d.ts`),
280
+ a path `.gitattributes` marks `linguist-generated`, and any `migrations/` or `drizzle/` folder,
281
+ because an applied migration is history the database already ran.
282
+
283
+ Each hit prints the file, line and token, then says to delete the old path and move every caller,
284
+ or rename the thing to what it is now. No allowlist, no baseline, no config, no ignore marker.
285
+ Installed with the other push gates by `bash ai-doc/scripts/pre-push-hooks.sh`. The source spells
286
+ the banned words in pieces, so it passes its own gate.
287
+
237
288
  ### Repo Rules Gate
238
289
 
239
290
  `heyramzi-repo-rules` judges staged files, and `--all` every tracked file for an audit. Each
@@ -109,9 +109,7 @@ function gate(files) {
109
109
  console.error(`${file}:${line}: ${label}`);
110
110
  }
111
111
  if (markers.length > 0) {
112
- console.error(
113
- "\nFix what the comment names, or delete it. No legacy name survives a refactor.",
114
- );
112
+ console.error("\nFix what the comment names, or delete it. No old name survives a refactor.");
115
113
  }
116
114
  exit(1);
117
115
  }
@@ -1,42 +1,72 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The file-token gate over staged files. Usage: this package's README, "File Token Gate".
3
+ * The file-token gate. Usage: this package's README, "File Token Gate".
4
4
  *
5
- * heyramzi-file-tokens # non-zero when a staged file runs over 5,000 tokens
5
+ * heyramzi-file-tokens # staged files
6
+ * heyramzi-file-tokens --file <p>… # working copies, for the write-time hook
6
7
  */
7
8
 
8
9
  import { execFileSync } from "node:child_process";
9
- import { exit } from "node:process";
10
+ import { readFileSync, statSync } from "node:fs";
11
+ import { dirname, relative, resolve } from "node:path";
12
+ import { argv, exit } from "node:process";
10
13
 
11
- import {
12
- MAX_TOKENS,
13
- check,
14
- generatedContent,
15
- ignoredPath,
16
- report,
17
- } from "#lint/file-tokens.js";
14
+ import { check, generatedContent, ignoredPath, report } from "#lint/file-tokens.js";
18
15
 
19
- const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
16
+ const fileMode = argv[2] === "--file";
17
+ // A hook passes a path from any repo, so the file's own folder names the repo, not the cwd.
18
+ const repoRoot = gitRoot(fileMode && argv[3] ? dirname(resolve(argv[3])) : ".");
19
+ if (!repoRoot) exit(0);
20
20
  const git = (args, input) =>
21
21
  execFileSync("git", args, { cwd: repoRoot, input, maxBuffer: 256 * 1024 * 1024 });
22
22
 
23
- const failures = check(stagedSizes(staged().filter((file) => !ignoredPath(file))));
23
+ const failures = fileMode
24
+ ? check(workingSizes(argv.slice(3)))
25
+ : check(stagedSizes(staged().filter((file) => !ignoredPath(file))));
24
26
  const real = failures.filter(({ file }) => !generated(file));
25
27
 
26
28
  if (real.length === 0) {
27
- console.log(`file-tokens: every staged file is under ${MAX_TOKENS.toLocaleString("en-US")} tokens`);
29
+ console.log(`file-tokens: every ${fileMode ? "" : "staged "}file is under its cap`);
28
30
  } else {
29
31
  console.error(report(real));
30
32
  exit(1);
31
33
  }
32
34
 
35
+ function gitRoot(cwd) {
36
+ try {
37
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
38
+ cwd,
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "ignore"],
41
+ }).trim();
42
+ } catch {
43
+ return "";
44
+ }
45
+ }
46
+
47
+ /** Working-copy sizes of the given paths, repo-relative, skipping what the gate never caps. */
48
+ function workingSizes(paths) {
49
+ return Object.fromEntries(
50
+ paths
51
+ .map((path) => relative(repoRoot, resolve(path)))
52
+ .filter((file) => !file.startsWith("..") && !ignoredPath(file))
53
+ .flatMap((file) => {
54
+ try {
55
+ return [[file, statSync(resolve(repoRoot, file)).size]];
56
+ } catch {
57
+ return [];
58
+ }
59
+ }),
60
+ );
61
+ }
62
+
33
63
  /** Added, copied, modified and renamed files. A pure move (R100) changes nothing, so it passes. */
34
64
  function staged() {
35
65
  const fields = git(["diff", "--cached", "--name-status", "-M", "-z", "--diff-filter=ACMR"])
36
66
  .toString("utf8")
37
67
  .split("\0");
38
68
  const files = [];
39
- for (let index = 0; index < fields.length - 1; ) {
69
+ for (let index = 0; index < fields.length - 1;) {
40
70
  const status = fields[index];
41
71
  const paired = status.startsWith("R") || status.startsWith("C");
42
72
  const target = fields[index + (paired ? 2 : 1)];
@@ -49,18 +79,32 @@ function staged() {
49
79
  /** Byte size of each file's staged blob, which is what gets committed, not the working copy. */
50
80
  function stagedSizes(files) {
51
81
  if (files.length === 0) return {};
52
- const lines = git(["cat-file", "--batch-check=%(objectsize)"], files.map((file) => `:${file}`).join("\n"))
82
+ const lines = git(
83
+ ["cat-file", "--batch-check=%(objectsize)"],
84
+ files.map((file) => `:${file}`).join("\n"),
85
+ )
53
86
  .toString("utf8")
54
87
  .trim()
55
88
  .split("\n");
56
89
  return Object.fromEntries(
57
- files.flatMap((file, index) => (/^\d+$/.test(lines[index]) ? [[file, Number(lines[index])]] : [])),
90
+ files.flatMap((file, index) =>
91
+ /^\d+$/.test(lines[index]) ? [[file, Number(lines[index])]] : [],
92
+ ),
58
93
  );
59
94
  }
60
95
 
61
96
  /** Content markers or `.gitattributes` say a machine wrote this file. Checked only for files over the cap. */
62
97
  function generated(file) {
63
- const attribute = git(["check-attr", "--cached", "linguist-generated", "--", file]).toString("utf8");
98
+ const attribute = git([
99
+ "check-attr",
100
+ ...(fileMode ? [] : ["--cached"]),
101
+ "linguist-generated",
102
+ "--",
103
+ file,
104
+ ]).toString("utf8");
64
105
  if (/: (set|true)\s*$/.test(attribute)) return true;
65
- return generatedContent(git(["cat-file", "blob", `:${file}`]).subarray(0, 8000));
106
+ const head = fileMode
107
+ ? readFileSync(resolve(repoRoot, file))
108
+ : git(["cat-file", "blob", `:${file}`]);
109
+ return generatedContent(head.subarray(0, 8000));
66
110
  }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The old-names gate over pushed files (every tracked one outside a push), `--all`, or named files.
4
+ * Usage: this package's README, "Old Names Gate".
5
+ */
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { exit } from "node:process";
11
+
12
+ import { gateFiles } from "#lint/gate-files.js";
13
+ import { check, OLD_NAME_EXTENSIONS, report, selectFiles } from "#lint/old-names.js";
14
+
15
+ const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
16
+ // Run from a product inside a monorepo, the gate reads that product's files, not its siblings'.
17
+ const scope = execFileSync("git", ["rev-parse", "--show-prefix"], { encoding: "utf8" })
18
+ .trim()
19
+ .replace(/\/$/, "");
20
+
21
+ const args = process.argv.slice(2);
22
+ if (args.includes("--all")) delete process.env.PUSH_FILES;
23
+ const targets = args.filter((arg) => !arg.startsWith("-"));
24
+
25
+ const candidates =
26
+ targets.length > 0
27
+ ? targets
28
+ : process.env.PUSH_FILES !== undefined
29
+ ? process.env.PUSH_FILES.split("\n").filter(Boolean)
30
+ : repoFiles();
31
+ const files = notMarkedGenerated(gateFiles(repoRoot, selectFiles(candidates)));
32
+
33
+ const hits = check(
34
+ Object.fromEntries(files.map((file) => [file, readFileSync(path.join(repoRoot, file), "utf8")])),
35
+ );
36
+ if (hits.length === 0) {
37
+ console.log(`old-names: ${files.length} file(s), no old name`);
38
+ } else {
39
+ console.error(report(hits));
40
+ exit(1);
41
+ }
42
+
43
+ /** Tracked files plus new ones git hasn't seen yet, minus any deleted from the working tree. */
44
+ function repoFiles() {
45
+ const patterns = OLD_NAME_EXTENSIONS.map((ext) => `*.${ext}`);
46
+ const args = ["ls-files", "--cached", "--others", "--exclude-standard", "--deduplicate"];
47
+ return execFileSync("git", [...args, "--full-name", "--", ...patterns], {
48
+ cwd: path.join(repoRoot, scope),
49
+ encoding: "utf8",
50
+ maxBuffer: 256 * 1024 * 1024,
51
+ })
52
+ .trim()
53
+ .split("\n")
54
+ .filter((file) => file && existsSync(path.join(repoRoot, file)));
55
+ }
56
+
57
+ /** Drops the files `.gitattributes` marks linguist-generated, the way the file-token gate does. */
58
+ function notMarkedGenerated(list) {
59
+ if (list.length === 0) return list;
60
+ const out = execFileSync("git", ["check-attr", "-z", "--stdin", "linguist-generated"], {
61
+ cwd: repoRoot,
62
+ input: `${list.join("\0")}\0`,
63
+ maxBuffer: 256 * 1024 * 1024,
64
+ })
65
+ .toString("utf8")
66
+ .split("\0");
67
+ const generated = new Set();
68
+ for (let index = 0; index + 2 < out.length; index += 3) {
69
+ if (out[index + 2] === "set" || out[index + 2] === "true") generated.add(out[index]);
70
+ }
71
+ return list.filter((file) => !generated.has(file));
72
+ }
@@ -59,11 +59,17 @@ function mergeBase() {
59
59
 
60
60
  /** New path to old path for every added, copied, modified or renamed file. A pure move is R100 and changes nothing. */
61
61
  function changedFiles() {
62
- const fields = git(["diff", "--name-status", "-M", "-z", "--diff-filter=ACMR", base, "HEAD"]).split(
63
- "\0",
64
- );
62
+ const fields = git([
63
+ "diff",
64
+ "--name-status",
65
+ "-M",
66
+ "-z",
67
+ "--diff-filter=ACMR",
68
+ base,
69
+ "HEAD",
70
+ ]).split("\0");
65
71
  const map = new Map();
66
- for (let index = 0; index < fields.length - 1; ) {
72
+ for (let index = 0; index < fields.length - 1;) {
67
73
  const status = fields[index];
68
74
  const paired = status.startsWith("R") || status.startsWith("C");
69
75
  const target = fields[index + (paired ? 2 : 1)];
@@ -87,7 +93,12 @@ function registryFiles(candidates) {
87
93
  "--",
88
94
  ...code.slice(index, index + 200),
89
95
  ]);
90
- found.push(...out.split("\n").filter(Boolean).map((line) => line.replace(/^HEAD:/, "")));
96
+ found.push(
97
+ ...out
98
+ .split("\n")
99
+ .filter(Boolean)
100
+ .map((line) => line.replace(/^HEAD:/, "")),
101
+ );
91
102
  }
92
103
  return found.filter((file) => hasStorageKeys(tryGit(["show", `HEAD:${file}`])));
93
104
  }
@@ -1,10 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The UI-rules gate over staged or pushed files. Usage: this package's README, "UI Rules Gate".
4
- *
5
- * heyramzi-ui-rules # staged files, or PUSH_FILES when the pre-push hook sets it
6
- * heyramzi-ui-rules --all # every tracked file
7
- * heyramzi-ui-rules a.svelte # named files
3
+ * The UI-rules gate over staged or pushed files, `--all` for every tracked file, or named files.
4
+ * Usage: this package's README, "UI Rules Gate".
8
5
  */
9
6
 
10
7
  import { execFileSync } from "node:child_process";
@@ -13,10 +10,18 @@ import path from "node:path";
13
10
  import { exit } from "node:process";
14
11
 
15
12
  import { gateFiles } from "#lint/gate-files.js";
16
- import { checkFile, checkProject, durationNames, ignoredPath, report, resolvedDurations } from "#lint/ui-rules.js";
13
+ import {
14
+ checkFile,
15
+ checkProject,
16
+ durationNames,
17
+ ignoredPath,
18
+ report,
19
+ resolvedDurations,
20
+ } from "#lint/ui-rules.js";
17
21
 
18
22
  const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
19
- const git = (args) => execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
23
+ const git = (args) =>
24
+ execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
20
25
  const lines = (text) => text.split("\n").filter(Boolean);
21
26
 
22
27
  const args = process.argv.slice(2);
@@ -33,7 +38,10 @@ const candidates = named.length
33
38
  ? lines(git(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]))
34
39
  : lines(process.env.PUSH_FILES);
35
40
 
36
- const files = gateFiles(repoRoot, candidates.filter((file) => !ignoredPath(file)));
41
+ const files = gateFiles(
42
+ repoRoot,
43
+ candidates.filter((file) => !ignoredPath(file)),
44
+ );
37
45
  const read = (file) => {
38
46
  const full = path.join(repoRoot, file);
39
47
  return existsSync(full) ? readFileSync(full, "utf8") : null;
@@ -46,7 +54,8 @@ for (const file of files) {
46
54
  for (const hit of checkFile(file, source)) hits.push({ file, ...hit });
47
55
  }
48
56
  hits.push(...checkProject(files, tracked, read));
49
- if (hits.some((hit) => hit.rule === "duration-bare")) hits = resolvedDurations(hits, durationNames(tracked, read));
57
+ if (hits.some((hit) => hit.rule === "duration-bare"))
58
+ hits = resolvedDurations(hits, durationNames(tracked, read));
50
59
 
51
60
  if (hits.length === 0) {
52
61
  console.log("ui-rules: no violations");
package/comment-length.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The 30-word comment cap: a comment that outgrows its explanation stops being
3
- * read. The rule is `ai-doc/references/agents-core.md`.
3
+ * read. The rule is `ai-doc/rules/comments.md`.
4
4
  *
5
5
  * Pure here, git and process in `bin/heyramzi-comment-length.js`.
6
6
  */
@@ -150,10 +150,13 @@ function mergeRuns(src, spans) {
150
150
  return merged;
151
151
  }
152
152
 
153
- /** The comments of a file in `extension`'s language, as spans. */
154
- export function commentSpansFor(src, extension) {
153
+ /**
154
+ * The comments of a file in `extension`'s language, as spans. Pass `strings` and the string and
155
+ * heredoc spans of Swift, Python, shell, CSS and SQL land in it.
156
+ */
157
+ export function commentSpansFor(src, extension, strings) {
155
158
  const syntax = syntaxOf(extension);
156
- return syntax ? simpleSpans(src, syntax) : commentSpans(src);
159
+ return syntax ? simpleSpans(src, syntax, strings) : commentSpans(src);
157
160
  }
158
161
 
159
162
  function skipString(src, i, syntax) {
@@ -202,14 +205,16 @@ function skipHeredocs(src, i, pending) {
202
205
  return j;
203
206
  }
204
207
 
205
- function simpleSpans(src, syntax) {
208
+ function simpleSpans(src, syntax, strings) {
206
209
  const spans = [];
207
210
  const pending = [];
208
211
  let i = 0;
209
212
  while (i < src.length) {
210
213
  const c = src[i];
211
214
  if (c === "\n" && pending.length) {
215
+ const bodyStart = i;
212
216
  i = skipHeredocs(src, i, pending.splice(0));
217
+ strings?.push({ start: bodyStart, end: i });
213
218
  } else if (c === "\\" && syntax.escapes) {
214
219
  i += 2;
215
220
  } else if (
@@ -238,12 +243,21 @@ function simpleSpans(src, syntax) {
238
243
  spans.push({ kind: "block", start: i, end: j });
239
244
  i = j;
240
245
  } else if (syntax.raw && c === "#" && rawStringEnd(src, i) !== null) {
246
+ strings?.push({ start: i, end: rawStringEnd(src, i) });
241
247
  i = rawStringEnd(src, i);
242
- } else if (syntax.url && src.startsWith("url(", i) && !/^url\(\s*["']/.test(src.slice(i, i + 12))) {
248
+ } else if (
249
+ syntax.url &&
250
+ src.startsWith("url(", i) &&
251
+ !/^url\(\s*["']/.test(src.slice(i, i + 12))
252
+ ) {
243
253
  const close = src.indexOf(")", i);
244
- i = close === -1 ? src.length : close + 1;
254
+ const stop = close === -1 ? src.length : close + 1;
255
+ strings?.push({ start: i, end: stop });
256
+ i = stop;
245
257
  } else if (syntax.quotes.includes(c)) {
246
- i = skipString(src, i, syntax);
258
+ const stop = skipString(src, i, syntax);
259
+ strings?.push({ start: i, end: stop });
260
+ i = stop;
247
261
  } else {
248
262
  // `<<<` is a here-string, and its 2nd and 3rd `<` must not read as a heredoc opener.
249
263
  const opener =
@@ -261,13 +275,13 @@ const REGEX_AFTER = new Set("(,=:[!&|?{};+-*%<>~^");
261
275
  const REGEX_KEYWORD = /\b(?:return|typeof|case|in|of|delete|void|throw|new|else|do|yield|await)$/;
262
276
 
263
277
  /** A `/` opens a regex after an operator or keyword, never after a value, so `a / b` stays division. */
264
- function regexStarts(src, i) {
278
+ export function regexStarts(src, i) {
265
279
  const before = src.slice(0, i).trimEnd();
266
280
  return before === "" || REGEX_AFTER.has(before[before.length - 1]) || REGEX_KEYWORD.test(before);
267
281
  }
268
282
 
269
283
  /** Where a regex literal ends, flags included. A newline first means it was division after all. */
270
- function regexEnd(src, i) {
284
+ export function regexEnd(src, i) {
271
285
  let j = i + 1;
272
286
  let inClass = false;
273
287
  while (j < src.length) {
@@ -318,10 +332,6 @@ export const MARKERS = [
318
332
  test: /\btemporar(?:y|ily)\s+(?:fix|workaround|hack|patch|solution)\b/i,
319
333
  },
320
334
  { label: "backward compatibility", test: /\bbackwards?[\s-]*compat/i },
321
- {
322
- label: "a legacy name or path",
323
- test: /\blegacy\s+(?:alias|synonym|shim|wrapper|fallback|export|import|path|name|field|support|code|compat)\b/i,
324
- },
325
335
  { label: "a @deprecated tag", test: /@deprecated\b/ },
326
336
  ];
327
337
 
@@ -58,8 +58,7 @@
58
58
  "@heyramzi/lint/no-inline-svg": "error",
59
59
  "@heyramzi/lint/no-inline-path": "error",
60
60
  "@heyramzi/lint/no-relative-imports": "error",
61
- "@heyramzi/lint/no-relative-type-imports": "off",
62
- "@heyramzi/lint/no-legacy-compat": "error",
61
+ "@heyramzi/lint/no-underscore-reexport": "error",
63
62
  "@heyramzi/lint/convex-react-client-module-scope": "error",
64
63
  "@heyramzi/lint/no-chained-type-assertions": "warn",
65
64
  "@heyramzi/lint/no-conditional-empty-object-spread": "warn",
@@ -73,7 +72,7 @@
73
72
  "@heyramzi/lint/convex-index-name": "warn",
74
73
  "@heyramzi/lint/convex-internal-only": "error",
75
74
  "@heyramzi/lint/convex-no-date-now-in-query": "warn",
76
- "@heyramzi/lint/convex-no-deprecated-crons": "error",
75
+ "@heyramzi/lint/convex-no-hourly-daily-crons": "error",
77
76
  "@heyramzi/lint/convex-no-filter": "warn",
78
77
  "@heyramzi/lint/convex-no-get-metadata": "error",
79
78
  "@heyramzi/lint/convex-no-v-any": "warn",
@@ -13,12 +13,12 @@ export const TRAIN = [
13
13
  code: `export async function load({ fetch }) {\n const body = await (await fetch("/api/me")).json();\n // @ts-ignore\n return { name: body.profile.name as string };\n}`,
14
14
  },
15
15
  {
16
- rule: "compat",
16
+ rule: "oldname",
17
17
  file: "src/lib/billing.ts",
18
18
  code: `export function chargeCustomer(id: string, cents: number) {\n return stripe.charges.create({ customer: id, amount: cents });\n}\n\n/** @deprecated use chargeCustomer */\nexport const charge = chargeCustomer;`,
19
19
  },
20
20
  {
21
- rule: "compat",
21
+ rule: "oldname",
22
22
  file: "src/lib/user.ts",
23
23
  code: `export function displayName(u: User) {\n const first = u.firstName ?? u.first_name;\n const last = u.lastName ?? u.last_name;\n return \`\${first} \${last}\`;\n}`,
24
24
  },
@@ -95,7 +95,7 @@ export const TRAIN = [
95
95
  {
96
96
  rule: "leftover",
97
97
  file: "src/lib/checkout.ts",
98
- code: `export function checkout(cart: Cart) {\n console.log("cart here", cart);\n // const old = legacyCheckout(cart);\n // return old;\n return submit(cart);\n}`,
98
+ code: `export function checkout(cart: Cart) {\n console.log("cart here", cart);\n // const old = oldCheckout(cart);\n // return old;\n return submit(cart);\n}`,
99
99
  },
100
100
  {
101
101
  rule: "leftover",
@@ -206,7 +206,7 @@ export const HELDOUT = [
206
206
  code: `export function handle(evt: Stripe.Event) {\n const session = evt.data.object as unknown as CheckoutSession;\n return session.customer_email!.toLowerCase();\n}`,
207
207
  },
208
208
  {
209
- rule: "compat",
209
+ rule: "oldname",
210
210
  file: "src/lib/index.ts",
211
211
  code: `export { renderCard } from "./card";\n// kept so old imports keep working\nexport { renderCard as drawCard } from "./card";`,
212
212
  },
@@ -248,7 +248,7 @@ export const HELDOUT = [
248
248
  {
249
249
  rule: "leftover",
250
250
  file: "Sources/Feed.swift",
251
- code: `func refresh() async {\n print("DEBUG items", items.count)\n // items = try await legacyFetch()\n items = try await api.feed()\n}`,
251
+ code: `func refresh() async {\n print("DEBUG items", items.count)\n // items = try await oldFetch()\n items = try await api.feed()\n}`,
252
252
  },
253
253
  {
254
254
  rule: "workaround",