@heyramzi/lint 0.0.0-stage → 1.32.4
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/LICENSE +21 -0
- package/README.md +277 -2
- package/bin/heyramzi-comment-length.js +99 -0
- package/bin/heyramzi-commit-msg.js +31 -0
- package/bin/heyramzi-file-tokens.js +66 -0
- package/bin/heyramzi-inline-path.js +79 -0
- package/bin/heyramzi-jev-code.js +107 -0
- package/bin/heyramzi-push-diff.js +93 -0
- package/bin/heyramzi-repo-rules.js +70 -0
- package/bin/heyramzi-slop.js +314 -0
- package/bin/heyramzi-svelte-markup.js +50 -0
- package/bin/heyramzi-ui-rules.js +56 -0
- package/comment-length.js +368 -0
- package/commit-msg.js +57 -0
- package/config/oxlintrc.json +87 -0
- package/data/invisibles.js +218 -0
- package/data/jev-bench.js +115 -0
- package/data/jev-code-bench.js +313 -0
- package/data/slop-words.d.ts +64 -0
- package/data/slop-words.js +2681 -0
- package/file-tokens.js +122 -0
- package/gate-files.js +14 -0
- package/index.js +116 -0
- package/inline-path.js +90 -0
- package/jev-code.js +194 -0
- package/jev.js +341 -0
- package/package.json +81 -4
- package/processors/markdown.js +22 -0
- package/push-diff.js +55 -0
- package/register.d.ts +43 -0
- package/register.js +255 -0
- package/repo-rules/packages.js +164 -0
- package/repo-rules/paths.js +101 -0
- package/repo-rules/toolchain.js +172 -0
- package/repo-rules/tree.js +51 -0
- package/repo-rules/workflows.js +116 -0
- package/repo-rules.js +54 -0
- package/rules/convex-ast.js +66 -0
- package/rules/convex-index-name.js +40 -0
- package/rules/convex-internal-only.js +59 -0
- package/rules/convex-no-date-now-in-query.js +48 -0
- package/rules/convex-no-deprecated-crons.js +40 -0
- package/rules/convex-no-filter.js +41 -0
- package/rules/convex-no-get-metadata.js +26 -0
- package/rules/convex-no-v-any.js +24 -0
- package/rules/convex-react-client-module-scope.js +37 -0
- package/rules/convex-require-args.js +39 -0
- package/rules/convex-require-returns.js +31 -0
- package/rules/convex-runtime-imports.js +48 -0
- package/rules/max-function-lines.js +105 -0
- package/rules/max-object-methods.js +86 -0
- package/rules/no-ai-words.js +78 -0
- package/rules/no-app-stores.js +25 -0
- package/rules/no-chained-type-assertions.js +91 -0
- package/rules/no-class.js +47 -0
- package/rules/no-conditional-empty-object-spread.js +64 -0
- package/rules/no-deep-phosphor-imports.js +29 -0
- package/rules/no-default-export.js +70 -0
- package/rules/no-em-dash.js +198 -0
- package/rules/no-emoji.js +56 -0
- package/rules/no-hardcoded-models.js +63 -0
- package/rules/no-inline-path.js +46 -0
- package/rules/no-inline-svg.js +67 -0
- package/rules/no-known-value-widening.js +289 -0
- package/rules/no-legacy-compat.js +35 -0
- package/rules/no-overkill-fallback.js +77 -0
- package/rules/no-raw-try-catch.js +26 -0
- package/rules/no-redundant-prop-fallback.js +153 -0
- package/rules/no-relative-imports.js +213 -0
- package/rules/no-relative-type-imports.js +40 -0
- package/rules/no-rune-outside-svelte-file.js +56 -0
- package/rules/no-rune-shadowing-binding.js +85 -0
- package/rules/no-skipped-or-focused-tests.js +74 -0
- package/rules/no-snake-case-props.js +45 -0
- package/rules/no-svelte4-syntax.js +33 -0
- package/rules/no-vitest-import-in-vite-plus.js +64 -0
- package/rules/props-interface.js +44 -0
- package/rules/redirect-external-needs-option.js +45 -0
- package/rules/require-safety-comment-for-type-assertion.js +110 -0
- package/rules/svelte-section-order.js +115 -0
- package/rules/test-file.js +8 -0
- package/scripts/count-hits.mjs +213 -0
- package/scripts/harvest-voice.mjs +161 -0
- package/scripts/jev-bench.mjs +41 -0
- package/scripts/jev-code-bench.mjs +61 -0
- package/scripts/measure-register.mjs +61 -0
- package/slop-views.js +122 -0
- package/slop.d.ts +53 -0
- package/slop.js +534 -0
- package/svelte-markup.js +107 -0
- package/svelte-template.js +258 -0
- package/ui-rules.js +324 -0
package/register.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The register gate: does this draft carry the rates of the voice it claims?
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* `auditSlop` is a ban list, and a ban list cannot see the failure that produced the September 2026 Shorts batch.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Every one of those scripts passed the word checks and still read as somebody else, because the fault is not a word.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* It is four rates: how often he contracts, how long his sentences run, how many fragments he leaves, and how often he says "you".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Written copy and spoken copy sit in different places on all four, and a spoken script written to the written numbers is the stilted result.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Bands are counted, never typed. `scripts/measure-register.mjs` recounts them from the corpora named in each band's `source`, and the corpora are the ones humanizer `references/voice-measured.md` records.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const SENTENCE_SPLIT = /[.!?]+["'”’)]*(?:\s+|$)/;
|
|
26
|
+
const CONTRACTION = /\b[\w]+['’](?:s|t|re|ve|ll|d|m)\b/gi;
|
|
27
|
+
const SECOND_PERSON = /\b(?:you|your|yours|you're|you've|you'll|you'd|yourself)\b/gi;
|
|
28
|
+
const FIRST_PERSON = /\b(?:i|me|my|mine|myself|i'm|i've|i'll|i'd)\b/gi;
|
|
29
|
+
const CHAINING = /\b(?:and|so|because|but)\b/gi;
|
|
30
|
+
|
|
31
|
+
/** Counts only. No judgement, so a caller can report without a band. */
|
|
32
|
+
export function measureRegister(text) {
|
|
33
|
+
const sentences = text
|
|
34
|
+
.split(SENTENCE_SPLIT)
|
|
35
|
+
.map((s) => s.trim())
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
const lengths = sentences.map((s) => s.split(/\s+/).filter(Boolean).length);
|
|
38
|
+
const words = lengths.reduce((a, b) => a + b, 0);
|
|
39
|
+
const per1k = (n) => (words === 0 ? 0 : (n / words) * 1000);
|
|
40
|
+
const count = (re) => (text.match(re) ?? []).length;
|
|
41
|
+
const share = (fn) =>
|
|
42
|
+
lengths.length === 0 ? 0 : (lengths.filter(fn).length / lengths.length) * 100;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
words,
|
|
46
|
+
sentences: lengths.length,
|
|
47
|
+
meanSentence: lengths.length === 0 ? 0 : words / lengths.length,
|
|
48
|
+
medianSentence: median(lengths),
|
|
49
|
+
fragmentShare: share((n) => n <= 4),
|
|
50
|
+
fragments: lengths.filter((n) => n <= 4).length,
|
|
51
|
+
longShare: share((n) => n >= 20),
|
|
52
|
+
// Per line too, because a list item ends without a full stop.
|
|
53
|
+
longestSentence: Math.max(0, ...lines(text).map((s) => s.split(/\s+/).length)),
|
|
54
|
+
mostCommas: Math.max(0, ...lines(text).map((s) => (s.match(/[,;]/g) ?? []).length)),
|
|
55
|
+
// The metronome: spread of sentence length, which separates him from a model
|
|
56
|
+
// where the mean does not. Counts and the rejected run rule are in humanizer
|
|
57
|
+
// `references/registers.md`, "The sixth rate".
|
|
58
|
+
lengthVariation: variation(lengths),
|
|
59
|
+
contractions: per1k(count(CONTRACTION)),
|
|
60
|
+
secondPerson: per1k(count(SECOND_PERSON)),
|
|
61
|
+
firstPerson: per1k(count(FIRST_PERSON)),
|
|
62
|
+
chaining: per1k(count(CHAINING)),
|
|
63
|
+
// "Every I should be a you." Second person over first, 5.25:1 across the four Shorts cut on 20 Aug 2026 against 1.38:1 in the written corpus,
|
|
64
|
+
|
|
65
|
+
// so the rule is short-form only. A raw take fails it at about 1.0 and is meant to: the ratio is what `descript-script-edit` produces, not what the mouth does.
|
|
66
|
+
|
|
67
|
+
// Guarded, because a script with no "I" at all is not a failure.
|
|
68
|
+
egoRatio: count(FIRST_PERSON) === 0 ? Infinity : count(SECOND_PERSON) / count(FIRST_PERSON),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lines(text) {
|
|
73
|
+
return text
|
|
74
|
+
.split(/\n+/)
|
|
75
|
+
.flatMap((l) => l.split(SENTENCE_SPLIT))
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Standard deviation over the mean, so a long register and a short one compare. */
|
|
81
|
+
function variation(xs) {
|
|
82
|
+
if (xs.length < 2) return 0;
|
|
83
|
+
const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
84
|
+
if (mean === 0) return 0;
|
|
85
|
+
const spread = xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length;
|
|
86
|
+
return Math.sqrt(spread) / mean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function median(xs) {
|
|
90
|
+
if (xs.length === 0) return 0;
|
|
91
|
+
const s = [...xs].toSorted((a, b) => a - b);
|
|
92
|
+
const mid = Math.floor(s.length / 2);
|
|
93
|
+
return s.length % 2 === 0 ? (s[mid - 1] + s[mid]) / 2 : s[mid];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The bands. `min`/`max` are inclusive; a missing bound is not checked.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* Every number is the corpus figure with a tolerance, not a target invented here.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/*
|
|
105
|
+
* The `shorts` band is the one that matters for a batch: he leaves no fragments out loud, and 0% is the counted figure on EC49,
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/*
|
|
109
|
+
* so the band allows a little and not the 35% the rejected batch carried.
|
|
110
|
+
*/
|
|
111
|
+
export const REGISTERS = {
|
|
112
|
+
spoken: {
|
|
113
|
+
label: "spoken long-form",
|
|
114
|
+
source: "9 YouTube transcripts, 44,398 words",
|
|
115
|
+
bands: {
|
|
116
|
+
meanSentence: { min: 16, max: 34 },
|
|
117
|
+
fragmentShare: { max: 12 },
|
|
118
|
+
lengthVariation: { min: 0.3 },
|
|
119
|
+
contractions: { min: 26 },
|
|
120
|
+
secondPerson: { min: 28 },
|
|
121
|
+
chaining: { min: 45 },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
shorts: {
|
|
125
|
+
label: "spoken short-form",
|
|
126
|
+
source: "his 4 Shorts, 680 words, plus EC49",
|
|
127
|
+
bands: {
|
|
128
|
+
meanSentence: { min: 13, max: 26 },
|
|
129
|
+
fragmentShare: { max: 10 },
|
|
130
|
+
// A floor clears his own lowest single Short, never the 4-file average: 19.5 contractions on one file and 31.4 chaining on another.
|
|
131
|
+
|
|
132
|
+
// A floor his own speech fails is a band tightened past him, which is the fault this module exists to prevent. Asserted per file in register.test.mjs.
|
|
133
|
+
contractions: { min: 18 },
|
|
134
|
+
secondPerson: { min: 30 },
|
|
135
|
+
egoRatio: { min: 2, max: 3.5 },
|
|
136
|
+
// Ceilings from the top band of 134 competitor Shorts (p75: 60 chaining, 3.5 ego). His own max is 53 and 1.6; A20 ran 70 and 6.5.
|
|
137
|
+
chaining: { min: 30, max: 60 },
|
|
138
|
+
lengthVariation: { min: 0.3 },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
/**
|
|
142
|
+
* Two voices in the room.
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
/*
|
|
146
|
+
* Counted 2026-09-14 over 303,956 words of his own turns on 100 English client calls, restricted per file to uninterrupted stretches of 120 words or more so the transcriber's turn-splitting cannot
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/*
|
|
150
|
+
* manufacture fragments.
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
* Every floor clears his own lowest call of 41, never the median: meanSentence ran 9.7 to 23.9, fragmentShare 1.8 to 33.3, contractions 29.0 to 66.7, chaining 53.6 to 77.7.
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* `spoken` would reject him here. Its band wants 16 to 34 words a sentence and under 12% fragments; live he is at 13.5 and 19.5%.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* A podcast, an interview or a sales-call script graded as `spoken` fails on his real speech, which is the fault this module exists to prevent.
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/*
|
|
166
|
+
* No egoRatio floor, deliberately.
|
|
167
|
+
*/
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
* It runs 0.3 to 4.2 across the 41 calls, median 1.1: he says "I" about as often as "you" when a person is in front of him.
|
|
171
|
+
*/
|
|
172
|
+
|
|
173
|
+
/*
|
|
174
|
+
* The "every I is a you" rule is a camera rule. `secondPerson` is a loose floor for the same reason.
|
|
175
|
+
*/
|
|
176
|
+
dialogue: {
|
|
177
|
+
label: "spoken dialogue",
|
|
178
|
+
source: "100 English client calls, his turns, 303,956 words",
|
|
179
|
+
bands: {
|
|
180
|
+
meanSentence: { min: 9, max: 24 },
|
|
181
|
+
fragmentShare: { max: 35 },
|
|
182
|
+
contractions: { min: 26 },
|
|
183
|
+
secondPerson: { min: 10 },
|
|
184
|
+
chaining: { min: 45 },
|
|
185
|
+
lengthVariation: { min: 0.3 },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
written: {
|
|
189
|
+
label: "written social",
|
|
190
|
+
source: "40 LinkedIn and 150 X posts",
|
|
191
|
+
bands: {
|
|
192
|
+
meanSentence: { min: 5, max: 15 },
|
|
193
|
+
contractions: { min: 14 },
|
|
194
|
+
lengthVariation: { min: 0.3 },
|
|
195
|
+
// Ramzi, 23 Sep 2026, on a 33-word, 3-comma course line: "too long, too many commas".
|
|
196
|
+
longestSentence: { max: 25 },
|
|
197
|
+
mostCommas: { max: 2 },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const DIRECTION = {
|
|
203
|
+
meanSentence: "words per sentence",
|
|
204
|
+
lengthVariation: "spread of sentence length, SD over mean",
|
|
205
|
+
fragmentShare: "% of sentences of 4 words or fewer",
|
|
206
|
+
longShare: "% of sentences of 20 words or more",
|
|
207
|
+
longestSentence: "words in the longest sentence",
|
|
208
|
+
mostCommas: "commas and semicolons in the busiest sentence",
|
|
209
|
+
contractions: "contractions per 1,000 words",
|
|
210
|
+
secondPerson: "you/your per 1,000 words",
|
|
211
|
+
firstPerson: "I/me/my per 1,000 words",
|
|
212
|
+
egoRatio: "second person for every first person",
|
|
213
|
+
chaining: "and/so/because/but per 1,000 words",
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Measure, then score against one band set.
|
|
218
|
+
*
|
|
219
|
+
* Findings carry the same shape `auditSlop` returns, so a caller merges the
|
|
220
|
+
* two lists and a hook fails on either.
|
|
221
|
+
*/
|
|
222
|
+
export function auditRegister(text, register = "spoken") {
|
|
223
|
+
const spec = REGISTERS[register];
|
|
224
|
+
if (!spec)
|
|
225
|
+
throw new Error(
|
|
226
|
+
`unknown register "${register}", expected ${Object.keys(REGISTERS).join(", ")}`,
|
|
227
|
+
);
|
|
228
|
+
const measured = measureRegister(text);
|
|
229
|
+
const findings = [];
|
|
230
|
+
|
|
231
|
+
for (const [key, band] of Object.entries(spec.bands)) {
|
|
232
|
+
const value = measured[key];
|
|
233
|
+
// A share is noise on a short text.
|
|
234
|
+
|
|
235
|
+
// His own Shorts corpus leaves roughly one fragment every 19 sentences, so a 9-sentence script that leaves one sits at 11% and is not a cadence fault. Two is.
|
|
236
|
+
|
|
237
|
+
// Only the count settles it.
|
|
238
|
+
if (key === "fragmentShare" && measured.fragments <= 1) continue;
|
|
239
|
+
// A spread over 7 sentences is noise: one long line carries it. His own floor
|
|
240
|
+
// of 0.34 was counted over the 46 documents that reach 8.
|
|
241
|
+
if (key === "lengthVariation" && measured.sentences < 8) continue;
|
|
242
|
+
const low = band.min !== undefined && value < band.min;
|
|
243
|
+
const high = band.max !== undefined && value > band.max;
|
|
244
|
+
if (!low && !high) continue;
|
|
245
|
+
const want = low ? `at least ${band.min}` : `at most ${band.max}`;
|
|
246
|
+
const shown = Number.isFinite(value) ? value.toFixed(1) : "no first person";
|
|
247
|
+
findings.push({
|
|
248
|
+
kind: "register",
|
|
249
|
+
id: key,
|
|
250
|
+
detail: `${shown} ${DIRECTION[key]}, ${spec.label} wants ${want}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { register, label: spec.label, source: spec.source, measured, findings };
|
|
255
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** Package manager, @heyramzi dependency and registry credential rules. */
|
|
2
|
+
|
|
3
|
+
const FOREIGN_LOCK = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|bun\.lockb?)$/;
|
|
4
|
+
const MANIFEST = /(^|\/)package\.json$/;
|
|
5
|
+
const LOCAL_SPEC = /^(link|file|portal):|^\.{1,2}\/|^\//;
|
|
6
|
+
const BUCKETS = [
|
|
7
|
+
"dependencies",
|
|
8
|
+
"devDependencies",
|
|
9
|
+
"peerDependencies",
|
|
10
|
+
"optionalDependencies",
|
|
11
|
+
"overrides",
|
|
12
|
+
"resolutions",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function pnpmOnly(tree) {
|
|
16
|
+
return tree.touchedMatching(FOREIGN_LOCK).map((file) => ({
|
|
17
|
+
file,
|
|
18
|
+
rule: "pnpm-only",
|
|
19
|
+
message: `Studio uses pnpm only, and ${file} is another package manager's lockfile. Delete it, run pnpm install so pnpm-lock.yaml is the one lockfile, and write pnpm in scripts and docs.`,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function heyramziSpecs(manifest) {
|
|
24
|
+
const specs = [];
|
|
25
|
+
for (const bucket of [...BUCKETS.map((key) => manifest[key]), manifest.pnpm?.overrides]) {
|
|
26
|
+
for (const [name, spec] of Object.entries(bucket ?? {})) {
|
|
27
|
+
if (name.startsWith("@heyramzi/") && typeof spec === "string") specs.push([name, spec]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return specs;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LINK_MESSAGE = (name, spec) =>
|
|
34
|
+
`${name} is "${spec}". A @heyramzi/* dependency is always a published semver range like ^0.4.0, never link:, file: or a path. A link breaks every build container with no sibling vibe-kit checkout. If the export isn't published yet, bump the version in vibe-kit/packages/<pkg>/package.json, publish, then move this range. Don't swap the protocol to fix a version mismatch.`;
|
|
35
|
+
|
|
36
|
+
export function heyramziLinks(tree) {
|
|
37
|
+
const found = [];
|
|
38
|
+
for (const file of tree.touchedMatching(MANIFEST)) {
|
|
39
|
+
const manifest = tree.json(file);
|
|
40
|
+
if (!manifest) continue;
|
|
41
|
+
for (const [name, spec] of heyramziSpecs(manifest)) {
|
|
42
|
+
if (LOCAL_SPEC.test(spec)) {
|
|
43
|
+
found.push({ file, rule: "heyramzi-link", message: LINK_MESSAGE(name, spec) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const file of tree.touchedMatching(/(^|\/)pnpm-workspace\.yaml$/)) {
|
|
48
|
+
for (const line of tree.text(file).split("\n")) {
|
|
49
|
+
const match = /^\s*['"]?(@heyramzi\/[^'":\s]+)['"]?\s*:\s*['"]?((link|file|portal):\S*)/.exec(
|
|
50
|
+
line,
|
|
51
|
+
);
|
|
52
|
+
if (match) {
|
|
53
|
+
found.push({ file, rule: "heyramzi-link", message: LINK_MESSAGE(match[1], match[2]) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const LOCK_ENTRY = /^ {2}'?(@heyramzi\/[^@']+)@([^'(:]+)'?:\s*$/;
|
|
61
|
+
|
|
62
|
+
export function heyramziTarballs(tree) {
|
|
63
|
+
const found = [];
|
|
64
|
+
for (const file of tree.touchedMatching(/(^|\/)pnpm-lock\.yaml$/)) {
|
|
65
|
+
const lines = tree.text(file).split("\n");
|
|
66
|
+
lines.forEach((line, index) => {
|
|
67
|
+
const entry = LOCK_ENTRY.exec(line);
|
|
68
|
+
const resolution = lines[index + 1] ?? "";
|
|
69
|
+
if (!entry || !/^ {4}resolution:/.test(resolution)) return;
|
|
70
|
+
if (!resolution.includes("integrity:") || resolution.includes("tarball:")) return;
|
|
71
|
+
found.push({
|
|
72
|
+
file,
|
|
73
|
+
rule: "heyramzi-tarball",
|
|
74
|
+
message: `Line ${index + 2}: ${entry[1]}@${entry[2]} has integrity and no tarball URL. Without it pnpm install --frozen-lockfile builds a canonical URL that GitHub Packages redirects to registry.npmjs.com with the scope stripped, which 404s or installs a public package that shares the bare name. Append ", tarball: <url>" to that resolution, where the URL is versions["${entry[2]}"].dist.tarball from https://npm.pkg.github.com/@heyramzi%2f<name>. Any pnpm install, update or advisory patch strips these, so re-check after every dependency change.`,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const SRC_KEY = /^#src(\/|$)/;
|
|
82
|
+
|
|
83
|
+
/** Entry points that land in dist. `files` listing dist doesn't count: a package can ship it and export src. */
|
|
84
|
+
function shipsDist(manifest) {
|
|
85
|
+
return JSON.stringify(manifest.exports ?? {}).includes('"./dist/');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A bundler resolves #src into the output, and relative-imports.mjs rewrites it before publish. */
|
|
89
|
+
function resolvesItself(manifest, dir, tree) {
|
|
90
|
+
return (
|
|
91
|
+
/\b(vp (build|pack)|tsdown|tsup|vite build|rolldown|esbuild|relative-imports)\b/.test(
|
|
92
|
+
manifest.scripts?.build ?? "",
|
|
93
|
+
) || tree.isTracked(`${dir}scripts/relative-imports.mjs`)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function srcImportMaps(tree) {
|
|
98
|
+
const found = [];
|
|
99
|
+
for (const file of tree.touchedMatching(MANIFEST)) {
|
|
100
|
+
const manifest = tree.json(file);
|
|
101
|
+
const keys = Object.keys(manifest?.imports ?? {}).filter((key) => SRC_KEY.test(key));
|
|
102
|
+
if (!manifest?.name?.startsWith("@heyramzi/") || keys.length === 0) continue;
|
|
103
|
+
if (manifest.private || !manifest.publishConfig) continue;
|
|
104
|
+
const dir = file.slice(0, file.length - "package.json".length);
|
|
105
|
+
if (resolvesItself(manifest, dir, tree)) continue;
|
|
106
|
+
const fail = (message) => found.push({ file, rule: "src-import-map", message });
|
|
107
|
+
if (shipsDist(manifest)) {
|
|
108
|
+
const map = manifest.imports["#src/*"];
|
|
109
|
+
if (typeof map?.types !== "string" || typeof map?.default !== "string") {
|
|
110
|
+
fail(
|
|
111
|
+
`${manifest.name} ships dist, so imports needs "#src/*": { "types": "./src/*", "default": "./dist/*" }. A string mapping to ./src makes the dist build emit #src specifiers that point at TypeScript the consumer can't run. tsc keeps the source, Node and every bundler land in dist.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const hasTs = tree.trackedMatching(/\.tsx?$/).some((path) => path.startsWith(`${dir}src/`));
|
|
117
|
+
if (hasTs && !String(manifest.imports["#src/*.js"]).endsWith("/*.ts")) {
|
|
118
|
+
fail(
|
|
119
|
+
`${manifest.name} ships src, so imports needs "#src/*.js": "./src/*.ts" and every #src import ends in .js. Apps compile the source: a bare path fails TypeScript, and a .js mapped to ./src/*.js fails Vite, which never swaps the extension. Or ship dist with a { types, default } mapping.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function npmrcCredentials(tree) {
|
|
127
|
+
const found = [];
|
|
128
|
+
for (const file of tree.touchedMatching(/(^|\/)\.npmrc$/)) {
|
|
129
|
+
tree
|
|
130
|
+
.text(file)
|
|
131
|
+
.split("\n")
|
|
132
|
+
.forEach((line, index) => {
|
|
133
|
+
if (/^\s*[#;]/.test(line) || !line.includes("_authToken")) return;
|
|
134
|
+
found.push({
|
|
135
|
+
file,
|
|
136
|
+
rule: "npmrc-token",
|
|
137
|
+
message: `Line ${index + 1} sets an _authToken in a committed .npmrc. pnpm 11 ignores registry credentials in a project .npmrc, so the line does nothing and risks leaking. Keep the registry line and a WHY comment only. The token lives in the user-level ~/.npmrc, and CI gets it from the runner.`,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return found;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const LEGACY_TOKEN = ["GITHUB", "PRIVATE", "PACKAGES", "TOKEN"].join("_");
|
|
145
|
+
const INSTALL_FILES =
|
|
146
|
+
/(^|\/)(\.npmrc|\.env[^/]*|Dockerfile[^/]*|(docker-)?compose[^/]*\.ya?ml|wrangler\.jsonc?|[^/]+\.sh|\.github\/workflows\/[^/]+\.ya?ml)$/;
|
|
147
|
+
|
|
148
|
+
export function legacyTokenName(tree) {
|
|
149
|
+
const found = [];
|
|
150
|
+
for (const file of tree.touchedMatching(INSTALL_FILES)) {
|
|
151
|
+
tree
|
|
152
|
+
.text(file)
|
|
153
|
+
.split("\n")
|
|
154
|
+
.forEach((line, index) => {
|
|
155
|
+
if (!line.includes(LEGACY_TOKEN)) return;
|
|
156
|
+
found.push({
|
|
157
|
+
file,
|
|
158
|
+
rule: "legacy-token-name",
|
|
159
|
+
message: `Line ${index + 1} names ${LEGACY_TOKEN}, a dead variable that 403s against the package registry. The package-install token is GH_PRIVATE_PACKAGES_TOKEN everywhere: .npmrc, workflow env, the shell. Leave secrets.GITHUB_TOKEN alone, it's the Actions built-in that release-please, GoReleaser and gh need.`,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Compose image tags and fossil file names. */
|
|
2
|
+
|
|
3
|
+
import { yamlCode } from "#lint/repo-rules/tree.js";
|
|
4
|
+
|
|
5
|
+
const COMPOSE = /(^|\/)(docker-)?compose([.-][\w.-]*)?\.ya?ml$/;
|
|
6
|
+
|
|
7
|
+
function tagOf(image) {
|
|
8
|
+
if (image.includes("@sha256:")) return "digest";
|
|
9
|
+
const name = image.slice(image.lastIndexOf("/") + 1);
|
|
10
|
+
const colon = name.indexOf(":");
|
|
11
|
+
return colon === -1 ? null : name.slice(colon + 1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function services(lines) {
|
|
15
|
+
const found = [];
|
|
16
|
+
let inServices = false;
|
|
17
|
+
let serviceIndent = null;
|
|
18
|
+
let current = null;
|
|
19
|
+
lines.forEach((raw, index) => {
|
|
20
|
+
const line = yamlCode(raw);
|
|
21
|
+
if (!line.trim()) return;
|
|
22
|
+
const indent = line.length - line.trimStart().length;
|
|
23
|
+
if (indent === 0) {
|
|
24
|
+
inServices = /^services\s*:/.test(line);
|
|
25
|
+
serviceIndent = null;
|
|
26
|
+
current = null;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!inServices) return;
|
|
30
|
+
serviceIndent ??= indent;
|
|
31
|
+
if (indent === serviceIndent) {
|
|
32
|
+
current = { name: line.trim().replace(/:.*$/, ""), build: false, image: null, line: 0 };
|
|
33
|
+
found.push(current);
|
|
34
|
+
} else if (current && indent > serviceIndent) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (/^build\s*:/.test(trimmed)) current.build = true;
|
|
37
|
+
const image = /^image\s*:\s*(.+)$/.exec(trimmed);
|
|
38
|
+
if (image) {
|
|
39
|
+
current.image = image[1].trim().replace(/^["']|["']$/g, "");
|
|
40
|
+
current.line = index + 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function composeImages(tree) {
|
|
48
|
+
const found = [];
|
|
49
|
+
for (const file of tree.touchedMatching(COMPOSE)) {
|
|
50
|
+
for (const service of services(tree.text(file).split("\n"))) {
|
|
51
|
+
if (!service.image) continue;
|
|
52
|
+
const tag = tagOf(service.image);
|
|
53
|
+
const floating =
|
|
54
|
+
tag?.includes("latest") ||
|
|
55
|
+
(tag === null && !service.build && !service.image.includes("${"));
|
|
56
|
+
if (!floating) continue;
|
|
57
|
+
found.push({
|
|
58
|
+
file,
|
|
59
|
+
rule: "compose-image-tag",
|
|
60
|
+
message: `Line ${service.line}: ${service.name} uses image ${service.image}, which floats. Pin an exact version tag (traefik v3.7.14, postgres 18.6-trixie), and pin Postgres to the distro base it already runs. A stale local latest masked a real gap once and can't be rolled back.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const FOSSIL_STEM = /(-old|-v2|-final| copy( \d+)?)$/;
|
|
68
|
+
const GRAB_BAG = new Set(["misc", "temp"]);
|
|
69
|
+
const GENERATED =
|
|
70
|
+
/(^|\/)(node_modules|\.upstream|vendor|dist|build|\.svelte-kit|\.agents|\.claude|\.codex|\.gemini|\.opencode)\/|^\.github\/skills\//;
|
|
71
|
+
|
|
72
|
+
/** The stem of a file name: `report-old.test.ts` is `report-old`, `v2.final.md` is `v2.final`. */
|
|
73
|
+
function stemOf(name) {
|
|
74
|
+
const dot = name.indexOf(".", 1);
|
|
75
|
+
return dot === -1 ? name : name.slice(0, dot);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function fossilNames(tree) {
|
|
79
|
+
const found = [];
|
|
80
|
+
for (const file of tree.added) {
|
|
81
|
+
if (GENERATED.test(file)) continue;
|
|
82
|
+
const parts = file.split("/");
|
|
83
|
+
const grabBag = parts.slice(0, -1).find((part) => GRAB_BAG.has(part));
|
|
84
|
+
if (grabBag) {
|
|
85
|
+
found.push({
|
|
86
|
+
file,
|
|
87
|
+
rule: "fossil-name",
|
|
88
|
+
message: `${grabBag}/ is a folder named for having no category. Put the file in the folder of what it does.`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const stem = stemOf(parts.at(-1));
|
|
92
|
+
if (FOSSIL_STEM.test(stem)) {
|
|
93
|
+
found.push({
|
|
94
|
+
file,
|
|
95
|
+
rule: "fossil-name",
|
|
96
|
+
message: `${parts.at(-1)} reads as a copy of a file already in use (-old, -v2, -final, copy). Edit the real file and let git keep the history, or name this one for what it does differently.`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}
|