@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,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `heyramzi-jev-code <file...>` or `--hook` (PostToolUse). 0.75+ exits 2, 0.6 to 0.75 returns
|
|
4
|
+
* context to double-check, and no key or a timeout passes. Why: TECHLOG.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { NO_KEY, kiloKey } from "#lint/jev.js";
|
|
11
|
+
import { RULES, auditCode } from "#lint/jev-code.js";
|
|
12
|
+
|
|
13
|
+
// =========================
|
|
14
|
+
// CONSTANTS
|
|
15
|
+
// =========================
|
|
16
|
+
const STUDIO = process.env.JEV_CODE_ROOT ?? join(homedir(), "Studio");
|
|
17
|
+
const SKIP =
|
|
18
|
+
/\/(node_modules|dist|build|\.svelte-kit|\.next|coverage|\.claude|\.codex|\.gemini|\.opencode|vendor|generated)\/|\.min\.js$|\.d\.ts$/;
|
|
19
|
+
/** Chunks of 60 lines; a Write of a whole file is judged on its first 240. */
|
|
20
|
+
const MAX_CHARS = 240 * 80;
|
|
21
|
+
const TIMEOUT_MS = 20_000;
|
|
22
|
+
|
|
23
|
+
// =========================
|
|
24
|
+
// FUNCTIONS
|
|
25
|
+
// =========================
|
|
26
|
+
/** The text an Edit, MultiEdit or Write put into the file. */
|
|
27
|
+
export function writtenText(input) {
|
|
28
|
+
if (typeof input.content === "string") return input.content;
|
|
29
|
+
if (typeof input.new_string === "string") return input.new_string;
|
|
30
|
+
if (Array.isArray(input.edits)) return input.edits.map((e) => e.new_string ?? "").join("\n\n");
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function report({ findings, review }, file) {
|
|
35
|
+
const line = (h) => `- ${h.id} (${h.p.toFixed(2)}): ${RULES[h.id].fix}`;
|
|
36
|
+
const out = [];
|
|
37
|
+
if (findings.length)
|
|
38
|
+
out.push(`jev-code: ${file} breaks a coding rule. Fix it now:`, ...findings.map(line));
|
|
39
|
+
if (review.length)
|
|
40
|
+
out.push(
|
|
41
|
+
`jev-code: double-check ${file}; these may not apply here, so fix only what's real:`,
|
|
42
|
+
...review.map(line),
|
|
43
|
+
);
|
|
44
|
+
if (out.length)
|
|
45
|
+
out.push(
|
|
46
|
+
"Change only the file you edited. Never delete a file or edit code you haven't read to satisfy a rule.",
|
|
47
|
+
);
|
|
48
|
+
return out.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const dedupe = (hits) => [
|
|
52
|
+
...new Map(hits.toSorted((a, b) => b.p - a.p).map((h) => [h.id, h])).values(),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
async function hook() {
|
|
56
|
+
let input;
|
|
57
|
+
try {
|
|
58
|
+
input = JSON.parse(readFileSync(0, "utf8"));
|
|
59
|
+
} catch {
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const file = input.tool_input?.file_path ?? "";
|
|
63
|
+
if (!file.startsWith(`${STUDIO}/`) || SKIP.test(file)) return 0;
|
|
64
|
+
const code = writtenText(input.tool_input).slice(0, MAX_CHARS);
|
|
65
|
+
const k = kiloKey();
|
|
66
|
+
if (!code || !k) return 0;
|
|
67
|
+
|
|
68
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS).unref());
|
|
69
|
+
const result = await Promise.race([auditCode(code, file, { key: k }).catch(() => null), timeout]);
|
|
70
|
+
if (!result) return 0;
|
|
71
|
+
const hits = { findings: dedupe(result.findings), review: dedupe(result.review) };
|
|
72
|
+
hits.review = hits.review.filter((r) => !hits.findings.some((f) => f.id === r.id));
|
|
73
|
+
if (hits.findings.length) {
|
|
74
|
+
process.stderr.write(`${report(hits, file)}\n`);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
if (hits.review.length) {
|
|
78
|
+
const additionalContext = report(hits, file);
|
|
79
|
+
process.stdout.write(
|
|
80
|
+
JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext } }),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function files(paths) {
|
|
87
|
+
const k = kiloKey();
|
|
88
|
+
if (!k) {
|
|
89
|
+
process.stderr.write(`heyramzi-jev-code ${NO_KEY}\n`);
|
|
90
|
+
return 2;
|
|
91
|
+
}
|
|
92
|
+
let failed = false;
|
|
93
|
+
for (const file of paths) {
|
|
94
|
+
const { findings, review } = await auditCode(readFileSync(file, "utf8"), file, { key: k });
|
|
95
|
+
const text = report({ findings: dedupe(findings), review: dedupe(review) }, file);
|
|
96
|
+
if (text) console.log(text);
|
|
97
|
+
failed ||= findings.length > 0;
|
|
98
|
+
}
|
|
99
|
+
return failed ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const args = process.argv.slice(2);
|
|
103
|
+
if (!args.length) {
|
|
104
|
+
process.stderr.write("usage: heyramzi-jev-code <file...> | --hook\n");
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
process.exit(args[0] === "--hook" ? await hook() : await files(args));
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The pushed-diff gate: no changed `StorageKey` string. Rule and message: `push-diff.js`.
|
|
4
|
+
* Run it from a pre-push hook.
|
|
5
|
+
*
|
|
6
|
+
* heyramzi-push-diff [--base <rev>] # non-zero when the push changes a stored key's value
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { exit } from "node:process";
|
|
11
|
+
|
|
12
|
+
import { gateFiles } from "#lint/gate-files.js";
|
|
13
|
+
import { changedStorageValues, hasStorageKeys, report } from "#lint/push-diff.js";
|
|
14
|
+
|
|
15
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
|
|
16
|
+
const git = (args) =>
|
|
17
|
+
execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
|
|
18
|
+
const tryGit = (args) => {
|
|
19
|
+
try {
|
|
20
|
+
return git(args);
|
|
21
|
+
} catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const flagAt = process.argv.indexOf("--base");
|
|
27
|
+
const base = (flagAt === -1 ? process.env.PUSH_BASE : process.argv[flagAt + 1]) || mergeBase();
|
|
28
|
+
|
|
29
|
+
if (!base) {
|
|
30
|
+
console.log("push-diff: skip (no upstream to compare with)");
|
|
31
|
+
exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const changed = changedFiles();
|
|
35
|
+
const inPush = new Set(gateFiles(repoRoot, [...changed.keys()]));
|
|
36
|
+
const files = [...changed].filter(([file]) => inPush.has(file));
|
|
37
|
+
|
|
38
|
+
const storage = registryFiles(files.map(([file]) => file)).flatMap((file) =>
|
|
39
|
+
changedStorageValues(
|
|
40
|
+
tryGit(["show", `${base}:${changed.get(file)}`]),
|
|
41
|
+
tryGit(["show", `HEAD:${file}`]),
|
|
42
|
+
).map((change) => ({ file, ...change })),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (storage.length === 0) {
|
|
46
|
+
console.log("push-diff: no changed StorageKey string");
|
|
47
|
+
} else {
|
|
48
|
+
console.error(report(storage));
|
|
49
|
+
exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mergeBase() {
|
|
53
|
+
for (const upstream of ["@{u}", "origin/HEAD"]) {
|
|
54
|
+
const found = tryGit(["merge-base", "HEAD", upstream]).trim();
|
|
55
|
+
if (found) return found;
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** New path to old path for every added, copied, modified or renamed file. A pure move is R100 and changes nothing. */
|
|
61
|
+
function changedFiles() {
|
|
62
|
+
const fields = git(["diff", "--name-status", "-M", "-z", "--diff-filter=ACMR", base, "HEAD"]).split(
|
|
63
|
+
"\0",
|
|
64
|
+
);
|
|
65
|
+
const map = new Map();
|
|
66
|
+
for (let index = 0; index < fields.length - 1; ) {
|
|
67
|
+
const status = fields[index];
|
|
68
|
+
const paired = status.startsWith("R") || status.startsWith("C");
|
|
69
|
+
const target = fields[index + (paired ? 2 : 1)];
|
|
70
|
+
if (status !== "R100") map.set(target, paired ? fields[index + 1] : target);
|
|
71
|
+
index += paired ? 3 : 2;
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The changed files that hold a `StorageKey` registry. One `git grep` per 200 paths keeps the command line short. */
|
|
77
|
+
function registryFiles(candidates) {
|
|
78
|
+
const code = candidates.filter((file) => /\.(?:ts|js|mjs|cjs)$/.test(file));
|
|
79
|
+
const found = [];
|
|
80
|
+
for (let index = 0; index < code.length; index += 200) {
|
|
81
|
+
const out = tryGit([
|
|
82
|
+
"grep",
|
|
83
|
+
"-l",
|
|
84
|
+
"-E",
|
|
85
|
+
String.raw`(enum[[:space:]]+StorageKey[[:space:]]*\{|StorageKey[[:space:]]*(:[^=]+)?=[[:space:]]*\{)`,
|
|
86
|
+
"HEAD",
|
|
87
|
+
"--",
|
|
88
|
+
...code.slice(index, index + 200),
|
|
89
|
+
]);
|
|
90
|
+
found.push(...out.split("\n").filter(Boolean).map((line) => line.replace(/^HEAD:/, "")));
|
|
91
|
+
}
|
|
92
|
+
return found.filter((file) => hasStorageKeys(tryGit(["show", `HEAD:${file}`])));
|
|
93
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The repo-rules gate over staged files. Usage: this package's README, "Repo Rules Gate".
|
|
4
|
+
*
|
|
5
|
+
* heyramzi-repo-rules # non-zero when a staged file breaks a rule
|
|
6
|
+
* heyramzi-repo-rules --all # judge every tracked file, for an audit
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { exit } from "node:process";
|
|
13
|
+
|
|
14
|
+
import { check, createTree, report } from "#lint/repo-rules.js";
|
|
15
|
+
|
|
16
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
|
|
17
|
+
const git = (args) =>
|
|
18
|
+
execFileSync("git", args, {
|
|
19
|
+
cwd: repoRoot,
|
|
20
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
21
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
22
|
+
});
|
|
23
|
+
const all = process.argv.includes("--all");
|
|
24
|
+
|
|
25
|
+
const tracked = git(["ls-files", "-z"]).toString("utf8").split("\0").filter(Boolean);
|
|
26
|
+
const changes = all ? { touched: tracked, added: tracked } : staged();
|
|
27
|
+
const tree = createTree({ ...changes, tracked, read: all ? readWorking : readStaged });
|
|
28
|
+
|
|
29
|
+
const violations = check(tree);
|
|
30
|
+
if (violations.length === 0) {
|
|
31
|
+
console.log(`repo-rules: ${all ? "every tracked" : "every staged"} file passes`);
|
|
32
|
+
} else {
|
|
33
|
+
console.error(report(violations));
|
|
34
|
+
exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What the commit changes. A pure move (R100) keeps its content, but its new path still counts as added. */
|
|
38
|
+
function staged() {
|
|
39
|
+
const fields = git(["diff", "--cached", "--name-status", "-M", "-z", "--diff-filter=ACMR"])
|
|
40
|
+
.toString("utf8")
|
|
41
|
+
.split("\0");
|
|
42
|
+
const touched = [];
|
|
43
|
+
const added = [];
|
|
44
|
+
for (let index = 0; index < fields.length - 1;) {
|
|
45
|
+
const status = fields[index];
|
|
46
|
+
const paired = status.startsWith("R") || status.startsWith("C");
|
|
47
|
+
const target = fields[index + (paired ? 2 : 1)];
|
|
48
|
+
if (status !== "R100") touched.push(target);
|
|
49
|
+
if (status !== "M") added.push(target);
|
|
50
|
+
index += paired ? 3 : 2;
|
|
51
|
+
}
|
|
52
|
+
return { touched, added };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The staged blob, which is what gets committed. A binary file reads as text noise nobody matches. */
|
|
56
|
+
function readStaged(file) {
|
|
57
|
+
try {
|
|
58
|
+
return git(["cat-file", "blob", `:${file}`]).toString("utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readWorking(file) {
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync(path.join(repoRoot, file), "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The publish gate on the command line, and the only entry point to it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* heyramzi-slop draft.md report, exit 1 on any hit heyramzi-slop src/lib/content a directory, walked heyramzi-slop a.md b.md src/copy any mix of files and directories
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* heyramzi-slop short.md --register=shorts also grade the delivery rates heyramzi-slop draft.html --fix rewrite with the invisibles gone heyramzi-slop draft.md --json machine-readable, for a build step
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* cat draft.txt | heyramzi-slop - stdin in, cleaned text on stdout
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* It takes the paths itself rather than a shell pipeline, because `cat dir/*.ts |
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* heyramzi-slop -` is what every repo invented instead, and it loses the file a hit is in. One command, whatever you point it at.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* `--fix` only ever removes invisible characters. It never touches a word: a ban list cannot write the replacement sentence, and a gate that guesses is a gate nobody trusts.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* The words are the writer's job, and the report names each one.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* Exit 1 on a slop hit, so a hook or a build step can gate on it.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* Exit 0 with invisible characters removed under `--fix`, because that half is repaired rather than reported.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* `--jev` adds model-scored shapes. They are a report, never a failure: one sentence scored 0.71, then 0.78.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* `--shape-block=0.85` makes a shape at that score or above fail the run. Scores are cached per sentence.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* With the model unreachable the exact patterns still run, stderr says the model check didn't, and the exit code is theirs.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
55
|
+
import { join } from "node:path";
|
|
56
|
+
import { patterns, shapes, titleTells } from "#lint/data/slop-words.js";
|
|
57
|
+
import { REVIEW, auditShapes, isUnavailable, kiloKey, proseOf } from "#lint/jev.js";
|
|
58
|
+
import { slopGate } from "#lint/slop.js";
|
|
59
|
+
|
|
60
|
+
// =========================
|
|
61
|
+
// CONSTANTS
|
|
62
|
+
// =========================
|
|
63
|
+
const READ = /\.(md|mdx|txt|html?|svelte|vue|m?[jt]sx?|json)$/i;
|
|
64
|
+
const SKIP = new Set(["node_modules", ".git", ".svelte-kit", "dist", "build", ".next", "coverage"]);
|
|
65
|
+
// Keys from upsys/app/src/lib/diagram/board-doc.ts.
|
|
66
|
+
const BOARD_TITLE = new Set(["name", "label", "title", "headers"]);
|
|
67
|
+
const BOARD_PROSE = new Set(["text", "point", "lines", "spoken", "a", "b", "rows", "kicker"]);
|
|
68
|
+
const BOARD_CAPTION = new Set(["arrow", "annotate", "icon", "figure", "image"]);
|
|
69
|
+
|
|
70
|
+
// =========================
|
|
71
|
+
// FUNCTIONS
|
|
72
|
+
// =========================
|
|
73
|
+
/** Every file a path stands for. A directory is walked; a named file is taken as given. */
|
|
74
|
+
function expand(path) {
|
|
75
|
+
if (path === "-") return [path];
|
|
76
|
+
if (!statSync(path).isDirectory()) return [path];
|
|
77
|
+
return readdirSync(path, { withFileTypes: true })
|
|
78
|
+
.filter((e) => !SKIP.has(e.name) && !e.name.startsWith("."))
|
|
79
|
+
.flatMap((e) => {
|
|
80
|
+
const child = join(path, e.name);
|
|
81
|
+
return e.isDirectory() ? expand(child) : READ.test(e.name) ? [child] : [];
|
|
82
|
+
})
|
|
83
|
+
.toSorted();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function gate(file, register) {
|
|
87
|
+
const raw = file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8");
|
|
88
|
+
// HTML gets its tags stripped before anything is matched, so an email body audits the same way a post does.
|
|
89
|
+
|
|
90
|
+
// A `--fix` on HTML still writes the original markup back with only the invisible characters gone, never the stripped prose.
|
|
91
|
+
const markup = /\.(html?|svelte|vue)$/i.test(file);
|
|
92
|
+
// A copy module opens its sentences after `key: "`, which no sentence anchor
|
|
93
|
+
// reads as a start. `withStringStarts` in slop.js carries what that cost.
|
|
94
|
+
const code = /\.(m?[jt]sx?|json)$/i.test(file);
|
|
95
|
+
if (/\.board\.json$/i.test(file)) return { raw, result: gateBoard(raw, register) };
|
|
96
|
+
const text = /\.mdx?$/i.test(file) ? proseOfMarkdown(raw) : raw;
|
|
97
|
+
return { raw, result: slopGate(text, { markup, code, register }) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Markdown prose only: frontmatter and fences go blank, inline code becomes a neutral word.
|
|
102
|
+
* Unmasked, a fenced heading read as a second h1 (4 Oct 2026).
|
|
103
|
+
*/
|
|
104
|
+
function proseOfMarkdown(raw) {
|
|
105
|
+
const blank = (block) => block.replace(/[^\n]/g, "");
|
|
106
|
+
// A distinct token per span, or two spans side by side read as an echoed word.
|
|
107
|
+
let n = 0;
|
|
108
|
+
return (
|
|
109
|
+
raw
|
|
110
|
+
.replace(/^---\n[\s\S]*?\n---\n/, blank)
|
|
111
|
+
// `next dev` rewrites this block whenever it differs from its own copy, so an edit never sticks.
|
|
112
|
+
.replace(/<!-- BEGIN:nextjs-agent-rules -->[\s\S]*?<!-- END:nextjs-agent-rules -->/g, blank)
|
|
113
|
+
.replace(/^([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1\2[^\n]*$/gm, blank)
|
|
114
|
+
.replace(/`[^`\n]+`/g, () => `code${(n += 1)}`)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A board's titles face `titleTells` and its prose the gate; ids and layout numbers are neither. */
|
|
119
|
+
function gateBoard(raw, register) {
|
|
120
|
+
const titles = [];
|
|
121
|
+
const prose = [];
|
|
122
|
+
(function walk(node, key, kind) {
|
|
123
|
+
if (Array.isArray(node)) return node.forEach((n) => walk(n, key, kind));
|
|
124
|
+
if (node && typeof node === "object") {
|
|
125
|
+
for (const [k, v] of Object.entries(node)) walk(v, k, node.kind);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (typeof node !== "string") return;
|
|
129
|
+
const heading = key === "text" && (kind === "title" || kind === "heading");
|
|
130
|
+
if ((BOARD_TITLE.has(key) && !BOARD_CAPTION.has(kind)) || heading) titles.push(node);
|
|
131
|
+
else if (BOARD_PROSE.has(key) || BOARD_TITLE.has(key)) prose.push(node);
|
|
132
|
+
})(JSON.parse(raw));
|
|
133
|
+
const result = slopGate([...titles, ...prose].join("\n\n"), { register });
|
|
134
|
+
for (const title of titles)
|
|
135
|
+
for (const t of titleTells.filter((t) => t.test.test(title)))
|
|
136
|
+
result.findings.push({
|
|
137
|
+
kind: "title",
|
|
138
|
+
id: t.id,
|
|
139
|
+
detail: `${JSON.stringify(title)}: ${t.rule}`,
|
|
140
|
+
});
|
|
141
|
+
result.ok = result.findings.length === 0;
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The model-scored shapes of one file, or null after noting why the model could not answer. */
|
|
146
|
+
async function modelShapes(file, raw) {
|
|
147
|
+
try {
|
|
148
|
+
jevKey ??= kiloKey();
|
|
149
|
+
const text = /\.(html?|svelte|vue)$/i.test(file) ? proseOf(raw) : raw;
|
|
150
|
+
return await auditShapes(text, { key: jevKey, block: shapeBlock });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (!isUnavailable(err)) throw err;
|
|
153
|
+
jevDown = err.message;
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function report(file, result, fixed) {
|
|
159
|
+
const { first, second } = result.ledger;
|
|
160
|
+
console.log(
|
|
161
|
+
`${file}: ${result.words} words, ${result.language}, ledger ${first} first / ${second} second`,
|
|
162
|
+
);
|
|
163
|
+
if (result.register) {
|
|
164
|
+
const m = result.register.measured;
|
|
165
|
+
console.log(
|
|
166
|
+
` register ${result.register.label}: ${m.meanSentence.toFixed(1)} words/sentence, ` +
|
|
167
|
+
`${m.fragmentShare.toFixed(0)}% fragments, ${m.contractions.toFixed(1)} contractions/1k, ` +
|
|
168
|
+
`${m.secondPerson.toFixed(1)} you/1k, ${m.chaining.toFixed(1)} chaining/1k`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
for (const r of result.removed) {
|
|
172
|
+
console.log(` invisible ${r.what} x${r.count}${fixed ? " (removed)" : ""}`);
|
|
173
|
+
}
|
|
174
|
+
for (const f of result.findings) console.log(` ${f.kind.padEnd(13)} ${f.id} ${f.detail}`);
|
|
175
|
+
for (const r of result.review) console.log(` ${r.kind.padEnd(13)} ${r.id} ${r.detail}`);
|
|
176
|
+
if (result.removed.length === 0 && result.ok) console.log("clean");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// =========================
|
|
180
|
+
// MAIN
|
|
181
|
+
// =========================
|
|
182
|
+
const args = process.argv.slice(2);
|
|
183
|
+
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
184
|
+
const paths = args.filter((a) => !a.startsWith("--"));
|
|
185
|
+
|
|
186
|
+
if (paths.length === 0) {
|
|
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",
|
|
189
|
+
);
|
|
190
|
+
process.exit(2);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const registerFlag = args.find((a) => a.startsWith("--register"));
|
|
194
|
+
// A bare `--register`, or one written with a space, used to grade nothing and exit 0.
|
|
195
|
+
|
|
196
|
+
// That is the worst thing a gate can do: silence a caller reads as a pass.
|
|
197
|
+
if (registerFlag && !registerFlag.includes("=")) {
|
|
198
|
+
process.stderr.write("--register takes a value: --register=spoken|shorts|written\n");
|
|
199
|
+
process.exit(2);
|
|
200
|
+
}
|
|
201
|
+
const register = registerFlag ? registerFlag.split("=")[1] : undefined;
|
|
202
|
+
|
|
203
|
+
// `--only=dash,emoji` keeps findings whose kind or id is named, dropping review and ledger. The
|
|
204
|
+
// instruction-file hook runs it: the full set fails nearly every skill.
|
|
205
|
+
const onlyFlag = args.find((a) => a.startsWith("--only"));
|
|
206
|
+
if (onlyFlag && !onlyFlag.includes("=")) {
|
|
207
|
+
process.stderr.write("--only takes a value: --only=dash or --only=dash,emoji\n");
|
|
208
|
+
process.exit(2);
|
|
209
|
+
}
|
|
210
|
+
const only = onlyFlag ? new Set(onlyFlag.split("=")[1].split(",").filter(Boolean)) : null;
|
|
211
|
+
|
|
212
|
+
// The opt-in bar. Below REVIEW would promise to block scores the model check never keeps.
|
|
213
|
+
const blockFlag = args.find((a) => a.startsWith("--shape-block"));
|
|
214
|
+
let shapeBlock = Infinity;
|
|
215
|
+
if (blockFlag) {
|
|
216
|
+
shapeBlock = blockFlag.includes("=") ? Number(blockFlag.split("=")[1]) : Number.NaN;
|
|
217
|
+
if (!(shapeBlock >= REVIEW && shapeBlock <= 1) || !flags.has("--jev")) {
|
|
218
|
+
process.stderr.write(
|
|
219
|
+
`--shape-block takes a score from ${REVIEW} to 1 and needs --jev: --jev --shape-block=0.85\n`,
|
|
220
|
+
);
|
|
221
|
+
process.exit(2);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const files = paths.flatMap(expand);
|
|
226
|
+
const results = [];
|
|
227
|
+
/** Looked up once, on the first file that needs Jev. */
|
|
228
|
+
let jevKey;
|
|
229
|
+
/** Why the model check could not run, once it has failed: later files don't wait on it again. */
|
|
230
|
+
let jevDown = "";
|
|
231
|
+
|
|
232
|
+
for (const file of files) {
|
|
233
|
+
const { raw, result } = gate(file, register);
|
|
234
|
+
if (only) {
|
|
235
|
+
result.findings = result.findings.filter((f) => only.has(f.kind) || only.has(f.id));
|
|
236
|
+
result.review = [];
|
|
237
|
+
result.ok = result.findings.length === 0;
|
|
238
|
+
}
|
|
239
|
+
if (flags.has("--jev") && !jevDown) {
|
|
240
|
+
const found = await modelShapes(file, raw);
|
|
241
|
+
if (found) {
|
|
242
|
+
result.findings.push(...found.findings);
|
|
243
|
+
result.review.push(...found.review);
|
|
244
|
+
result.ok = result.ok && found.findings.length === 0;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (flags.has("--jev")) result.jev = jevDown ? { ran: false, reason: jevDown } : { ran: true };
|
|
248
|
+
results.push({ file, raw, result });
|
|
249
|
+
|
|
250
|
+
if (flags.has("--fix") && result.removed.length > 0) {
|
|
251
|
+
const repaired = slopGate(raw, { markup: false }).text;
|
|
252
|
+
if (file === "-") process.stdout.write(repaired);
|
|
253
|
+
else writeFileSync(file, repaired);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The pronoun ledger is a property of a body of copy, never of a file.
|
|
258
|
+
|
|
259
|
+
// A module holding four nav labels is not selfish framing, it is a list, and it failed as one the first time this walked a directory.
|
|
260
|
+
|
|
261
|
+
// So over several files it is measured once over all of them and the per-file copies go.
|
|
262
|
+
let ledger = null;
|
|
263
|
+
if (files.length > 1) {
|
|
264
|
+
for (const r of results) {
|
|
265
|
+
r.result.findings = r.result.findings.filter((f) => f.id !== "selfish-framing");
|
|
266
|
+
r.result.ok = r.result.findings.length === 0;
|
|
267
|
+
}
|
|
268
|
+
// Summed from each file's own ledger, which reads markup as prose and source as its copy strings.
|
|
269
|
+
const first = results.reduce((n, r) => n + r.result.ledger.first, 0);
|
|
270
|
+
const second = results.reduce((n, r) => n + r.result.ledger.second, 0);
|
|
271
|
+
if (!only && first > 0 && second < first)
|
|
272
|
+
ledger = { kind: "ledger", id: "selfish-framing", detail: `first ${first}, second ${second}` };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const ok = results.every((r) => r.result.ok) && !ledger;
|
|
276
|
+
|
|
277
|
+
// Said on stderr in every mode, so a quiet or JSON run never reads as a full pass.
|
|
278
|
+
if (jevDown)
|
|
279
|
+
process.stderr.write(
|
|
280
|
+
`model check did not run: ${jevDown}. Exact patterns only; the exit code is theirs alone.\n`,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (flags.has("--json")) {
|
|
284
|
+
console.log(JSON.stringify(files.length === 1 ? results[0].result : results, null, 2));
|
|
285
|
+
process.exit(ok ? 0 : 1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (files.length === 1 && files[0] === "-" && flags.has("--fix")) process.exit(ok ? 0 : 1);
|
|
289
|
+
|
|
290
|
+
if (!flags.has("--quiet")) {
|
|
291
|
+
// A clean file prints one line, so a directory of them stays readable; a file
|
|
292
|
+
// with anything to say prints the detail under it.
|
|
293
|
+
for (const { file, result } of results) report(file, result, flags.has("--fix"));
|
|
294
|
+
if (ledger)
|
|
295
|
+
console.log(
|
|
296
|
+
`\nover all ${files.length} files:\n ${ledger.kind.padEnd(13)} ${ledger.id} ${ledger.detail}`,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const hits = results.reduce((n, r) => n + r.result.findings.length, 0) + (ledger ? 1 : 0);
|
|
300
|
+
const review = results.reduce((n, r) => n + r.result.review.length, 0);
|
|
301
|
+
const invisible = results.reduce((n, r) => n + r.result.removed.length, 0);
|
|
302
|
+
if (invisible > 0 && !flags.has("--fix")) {
|
|
303
|
+
console.log(`\nrun again with --fix to remove the invisible characters.`);
|
|
304
|
+
}
|
|
305
|
+
const scope = files.length === 1 ? "" : ` across ${files.length} files`;
|
|
306
|
+
console.log(`\n${hits} hits, ${review} to review by hand${scope}.`);
|
|
307
|
+
// Counted, never typed: the same two numbers were hand-written in this line
|
|
308
|
+
// and in SKILL.md, and both were stale within a week of the last promotion.
|
|
309
|
+
console.log(
|
|
310
|
+
`This is the enumerable half: ${patterns.length} patterns matched exactly. ${shapes.length} \`shapes\` still need a human read of humanizer references/patterns.md.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
process.exit(ok ? 0 : 1);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The Svelte markup gate. Usage in packages/lint/README.md, "Svelte Markup Gate".
|
|
4
|
+
*
|
|
5
|
+
* heyramzi-svelte-markup # the pushed .svelte files, or all of them outside a push
|
|
6
|
+
* heyramzi-svelte-markup --all # every tracked .svelte file
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { exit } from "node:process";
|
|
13
|
+
|
|
14
|
+
import { gateFiles } from "#lint/gate-files.js";
|
|
15
|
+
import { findMarkupViolations, selectFiles } from "#lint/svelte-markup.js";
|
|
16
|
+
|
|
17
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
if (args.includes("--all")) delete process.env.PUSH_FILES;
|
|
20
|
+
const targets = args.filter((arg) => !arg.startsWith("-"));
|
|
21
|
+
|
|
22
|
+
const tracked = execFileSync(
|
|
23
|
+
"git",
|
|
24
|
+
["ls-files", "--cached", "--others", "--exclude-standard", "*.svelte"],
|
|
25
|
+
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
|
|
26
|
+
)
|
|
27
|
+
.trim()
|
|
28
|
+
.split("\n")
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
|
|
31
|
+
const files = gateFiles(repoRoot, selectFiles(tracked, { targets })).filter((file) =>
|
|
32
|
+
existsSync(path.join(repoRoot, file)),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
let failures = 0;
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
for (const { line, rule, message } of findMarkupViolations(
|
|
38
|
+
readFileSync(path.join(repoRoot, file), "utf8"),
|
|
39
|
+
)) {
|
|
40
|
+
console.error(`${file}:${line}: svelte-markup/${rule}: ${message}`);
|
|
41
|
+
failures += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (failures === 0) {
|
|
46
|
+
console.log(`svelte-markup: ${files.length} .svelte files, no Svelte 4 or broken markup`);
|
|
47
|
+
} else {
|
|
48
|
+
console.error(`\nsvelte-markup: ${failures} violation(s) in ${files.length} .svelte files`);
|
|
49
|
+
exit(1);
|
|
50
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The UI-rules gate over staged or pushed files. Usage: this package's README, "UI Rules Gate".
|
|
4
|
+
*
|
|
5
|
+
* heyramzi-ui-rules # staged files, or PUSH_FILES when the pre-push hook sets it
|
|
6
|
+
* heyramzi-ui-rules --all # every tracked file
|
|
7
|
+
* heyramzi-ui-rules a.svelte # named files
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { exit } from "node:process";
|
|
14
|
+
|
|
15
|
+
import { gateFiles } from "#lint/gate-files.js";
|
|
16
|
+
import { checkFile, checkProject, durationNames, ignoredPath, report, resolvedDurations } from "#lint/ui-rules.js";
|
|
17
|
+
|
|
18
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
|
|
19
|
+
const git = (args) => execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
20
|
+
const lines = (text) => text.split("\n").filter(Boolean);
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const all = args.includes("--all");
|
|
24
|
+
const named = args.filter((arg) => !arg.startsWith("-"));
|
|
25
|
+
if (all) delete process.env.PUSH_FILES;
|
|
26
|
+
|
|
27
|
+
const tracked = lines(git(["ls-files", "--cached", "--others", "--exclude-standard"]));
|
|
28
|
+
const candidates = named.length
|
|
29
|
+
? named
|
|
30
|
+
: all
|
|
31
|
+
? tracked
|
|
32
|
+
: process.env.PUSH_FILES === undefined
|
|
33
|
+
? lines(git(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]))
|
|
34
|
+
: lines(process.env.PUSH_FILES);
|
|
35
|
+
|
|
36
|
+
const files = gateFiles(repoRoot, candidates.filter((file) => !ignoredPath(file)));
|
|
37
|
+
const read = (file) => {
|
|
38
|
+
const full = path.join(repoRoot, file);
|
|
39
|
+
return existsSync(full) ? readFileSync(full, "utf8") : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let hits = [];
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
const source = read(file);
|
|
45
|
+
if (source === null) continue;
|
|
46
|
+
for (const hit of checkFile(file, source)) hits.push({ file, ...hit });
|
|
47
|
+
}
|
|
48
|
+
hits.push(...checkProject(files, tracked, read));
|
|
49
|
+
if (hits.some((hit) => hit.rule === "duration-bare")) hits = resolvedDurations(hits, durationNames(tracked, read));
|
|
50
|
+
|
|
51
|
+
if (hits.length === 0) {
|
|
52
|
+
console.log("ui-rules: no violations");
|
|
53
|
+
} else {
|
|
54
|
+
console.error(report(hits));
|
|
55
|
+
exit(1);
|
|
56
|
+
}
|