@heyramzi/lint 1.33.0 → 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
@@ -195,8 +195,12 @@ Installed into every Studio repo's active pre-push hook by `bash ai-doc/scripts/
195
195
 
196
196
  ### File Token Gate
197
197
 
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.
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.
200
204
 
201
205
  It skips pages, stylesheets and logs (`.html`, `.css`, `.log`), and what no person or agent
202
206
  writes by hand: lockfiles, `CHANGELOG.md`, `*.generated.*`, minified bundles, source maps, snapshots, SVGs, binaries, generated and vendored trees (`dist`,
@@ -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
  }
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. */
@@ -105,21 +107,26 @@ export function generatedContent(head) {
105
107
  return GENERATED_HEADER.test(head.subarray(0, 2048).toString("utf8"));
106
108
  }
107
109
 
108
- /** Files over the cap, biggest first. `sizes` maps path to staged bytes. */
109
- 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) {
110
117
  return Object.entries(sizes)
111
- .map(([file, bytes]) => ({ file, bytes, tokens: tokens(bytes) }))
112
- .filter((entry) => entry.tokens > max)
118
+ .map(([file, bytes]) => ({ file, bytes, tokens: tokens(bytes), cap: capFor(file) }))
119
+ .filter((entry) => entry.tokens > entry.cap)
113
120
  .sort((left, right) => right.tokens - left.tokens);
114
121
  }
115
122
 
116
123
  /** The refusal. It tells the agent how to split, because "too big" alone gets a file squashed, not split. */
117
- export function report(failures, max = MAX_TOKENS) {
124
+ export function report(failures) {
118
125
  const rows = failures.map(
119
- ({ file, bytes, tokens: count }) =>
120
- ` ${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`,
121
128
  );
122
- 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}):
123
130
  ${rows.join("\n")}
124
131
 
125
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.33.0",
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": {