@heyramzi/lint 1.32.5 → 1.33.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
@@ -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
  {
@@ -196,8 +198,8 @@ Installed into every Studio repo's active pre-push hook by `bash ai-doc/scripts/
196
198
  No staged file may run past 5,000 tokens, counted as bytes / 4. `heyramzi-file-tokens` reads the
197
199
  staged blobs, not the working copy, so it judges exactly what the commit records.
198
200
 
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`,
201
+ It skips pages, stylesheets and logs (`.html`, `.css`, `.log`), and what no person or agent
202
+ writes by hand: lockfiles, `CHANGELOG.md`, `*.generated.*`, minified bundles, source maps, snapshots, SVGs, binaries, generated and vendored trees (`dist`,
201
203
  `_generated`, `vendor`, the `.claude/` projections, …), files whose first 2 KB carry a codegen
202
204
  header (`@generated`, `Do not edit`, `Generated by`), and paths `.gitattributes` marks
203
205
  `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);
@@ -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/file-tokens.js CHANGED
@@ -38,7 +38,11 @@ export const DEFAULT_IGNORED = [
38
38
  const IGNORED_PREFIXES = [".github/skills/"];
39
39
 
40
40
  /** 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;
41
+ const DATA_EXTENSIONS =
42
+ /\.(json|jsonl|ndjson|csv|tsv|txt|srt|vtt|pdf|xcstrings|strings|pbxproj|excalidraw|tldr|geojson)$/i;
43
+
44
+ /** 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). */
45
+ const UNCAPPED_EXTENSIONS = /\.(html|css|log)$/i;
42
46
 
43
47
  const LOCKFILES = new Set([
44
48
  "pnpm-lock.yaml",
@@ -61,7 +65,15 @@ const LOCKFILES = new Set([
61
65
  ]);
62
66
 
63
67
  /** 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$/];
68
+ const GENERATED_NAMES = [
69
+ /^CHANGELOG\.md$/,
70
+ /\.generated\.\w+$/,
71
+ /\.min\.(js|css)$/,
72
+ /\.map$/,
73
+ /\.snap$/,
74
+ /\.tsbuildinfo$/,
75
+ /\.svg$/,
76
+ ];
65
77
 
66
78
  /** A header any codegen tool writes. Read from the first 2 KB only. */
67
79
  const GENERATED_HEADER = /@generated|auto-?generated|do not edit|generated by /i;
@@ -74,13 +86,16 @@ export function tokens(bytes) {
74
86
  export function ignoredPath(file) {
75
87
  const parts = file.split("/");
76
88
  const name = parts.at(-1);
77
- if (LOCKFILES.has(name) || DATA_EXTENSIONS.test(name)) return true;
89
+ if (LOCKFILES.has(name) || DATA_EXTENSIONS.test(name) || UNCAPPED_EXTENSIONS.test(name))
90
+ return true;
78
91
  if (GENERATED_NAMES.some((pattern) => pattern.test(name))) return true;
79
92
  if (IGNORED_PREFIXES.some((prefix) => file.startsWith(prefix))) return true;
80
93
  // Match the name as written (`.claude`) and with a symbol prefix stripped; stripping alone dropped every dot-folder.
81
94
  return parts.slice(0, -1).some((part) => {
82
95
  const lower = part.toLowerCase();
83
- return DEFAULT_IGNORED.includes(lower) || DEFAULT_IGNORED.includes(lower.replace(/^\W+\s*/u, ""));
96
+ return (
97
+ DEFAULT_IGNORED.includes(lower) || DEFAULT_IGNORED.includes(lower.replace(/^\W+\s*/u, ""))
98
+ );
84
99
  });
85
100
  }
86
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/lint",
3
- "version": "1.32.5",
3
+ "version": "1.33.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
  },