@heyramzi/lint 1.34.0 → 2.1.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 (60) hide show
  1. package/README.md +52 -5
  2. package/bin/heyramzi-comment-length.js +5 -6
  3. package/bin/heyramzi-file-tokens.js +2 -2
  4. package/bin/heyramzi-old-names.js +72 -0
  5. package/bin/heyramzi-push-diff.js +16 -5
  6. package/bin/heyramzi-slop.js +3 -1
  7. package/bin/heyramzi-ui-rules.js +18 -9
  8. package/comment-length.js +18 -12
  9. package/config/oxlintrc.json +3 -4
  10. package/data/jev-code-bench.js +5 -5
  11. package/data/slop/artifacts.js +87 -0
  12. package/data/slop/pattern-parts.js +56 -0
  13. package/data/slop/patterns/appositives-and-sayings.js +81 -0
  14. package/data/slop/patterns/comma-clauses.js +106 -0
  15. package/data/slop/patterns/diagram-and-script-tells.js +71 -0
  16. package/data/slop/patterns/dropped-elements.js +149 -0
  17. package/data/slop/patterns/duration-and-openers.js +102 -0
  18. package/data/slop/patterns/headings-and-laws.js +116 -0
  19. package/data/slop/patterns/numbers-and-runs.js +94 -0
  20. package/data/slop/patterns/openers-and-tails.js +90 -0
  21. package/data/slop/patterns/parallel-shapes.js +100 -0
  22. package/data/slop/patterns/reveals-and-tallies.js +101 -0
  23. package/data/slop/patterns/setups-and-closers.js +83 -0
  24. package/data/slop/patterns/subject-voice.js +67 -0
  25. package/data/slop/patterns/vague-and-flourish.js +102 -0
  26. package/data/slop/patterns/wikipedia-signs.js +103 -0
  27. package/data/slop/patterns.js +62 -0
  28. package/data/slop/phrases-en-closers.js +66 -0
  29. package/data/slop/phrases-en-core.js +185 -0
  30. package/data/slop/phrases-en-wikipedia.js +136 -0
  31. package/data/slop/phrases-fr.js +152 -0
  32. package/data/slop/shapes-density.js +81 -0
  33. package/data/slop/shapes-framing.js +101 -0
  34. package/data/slop/shapes-reveals.js +88 -0
  35. package/data/slop/title-tells.js +8 -0
  36. package/data/slop/words-en.js +178 -0
  37. package/data/slop/words-fr.js +101 -0
  38. package/data/slop-words.js +18 -2576
  39. package/data/vendor/heading-words.js +6 -0
  40. package/file-tokens.js +26 -13
  41. package/index.js +4 -6
  42. package/inline-path.js +2 -0
  43. package/jev-code.js +1 -1
  44. package/old-names-mask.js +206 -0
  45. package/old-names.js +145 -0
  46. package/package.json +8 -2
  47. package/push-diff.js +2 -2
  48. package/repo-rules/packages.js +5 -5
  49. package/repo-rules.js +2 -2
  50. package/rules/convex-index-name.js +3 -1
  51. package/rules/convex-internal-only.js +2 -1
  52. package/rules/{convex-no-deprecated-crons.js → convex-no-hourly-daily-crons.js} +8 -7
  53. package/rules/convex-runtime-imports.js +2 -1
  54. package/rules/{no-legacy-compat.js → no-underscore-reexport.js} +1 -1
  55. package/slop.js +4 -2
  56. package/ui-rules-css.js +221 -0
  57. package/ui-rules-markup.js +130 -0
  58. package/ui-rules.js +98 -25
  59. package/vendor/old-names-words.js +9 -0
  60. package/rules/no-relative-type-imports.js +0 -40
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
 
@@ -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
 
@@ -204,7 +203,7 @@ crosses the cap, not at someone else's commit.
204
203
 
205
204
  It skips pages, stylesheets and logs (`.html`, `.css`, `.log`), and what no person or agent
206
205
  writes by hand: lockfiles, `CHANGELOG.md`, `*.generated.*`, minified bundles, source maps, snapshots, SVGs, binaries, generated and vendored trees (`dist`,
207
- `_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
208
207
  header (`@generated`, `Do not edit`, `Generated by`), and paths `.gitattributes` marks
209
208
  `linguist-generated`. A pure rename passes. There's no config and no allowlist: a hand-written
210
209
  file over the cap is split, and the refusal says how.
@@ -223,6 +222,23 @@ classes in Swift, zoom-blocking viewports, `user-select: none` on body, `outline
223
222
  `@heyramzi/ui/src`, `adapter-cloudflare` without `paths.relative: false`, and a `static/` file under a route.
224
223
  No allowlist and no config.
225
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
+
226
242
  ### Svelte Markup Gate
227
243
 
228
244
  oxlint never reads a `.svelte` template, and skips a file with no `<script>`, so
@@ -238,6 +254,37 @@ pnpm exec heyramzi-svelte-markup --all # every tracked .svelte file
238
254
  Generated and vendored trees are skipped (`DEFAULT_IGNORED` in `svelte-markup.js`). It runs in the
239
255
  managed `push gates` block. No allowlist and no config.
240
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
+
241
288
  ### Repo Rules Gate
242
289
 
243
290
  `heyramzi-repo-rules` judges staged files, and `--all` every tracked file for an audit. Each
@@ -101,17 +101,16 @@ function gate(files) {
101
101
  return;
102
102
  }
103
103
 
104
+ // A long comment is a warning: one word over the cap never broke anything. A hack marker fails.
104
105
  for (const { file, words } of failures) {
105
- console.error(`${file}: longest comment ${words} words (over ${max})`);
106
+ console.warn(`warning ${file}: longest comment ${words} words (over ${max})`);
106
107
  }
107
- if (failures.length > 0) console.error("\nCompress the comment to the point it makes.");
108
+ if (failures.length > 0) console.warn("Compress the comment to the point it makes.\n");
108
109
  for (const { file, line, label } of markers) {
109
110
  console.error(`${file}:${line}: ${label}`);
110
111
  }
111
112
  if (markers.length > 0) {
112
- console.error(
113
- "\nFix what the comment names, or delete it. No legacy name survives a refactor.",
114
- );
113
+ console.error("\nFix what the comment names, or delete it. No old name survives a refactor.");
114
+ exit(1);
115
115
  }
116
- exit(1);
117
116
  }
@@ -28,8 +28,8 @@ const real = failures.filter(({ file }) => !generated(file));
28
28
  if (real.length === 0) {
29
29
  console.log(`file-tokens: every ${fileMode ? "" : "staged "}file is under its cap`);
30
30
  } else {
31
- console.error(report(real));
32
- exit(1);
31
+ // A warning: splits made only to clear a few tokens were churn. Split by job when you're in the file.
32
+ console.warn(`warning ${report(real)}`);
33
33
  }
34
34
 
35
35
  function gitRoot(cwd) {
@@ -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
  }
@@ -53,7 +53,9 @@
53
53
 
54
54
  import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
55
55
  import { join } from "node:path";
56
- import { patterns, shapes, titleTells } from "#lint/data/slop-words.js";
56
+ import { shapes } from "#lint/data/slop-words.js";
57
+ import { patterns } from "#lint/data/slop/patterns.js";
58
+ import { titleTells } from "#lint/data/slop/title-tells.js";
57
59
  import { REVIEW, auditShapes, isUnavailable, kiloKey, proseOf } from "#lint/jev.js";
58
60
  import { slopGate } from "#lint/slop.js";
59
61
 
@@ -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
@@ -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,6 +243,7 @@ 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
248
  } else if (
243
249
  syntax.url &&
@@ -245,9 +251,13 @@ function simpleSpans(src, syntax) {
245
251
  !/^url\(\s*["']/.test(src.slice(i, i + 12))
246
252
  ) {
247
253
  const close = src.indexOf(")", i);
248
- 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;
249
257
  } else if (syntax.quotes.includes(c)) {
250
- i = skipString(src, i, syntax);
258
+ const stop = skipString(src, i, syntax);
259
+ strings?.push({ start: i, end: stop });
260
+ i = stop;
251
261
  } else {
252
262
  // `<<<` is a here-string, and its 2nd and 3rd `<` must not read as a heredoc opener.
253
263
  const opener =
@@ -265,13 +275,13 @@ const REGEX_AFTER = new Set("(,=:[!&|?{};+-*%<>~^");
265
275
  const REGEX_KEYWORD = /\b(?:return|typeof|case|in|of|delete|void|throw|new|else|do|yield|await)$/;
266
276
 
267
277
  /** A `/` opens a regex after an operator or keyword, never after a value, so `a / b` stays division. */
268
- function regexStarts(src, i) {
278
+ export function regexStarts(src, i) {
269
279
  const before = src.slice(0, i).trimEnd();
270
280
  return before === "" || REGEX_AFTER.has(before[before.length - 1]) || REGEX_KEYWORD.test(before);
271
281
  }
272
282
 
273
283
  /** Where a regex literal ends, flags included. A newline first means it was division after all. */
274
- function regexEnd(src, i) {
284
+ export function regexEnd(src, i) {
275
285
  let j = i + 1;
276
286
  let inClass = false;
277
287
  while (j < src.length) {
@@ -322,10 +332,6 @@ export const MARKERS = [
322
332
  test: /\btemporar(?:y|ily)\s+(?:fix|workaround|hack|patch|solution)\b/i,
323
333
  },
324
334
  { label: "backward compatibility", test: /\bbackwards?[\s-]*compat/i },
325
- {
326
- label: "a legacy name or path",
327
- test: /\blegacy\s+(?:alias|synonym|shim|wrapper|fallback|export|import|path|name|field|support|code|compat)\b/i,
328
- },
329
335
  { label: "a @deprecated tag", test: /@deprecated\b/ },
330
336
  ];
331
337
 
@@ -57,9 +57,8 @@
57
57
  "@heyramzi/lint/svelte-section-order": "warn",
58
58
  "@heyramzi/lint/no-inline-svg": "error",
59
59
  "@heyramzi/lint/no-inline-path": "error",
60
- "@heyramzi/lint/no-relative-imports": "error",
61
- "@heyramzi/lint/no-relative-type-imports": "off",
62
- "@heyramzi/lint/no-legacy-compat": "error",
60
+ "@heyramzi/lint/no-relative-imports": "warn",
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",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Machine residue. The citation and formatting markup a chatbot leaks into its own output, one entry per tool that leaks it.
3
+ */
4
+
5
+ /*
6
+ * WHY this is separate from `patterns`: a pattern is a judgement about a sentence and a person can disagree with it. These are certainties.
7
+ */
8
+
9
+ /*
10
+ * No writer types `:contentReference[oaicite:0]`, `【85†L261-269】` or `utm_source=chatgpt.com` into finished copy, so a hit is not a style opinion, it is the receipt.
11
+ */
12
+
13
+ /*
14
+ * Wikipedia:Signs of AI writing calls it "an unambiguous indicator that the text originated with AI" (WP:OAICITE, WP:STARTSPAN, WP:GROKCARD), and it is the only half of that page that needs no
15
+ */
16
+
17
+ /*
18
+ * corpus check before shipping.
19
+ */
20
+
21
+ /*
22
+ * Language neutral by construction: the residue is the same in every language, which is why the French page lists `:contentReference[oaicite:2]{index=2}` under "Sources rédigées avec une syntaxe inconnue".
23
+ */
24
+ export const artifacts = [
25
+ {
26
+ id: "openai-citation",
27
+ name: "ChatGPT citation residue",
28
+ test: /:contentReference\[oaicite:\d+\]|\boai_citation\b|\bcontentReference\b|\bcite\u{E200}?turn\d+|\bturn\d+(?:search|image|news|file|view|forecast)\d+|\{"attribution":\s*\{"attributableIndex"/u,
29
+ rule: "ChatGPT's internal reference markup, pasted through. Delete it and write the citation, or delete the sentence that needed one.",
30
+ example: '":contentReference[oaicite:0]{index=0}" / "citeturn0search3"',
31
+ },
32
+ {
33
+ id: "gemini-citation",
34
+ name: "Gemini citation residue",
35
+ test: /\[cite:\s*[\d,\s]+\]|\[span_\d+\]\((?:start|end)_span\)|\((?:start|end)_span\)/,
36
+ rule: "Gemini's citation and span markup, pasted through. Delete it.",
37
+ example: '"[cite: 3, 12, 13]" / "[span_1](start_span)"',
38
+ },
39
+ {
40
+ id: "grok-citation",
41
+ name: "Grok citation residue",
42
+ test: /grok_render_citation_card_json|<\/?grok-card\b|\bgrok_card\b|[?&]referrer=grok\.com\b/i,
43
+ rule: "Grok's citation card markup, pasted through. Delete it.",
44
+ example: '"<grok-card data-id=...>" / "grok_render_citation_card_json"',
45
+ },
46
+ {
47
+ id: "deepseek-citation",
48
+ name: "DeepSeek citation residue",
49
+ test: /【\d+†[^】]*】/,
50
+ rule: "DeepSeek's lenticular-bracket footnote markup, pasted through. Delete it.",
51
+ example: '"【85†L261-269】"',
52
+ },
53
+ {
54
+ id: "perplexity-citation",
55
+ name: "Perplexity citation residue",
56
+ test: /\[(?:attached_file|web):\d+\]|ppl-ai-file-upload/i,
57
+ rule: "Perplexity's file and web reference markup, pasted through. Delete it.",
58
+ example: '"[attached_file:1]" / "[web:1]"',
59
+ },
60
+ {
61
+ id: "assistant-utm",
62
+ name: "Assistant UTM tag",
63
+ // The link the model handed back, with its own referral tag still on it. It
64
+ // proves the URL came from a chatbot even when a human wrote the sentence.
65
+ test: /[?&](?:utm_source=(?:chatgpt\.com|openai|copilot\.com|perplexity\.ai)|referrer=grok\.com)\b/i,
66
+ rule: "A URL still carrying the chatbot's referral parameter. Strip the query string back to the link the reader needs.",
67
+ example: '"https://example.com/article?utm_source=chatgpt.com"',
68
+ },
69
+ {
70
+ id: "document-block-markup",
71
+ name: "Document block markup",
72
+ // The `:::writing{variant="document" id=...}` wrapper, and the fenced
73
+ // wikitext block a chatbot emits when asked for markup it does not speak.
74
+ test: /:::\s*writing\{|```\s*wikitext\b|↩️?/,
75
+ rule: "A document wrapper or a footnote return arrow the chat interface added around the answer. Delete the wrapper and keep the text.",
76
+ example: ':::writing{variant="document" id="12345"} / "```wikitext"',
77
+ },
78
+ {
79
+ id: "assistant-turn-markup",
80
+ name: "Assistant turn markup",
81
+ // A model's own control tags. `function_calls` and `thinking` reach copy
82
+ // when a transcript is pasted rather than the answer.
83
+ test: /<\/?(?:thinking|antml:[a-z_]+|function_calls|invoke name=)\b/i,
84
+ rule: "A control tag from the model's own turn, pasted with the answer. Delete it, and check what else came with it.",
85
+ example: '"<thinking>" / "<function_calls>"',
86
+ },
87
+ ];
@@ -0,0 +1,56 @@
1
+ // Grammar parts shared by `elided-passive` and `parallel-clauses`, kept as
2
+ // strings so each regex reads as the sentence shape it names.
3
+ export const SUBJECT_STOP =
4
+ "(?!(?:I|We|You|They|He|She|It|This|That|These|Those|There|Here|Who|What|Whatever|Which|When|Where|Why|How|If|Once|Then|And|But|So|Or|Every|Each|Anything|Everything|Something|Nothing|Everyone|Anyone|Someone|Nobody|No|Not|Please)\\b)";
5
+ export const PARTICIPLE =
6
+ "(?:[a-z]{3,}ed|(?:re|un|pre|over)?(?:built|made|kept|done|written|shown|sent|held|drawn|given|taken|chosen|driven|known|sold|told|paid|found|brought|bought|taught|grown|seen|spent|laid|hidden|tied))";
7
+ // Words that end a noun phrase: a determiner means verb plus object, an auxiliary means the verb is there.
8
+ export const NOUN_PHRASE_STOP =
9
+ "(?!(?:the|a|an|your|our|their|its|my|his|her|this|that|it|them|me|us|you|anything|everything|something|nothing|is|are|was|were|be|been|being|has|have|had|can|will|would|should|may|might|must|do|does|did|get|gets|got|not|never|to)\\b)";
10
+ // Past tenses that take a preposition without being passive: "the team worked on".
11
+ export const INTRANSITIVE_PAST =
12
+ "(?!(?:started|worked|moved|happened|stayed|waited|lived|failed|talked|looked|ended|lasted|belonged|listened|responded|reacted|agreed|arrived|returned|walked|travelled|traveled|asked|applied|voted|replied|opted|relied|depended|focused|pointed|referred)\\b)";
13
+ export const PREPOSITION =
14
+ "(?:around|for|by|with|into|from|in|on|to|at|over|under|through|across|inside|onto|per|behind|beside|without|before|after|until|as)";
15
+ export const FINITE_AUXILIARY =
16
+ "(?:is|are|was|were|has|have|had|can|will|would|should|must|may|might|does|do|did|gets|get)";
17
+ export const CLAUSE_SUBJECT = `(?:(?:[Tt]he|[Yy]our|[Oo]ur|[Tt]heir|[Ee]ach|[Ee]very)[ \\t]+[a-z][\\w'’-]*|${SUBJECT_STOP}[A-Z][\\w&'’-]*)`;
18
+ export const PRESENT_VERB =
19
+ "(?!(?:is|was|has|does|this|its|his|us|yes|plus|less|thus|across|always|perhaps|as|towards)\\b)[a-z]{2,}s";
20
+ export const OBJECT_DETERMINER = "(?:the|a|an|your|its|their|our|his|her|every|each|all|it|them)";
21
+
22
+ export const PARTICIPLE_LIST =
23
+ "(?:[a-z]{3,}ed|built|made|kept|done|written|shown|sent|held|drawn|given|taken|chosen|driven|known|sold|told|paid|found|brought|bought|taught|grown|seen|spent|laid|hidden)";
24
+
25
+ // A quantity, written either way. Thousands take a comma, because "3,556 videos"
26
+ // is how the measured claims in the video skills are written.
27
+ export const COUNT =
28
+ "(?:\\d{1,3}(?:,\\d{3})+|\\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)";
29
+ // Time units, for `bare-duration-clause`. Closed on purpose: every one of these
30
+ // is a noun in every reading, which is what makes a missing verb decidable.
31
+ export const DURATION =
32
+ "(?:sec|secs|second|seconds|min|mins|minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)";
33
+ // A word that cannot carry a real second clause: no auxiliary, and no pronoun
34
+ // that would supply the subject ("15 minutes, it takes time" is a sentence).
35
+ export const TAIL_WORD =
36
+ "(?!(?:is|are|was|were|be|been|being|has|have|had|can|will|would|should|may|might|must|does|do|did|gets|get|i|it|we|you|they|he|she|there|this|that)(?![\\w'’-]))[a-zà-ÿ][\\w'’-]*";
37
+ /**
38
+ * Where a sentence starts, for the patterns that judge a whole clause.
39
+ */
40
+
41
+ /*
42
+ * A bare `\n` is deliberately not in it.
43
+ */
44
+
45
+ /*
46
+ * Markdown prose here is hard-wrapped, so every line start looks sentence-initial and a clause anchored on `\n` reads the middle of a paragraph as its beginning:
47
+ */
48
+
49
+ /*
50
+ * it put 4 false hits on shipped skills before the paragraph anchor replaced it, all of them mid-sentence ("the next video without the algorithm's help").
51
+ */
52
+
53
+ /*
54
+ * A blank line is a real paragraph start, and `.!?:;` plus a space is a real sentence start.
55
+ */
56
+ export const SENTENCE_OPEN = "(?:^|(?<=[.!?:;][\"'’”)\\]]?\\**[ \\t])|(?<=\\n\\n)|(?<=\\n\\n[ \\t]))";