@heyramzi/lint 1.32.4 → 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 +6 -4
- package/bin/heyramzi-comment-length.js +24 -6
- package/bin/heyramzi-slop.js +2 -2
- package/file-tokens.js +23 -4
- package/package.json +2 -2
- package/register.d.ts +9 -1
- package/register.js +24 -0
- package/rules/no-hardcoded-models.js +5 -5
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.
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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], {
|
|
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);
|
package/bin/heyramzi-slop.js
CHANGED
|
@@ -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 =
|
|
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 = [
|
|
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,10 +86,17 @@ 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)
|
|
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.
|
|
94
|
+
return parts.slice(0, -1).some((part) => {
|
|
95
|
+
const lower = part.toLowerCase();
|
|
96
|
+
return (
|
|
97
|
+
DEFAULT_IGNORED.includes(lower) || DEFAULT_IGNORED.includes(lower.replace(/^\W+\s*/u, ""))
|
|
98
|
+
);
|
|
99
|
+
});
|
|
81
100
|
}
|
|
82
101
|
|
|
83
102
|
/** True when the content says a machine wrote it: a NUL byte (binary, as git decides) or a codegen header. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/lint",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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:
|
|
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
|
|
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
|
},
|