@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 +7 -3
- package/bin/heyramzi-file-tokens.js +62 -18
- package/comment-length.js +6 -2
- package/file-tokens.js +17 -10
- package/package.json +1 -1
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
|
-
|
|
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
|
|
199
|
-
|
|
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
|
|
3
|
+
* The file-token gate. Usage: this package's README, "File Token Gate".
|
|
4
4
|
*
|
|
5
|
-
* heyramzi-file-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 {
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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) =>
|
|
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([
|
|
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
|
-
|
|
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/
|
|
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 (
|
|
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
|
-
/**
|
|
109
|
-
export function
|
|
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 >
|
|
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
|
|
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 /
|
|
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}
|
|
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.
|