@heyramzi/lint 0.0.0-stage → 1.32.2
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 +80 -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 +194 -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.d.ts +53 -0
- package/slop.js +644 -0
- package/svelte-markup.js +107 -0
- package/svelte-template.js +258 -0
- package/ui-rules.js +324 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SvelteKit `redirect()` to a literal absolute URL needs `{ external: true }`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Kit 3 throws `redirect_external_not_allowed` and the visitor gets a 500. A dynamic location can't be judged statically, so only `"https://..."` and `//host` literals are flagged.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function literalLocation(node) {
|
|
10
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
11
|
+
if (node.type === "TemplateLiteral") return node.quasis[0]?.value.cooked ?? "";
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
meta: {
|
|
17
|
+
type: "problem",
|
|
18
|
+
docs: { description: "Require { external: true } on a redirect() to an absolute URL." },
|
|
19
|
+
messages: {
|
|
20
|
+
needsExternal:
|
|
21
|
+
"`redirect()` to the absolute URL `{{ url }}` throws `redirect_external_not_allowed` in SvelteKit 3 and the visitor gets a 500. Pass `{ external: true }` as the third argument, or an array of the allowed origins.",
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
const local = new Set();
|
|
27
|
+
return {
|
|
28
|
+
ImportDeclaration(node) {
|
|
29
|
+
if (node.source.value !== "@sveltejs/kit") return;
|
|
30
|
+
for (const specifier of node.specifiers) {
|
|
31
|
+
if (specifier.type === "ImportSpecifier" && specifier.imported.name === "redirect") {
|
|
32
|
+
local.add(specifier.local.name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
CallExpression(node) {
|
|
37
|
+
if (node.callee.type !== "Identifier" || !local.has(node.callee.name)) return;
|
|
38
|
+
if (node.arguments.length !== 2) return;
|
|
39
|
+
const location = literalLocation(node.arguments[1]);
|
|
40
|
+
if (location === null || !/^(?:https?:)?\/\/(?:[^/]|$)/i.test(location)) return;
|
|
41
|
+
context.report({ node, messageId: "needsExternal", data: { url: location.slice(0, 60) } });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Require a `SAFETY:` comment on every non-const type assertion.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* An assertion tells the compiler to trust a claim it cannot check. The comment is where the invariant that makes the claim true gets written down.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `as const` is exempt: it narrows rather than claims.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The comment must sit inside the containing statement, or immediately above it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Ported from dmmulroy/anti-slop (MIT).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const SAFETY_PATTERN = /\bSAFETY\s*:/u;
|
|
22
|
+
|
|
23
|
+
/** Statement kinds a SAFETY comment may be attached above. */
|
|
24
|
+
const COMMENT_OWNERS = new Set([
|
|
25
|
+
"ExpressionStatement",
|
|
26
|
+
"PropertyDefinition",
|
|
27
|
+
"ReturnStatement",
|
|
28
|
+
"ThrowStatement",
|
|
29
|
+
"VariableDeclaration",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function isConstAssertion(node) {
|
|
33
|
+
const annotation = node.typeAnnotation;
|
|
34
|
+
return (
|
|
35
|
+
annotation?.type === "TSTypeReference" &&
|
|
36
|
+
annotation.typeName?.type === "Identifier" &&
|
|
37
|
+
annotation.typeName.name === "const"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One diagnostic per chain: an inner assertion shares its position with the
|
|
43
|
+
* outer one, so reporting both prints the same line twice.
|
|
44
|
+
*/
|
|
45
|
+
function isInnerAssertionOfChain(node) {
|
|
46
|
+
let current = node;
|
|
47
|
+
let parent = node.parent;
|
|
48
|
+
while (parent?.type === "ParenthesizedExpression" && parent.expression === current) {
|
|
49
|
+
current = parent;
|
|
50
|
+
parent = parent.parent;
|
|
51
|
+
}
|
|
52
|
+
return (
|
|
53
|
+
(parent?.type === "TSAsExpression" || parent?.type === "TSTypeAssertion") &&
|
|
54
|
+
parent.expression === current
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ownerStart(node) {
|
|
59
|
+
let current = node;
|
|
60
|
+
while (current.parent && current.parent.type !== "Program") {
|
|
61
|
+
if (COMMENT_OWNERS.has(current.type)) break;
|
|
62
|
+
current = current.parent;
|
|
63
|
+
}
|
|
64
|
+
return current.start;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
meta: {
|
|
69
|
+
type: "problem",
|
|
70
|
+
docs: {
|
|
71
|
+
description:
|
|
72
|
+
"Require a nearby SAFETY comment for every type assertion except const assertions.",
|
|
73
|
+
},
|
|
74
|
+
messages: {
|
|
75
|
+
missingSafetyComment:
|
|
76
|
+
"This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.",
|
|
77
|
+
},
|
|
78
|
+
schema: [],
|
|
79
|
+
},
|
|
80
|
+
create(context) {
|
|
81
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
82
|
+
let safetyComments = null;
|
|
83
|
+
|
|
84
|
+
const hasSafetyComment = (node) => {
|
|
85
|
+
safetyComments ??= sourceCode
|
|
86
|
+
.getAllComments()
|
|
87
|
+
.filter((comment) => SAFETY_PATTERN.test(comment.value));
|
|
88
|
+
|
|
89
|
+
if (safetyComments.length === 0) return false;
|
|
90
|
+
|
|
91
|
+
const start = ownerStart(node);
|
|
92
|
+
return safetyComments.some(
|
|
93
|
+
(comment) =>
|
|
94
|
+
comment.end <= node.start &&
|
|
95
|
+
// Inside the statement, or directly above it with only whitespace between.
|
|
96
|
+
(comment.start >= start || sourceCode.text.slice(comment.end, start).trim() === ""),
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const check = (node) => {
|
|
101
|
+
if (isConstAssertion(node) || isInnerAssertionOfChain(node) || hasSafetyComment(node)) return;
|
|
102
|
+
context.report({ node, messageId: "missingSafetyComment" });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
TSAsExpression: check,
|
|
107
|
+
TSTypeAssertion: check,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforce section comment ordering in Svelte <script> blocks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Valid order: IMPORTS → TYPES → PROPS → STATE → DERIVED → EFFECTS → FUNCTIONS Each section comment must use exactly 25 '=' signs. Skips files in components/ui/ (shadcn-svelte primitives).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const VALID_SECTIONS = ["IMPORTS", "TYPES", "PROPS", "STATE", "DERIVED", "EFFECTS", "FUNCTIONS"];
|
|
10
|
+
|
|
11
|
+
const SECTION_COMMENT_RE = /\/\/ (=+)\n\/\/ ([A-Z]+)\n\/\/ (=+)/g;
|
|
12
|
+
|
|
13
|
+
const EXPECTED_EQUALS = 25;
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
meta: {
|
|
17
|
+
type: "suggestion",
|
|
18
|
+
docs: {
|
|
19
|
+
description: "Enforce section comment ordering and format in Svelte script blocks.",
|
|
20
|
+
},
|
|
21
|
+
messages: {
|
|
22
|
+
wrongOrder:
|
|
23
|
+
'"{{ current }}" section must come after "{{ previous }}", not before. Expected order: IMPORTS → TYPES → PROPS → STATE → DERIVED → EFFECTS → FUNCTIONS.',
|
|
24
|
+
wrongEquals:
|
|
25
|
+
'"{{ section }}" section header uses {{ count }} equals signs, expected exactly {{ expected }}.',
|
|
26
|
+
unknownSection: '"{{ section }}" is not a recognized section. Valid sections: {{ valid }}.',
|
|
27
|
+
},
|
|
28
|
+
schema: [],
|
|
29
|
+
},
|
|
30
|
+
create(context) {
|
|
31
|
+
const filename = context.filename;
|
|
32
|
+
|
|
33
|
+
if (!filename.endsWith(".svelte")) return {};
|
|
34
|
+
if (/[\\/]components[\\/]ui[\\/]/.test(filename)) return {};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
Program() {
|
|
38
|
+
const source = context.sourceCode?.text ?? "";
|
|
39
|
+
if (!source) return;
|
|
40
|
+
|
|
41
|
+
const found = [];
|
|
42
|
+
let match;
|
|
43
|
+
|
|
44
|
+
SECTION_COMMENT_RE.lastIndex = 0;
|
|
45
|
+
while ((match = SECTION_COMMENT_RE.exec(source)) !== null) {
|
|
46
|
+
const [, equalsTop, name, equalsBottom] = match;
|
|
47
|
+
found.push({
|
|
48
|
+
name,
|
|
49
|
+
equalsTop: equalsTop.length,
|
|
50
|
+
equalsBottom: equalsBottom.length,
|
|
51
|
+
index: match.index,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (found.length === 0) return;
|
|
56
|
+
|
|
57
|
+
let lastValidIndex = -1;
|
|
58
|
+
|
|
59
|
+
for (const section of found) {
|
|
60
|
+
const sectionIndex = VALID_SECTIONS.indexOf(section.name);
|
|
61
|
+
|
|
62
|
+
if (sectionIndex === -1) {
|
|
63
|
+
context.report({
|
|
64
|
+
loc: { line: 1, column: 0 },
|
|
65
|
+
messageId: "unknownSection",
|
|
66
|
+
data: {
|
|
67
|
+
section: section.name,
|
|
68
|
+
valid: VALID_SECTIONS.join(", "),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (section.equalsTop !== EXPECTED_EQUALS) {
|
|
75
|
+
context.report({
|
|
76
|
+
loc: { line: 1, column: 0 },
|
|
77
|
+
messageId: "wrongEquals",
|
|
78
|
+
data: {
|
|
79
|
+
section: section.name,
|
|
80
|
+
count: String(section.equalsTop),
|
|
81
|
+
expected: String(EXPECTED_EQUALS),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (section.equalsBottom !== EXPECTED_EQUALS) {
|
|
87
|
+
context.report({
|
|
88
|
+
loc: { line: 1, column: 0 },
|
|
89
|
+
messageId: "wrongEquals",
|
|
90
|
+
data: {
|
|
91
|
+
section: section.name,
|
|
92
|
+
count: String(section.equalsBottom),
|
|
93
|
+
expected: String(EXPECTED_EQUALS),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sectionIndex <= lastValidIndex) {
|
|
99
|
+
const previousName = VALID_SECTIONS[lastValidIndex];
|
|
100
|
+
context.report({
|
|
101
|
+
loc: { line: 1, column: 0 },
|
|
102
|
+
messageId: "wrongOrder",
|
|
103
|
+
data: {
|
|
104
|
+
current: section.name,
|
|
105
|
+
previous: previousName,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lastValidIndex = Math.max(lastValidIndex, sectionIndex);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test or fixture file: `*.test.*`, `*.spec.*`, `__fixtures__/` or `fixtures/`. Its strings are inputs under test, so rules about product copy and model ids leave it alone.
|
|
3
|
+
*/
|
|
4
|
+
const TEST_FILE = /\.(?:test|spec)\.|(?:^|[\\/])(?:__fixtures__|fixtures)[\\/]/;
|
|
5
|
+
|
|
6
|
+
export function isTestFile(filename) {
|
|
7
|
+
return TEST_FILE.test(filename ?? "");
|
|
8
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Counts lint hits per rule per repo with this working copy of @heyramzi/lint, not the published one.
|
|
3
|
+
// Usage: node packages/lint/scripts/count-hits.mjs [repo ...] [--warn] [--rule <name>] [--files] [--json]
|
|
4
|
+
|
|
5
|
+
// Base ignorePatterns apply to every count; repos not carrying them are listed after (`ignoreGaps` in --json).
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
const baseConfig = join(packageDir, "config", "oxlintrc.json");
|
|
22
|
+
const vibeKit = resolve(packageDir, "..", "..");
|
|
23
|
+
const studio = resolve(vibeKit, "..");
|
|
24
|
+
const vp = join(vibeKit, "node_modules", ".bin", "vp");
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const flags = {
|
|
28
|
+
warn: args.includes("--warn"),
|
|
29
|
+
files: args.includes("--files"),
|
|
30
|
+
json: args.includes("--json"),
|
|
31
|
+
rule: args.includes("--rule") ? args[args.indexOf("--rule") + 1] : null,
|
|
32
|
+
};
|
|
33
|
+
const repoArgs = args.filter((arg, i) => !arg.startsWith("--") && args[i - 1] !== "--rule");
|
|
34
|
+
|
|
35
|
+
// A Studio repo is a git checkout with a package.json, the same set the counts in the report came from.
|
|
36
|
+
function studioRepos() {
|
|
37
|
+
return readdirSync(studio, { withFileTypes: true })
|
|
38
|
+
.filter((entry) => entry.isDirectory())
|
|
39
|
+
.map((entry) => entry.name)
|
|
40
|
+
.filter(
|
|
41
|
+
(name) =>
|
|
42
|
+
existsSync(join(studio, name, ".git")) && existsSync(join(studio, name, "package.json")),
|
|
43
|
+
)
|
|
44
|
+
.toSorted();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pointsAtThisPackage = (entry) => /@heyramzi\/lint|packages\/lint/.test(entry);
|
|
48
|
+
|
|
49
|
+
// oxlint configs are JSONC. Strings go through untouched so a `//` inside a URL or glob survives.
|
|
50
|
+
function parseJsonc(text) {
|
|
51
|
+
const stripped = text.replace(
|
|
52
|
+
/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g,
|
|
53
|
+
(match, string) => string ?? "",
|
|
54
|
+
);
|
|
55
|
+
return JSON.parse(stripped.replace(/,(\s*[}\]])/g, "$1"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The paths the repo's own `vp lint` script names, so upsys counts `app website packages` and not its vendored bundles.
|
|
59
|
+
function lintTargets(repoDir) {
|
|
60
|
+
const scripts = parseJsonc(readFileSync(join(repoDir, "package.json"), "utf8")).scripts ?? {};
|
|
61
|
+
for (const script of Object.values(scripts)) {
|
|
62
|
+
const match = /\bvp lint ([^&|;]*)/.exec(script);
|
|
63
|
+
if (!match) continue;
|
|
64
|
+
const words = match[1].trim().split(/\s+/);
|
|
65
|
+
const targets = words.filter(
|
|
66
|
+
(word, i) => !word.startsWith("-") && words[i - 1] !== "-c" && words[i - 1] !== "--config",
|
|
67
|
+
);
|
|
68
|
+
const present = targets.filter((target) => existsSync(join(repoDir, target)));
|
|
69
|
+
if (present.length > 0) return present;
|
|
70
|
+
}
|
|
71
|
+
return ["."];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// oxlint walks into a symlinked folder, and upsys links all of vibe-kit under heyramzi-website/lib, so those get ignored.
|
|
75
|
+
function symlinkedDirs(repoDir) {
|
|
76
|
+
const listed = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
|
|
77
|
+
cwd: repoDir,
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
maxBuffer: 1024 * 1024 * 1024,
|
|
80
|
+
}).stdout;
|
|
81
|
+
return (listed ?? "")
|
|
82
|
+
.split("\n")
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.filter((file) => {
|
|
85
|
+
const full = join(repoDir, file);
|
|
86
|
+
return (
|
|
87
|
+
lstatSync(full, { throwIfNoEntry: false })?.isSymbolicLink() &&
|
|
88
|
+
statSync(full, { throwIfNoEntry: false })?.isDirectory()
|
|
89
|
+
);
|
|
90
|
+
})
|
|
91
|
+
.map((dir) => `${dir}/**`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// An extended config's ignorePatterns resolve against its own folder and match nothing in a repo, so the repo carries them. This lists the base ones it lacks.
|
|
95
|
+
const bare = (pattern) => pattern.replace(/^\*\*\//, "");
|
|
96
|
+
function missingIgnores(repoDir) {
|
|
97
|
+
const ownPath = join(repoDir, ".oxlintrc.json");
|
|
98
|
+
const own = existsSync(ownPath) ? parseJsonc(readFileSync(ownPath, "utf8")) : {};
|
|
99
|
+
const carried = new Set((own.ignorePatterns ?? []).map(bare));
|
|
100
|
+
const base = parseJsonc(readFileSync(baseConfig, "utf8"));
|
|
101
|
+
return (base.ignorePatterns ?? []).filter((pattern) => !carried.has(bare(pattern)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The temp config sits in the repo root because oxlint resolves ignorePatterns and relative plugins against the config's own folder.
|
|
105
|
+
function tempConfig(repoDir) {
|
|
106
|
+
const ownPath = join(repoDir, ".oxlintrc.json");
|
|
107
|
+
const own = existsSync(ownPath) ? parseJsonc(readFileSync(ownPath, "utf8")) : {};
|
|
108
|
+
const base = parseJsonc(readFileSync(baseConfig, "utf8"));
|
|
109
|
+
const extendsList = (own.extends ?? []).filter((entry) => !pointsAtThisPackage(entry));
|
|
110
|
+
const plugins = (own.jsPlugins ?? []).filter((entry) => !pointsAtThisPackage(entry));
|
|
111
|
+
return {
|
|
112
|
+
...own,
|
|
113
|
+
extends: [baseConfig, ...extendsList],
|
|
114
|
+
jsPlugins: [packageDir, ...plugins],
|
|
115
|
+
ignorePatterns: [
|
|
116
|
+
...new Set([
|
|
117
|
+
...(base.ignorePatterns ?? []),
|
|
118
|
+
...(own.ignorePatterns ?? []),
|
|
119
|
+
...symlinkedDirs(repoDir),
|
|
120
|
+
]),
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lintRepo(name) {
|
|
126
|
+
const repoDir = join(studio, name);
|
|
127
|
+
const configPath = join(repoDir, `.oxlintrc.count-hits-${process.pid}.json`);
|
|
128
|
+
writeFileSync(configPath, JSON.stringify(tempConfig(repoDir), null, 2));
|
|
129
|
+
try {
|
|
130
|
+
const run = spawnSync(vp, ["lint", "-c", configPath, "-f", "json", ...lintTargets(repoDir)], {
|
|
131
|
+
cwd: repoDir,
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
maxBuffer: 1024 * 1024 * 1024,
|
|
134
|
+
});
|
|
135
|
+
const start = run.stdout.indexOf("{");
|
|
136
|
+
if (start === -1)
|
|
137
|
+
return { error: (run.stderr || run.stdout).trim().split("\n").slice(0, 5).join("\n") };
|
|
138
|
+
return { diagnostics: JSON.parse(run.stdout.slice(start)).diagnostics };
|
|
139
|
+
} finally {
|
|
140
|
+
rmSync(configPath, { force: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// oxlint writes a rule code as `plugin(rule)`; the config writes it as `plugin/rule`, so the report uses the config's spelling.
|
|
145
|
+
const ruleName = (code) =>
|
|
146
|
+
(code ?? "unknown").replace(/^(.*)\((.*)\)$/, "$1/$2").replace(/^eslint-plugin-/, "");
|
|
147
|
+
|
|
148
|
+
const repos = repoArgs.length > 0 ? repoArgs : studioRepos();
|
|
149
|
+
const counts = new Map();
|
|
150
|
+
const hits = [];
|
|
151
|
+
const failures = [];
|
|
152
|
+
const ignoreGaps = {};
|
|
153
|
+
|
|
154
|
+
for (const repo of repos) {
|
|
155
|
+
if (!existsSync(join(studio, repo, "package.json"))) {
|
|
156
|
+
failures.push({ repo, error: `no package.json under ${join(studio, repo)}` });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!flags.json) process.stderr.write(`linting ${repo}\n`);
|
|
160
|
+
const gaps = missingIgnores(join(studio, repo));
|
|
161
|
+
if (gaps.length > 0) ignoreGaps[repo] = gaps;
|
|
162
|
+
const result = lintRepo(repo);
|
|
163
|
+
if (result.error) {
|
|
164
|
+
failures.push({ repo, error: result.error });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const diagnostic of result.diagnostics) {
|
|
168
|
+
if (diagnostic.severity !== "error" && !flags.warn) continue;
|
|
169
|
+
const rule = ruleName(diagnostic.code);
|
|
170
|
+
if (flags.rule && !rule.endsWith(flags.rule)) continue;
|
|
171
|
+
const perRepo = counts.get(rule) ?? new Map();
|
|
172
|
+
perRepo.set(repo, (perRepo.get(repo) ?? 0) + 1);
|
|
173
|
+
counts.set(rule, perRepo);
|
|
174
|
+
const line = diagnostic.labels?.[0]?.span?.line ?? 0;
|
|
175
|
+
hits.push({ repo, rule, file: diagnostic.filename, line, message: diagnostic.message });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const total = (perRepo) => [...perRepo.values()].reduce((sum, n) => sum + n, 0);
|
|
180
|
+
const ranked = [...counts.entries()].toSorted((a, b) => total(b[1]) - total(a[1]));
|
|
181
|
+
|
|
182
|
+
if (flags.json) {
|
|
183
|
+
const rules = Object.fromEntries(
|
|
184
|
+
ranked.map(([rule, perRepo]) => [rule, Object.fromEntries(perRepo)]),
|
|
185
|
+
);
|
|
186
|
+
const report = { repos, rules, failures, ignoreGaps };
|
|
187
|
+
if (flags.files) report.hits = hits;
|
|
188
|
+
console.log(JSON.stringify(report, null, 2));
|
|
189
|
+
} else {
|
|
190
|
+
for (const [rule, perRepo] of ranked) {
|
|
191
|
+
const byRepo = [...perRepo.entries()].toSorted((a, b) => b[1] - a[1]);
|
|
192
|
+
console.log(`${String(total(perRepo)).padStart(6)} ${rule}`);
|
|
193
|
+
for (const [repo, n] of byRepo) console.log(`${String(n).padStart(14)} ${repo}`);
|
|
194
|
+
}
|
|
195
|
+
if (flags.files) {
|
|
196
|
+
for (const hit of hits)
|
|
197
|
+
console.log(`${hit.repo}/${hit.file}:${hit.line} ${hit.rule} ${hit.message}`);
|
|
198
|
+
}
|
|
199
|
+
const grand = ranked.reduce((sum, [, perRepo]) => sum + total(perRepo), 0);
|
|
200
|
+
console.log(
|
|
201
|
+
`\n${grand} ${flags.warn ? "hits" : "errors"} across ${repos.length - failures.length} repos`,
|
|
202
|
+
);
|
|
203
|
+
for (const { repo, error } of failures) console.log(`FAILED ${repo}: ${error}`);
|
|
204
|
+
const gapped = Object.entries(ignoreGaps);
|
|
205
|
+
if (gapped.length > 0) {
|
|
206
|
+
console.log(
|
|
207
|
+
`\nCounts above apply the base ignores. ${gapped.length} repos don't carry them in their own .oxlintrc.json, so their real lint run reads files the counts skip:`,
|
|
208
|
+
);
|
|
209
|
+
for (const [repo, gaps] of gapped) console.log(` ${repo}: ${gaps.join(" ")}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
process.exitCode = failures.length > 0 ? 2 : 0;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Folds Ramzi's dictated messages into the voice profile. `--write` also rewrites the generated
|
|
4
|
+
* block in registers.md. Raw text stays in `~/.claude-ramzi/voice-corpus/`; only counts reach the repo.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { appendFileSync, openSync, readSync, closeSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { measureRegister } from "#lint/register.js";
|
|
13
|
+
|
|
14
|
+
const START = "<!-- voice:generated:start -->";
|
|
15
|
+
const END = "<!-- voice:generated:end -->";
|
|
16
|
+
/** A longer message is pasted material, not speech. */
|
|
17
|
+
const MAX_WORDS = 400;
|
|
18
|
+
const MIN_WORDS = 8;
|
|
19
|
+
const FILLERS = ["basically", "actually", "literally", "you know", "kind of", "sort of", "i mean"];
|
|
20
|
+
|
|
21
|
+
/** The words he typed in one transcript entry, or null when the entry is not his speech. */
|
|
22
|
+
export function humanText(entry) {
|
|
23
|
+
if (entry?.type !== "user" || entry.isSidechain || entry.origin?.kind !== "human") return null;
|
|
24
|
+
const c = entry.message?.content;
|
|
25
|
+
const raw =
|
|
26
|
+
typeof c === "string" ? c : (c ?? []).map((b) => (b.type === "text" ? b.text : "")).join(" ");
|
|
27
|
+
const text = raw
|
|
28
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
|
|
29
|
+
.replace(/<\/?pasted_content[^>]*>/g, "")
|
|
30
|
+
.replace(/https?:\/\/\S+/g, "")
|
|
31
|
+
.trim();
|
|
32
|
+
if (!text || /^<(?:local-)?command/.test(text)) return null;
|
|
33
|
+
const tokens = text.split(/\s+/);
|
|
34
|
+
if (tokens.length < MIN_WORDS || tokens.length > MAX_WORDS) return null;
|
|
35
|
+
return pasted(text, tokens) ? null : text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A log, a stack trace, SVG path data, a shell session or an image placeholder, none of which he said. */
|
|
39
|
+
function pasted(text, tokens) {
|
|
40
|
+
const lone = tokens.filter((t) => t.length === 1 && !/^[Iia]$/.test(t)).length;
|
|
41
|
+
const junk =
|
|
42
|
+
/\/Users\/|node_modules|[│├└┌─]|\bat \S+:\d+:\d+|\bimage\s+image\b|\[Image|\b[b-hj-zB-HJ-Z]\s+[b-hj-zB-HJ-Z]\s+[b-hj-zB-HJ-Z]\b/;
|
|
43
|
+
const code = tokens.filter((t) => /[/\\{}()<>=;_|$]|\d[a-z]+\d/i.test(t)).length;
|
|
44
|
+
return junk.test(text) || lone / tokens.length > 0.1 || code / tokens.length > 0.15;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Complete lines appended to a file since `from`, and the offset to resume at. */
|
|
48
|
+
function newLines(file, from) {
|
|
49
|
+
const size = statSync(file).size;
|
|
50
|
+
if (size <= from) return { lines: [], next: from };
|
|
51
|
+
const fd = openSync(file, "r");
|
|
52
|
+
const buf = Buffer.alloc(size - from);
|
|
53
|
+
readSync(fd, buf, 0, buf.length, from);
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
const chunk = buf.toString("utf8");
|
|
56
|
+
const end = chunk.lastIndexOf("\n") + 1;
|
|
57
|
+
return {
|
|
58
|
+
lines: chunk.slice(0, end).split("\n"),
|
|
59
|
+
next: from + Buffer.byteLength(chunk.slice(0, end)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function harvest({ projects, dir }) {
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
const stateFile = join(dir, "offsets.json");
|
|
66
|
+
const corpusFile = join(dir, "dictation.jsonl");
|
|
67
|
+
const state = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, "utf8")) : {};
|
|
68
|
+
let added = 0;
|
|
69
|
+
for (const project of readdirSync(projects)) {
|
|
70
|
+
if (project.includes("observer")) continue;
|
|
71
|
+
const base = join(projects, project);
|
|
72
|
+
if (!statSync(base).isDirectory()) continue;
|
|
73
|
+
for (const name of readdirSync(base).filter((f) => f.endsWith(".jsonl"))) {
|
|
74
|
+
const file = join(base, name);
|
|
75
|
+
const { lines, next } = newLines(file, state[file] ?? 0);
|
|
76
|
+
state[file] = next;
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (!line.includes('"kind":"human"')) continue;
|
|
79
|
+
try {
|
|
80
|
+
const entry = JSON.parse(line);
|
|
81
|
+
const text = humanText(entry);
|
|
82
|
+
if (text) {
|
|
83
|
+
appendFileSync(corpusFile, `${JSON.stringify({ t: entry.timestamp, text })}\n`);
|
|
84
|
+
added++;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// A line cut mid-write is picked up on the next run.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
writeFileSync(stateFile, JSON.stringify(state));
|
|
93
|
+
const rows = existsSync(corpusFile)
|
|
94
|
+
? readFileSync(corpusFile, "utf8")
|
|
95
|
+
.split("\n")
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.map((l) => JSON.parse(l))
|
|
98
|
+
: [];
|
|
99
|
+
return { added, rows };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Counts per 1,000 words, and the first words he opens a message with. */
|
|
103
|
+
export function profile(rows) {
|
|
104
|
+
const body = rows.map((r) => r.text).join("\n\n");
|
|
105
|
+
const m = measureRegister(body);
|
|
106
|
+
const lower = body.toLowerCase();
|
|
107
|
+
const per1k = (n) => (n / m.words) * 1000;
|
|
108
|
+
const fillers = Object.fromEntries(
|
|
109
|
+
FILLERS.map((f) => [f, per1k((lower.match(new RegExp(`\\b${f}\\b`, "g")) ?? []).length)]),
|
|
110
|
+
);
|
|
111
|
+
const openers = new Map();
|
|
112
|
+
for (const r of rows) {
|
|
113
|
+
const first = r.text.toLowerCase().match(/^[a-z']+/)?.[0];
|
|
114
|
+
if (first) openers.set(first, (openers.get(first) ?? 0) + 1);
|
|
115
|
+
}
|
|
116
|
+
const top = [...openers].toSorted((a, b) => b[1] - a[1]).slice(0, 12);
|
|
117
|
+
return { messages: rows.length, m, fillers, top };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function block({ messages, m, fillers, top }, day) {
|
|
121
|
+
const f = (n) => n.toFixed(1);
|
|
122
|
+
return [
|
|
123
|
+
START,
|
|
124
|
+
"<!-- Generated by `node packages/lint/scripts/harvest-voice.mjs --write`. Do not edit by hand. -->",
|
|
125
|
+
"",
|
|
126
|
+
`**His dictation to Claude, ${day}.** ${messages} messages, ${m.words} words, read off the session transcripts (\`origin.kind = human\`, 8 to 400 words each, links stripped, so pasted material is out). Speech-to-text, so the punctuation is the software's and the word choice is his.`,
|
|
127
|
+
"",
|
|
128
|
+
`Mean sentence ${f(m.meanSentence)} words, median ${f(m.medianSentence)}, ${f(m.fragmentShare)}% fragments, ${f(m.longShare)}% long. Contractions ${f(m.contractions)} per 1,000 words, "you" ${f(m.secondPerson)}, "I" ${f(m.firstPerson)}.`,
|
|
129
|
+
"",
|
|
130
|
+
`Fillers per 1,000 words: ${Object.entries(fillers)
|
|
131
|
+
.map(([k, v]) => `${k} ${f(v)}`)
|
|
132
|
+
.join(", ")}.`,
|
|
133
|
+
"",
|
|
134
|
+
`Opens a message with: ${top.map(([w, n]) => `${w} (${n})`).join(", ")}.`,
|
|
135
|
+
END,
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function main() {
|
|
140
|
+
const dir = process.env.VOICE_CORPUS_DIR ?? join(homedir(), ".claude-ramzi", "voice-corpus");
|
|
141
|
+
const projects = process.env.VOICE_PROJECTS_DIR ?? join(homedir(), ".claude-ramzi", "projects");
|
|
142
|
+
const { added, rows } = harvest({ projects, dir });
|
|
143
|
+
const out = block(profile(rows), new Date().toISOString().slice(0, 10));
|
|
144
|
+
console.log(`${added} new messages, ${rows.length} in the corpus\n\n${out}`);
|
|
145
|
+
if (!process.argv.includes("--write")) return;
|
|
146
|
+
const target = fileURLToPath(
|
|
147
|
+
new URL(
|
|
148
|
+
"../../../ai-doc/skills/content/writing/humanizer/references/registers.md",
|
|
149
|
+
import.meta.url,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
const doc = readFileSync(target, "utf8");
|
|
153
|
+
const re = new RegExp(`${START}[\\s\\S]*?${END}`);
|
|
154
|
+
if (!re.test(doc)) throw new Error(`registers.md has no ${START} markers`);
|
|
155
|
+
writeFileSync(
|
|
156
|
+
target,
|
|
157
|
+
doc.replace(re, () => out),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
|