@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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -2
  3. package/bin/heyramzi-comment-length.js +99 -0
  4. package/bin/heyramzi-commit-msg.js +31 -0
  5. package/bin/heyramzi-file-tokens.js +66 -0
  6. package/bin/heyramzi-inline-path.js +79 -0
  7. package/bin/heyramzi-jev-code.js +107 -0
  8. package/bin/heyramzi-push-diff.js +93 -0
  9. package/bin/heyramzi-repo-rules.js +70 -0
  10. package/bin/heyramzi-slop.js +314 -0
  11. package/bin/heyramzi-svelte-markup.js +50 -0
  12. package/bin/heyramzi-ui-rules.js +56 -0
  13. package/comment-length.js +368 -0
  14. package/commit-msg.js +57 -0
  15. package/config/oxlintrc.json +87 -0
  16. package/data/invisibles.js +218 -0
  17. package/data/jev-bench.js +115 -0
  18. package/data/jev-code-bench.js +313 -0
  19. package/data/slop-words.d.ts +64 -0
  20. package/data/slop-words.js +2681 -0
  21. package/file-tokens.js +122 -0
  22. package/gate-files.js +14 -0
  23. package/index.js +116 -0
  24. package/inline-path.js +90 -0
  25. package/jev-code.js +194 -0
  26. package/jev.js +341 -0
  27. package/package.json +81 -4
  28. package/processors/markdown.js +22 -0
  29. package/push-diff.js +55 -0
  30. package/register.d.ts +43 -0
  31. package/register.js +255 -0
  32. package/repo-rules/packages.js +164 -0
  33. package/repo-rules/paths.js +101 -0
  34. package/repo-rules/toolchain.js +172 -0
  35. package/repo-rules/tree.js +51 -0
  36. package/repo-rules/workflows.js +116 -0
  37. package/repo-rules.js +54 -0
  38. package/rules/convex-ast.js +66 -0
  39. package/rules/convex-index-name.js +40 -0
  40. package/rules/convex-internal-only.js +59 -0
  41. package/rules/convex-no-date-now-in-query.js +48 -0
  42. package/rules/convex-no-deprecated-crons.js +40 -0
  43. package/rules/convex-no-filter.js +41 -0
  44. package/rules/convex-no-get-metadata.js +26 -0
  45. package/rules/convex-no-v-any.js +24 -0
  46. package/rules/convex-react-client-module-scope.js +37 -0
  47. package/rules/convex-require-args.js +39 -0
  48. package/rules/convex-require-returns.js +31 -0
  49. package/rules/convex-runtime-imports.js +48 -0
  50. package/rules/max-function-lines.js +105 -0
  51. package/rules/max-object-methods.js +86 -0
  52. package/rules/no-ai-words.js +78 -0
  53. package/rules/no-app-stores.js +25 -0
  54. package/rules/no-chained-type-assertions.js +91 -0
  55. package/rules/no-class.js +47 -0
  56. package/rules/no-conditional-empty-object-spread.js +64 -0
  57. package/rules/no-deep-phosphor-imports.js +29 -0
  58. package/rules/no-default-export.js +70 -0
  59. package/rules/no-em-dash.js +198 -0
  60. package/rules/no-emoji.js +56 -0
  61. package/rules/no-hardcoded-models.js +63 -0
  62. package/rules/no-inline-path.js +46 -0
  63. package/rules/no-inline-svg.js +67 -0
  64. package/rules/no-known-value-widening.js +289 -0
  65. package/rules/no-legacy-compat.js +35 -0
  66. package/rules/no-overkill-fallback.js +77 -0
  67. package/rules/no-raw-try-catch.js +26 -0
  68. package/rules/no-redundant-prop-fallback.js +153 -0
  69. package/rules/no-relative-imports.js +213 -0
  70. package/rules/no-relative-type-imports.js +40 -0
  71. package/rules/no-rune-outside-svelte-file.js +56 -0
  72. package/rules/no-rune-shadowing-binding.js +85 -0
  73. package/rules/no-skipped-or-focused-tests.js +74 -0
  74. package/rules/no-snake-case-props.js +45 -0
  75. package/rules/no-svelte4-syntax.js +33 -0
  76. package/rules/no-vitest-import-in-vite-plus.js +64 -0
  77. package/rules/props-interface.js +44 -0
  78. package/rules/redirect-external-needs-option.js +45 -0
  79. package/rules/require-safety-comment-for-type-assertion.js +110 -0
  80. package/rules/svelte-section-order.js +115 -0
  81. package/rules/test-file.js +8 -0
  82. package/scripts/count-hits.mjs +213 -0
  83. package/scripts/harvest-voice.mjs +161 -0
  84. package/scripts/jev-bench.mjs +41 -0
  85. package/scripts/jev-code-bench.mjs +61 -0
  86. package/scripts/measure-register.mjs +61 -0
  87. package/slop-views.js +122 -0
  88. package/slop.d.ts +53 -0
  89. package/slop.js +534 -0
  90. package/svelte-markup.js +107 -0
  91. package/svelte-template.js +258 -0
  92. package/ui-rules.js +324 -0
@@ -0,0 +1,172 @@
1
+ /** The one toolchain: no ESLint, knip, Prettier, Biome or Husky, one fallow shape, one oxlint base. */
2
+
3
+ import path from "node:path";
4
+
5
+ import { dirOf } from "#lint/repo-rules/tree.js";
6
+
7
+ const MANIFEST = /(^|\/)package\.json$/;
8
+ const ESLINT_DEP =
9
+ /^(eslint|eslint-.+|@eslint\/.+|typescript-eslint|@typescript-eslint\/.+|svelte-eslint-parser)$/;
10
+ const KNIP_DEP = /^(knip|@knip\/.+)$/;
11
+ const OTHER_DEP = /^(prettier|prettier-plugin-.+|@biomejs\/biome|husky)$/;
12
+ const FILES = {
13
+ eslint: /(^|\/)(eslint\.config\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?|\.eslintignore)$/,
14
+ knip: /(^|\/)(knip(\.config)?\.(jsonc?|[cm]?[jt]s)|\.knip(rc)?(\.jsonc?)?)$/,
15
+ other:
16
+ /(^|\/)(\.prettierrc(\.[a-z]+)?|prettier\.config\.[cm]?[jt]s|\.prettierignore|biome\.jsonc?)$/,
17
+ };
18
+
19
+ const ESLINT_MESSAGE =
20
+ "No ESLint: Oxlint reads a .svelte file's <script>, and a second toolchain buys only eslint-plugin-svelte's template rules plus a whole extra dependency tree. Check no package script runs it, then delete the dependency and the config. svelte-doctor covers the template gap.";
21
+ const KNIP_MESSAGE =
22
+ "knip is gone from every Studio repo: fallow is the one dead-code tool, and two tools answering one question is one nobody keeps tuned. Delete the knip dependency, script and config. `fallow migrate` converts a knip config.";
23
+ const OTHER_MESSAGE =
24
+ "All lint and format config lives in vite.config.ts (fmt, staged, test, pack) and .oxlintrc.json: no Prettier, Biome or Husky. Hooks come from `vp config` only, with steps in .vite-hooks/pre-commit and .vite-hooks/pre-push. Delete it and run vp fmt once.";
25
+
26
+ export function foreignTools(tree) {
27
+ const found = [];
28
+ const add = (file, rule, message, detail) =>
29
+ found.push({ file, rule, message: `${detail} ${message}` });
30
+ for (const file of tree.touchedMatching(MANIFEST)) {
31
+ const manifest = tree.json(file);
32
+ if (!manifest) continue;
33
+ const deps = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies });
34
+ for (const name of deps) {
35
+ if (ESLINT_DEP.test(name)) add(file, "no-eslint", ESLINT_MESSAGE, `${name} is a dependency.`);
36
+ if (KNIP_DEP.test(name)) add(file, "no-knip", KNIP_MESSAGE, `${name} is a dependency.`);
37
+ if (OTHER_DEP.test(name))
38
+ add(file, "no-other-toolchain", OTHER_MESSAGE, `${name} is a dependency.`);
39
+ }
40
+ for (const [name, command] of Object.entries(manifest.scripts ?? {})) {
41
+ if (/\beslint\b/.test(command))
42
+ add(file, "no-eslint", ESLINT_MESSAGE, `Script ${name} runs eslint.`);
43
+ if (/\bknip\b/.test(command)) add(file, "no-knip", KNIP_MESSAGE, `Script ${name} runs knip.`);
44
+ if (/\.git\/hooks/.test(command)) {
45
+ add(
46
+ file,
47
+ "no-copied-hooks",
48
+ 'Hooks come from `vp config` ("prepare": "vp config"), with repo steps in .vite-hooks/pre-commit and .vite-hooks/pre-push. A script copied into .git/hooks is never in a fresh clone.',
49
+ `Script ${name} writes into .git/hooks.`,
50
+ );
51
+ }
52
+ }
53
+ if (manifest.eslintConfig) add(file, "no-eslint", ESLINT_MESSAGE, "eslintConfig is set.");
54
+ if (manifest.knip) add(file, "no-knip", KNIP_MESSAGE, "A knip key is set.");
55
+ if (manifest.prettier) add(file, "no-other-toolchain", OTHER_MESSAGE, "A prettier key is set.");
56
+ }
57
+ for (const [kind, pattern] of Object.entries(FILES)) {
58
+ const rule = { eslint: "no-eslint", knip: "no-knip", other: "no-other-toolchain" }[kind];
59
+ const message = { eslint: ESLINT_MESSAGE, knip: KNIP_MESSAGE, other: OTHER_MESSAGE }[kind];
60
+ for (const file of tree.touchedMatching(pattern))
61
+ add(file, rule, message, "This config file is tracked.");
62
+ }
63
+ for (const file of tree.touchedMatching(/(^|\/)\.husky\//))
64
+ add(file, "no-other-toolchain", OTHER_MESSAGE, "Husky hook files are tracked.");
65
+ return found;
66
+ }
67
+
68
+ const FALLOW_SCRIPTS = [
69
+ "fallow",
70
+ "fallow:dead-code",
71
+ "fallow:audit",
72
+ "fallow:health",
73
+ "fallow:dupes",
74
+ ];
75
+ const SCHEMA = "./node_modules/fallow/schema.json";
76
+
77
+ /** A repo with fallow holds the dependency, the config, 5 scripts, the reference and no tracked caches. */
78
+ export function fallowShape(tree) {
79
+ const found = [];
80
+ const fail = (file, message) => found.push({ file, rule: "fallow-shape", message });
81
+ for (const file of tree.touchedMatching(/(^|\/)\.fallow\/.*\.bin$/)) {
82
+ fail(
83
+ file,
84
+ "fallow caches are never committed. Run `git rm --cached` on every .fallow/*.bin, in every nested workspace that keeps its own .fallow/. Baselines under .fallow/baselines/ stay tracked.",
85
+ );
86
+ }
87
+ const rootTouched =
88
+ tree.touched.includes("package.json") || tree.touched.includes(".fallowrc.json");
89
+ const manifest = tree.json("package.json");
90
+ const hasConfig = tree.isTracked(".fallowrc.json");
91
+ const hasDep = Boolean({ ...manifest?.dependencies, ...manifest?.devDependencies }.fallow);
92
+ if (!rootTouched || !manifest || (!hasConfig && !hasDep)) return found;
93
+ if (hasConfig && !hasDep)
94
+ fail(
95
+ ".fallowrc.json",
96
+ 'Add the devDependency "fallow": "^3.18.0" beside it, and the 5 fallow scripts.',
97
+ );
98
+ if (hasDep && !hasConfig)
99
+ fail(
100
+ "package.json",
101
+ 'fallow is a dependency with no .fallowrc.json. Add one at the repo root with "$schema": "./node_modules/fallow/schema.json" as its first key.',
102
+ );
103
+ if (hasConfig) {
104
+ const config = tree.json(".fallowrc.json");
105
+ if (config && Object.keys(config)[0] !== "$schema") {
106
+ fail(".fallowrc.json", `The first key must be "$schema": "${SCHEMA}".`);
107
+ } else if (config && config.$schema !== SCHEMA) {
108
+ fail(".fallowrc.json", `"$schema" must be "${SCHEMA}".`);
109
+ }
110
+ }
111
+ const missing = FALLOW_SCRIPTS.filter((name) => !manifest.scripts?.[name]);
112
+ if (hasDep && missing.length > 0) {
113
+ fail(
114
+ "package.json",
115
+ `Missing fallow scripts: ${missing.join(", ")}. Every repo with fallow has all of ${FALLOW_SCRIPTS.join(", ")}.`,
116
+ );
117
+ }
118
+ const vibekit = tree.json("vibekit.json");
119
+ if (hasDep && vibekit && !vibekit.claude?.references?.includes("fallow")) {
120
+ fail(
121
+ "vibekit.json",
122
+ 'claude.references must list "fallow", so the fallow reference reaches this repo.',
123
+ );
124
+ }
125
+ return found;
126
+ }
127
+
128
+ const SHARED_BASE = /lint\/config\/oxlintrc\.json$/;
129
+
130
+ /** True when the config extends the shared base, directly or through another tracked config. */
131
+ function extendsShared(file, tree, depth = 0) {
132
+ const config = tree.json(file);
133
+ if (!config || depth > 4) return false;
134
+ return [config.extends ?? []].flat().some((entry) => {
135
+ if (SHARED_BASE.test(entry)) return true;
136
+ const next = path.posix.join(dirOf(file), entry);
137
+ return tree.isTracked(next) && extendsShared(next, tree, depth + 1);
138
+ });
139
+ }
140
+
141
+ export function oxlintBase(tree) {
142
+ const found = [];
143
+ for (const file of tree.touchedMatching(/(^|\/)\.oxlintrc\.json$/)) {
144
+ if (!tree.json(file)) continue;
145
+ if (!extendsShared(file, tree)) {
146
+ found.push({
147
+ file,
148
+ rule: "oxlint-base",
149
+ message: `${file} must extend the shared config: "extends": ["./node_modules/@heyramzi/lint/config/oxlintrc.json"] with "jsPlugins": ["@heyramzi/lint"], plus the ignores this repo needs. Every rule lives in @heyramzi/lint, shared and project-specific alike.`,
150
+ });
151
+ }
152
+ }
153
+ return found;
154
+ }
155
+
156
+ /** A top-level `lint:` key in a Vite config, at the indent defineConfig's keys sit. */
157
+ export function viteLintBlock(tree) {
158
+ const found = [];
159
+ for (const file of tree.touchedMatching(/(^|\/)vite\.config\.[cm]?[jt]s$/)) {
160
+ const line = tree
161
+ .text(file)
162
+ .split("\n")
163
+ .findIndex((text) => /^ {2}lint\s*:\s*[{[]/.test(text));
164
+ if (line === -1) continue;
165
+ found.push({
166
+ file,
167
+ rule: "no-vite-lint-block",
168
+ message: `Line ${line + 1} sets a lint block in ${dirOf(file)}vite.config. Once a lint key exists vp passes its own -c and dies with "argument -c cannot be used multiple times". The key takes no extends or jsPlugins either. Delete it, keep ignores in .oxlintrc.json and run vp lint -c .oxlintrc.json .`,
169
+ });
170
+ }
171
+ return found;
172
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What a repo rule reads. `touched` is the files a gate judges (staged, or every
3
+ * tracked file under `--all`), `tracked` is the whole index, `added` is new paths.
4
+ * Pure here: the caller supplies `read`.
5
+ */
6
+
7
+ /** Drops comments and trailing commas from JSONC, leaving strings alone. */
8
+ export function stripJsonc(text) {
9
+ return text
10
+ .replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_match, string) => string ?? "")
11
+ .replace(/,(\s*[}\]])/g, "$1");
12
+ }
13
+
14
+ /** The parsed value, or null when the file isn't JSON. */
15
+ export function parseJsonc(text) {
16
+ try {
17
+ return JSON.parse(stripJsonc(text));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function createTree({ touched, added = [], tracked, read }) {
24
+ const cache = new Map();
25
+ const text = (file) => {
26
+ if (!cache.has(file)) cache.set(file, read(file) ?? "");
27
+ return cache.get(file);
28
+ };
29
+ return {
30
+ touched,
31
+ added,
32
+ tracked,
33
+ text,
34
+ json(file) {
35
+ return parseJsonc(text(file));
36
+ },
37
+ isTracked: (file) => tracked.includes(file),
38
+ touchedMatching: (pattern) => touched.filter((file) => pattern.test(file)),
39
+ trackedMatching: (pattern) => tracked.filter((file) => pattern.test(file)),
40
+ };
41
+ }
42
+
43
+ /** A workflow or compose line without its YAML comment. */
44
+ export function yamlCode(line) {
45
+ return line.replace(/^\s*#.*$/, "").replace(/\s+#.*$/, "");
46
+ }
47
+
48
+ export function dirOf(file) {
49
+ const slash = file.lastIndexOf("/");
50
+ return slash === -1 ? "" : file.slice(0, slash + 1);
51
+ }
@@ -0,0 +1,116 @@
1
+ /** GitHub Actions rules: where jobs run, what triggers them, how Node and credentials are set. */
2
+
3
+ import { yamlCode } from "#lint/repo-rules/tree.js";
4
+
5
+ const WORKFLOW = /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/;
6
+ const HOSTED = /(^|[^\w-])(ubuntu|windows|macos)-[\w.-]+/i;
7
+ const INSTALL = /\b(pnpm|npm|yarn)\s+(install|i|ci)\b|\bvp\s+install\b/;
8
+ const NPMRC_WRITES = [
9
+ />>?\s*["']?(~|\$HOME|\$\{HOME\}|\/home\/[\w-]+)\/\.npmrc/,
10
+ /\btee\s+(-a\s+)?["']?(~|\$HOME|\$\{HOME\})\/\.npmrc/,
11
+ /\bnpm\s+config\s+set\b.*_authToken/,
12
+ ];
13
+
14
+ /** The `on:` block of a workflow as one string. */
15
+ function triggers(lines) {
16
+ const start = lines.findIndex((line) => /^on\s*:/.test(line));
17
+ if (start === -1) return "";
18
+ const block = [yamlCode(lines[start])];
19
+ for (const line of lines.slice(start + 1)) {
20
+ if (/^\S/.test(line) && !/^\s*#/.test(line)) break;
21
+ block.push(yamlCode(line));
22
+ }
23
+ return block.join("\n");
24
+ }
25
+
26
+ /** The value after `runs-on:`, with a list or flow sequence that spans lines joined. */
27
+ function runsOnValue(lines, index, inline) {
28
+ if (inline) return inline;
29
+ const values = [];
30
+ for (const line of lines.slice(index + 1)) {
31
+ if (!/^\s+(-|\[)/.test(yamlCode(line))) break;
32
+ values.push(yamlCode(line));
33
+ }
34
+ return values.join(" ");
35
+ }
36
+
37
+ function nodeVersionFindings(file, lines, tree) {
38
+ const found = [];
39
+ const fail = (message) => found.push({ file, rule: "workflow-node-24", message });
40
+ let pinned = false;
41
+ lines.forEach((raw, index) => {
42
+ const match = /^\s*(?:-\s*)?(node-version|node-version-file)\s*:\s*(.*)$/.exec(yamlCode(raw));
43
+ if (!match) return;
44
+ pinned = true;
45
+ const value = match[2].trim().replace(/^["']|["']$/g, "");
46
+ // An empty value is an input definition (`type:` follows), not a setup-node step.
47
+ if (!value || value.includes("${{")) return;
48
+ if (match[1] === "node-version") {
49
+ if (value !== "24")
50
+ fail(
51
+ `Line ${index + 1} sets node-version ${value}. Set node-version: "24" in every workflow that installs, disabled ones too. pnpm install imports node:sqlite through @voidzero-dev/vite-plus-test, so older Node crashes the install with ERR_UNKNOWN_BUILTIN_MODULE, which reads as a broken lockfile.`,
52
+ );
53
+ return;
54
+ }
55
+ const first =
56
+ tree
57
+ .text(value)
58
+ .split("\n")
59
+ .find((line) => line.trim()) ?? "";
60
+ if (first && !/^v?24(\.|$)/.test(first.trim())) {
61
+ fail(
62
+ `Line ${index + 1} reads Node from ${value}, which says ${first.trim()}. Workflows run Node 24: set node-version: "24" or fix that file.`,
63
+ );
64
+ }
65
+ });
66
+ const installs = lines.some((line) => INSTALL.test(yamlCode(line)));
67
+ if (installs && !pinned) {
68
+ fail(
69
+ `This workflow installs dependencies and never sets a Node version. Add actions/setup-node with node-version: "24". pnpm install imports node:sqlite, so older Node crashes the install with ERR_UNKNOWN_BUILTIN_MODULE.`,
70
+ );
71
+ }
72
+ return found;
73
+ }
74
+
75
+ export function workflows(tree) {
76
+ const found = [];
77
+ for (const file of tree.touchedMatching(WORKFLOW)) {
78
+ const lines = tree.text(file).split("\n");
79
+ const add = (rule, index, message) =>
80
+ found.push({
81
+ file,
82
+ rule,
83
+ message: index === null ? message : `Line ${index + 1}: ${message}`,
84
+ });
85
+ lines.forEach((raw, index) => {
86
+ const line = yamlCode(raw);
87
+ const runsOn = /^\s*runs-on\s*:\s*(.*)$/.exec(line);
88
+ if (runsOn) {
89
+ const value = runsOnValue(lines, index, runsOn[1].trim());
90
+ if (HOSTED.test(value) && !value.includes("self-hosted")) {
91
+ add(
92
+ "workflow-runs-on",
93
+ index,
94
+ `runs-on ${value} is a hosted runner, and private repos burn the billed Actions allowance on them. Every job runs on our own hardware with no hosted fallback: runs-on: [self-hosted, Linux, X64, studio-linux], or [self-hosted, macOS, ARM64, studio-mac] for Xcode work only. Public forks keep hosted runners.`,
95
+ );
96
+ }
97
+ }
98
+ if (NPMRC_WRITES.some((pattern) => pattern.test(line))) {
99
+ add(
100
+ "workflow-npmrc-write",
101
+ index,
102
+ "a workflow step writes a token to ~/.npmrc. On a self-hosted runner that file outlives the job, so each run stacks another line and the last one wins, which buried the good key under a dead one. The packages key is installed on the runner by infrastructure/scripts/runner-key.sh. Delete the step.",
103
+ );
104
+ }
105
+ });
106
+ if (/\bpull_request(_target)?\b/.test(triggers(lines))) {
107
+ add(
108
+ "workflow-dispatch-only",
109
+ null,
110
+ "a pull_request trigger runs a check on billed minutes. Check workflows are workflow_dispatch only, pull requests included. Move what the Mac doesn't already run into the repo's ci:local script, which the managed pre-push hook runs, and trigger this one with workflow_dispatch.",
111
+ );
112
+ }
113
+ found.push(...nodeVersionFindings(file, lines, tree));
114
+ }
115
+ return found;
116
+ }
package/repo-rules.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The repo-rules gate: the repository, package, workflow and toolchain rules that used
3
+ * to live in Markdown. Pure here, git and process in `bin/heyramzi-repo-rules.js`.
4
+ */
5
+
6
+ import { composeImages, fossilNames } from "#lint/repo-rules/paths.js";
7
+ import {
8
+ heyramziLinks,
9
+ heyramziTarballs,
10
+ legacyTokenName,
11
+ npmrcCredentials,
12
+ pnpmOnly,
13
+ srcImportMaps,
14
+ } from "#lint/repo-rules/packages.js";
15
+ import {
16
+ fallowShape,
17
+ foreignTools,
18
+ oxlintBase,
19
+ viteLintBlock,
20
+ } from "#lint/repo-rules/toolchain.js";
21
+ import { workflows } from "#lint/repo-rules/workflows.js";
22
+
23
+ export { createTree } from "#lint/repo-rules/tree.js";
24
+
25
+ export const RULES = [
26
+ pnpmOnly,
27
+ heyramziLinks,
28
+ heyramziTarballs,
29
+ srcImportMaps,
30
+ npmrcCredentials,
31
+ legacyTokenName,
32
+ workflows,
33
+ foreignTools,
34
+ fallowShape,
35
+ oxlintBase,
36
+ viteLintBlock,
37
+ composeImages,
38
+ fossilNames,
39
+ ];
40
+
41
+ /** Every violation in the files the tree says were touched. */
42
+ export function check(tree) {
43
+ return RULES.flatMap((rule) => rule(tree));
44
+ }
45
+
46
+ /** The refusal, grouped by file. Each message says what to do instead. */
47
+ export function report(violations) {
48
+ const byFile = Map.groupBy(violations, ({ file }) => file);
49
+ const blocks = [...byFile].map(
50
+ ([file, entries]) =>
51
+ `${file}\n${entries.map(({ rule, message }) => ` [${rule}] ${message}`).join("\n")}`,
52
+ );
53
+ return `repo-rules: ${violations.length} violation(s) in ${byFile.size} file(s)\n\n${blocks.join("\n\n")}`;
54
+ }
@@ -0,0 +1,66 @@
1
+ /** Shared AST helpers for the convex-* rules. */
2
+
3
+ export const REGISTERS = new Set([
4
+ "query",
5
+ "mutation",
6
+ "action",
7
+ "internalQuery",
8
+ "internalMutation",
9
+ "internalAction",
10
+ ]);
11
+
12
+ /** The name of a `query(...)`-style call, or null. */
13
+ export function registerName(node) {
14
+ if (node.callee?.type !== "Identifier") return null;
15
+ return REGISTERS.has(node.callee.name) ? node.callee.name : null;
16
+ }
17
+
18
+ /** Step down one link of a call or member chain, or null at the root. */
19
+ export function stepDown(node) {
20
+ switch (node.type) {
21
+ case "CallExpression":
22
+ return node.callee;
23
+ case "MemberExpression":
24
+ return node.object;
25
+ case "ChainExpression":
26
+ case "TSNonNullExpression":
27
+ case "TSAsExpression":
28
+ return node.expression;
29
+ case "AwaitExpression":
30
+ return node.argument;
31
+ default:
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** The leftmost expression of a chain: `internal` in `internal.a.b`. */
37
+ export function chainRoot(node) {
38
+ let current = node;
39
+ for (let next = stepDown(current); next; next = stepDown(current)) current = next;
40
+ return current;
41
+ }
42
+
43
+ export function propertyName(member) {
44
+ if (member?.type !== "MemberExpression" || member.computed) return null;
45
+ return member.property.type === "Identifier" ? member.property.name : null;
46
+ }
47
+
48
+ export function keyName(property) {
49
+ if (property.type !== "Property" || property.computed) return null;
50
+ if (property.key.type === "Identifier") return property.key.name;
51
+ return typeof property.key.value === "string" ? property.key.value : null;
52
+ }
53
+
54
+ export function findProperty(object, name) {
55
+ return object.properties.find((property) => keyName(property) === name);
56
+ }
57
+
58
+ /** The `"use node"` directive, read from the leading statements of a program. */
59
+ export function hasUseNode(program) {
60
+ for (const statement of program.body) {
61
+ if (statement.type !== "ExpressionStatement") return false;
62
+ if (statement.expression.type !== "Literal") return false;
63
+ if (statement.expression.value === "use node") return true;
64
+ }
65
+ return false;
66
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * An index name lists every field: `by_channel_and_author` for `["channel", "author"]`.
3
+ */
4
+ import { propertyName } from "#lint/rules/convex-ast.js";
5
+
6
+ export default {
7
+ meta: {
8
+ type: "problem",
9
+ docs: { description: "Require index names to start with `by_` and list every field with `_and_`." },
10
+ messages: {
11
+ badName:
12
+ 'Index "{{ name }}" covers {{ count }} field(s) but its name lists {{ listed }}. Name it `by_` plus every field joined with `_and_`, like `by_channel_and_author`, and update its `withIndex` calls.',
13
+ noPrefix:
14
+ 'Index "{{ name }}" must start with `by_` and list every field joined with `_and_`, like `by_channel_and_author`.',
15
+ },
16
+ schema: [],
17
+ },
18
+ create(context) {
19
+ return {
20
+ CallExpression(node) {
21
+ if (propertyName(node.callee) !== "index") return;
22
+ const [name, fields] = node.arguments;
23
+ if (name?.type !== "Literal" || typeof name.value !== "string") return;
24
+ if (fields?.type !== "ArrayExpression") return;
25
+ if (!name.value.startsWith("by_")) {
26
+ context.report({ node: name, messageId: "noPrefix", data: { name: name.value } });
27
+ return;
28
+ }
29
+ const listed = name.value.slice(3).split("_and_").length;
30
+ if (listed !== fields.elements.length) {
31
+ context.report({
32
+ node: name,
33
+ messageId: "badName",
34
+ data: { name: name.value, count: fields.elements.length, listed },
35
+ });
36
+ }
37
+ },
38
+ };
39
+ },
40
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Crons, the scheduler and HTTP actions call `internal` functions, never `api` ones.
3
+ */
4
+ import { chainRoot, propertyName } from "#lint/rules/convex-ast.js";
5
+
6
+ const CRON_METHODS = new Set(["interval", "cron"]);
7
+ const SCHEDULER_METHODS = new Set(["runAfter", "runAt"]);
8
+ const RUN_METHODS = new Set(["runQuery", "runMutation", "runAction"]);
9
+
10
+ function isPublicRef(node) {
11
+ if (!node || node.type === "Identifier") return false;
12
+ const root = chainRoot(node);
13
+ return root.type === "Identifier" && root.name === "api";
14
+ }
15
+
16
+ export default {
17
+ meta: {
18
+ type: "problem",
19
+ docs: {
20
+ description:
21
+ "Require `internal.*` references in crons, scheduler calls and HTTP actions. `api.*` is public.",
22
+ },
23
+ messages: {
24
+ publicRef:
25
+ "{{ where }} calls a public `api.*` function. Make it an `internalQuery`, `internalMutation` or `internalAction` and reference it as `internal.*`.",
26
+ },
27
+ schema: [],
28
+ },
29
+ create(context) {
30
+ const jobs = new Set(["crons"]);
31
+ let httpDepth = 0;
32
+ const report = (node, where) => context.report({ node, messageId: "publicRef", data: { where } });
33
+ return {
34
+ VariableDeclarator(node) {
35
+ const init = node.init;
36
+ if (node.id.type !== "Identifier" || init?.type !== "CallExpression") return;
37
+ if (init.callee.type === "Identifier" && init.callee.name === "cronJobs") {
38
+ jobs.add(node.id.name);
39
+ }
40
+ },
41
+ CallExpression(node) {
42
+ if (node.callee.type === "Identifier" && node.callee.name === "httpAction") httpDepth++;
43
+ const method = propertyName(node.callee);
44
+ if (!method) return;
45
+ const owner = node.callee.object;
46
+ if (CRON_METHODS.has(method) && owner.type === "Identifier" && jobs.has(owner.name)) {
47
+ if (isPublicRef(node.arguments[2])) report(node, "A cron");
48
+ } else if (SCHEDULER_METHODS.has(method) && propertyName(owner) === "scheduler") {
49
+ if (isPublicRef(node.arguments[1])) report(node, "The scheduler");
50
+ } else if (RUN_METHODS.has(method) && httpDepth > 0) {
51
+ if (isPublicRef(node.arguments[0])) report(node, "An HTTP action");
52
+ }
53
+ },
54
+ "CallExpression:exit"(node) {
55
+ if (node.callee.type === "Identifier" && node.callee.name === "httpAction") httpDepth--;
56
+ },
57
+ };
58
+ },
59
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `Date.now()` inside a query handler defeats the query cache.
3
+ */
4
+ import { findProperty } from "#lint/rules/convex-ast.js";
5
+
6
+ const QUERIES = new Set(["query", "internalQuery"]);
7
+
8
+ export default {
9
+ meta: {
10
+ type: "problem",
11
+ docs: { description: "Disallow `Date.now()` inside a `query` or `internalQuery` handler." },
12
+ messages: {
13
+ noDateNow:
14
+ "`Date.now()` in a query defeats the cache. Have a scheduled function flip a boolean, or pass the time in as an argument rounded to the minute.",
15
+ },
16
+ schema: [],
17
+ },
18
+ create(context) {
19
+ const handlers = new Set();
20
+ let depth = 0;
21
+ const enter = (node) => {
22
+ if (handlers.has(node)) depth++;
23
+ };
24
+ const exit = (node) => {
25
+ if (handlers.has(node)) depth--;
26
+ };
27
+ return {
28
+ CallExpression(node) {
29
+ if (node.callee.type === "Identifier" && QUERIES.has(node.callee.name)) {
30
+ const [config] = node.arguments;
31
+ if (config?.type !== "ObjectExpression") return;
32
+ const handler = findProperty(config, "handler")?.value;
33
+ if (handler) handlers.add(handler);
34
+ return;
35
+ }
36
+ if (depth === 0) return;
37
+ const callee = node.callee;
38
+ if (callee.type !== "MemberExpression" || callee.computed) return;
39
+ if (callee.object.type !== "Identifier" || callee.object.name !== "Date") return;
40
+ if (callee.property.name === "now") context.report({ node, messageId: "noDateNow" });
41
+ },
42
+ FunctionExpression: enter,
43
+ "FunctionExpression:exit": exit,
44
+ ArrowFunctionExpression: enter,
45
+ "ArrowFunctionExpression:exit": exit,
46
+ };
47
+ },
48
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `crons.hourly` and `crons.daily` are the deprecated helpers.
3
+ */
4
+ import { propertyName } from "#lint/rules/convex-ast.js";
5
+
6
+ const DEPRECATED = new Set(["hourly", "daily"]);
7
+
8
+ export default {
9
+ meta: {
10
+ type: "problem",
11
+ docs: {
12
+ description: "Disallow the deprecated cron helpers. Use `crons.interval` or `crons.cron`.",
13
+ },
14
+ messages: {
15
+ deprecated:
16
+ "`crons.{{ method }}` is deprecated. Use `crons.cron(name, '30 3 * * *', internal.x.y, {})` or `crons.interval(name, { hours: 6 }, internal.x.y, {})`. Both run in UTC.",
17
+ },
18
+ schema: [],
19
+ },
20
+ create(context) {
21
+ const jobs = new Set(["crons"]);
22
+ return {
23
+ VariableDeclarator(node) {
24
+ const init = node.init;
25
+ if (node.id.type !== "Identifier" || init?.type !== "CallExpression") return;
26
+ if (init.callee.type === "Identifier" && init.callee.name === "cronJobs") {
27
+ jobs.add(node.id.name);
28
+ }
29
+ },
30
+ CallExpression(node) {
31
+ const method = propertyName(node.callee);
32
+ if (!method || !DEPRECATED.has(method)) return;
33
+ const owner = node.callee.object;
34
+ if (owner.type === "Identifier" && jobs.has(owner.name)) {
35
+ context.report({ node, messageId: "deprecated", data: { method } });
36
+ }
37
+ },
38
+ };
39
+ },
40
+ };