@heyramzi/lint 1.32.5 → 1.34.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
@@ -143,7 +143,7 @@ Test and fixture files need no entry: `no-emoji` skips them already.
143
143
 
144
144
  ### Comment Length Gate
145
145
 
146
- The conduct core caps a comment at 30 words. `heyramzi-comment-length` enforces it over a
146
+ `ai-doc/rules/comments.md` caps a comment at 30 words. `heyramzi-comment-length` enforces it over a
147
147
  whole repo: block comments, HTML comments and runs of consecutive line comments (`//`, `#`, `--`).
148
148
 
149
149
  ```bash
@@ -160,8 +160,10 @@ backticks is a quote and passes. The list is `MARKERS` in `comment-length.js`.
160
160
 
161
161
  Every tracked `.ts .tsx .js .jsx .mjs .cjs .svelte .swift .py .sh .bash .zsh .css .sql` file is
162
162
  read (the hack markers above apply to the first seven only), minus generated and vendored
163
- trees (`dist`, `build`, `.svelte-kit`, `paraglide`, `vendor`, …) and symlinks. Narrow or widen
164
- it with an optional `commentLength` key in the repo's root `package.json`:
163
+ trees (`dist`, `build`, `.svelte-kit`, `paraglide`, `vendor`, …) and symlinks. Run from a folder
164
+ below the repo root, such as a product in a monorepo, it reads only that folder's files. Narrow or
165
+ widen it with an optional `commentLength` key in the nearest `package.json` at or above where it
166
+ runs (its `targets` are relative to that file):
165
167
 
166
168
  ```json
167
169
  {
@@ -193,11 +195,15 @@ Installed into every Studio repo's active pre-push hook by `bash ai-doc/scripts/
193
195
 
194
196
  ### File Token Gate
195
197
 
196
- No staged file may run past 5,000 tokens, counted as bytes / 4. `heyramzi-file-tokens` reads the
197
- staged blobs, not the working copy, so it judges exactly what the commit records.
198
+ No staged file may run past its cap, counted as bytes / 4: 5,000 tokens for code, 8,000 for
199
+ Markdown, because a reference reads top to bottom and splitting it at 5,000 scattered one topic over
200
+ linked files. `heyramzi-file-tokens` reads the staged blobs, not the working copy, so it judges
201
+ exactly what the commit records. `heyramzi-file-tokens --file <path>…` runs the same check on working
202
+ copies; the `file-tokens-warn.sh` hook calls it after every write, so a file is split when it
203
+ crosses the cap, not at someone else's commit.
198
204
 
199
- It skips what no person or agent writes by hand: lockfiles, `CHANGELOG.md`, `*.generated.*`,
200
- minified bundles, source maps, snapshots, SVGs, binaries, generated and vendored trees (`dist`,
205
+ It skips pages, stylesheets and logs (`.html`, `.css`, `.log`), and what no person or agent
206
+ writes by hand: lockfiles, `CHANGELOG.md`, `*.generated.*`, minified bundles, source maps, snapshots, SVGs, binaries, generated and vendored trees (`dist`,
201
207
  `_generated`, `vendor`, the `.claude/` projections, …), files whose first 2 KB carry a codegen
202
208
  header (`@generated`, `Do not edit`, `Generated by`), and paths `.gitattributes` marks
203
209
  `linguist-generated`. A pure rename passes. There's no config and no allowlist: a hand-written
@@ -23,7 +23,12 @@ import {
23
23
  import { gateFiles } from "#lint/gate-files.js";
24
24
 
25
25
  const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
26
- const config = readConfig(repoRoot);
26
+ // Run from a product inside a monorepo, the gate reads that product's files and config, not
27
+ // its siblings': four add-ons in to-sheets failed each other's releases (21 Oct 2026).
28
+ const scope = execFileSync("git", ["rev-parse", "--show-prefix"], { encoding: "utf8" })
29
+ .trim()
30
+ .replace(/\/$/, "");
31
+ const config = readConfig(repoRoot, scope);
27
32
  const max = config.max ?? MAX_WORDS;
28
33
 
29
34
  const args = process.argv.slice(2);
@@ -42,17 +47,30 @@ const files = gateFiles(repoRoot, selectFiles(candidates, { ...config, ignored }
42
47
 
43
48
  gate(files);
44
49
 
45
- function readConfig(root) {
46
- const manifest = path.join(root, "package.json");
47
- if (!existsSync(manifest)) return {};
48
- return JSON.parse(readFileSync(manifest, "utf8")).commentLength ?? {};
50
+ /** The nearest `commentLength` from the scope up to the repo root; its targets are scope-relative. */
51
+ function readConfig(root, dir) {
52
+ for (let rel = dir; ; rel = path.dirname(rel)) {
53
+ const at = rel === "." ? "" : rel;
54
+ const manifest = path.join(root, at, "package.json");
55
+ const found = existsSync(manifest)
56
+ ? JSON.parse(readFileSync(manifest, "utf8")).commentLength
57
+ : undefined;
58
+ if (found) {
59
+ const targets = found.targets?.map((t) => (at ? `${at}/${t}` : t));
60
+ return targets ? { ...found, targets } : found;
61
+ }
62
+ if (!at) return {};
63
+ }
49
64
  }
50
65
 
51
66
  function tracked(root) {
52
67
  const patterns = (config.extensions ?? DEFAULT_EXTENSIONS).map(
53
68
  (ext) => `*.${ext.replace(/^\./, "")}`,
54
69
  );
55
- return execFileSync("git", ["ls-files", ...patterns], { cwd: root, encoding: "utf8" })
70
+ return execFileSync("git", ["ls-files", "--full-name", "--", ...patterns], {
71
+ cwd: path.join(root, scope),
72
+ encoding: "utf8",
73
+ })
56
74
  .trim()
57
75
  .split("\n")
58
76
  .filter(Boolean);
@@ -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
  }
@@ -185,7 +185,7 @@ const paths = args.filter((a) => !a.startsWith("--"));
185
185
 
186
186
  if (paths.length === 0) {
187
187
  process.stderr.write(
188
- "usage: heyramzi-slop <file|dir|-> [more...] [--fix] [--json] [--jev [--shape-block=0.6-1]] [--quiet] [--register=spoken|shorts|written] [--only=kind|id,...]\n",
188
+ "usage: heyramzi-slop <file|dir|-> [more...] [--fix] [--json] [--jev [--shape-block=0.6-1]] [--quiet] [--register=spoken|shorts|written|forum] [--only=kind|id,...]\n",
189
189
  );
190
190
  process.exit(2);
191
191
  }
@@ -195,7 +195,7 @@ const registerFlag = args.find((a) => a.startsWith("--register"));
195
195
 
196
196
  // That is the worst thing a gate can do: silence a caller reads as a pass.
197
197
  if (registerFlag && !registerFlag.includes("=")) {
198
- process.stderr.write("--register takes a value: --register=spoken|shorts|written\n");
198
+ process.stderr.write("--register takes a value: --register=spoken|shorts|written|forum\n");
199
199
  process.exit(2);
200
200
  }
201
201
  const register = registerFlag ? registerFlag.split("=")[1] : undefined;
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
  */
@@ -239,7 +239,11 @@ function simpleSpans(src, syntax) {
239
239
  i = j;
240
240
  } else if (syntax.raw && c === "#" && rawStringEnd(src, i) !== null) {
241
241
  i = rawStringEnd(src, i);
242
- } else if (syntax.url && src.startsWith("url(", i) && !/^url\(\s*["']/.test(src.slice(i, i + 12))) {
242
+ } else if (
243
+ syntax.url &&
244
+ src.startsWith("url(", i) &&
245
+ !/^url\(\s*["']/.test(src.slice(i, i + 12))
246
+ ) {
243
247
  const close = src.indexOf(")", i);
244
248
  i = close === -1 ? src.length : close + 1;
245
249
  } else if (syntax.quotes.includes(c)) {
package/file-tokens.js CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
- * The file-token gate: no staged file an agent writes may run past 5,000 tokens.
3
- * Pure here, git and process in `bin/heyramzi-file-tokens.js`.
2
+ * The file-token gate: no staged file an agent writes may run past its cap, 5,000 tokens for code
3
+ * and 8,000 for Markdown. Pure here, git and process in `bin/heyramzi-file-tokens.js`.
4
4
  */
5
5
 
6
6
  export const MAX_TOKENS = 5000;
7
+ /** A reference reads top to bottom; split at 5,000 it scattered one topic over linked files (Ramzi, 22 Oct 2026). */
8
+ export const MARKDOWN_MAX_TOKENS = 8000;
7
9
  export const BYTES_PER_TOKEN = 4;
8
10
 
9
11
  /** Generated, vendored or projected trees: nobody hand-writes these, so nobody splits them. */
@@ -38,7 +40,11 @@ export const DEFAULT_IGNORED = [
38
40
  const IGNORED_PREFIXES = [".github/skills/"];
39
41
 
40
42
  /** Data, transcripts and tool-managed files: nobody splits a dataset, a call recording or an Xcode project by hand. */
41
- const DATA_EXTENSIONS = /\.(json|jsonl|ndjson|csv|tsv|txt|srt|vtt|pdf|xcstrings|strings|pbxproj|excalidraw|tldr|geojson)$/i;
43
+ const DATA_EXTENSIONS =
44
+ /\.(json|jsonl|ndjson|csv|tsv|txt|srt|vtt|pdf|xcstrings|strings|pbxproj|excalidraw|tldr|geojson)$/i;
45
+
46
+ /** Pages, stylesheets and logs: a page or deck CSS is one document, a log is tool output. A token cap means nothing there (Ramzi, 21 Oct 2026). */
47
+ const UNCAPPED_EXTENSIONS = /\.(html|css|log)$/i;
42
48
 
43
49
  const LOCKFILES = new Set([
44
50
  "pnpm-lock.yaml",
@@ -61,7 +67,15 @@ const LOCKFILES = new Set([
61
67
  ]);
62
68
 
63
69
  /** Release tooling writes these, and a minified bundle, source map or snapshot is machine output. */
64
- const GENERATED_NAMES = [/^CHANGELOG\.md$/, /\.generated\.\w+$/,/\.min\.(js|css)$/, /\.map$/, /\.snap$/, /\.tsbuildinfo$/, /\.svg$/];
70
+ const GENERATED_NAMES = [
71
+ /^CHANGELOG\.md$/,
72
+ /\.generated\.\w+$/,
73
+ /\.min\.(js|css)$/,
74
+ /\.map$/,
75
+ /\.snap$/,
76
+ /\.tsbuildinfo$/,
77
+ /\.svg$/,
78
+ ];
65
79
 
66
80
  /** A header any codegen tool writes. Read from the first 2 KB only. */
67
81
  const GENERATED_HEADER = /@generated|auto-?generated|do not edit|generated by /i;
@@ -74,13 +88,16 @@ export function tokens(bytes) {
74
88
  export function ignoredPath(file) {
75
89
  const parts = file.split("/");
76
90
  const name = parts.at(-1);
77
- if (LOCKFILES.has(name) || DATA_EXTENSIONS.test(name)) return true;
91
+ if (LOCKFILES.has(name) || DATA_EXTENSIONS.test(name) || UNCAPPED_EXTENSIONS.test(name))
92
+ return true;
78
93
  if (GENERATED_NAMES.some((pattern) => pattern.test(name))) return true;
79
94
  if (IGNORED_PREFIXES.some((prefix) => file.startsWith(prefix))) return true;
80
95
  // Match the name as written (`.claude`) and with a symbol prefix stripped; stripping alone dropped every dot-folder.
81
96
  return parts.slice(0, -1).some((part) => {
82
97
  const lower = part.toLowerCase();
83
- return DEFAULT_IGNORED.includes(lower) || DEFAULT_IGNORED.includes(lower.replace(/^\W+\s*/u, ""));
98
+ return (
99
+ DEFAULT_IGNORED.includes(lower) || DEFAULT_IGNORED.includes(lower.replace(/^\W+\s*/u, ""))
100
+ );
84
101
  });
85
102
  }
86
103
 
@@ -90,21 +107,26 @@ export function generatedContent(head) {
90
107
  return GENERATED_HEADER.test(head.subarray(0, 2048).toString("utf8"));
91
108
  }
92
109
 
93
- /** Files over the cap, biggest first. `sizes` maps path to staged bytes. */
94
- export function check(sizes, max = MAX_TOKENS) {
110
+ /** The cap a file is held to. */
111
+ export function capFor(file) {
112
+ return /\.mdx?$/i.test(file) ? MARKDOWN_MAX_TOKENS : MAX_TOKENS;
113
+ }
114
+
115
+ /** Files over their cap, biggest first. `sizes` maps path to bytes. */
116
+ export function check(sizes) {
95
117
  return Object.entries(sizes)
96
- .map(([file, bytes]) => ({ file, bytes, tokens: tokens(bytes) }))
97
- .filter((entry) => entry.tokens > max)
118
+ .map(([file, bytes]) => ({ file, bytes, tokens: tokens(bytes), cap: capFor(file) }))
119
+ .filter((entry) => entry.tokens > entry.cap)
98
120
  .sort((left, right) => right.tokens - left.tokens);
99
121
  }
100
122
 
101
123
  /** The refusal. It tells the agent how to split, because "too big" alone gets a file squashed, not split. */
102
- export function report(failures, max = MAX_TOKENS) {
124
+ export function report(failures) {
103
125
  const rows = failures.map(
104
- ({ file, bytes, tokens: count }) =>
105
- ` ${file} ${count.toLocaleString("en-US")} tokens (${bytes.toLocaleString("en-US")} bytes), ${Math.round((count / max) * 100)}% of the cap`,
126
+ ({ file, bytes, tokens: count, cap }) =>
127
+ ` ${file} ${count.toLocaleString("en-US")} tokens (${bytes.toLocaleString("en-US")} bytes), ${Math.round((count / cap) * 100)}% of its ${cap.toLocaleString("en-US")} cap`,
106
128
  );
107
- return `file-tokens: ${failures.length} staged file(s) over ${max.toLocaleString("en-US")} tokens (tokens = bytes / ${BYTES_PER_TOKEN}):
129
+ return `file-tokens: ${failures.length} file(s) over their cap: ${MAX_TOKENS.toLocaleString("en-US")} tokens for code, ${MARKDOWN_MAX_TOKENS.toLocaleString("en-US")} for Markdown (tokens = bytes / ${BYTES_PER_TOKEN}):
108
130
  ${rows.join("\n")}
109
131
 
110
132
  Split each file before you commit. Don't get under the cap by deleting comments, squashing lines or shortening names: that keeps one file doing too many jobs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/lint",
3
- "version": "1.32.5",
3
+ "version": "1.34.0",
4
4
  "description": "Shared oxlint rules, base config and gates for Studio projects",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -74,7 +74,7 @@
74
74
  "access": "public"
75
75
  },
76
76
  "dependencies": {
77
- "@heyramzi/social": "^1.0.0"
77
+ "@heyramzi/social": "^1.1.0"
78
78
  },
79
79
  "scripts": {
80
80
  "voice:refresh": "node scripts/harvest-voice.mjs --write",
package/register.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type RegisterName = "spoken" | "shorts" | "written";
1
+ export type RegisterName = "spoken" | "shorts" | "written" | "forum";
2
2
 
3
3
  export type RegisterMeasurement = {
4
4
  words: number;
@@ -20,6 +20,14 @@ export type RegisterMeasurement = {
20
20
  chaining: number;
21
21
  /** Second person for every first person. `Infinity` when there is no first person. */
22
22
  egoRatio: number;
23
+ /** Paragraphs, split on blank lines. */
24
+ paragraphs: number;
25
+ /** Sentences in the longest paragraph. A forum reply holds 1 to 3. */
26
+ paragraphSentences: number;
27
+ /** Words in the longest paragraph. */
28
+ paragraphWords: number;
29
+ /** Every word when the text has no paragraph break, else 0. */
30
+ unbrokenWords: number;
23
31
  };
24
32
 
25
33
  export type RegisterAudit = {
package/register.js CHANGED
@@ -40,6 +40,10 @@ export function measureRegister(text) {
40
40
  const count = (re) => (text.match(re) ?? []).length;
41
41
  const share = (fn) =>
42
42
  lengths.length === 0 ? 0 : (lengths.filter(fn).length / lengths.length) * 100;
43
+ const paragraphs = text
44
+ .split(/\n\s*\n/)
45
+ .map((p) => p.trim())
46
+ .filter(Boolean);
43
47
 
44
48
  return {
45
49
  words,
@@ -66,6 +70,11 @@ export function measureRegister(text) {
66
70
 
67
71
  // Guarded, because a script with no "I" at all is not a failure.
68
72
  egoRatio: count(FIRST_PERSON) === 0 ? Infinity : count(SECOND_PERSON) / count(FIRST_PERSON),
73
+ paragraphs: paragraphs.length,
74
+ paragraphSentences: Math.max(0, ...paragraphs.map((p) => lines(p).length)),
75
+ paragraphWords: Math.max(0, ...paragraphs.map((p) => p.split(/\s+/).length)),
76
+ // The wall of text: every word in one paragraph, counted only when there's no break at all.
77
+ unbrokenWords: paragraphs.length === 1 ? words : 0,
69
78
  };
70
79
  }
71
80
 
@@ -199,6 +208,18 @@ export const REGISTERS = {
199
208
  },
200
209
  };
201
210
 
211
+ // A Reddit or community reply: the written bands, broken into short paragraphs. Ramzi, 21 Oct 2026: our replies shipped as one block of text.
212
+ REGISTERS.forum = {
213
+ label: "forum reply",
214
+ source: "the written bands, plus his position replies at 1 to 3 sentences a paragraph",
215
+ bands: {
216
+ ...REGISTERS.written.bands,
217
+ paragraphSentences: { max: 3 },
218
+ paragraphWords: { max: 60 },
219
+ unbrokenWords: { max: 40 },
220
+ },
221
+ };
222
+
202
223
  const DIRECTION = {
203
224
  meanSentence: "words per sentence",
204
225
  lengthVariation: "spread of sentence length, SD over mean",
@@ -211,6 +232,9 @@ const DIRECTION = {
211
232
  firstPerson: "I/me/my per 1,000 words",
212
233
  egoRatio: "second person for every first person",
213
234
  chaining: "and/so/because/but per 1,000 words",
235
+ paragraphSentences: "sentences in the longest paragraph",
236
+ paragraphWords: "words in the longest paragraph",
237
+ unbrokenWords: "words with no paragraph break",
214
238
  };
215
239
 
216
240
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Ban hardcoded AI model identifiers in source code.
3
- * Model versions should come from @heyramzi/ai tier system (MODEL_IDS / MODEL_CATALOG).
3
+ * Model versions should come from the @heyramzi/utils/ai tier system (MODEL_IDS / MODEL_CATALOG).
4
4
  */
5
5
 
6
6
  import { isTestFile } from "#lint/rules/test-file.js";
@@ -14,9 +14,9 @@ const MODEL_PATTERNS = [
14
14
 
15
15
  const COMBINED = new RegExp(MODEL_PATTERNS.map((r) => r.source).join("|"));
16
16
 
17
- // WHY: files inside @heyramzi/ai define the canonical model catalog, so they must use literal IDs.
17
+ // WHY: the model catalog in @heyramzi/utils/ai (and the old @heyramzi/ai) names the literal IDs.
18
18
  function isModelCatalogFile(filename) {
19
- return /packages\/ai\//.test(filename);
19
+ return /packages\/(?:ai|utils\/src\/ai)\//.test(filename);
20
20
  }
21
21
 
22
22
  // WHY: a `ladder.ts` is where an app lists its model ids, so naming them there is the job.
@@ -29,11 +29,11 @@ export default {
29
29
  type: "suggestion",
30
30
  docs: {
31
31
  description:
32
- "Disallow hardcoded AI model identifiers. Use @heyramzi/ai MODEL_IDS or MODEL_CATALOG instead.",
32
+ "Disallow hardcoded AI model identifiers. Use MODEL_IDS or MODEL_CATALOG from @heyramzi/utils/ai instead.",
33
33
  },
34
34
  messages: {
35
35
  noHardcodedModel:
36
- 'Hardcoded model "{{ model }}". Import MODEL_IDS or MODEL_CATALOG from @heyramzi/ai instead.',
36
+ 'Hardcoded model "{{ model }}". Import MODEL_IDS or MODEL_CATALOG from @heyramzi/utils/ai instead.',
37
37
  },
38
38
  schema: [],
39
39
  },