@antelopejs/tooling-configs 0.0.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -18
- package/dist/bin/check-interface-ranges.mjs +10 -1
- package/dist/bin/check-size.d.mts +1 -0
- package/dist/bin/check-size.mjs +51 -0
- package/dist/bin/checks.d.mts +1 -0
- package/dist/bin/checks.mjs +51 -0
- package/dist/bin/ratchet.d.mts +1 -0
- package/dist/bin/ratchet.mjs +66 -0
- package/dist/bin/sync.d.mts +1 -0
- package/dist/bin/sync.mjs +485 -0
- package/dist/ceilings--pJmhSde.mjs +64 -0
- package/dist/eslint.mjs +24 -4
- package/dist/files-BSl0U8lT.mjs +98 -0
- package/dist/knip.mjs +5 -0
- package/dist/oxc/fmt.mjs +1 -1
- package/dist/oxc/guardrails/index.d.mts +6 -0
- package/dist/oxc/guardrails/index.mjs +318 -0
- package/dist/oxc/lint.d.mts +128 -151
- package/dist/oxc/lint.mjs +100 -125
- package/dist/package-B1aipTbn.mjs +115 -0
- package/dist/ratchet-DNsVZH9s.mjs +172 -0
- package/dist/shared-BG0Nkzh0.d.mts +8 -0
- package/dist/{shared-Ch-ZcMQb.mjs → shared-NC0T8ykm.mjs} +5 -1
- package/dist/shared-WC0z3y9F.mjs +63 -0
- package/dist/size-DRCgYCLA.mjs +17 -0
- package/package.json +22 -6
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { a as formatCeilings, t as CEILINGS_FILE } from "../ceilings--pJmhSde.mjs";
|
|
3
|
+
import { a as repositoryRoot, o as runCli } from "../files-BSl0U8lT.mjs";
|
|
4
|
+
import { a as repositoryTools, i as parseManifest, n as managedPackage, o as toolingVersions, r as packageManager, t as execCommand } from "../package-B1aipTbn.mjs";
|
|
5
|
+
import { t as measureBaseline } from "../ratchet-DNsVZH9s.mjs";
|
|
6
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { isMap, isScalar, isSeq, parseDocument } from "yaml";
|
|
10
|
+
//#region src/guardrails/agents-block.ts
|
|
11
|
+
const AGENTS_BLOCK_START = "<!-- antelopejs-guardrails:start -->";
|
|
12
|
+
const AGENTS_BLOCK_END = "<!-- antelopejs-guardrails:end -->";
|
|
13
|
+
const COMMENTS = `### Comments
|
|
14
|
+
|
|
15
|
+
Say it in code. A constraint belongs in a type, a lint rule, or a test. A public item's contract belongs in TSDoc (\`/** */\`) on that item. The only other comments are lint directives that name their rules, \`@ts-expect-error <reason>\`, \`SAFETY: <reason>\` above a type assertion, \`/*!\` license headers, and bundler or formatter directives.`;
|
|
16
|
+
const TESTS = `### Tests
|
|
17
|
+
|
|
18
|
+
Hold every test you write or keep to this checklist:
|
|
19
|
+
|
|
20
|
+
- Name the wrong implementation this test fails on. A test with no answer is deleted, not kept.
|
|
21
|
+
- No test reads the file it tests. Drive the behaviour through the public interface.
|
|
22
|
+
- An invariant over many files is a lint rule, not a test.`;
|
|
23
|
+
const SHARED_RULES = [
|
|
24
|
+
["Formatting", "oxfmt"],
|
|
25
|
+
["Lint findings are errors; no rule warns", "oxlint preset"],
|
|
26
|
+
["Comments are TSDoc, directives, or `SAFETY:` only", "`antelopejs/no-comments`"],
|
|
27
|
+
["Every test asserts", "`antelopejs/require-assertion`"],
|
|
28
|
+
["Tests read no files outside fixtures, helpers, locales", "`antelopejs/no-fs-read-in-tests`"],
|
|
29
|
+
["No assertion that restates a constant", "`antelopejs/no-constant-value-assertion`"],
|
|
30
|
+
["No test whose only check is \"is a function\"", "`antelopejs/no-typeof-function-only-test`"],
|
|
31
|
+
["Params ≤ 5, function ≤ 120 lines, file ≤ 500 lines, depth ≤ 4, complexity ≤ 20", "`eslint/max-*`, `eslint/complexity`"]
|
|
32
|
+
];
|
|
33
|
+
const FULL_RULES = [
|
|
34
|
+
["Files ≤ 20,000 bytes, tests included", "`antelopejs-check-size` (pre-commit)"],
|
|
35
|
+
["Existing debt only shrinks", "`antelopejs-ratchet`"],
|
|
36
|
+
["Types check", "`tsc` (pre-push)"],
|
|
37
|
+
["No dead files, exports, or dependencies", "knip"],
|
|
38
|
+
["Managed files match the pack", "`antelopejs-sync --check`"],
|
|
39
|
+
["A test names the wrong implementation it catches", "review; no tool"]
|
|
40
|
+
];
|
|
41
|
+
/** Pads each column the way oxfmt does, so formatting AGENTS.md never reports sync drift. */
|
|
42
|
+
function ruleTable(rows) {
|
|
43
|
+
const table = [["Rule", "Enforced by"], ...rows];
|
|
44
|
+
const widths = [0, 1].map((column) => Math.max(...table.map((row) => row[column].length)));
|
|
45
|
+
const line = (cells) => `| ${cells.map((cell, column) => cell.padEnd(widths[column])).join(" | ")} |`;
|
|
46
|
+
const [header, ...body] = table.map(line);
|
|
47
|
+
return [
|
|
48
|
+
"### What enforces what",
|
|
49
|
+
"",
|
|
50
|
+
header,
|
|
51
|
+
line(widths.map((width) => "-".repeat(width))),
|
|
52
|
+
...body
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
function pointers(context) {
|
|
56
|
+
const lines = [];
|
|
57
|
+
if (context.verificationSkill !== void 0) lines.push(`Prove a change works with the verification skill in [\`${context.verificationSkill}\`](${context.verificationSkill}) before you call it done.`);
|
|
58
|
+
if (context.glossary !== void 0) lines.push(`Name things with the terms in [\`${context.glossary}\`](${context.glossary}).`);
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
function fullBody(context) {
|
|
62
|
+
return [
|
|
63
|
+
"## Guardrails",
|
|
64
|
+
"",
|
|
65
|
+
"Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, ratchet, and sync, and prints only what failed. The change is done when it prints no failure.",
|
|
66
|
+
"",
|
|
67
|
+
...pointers(context).flatMap((line) => [line, ""]),
|
|
68
|
+
"The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks.",
|
|
69
|
+
"",
|
|
70
|
+
COMMENTS,
|
|
71
|
+
"",
|
|
72
|
+
TESTS,
|
|
73
|
+
"",
|
|
74
|
+
"### Debt",
|
|
75
|
+
"",
|
|
76
|
+
"`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, run `pnpm run ratchet:update` and commit the lowered file. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add.",
|
|
77
|
+
"",
|
|
78
|
+
ruleTable([...SHARED_RULES, ...FULL_RULES])
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
function templateBody(context) {
|
|
82
|
+
return [
|
|
83
|
+
"## Guardrails",
|
|
84
|
+
"",
|
|
85
|
+
"Lint with `oxlint` and format with `oxfmt`; both read the configs at the repository root. Fix every finding: no rule warns.",
|
|
86
|
+
"",
|
|
87
|
+
...pointers(context).flatMap((line) => [line, ""]),
|
|
88
|
+
COMMENTS,
|
|
89
|
+
"",
|
|
90
|
+
TESTS,
|
|
91
|
+
"",
|
|
92
|
+
ruleTable(SHARED_RULES)
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
/** The managed block, markers included, ending with a newline. */
|
|
96
|
+
function agentsBlock(context) {
|
|
97
|
+
const body = context.profile === "full" ? fullBody(context) : templateBody(context);
|
|
98
|
+
return [
|
|
99
|
+
AGENTS_BLOCK_START,
|
|
100
|
+
"<!-- Managed by @antelopejs/tooling-configs. `antelopejs-sync` rewrites everything between these markers; write repository-specific guidance outside them. -->",
|
|
101
|
+
"",
|
|
102
|
+
...body,
|
|
103
|
+
"",
|
|
104
|
+
AGENTS_BLOCK_END,
|
|
105
|
+
""
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/guardrails/templates.ts
|
|
110
|
+
const PRESET_CONFIGS = [
|
|
111
|
+
{
|
|
112
|
+
path: "oxlint.config.ts",
|
|
113
|
+
legacy: [".oxlintrc.json"],
|
|
114
|
+
specifier: "@antelopejs/tooling-configs/oxc/lint",
|
|
115
|
+
content: "import { defineConfig } from \"oxlint\";\nimport { antelopePreset } from \"@antelopejs/tooling-configs/oxc/lint\";\n\nexport default defineConfig({\n extends: [antelopePreset()],\n options: { typeAware: true },\n});\n"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
path: "oxfmt.config.ts",
|
|
119
|
+
legacy: [".oxfmtrc.json"],
|
|
120
|
+
specifier: "@antelopejs/tooling-configs/oxc/fmt",
|
|
121
|
+
content: "import { antelopeFmtPreset } from \"@antelopejs/tooling-configs/oxc/fmt\";\n\nexport default antelopeFmtPreset();\n"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
path: "knip.config.ts",
|
|
125
|
+
legacy: [
|
|
126
|
+
"knip.json",
|
|
127
|
+
"knip.jsonc",
|
|
128
|
+
".knip.json"
|
|
129
|
+
],
|
|
130
|
+
specifier: "@antelopejs/tooling-configs/knip",
|
|
131
|
+
content: "import { antelopeKnipConfig } from \"@antelopejs/tooling-configs/knip\";\n\nexport default antelopeKnipConfig();\n"
|
|
132
|
+
}
|
|
133
|
+
];
|
|
134
|
+
function presetConfig(current, preset, tools) {
|
|
135
|
+
const target = tools.presets[preset.path];
|
|
136
|
+
const specifier = target.replaceAll("/", "\\/").replaceAll(".", "\\.");
|
|
137
|
+
const imported = new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*\\(?\\s*)["']${specifier}["']`);
|
|
138
|
+
if (current === void 0) return preset.content.replace(preset.specifier, target);
|
|
139
|
+
if (imported.test(current)) return current;
|
|
140
|
+
const importLine = preset.content.split("\n").filter((line) => line.includes(preset.specifier)).join("\n").replace(preset.specifier, target);
|
|
141
|
+
throw new Error(`${preset.path} does not import its preset. Add \`${importLine}\` and extend that preset, then rerun antelopejs-sync. The file was not replaced.`);
|
|
142
|
+
}
|
|
143
|
+
function lefthook(current, manager, tools) {
|
|
144
|
+
const exec = execCommand(manager);
|
|
145
|
+
const document = parseDocument(current ?? "{}\n");
|
|
146
|
+
if (document.errors.length) throw new Error(`lefthook.yml: ${document.errors.map(({ message }) => message).join("; ")}`);
|
|
147
|
+
if (!isMap(document.contents)) throw new Error("lefthook.yml must contain a YAML mapping. Merge the guardrail hooks manually and rerun antelopejs-sync.");
|
|
148
|
+
if (current === void 0) document.contents.flow = false;
|
|
149
|
+
const glob = "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}";
|
|
150
|
+
const hooks = {
|
|
151
|
+
"pre-commit": {
|
|
152
|
+
"antelopejs-format": {
|
|
153
|
+
run: `${exec} oxfmt --no-error-on-unmatched-pattern {staged_files}`,
|
|
154
|
+
stage_fixed: true
|
|
155
|
+
},
|
|
156
|
+
"antelopejs-lint": {
|
|
157
|
+
glob,
|
|
158
|
+
run: `${exec} oxlint --no-error-on-unmatched-pattern {staged_files}`
|
|
159
|
+
},
|
|
160
|
+
"antelopejs-size": { run: `${tools.hookPrefix}${tools.commands.size} --staged {staged_files}` },
|
|
161
|
+
"antelopejs-ratchet": {
|
|
162
|
+
glob,
|
|
163
|
+
run: `${tools.hookPrefix}${tools.commands.ratchet} --staged {staged_files}`
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"pre-push": { "antelopejs-typecheck": { run: `${manager} run typecheck` } }
|
|
167
|
+
};
|
|
168
|
+
for (const [hook, commands] of Object.entries(hooks)) {
|
|
169
|
+
for (const path of [[hook], [hook, "commands"]]) {
|
|
170
|
+
if (!document.hasIn(path)) document.setIn(path, document.createNode({}));
|
|
171
|
+
if (!isMap(document.getIn(path))) throw new Error(`lefthook.yml: ${path.join(".")} must be a mapping. Preserve the existing hook and merge its commands before rerunning antelopejs-sync.`);
|
|
172
|
+
}
|
|
173
|
+
for (const [key, command] of Object.entries(commands)) document.setIn([
|
|
174
|
+
hook,
|
|
175
|
+
"commands",
|
|
176
|
+
key
|
|
177
|
+
], document.createNode(command));
|
|
178
|
+
}
|
|
179
|
+
return document.toString({ lineWidth: 0 });
|
|
180
|
+
}
|
|
181
|
+
const SETUP_SCRIPT = `#!/usr/bin/env bash
|
|
182
|
+
set -euo pipefail
|
|
183
|
+
cd "$(dirname "$BASH_SOURCE")/.."
|
|
184
|
+
export CI=1 COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
|
185
|
+
major="$(tr -d 'v[:space:]' < .nvmrc)"
|
|
186
|
+
if [[ "$(node -p 'process.versions.node.split(".")[0]')" != "$major" ]]; then
|
|
187
|
+
if command -v fnm >/dev/null 2>&1; then
|
|
188
|
+
eval "$(fnm env --shell bash)"
|
|
189
|
+
fnm install "$major"
|
|
190
|
+
fnm use "$major"
|
|
191
|
+
else
|
|
192
|
+
export NVM_DIR="\${NVM_DIR:-$HOME/.nvm}"
|
|
193
|
+
if [[ ! -s "$NVM_DIR/nvm.sh" ]]; then
|
|
194
|
+
git clone --quiet --depth 1 --branch v0.40.3 https://github.com/nvm-sh/nvm.git "$NVM_DIR"
|
|
195
|
+
fi
|
|
196
|
+
source "$NVM_DIR/nvm.sh" --no-use
|
|
197
|
+
nvm install "$major"
|
|
198
|
+
nvm use "$major"
|
|
199
|
+
fi
|
|
200
|
+
fi
|
|
201
|
+
pin="$(node -p 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).packageManager || "pnpm"')"
|
|
202
|
+
manager="\${pin%%@*}"
|
|
203
|
+
corepack enable
|
|
204
|
+
if [[ "$pin" == *@* ]]; then
|
|
205
|
+
corepack prepare "$pin" --activate
|
|
206
|
+
fi
|
|
207
|
+
case "$manager" in
|
|
208
|
+
pnpm) pnpm install --frozen-lockfile ;;
|
|
209
|
+
yarn) YARN_ENABLE_IMMUTABLE_INSTALLS=true yarn install --frozen-lockfile ;;
|
|
210
|
+
npm) npm ci ;;
|
|
211
|
+
bun) bun install --frozen-lockfile ;;
|
|
212
|
+
*) printf 'Unsupported package manager %s\\n' "$manager" >&2; exit 1 ;;
|
|
213
|
+
esac
|
|
214
|
+
`;
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/guardrails/workflow.ts
|
|
217
|
+
const MISSING_CHECKS_WORKFLOW = "No workflow in .github/workflows runs pnpm run checks. Add a step running `pnpm run checks` (or `pnpm checks`), or a job using AntelopeJS/.github/.github/workflows/ci-node.yml@<ref>.";
|
|
218
|
+
function hasChecksWorkflow(root) {
|
|
219
|
+
const directory = join(root, ".github/workflows");
|
|
220
|
+
if (!existsSync(directory)) return false;
|
|
221
|
+
for (const file of readdirSync(directory).filter((name) => /\.ya?ml$/.test(name))) {
|
|
222
|
+
const document = parseDocument(readFileSync(join(directory, file), "utf8"));
|
|
223
|
+
if (document.errors.length) continue;
|
|
224
|
+
const jobs = document.get("jobs", true);
|
|
225
|
+
if (!isMap(jobs)) continue;
|
|
226
|
+
for (const { value: job } of jobs.items) {
|
|
227
|
+
if (!isMap(job)) continue;
|
|
228
|
+
const uses = job.get("uses", true);
|
|
229
|
+
if (isScalar(uses) && /^AntelopeJS\/\.github\/\.github\/workflows\/ci-node\.yml@\S+$/.test(String(uses.value))) return true;
|
|
230
|
+
const steps = job.get("steps", true);
|
|
231
|
+
if (!isSeq(steps)) continue;
|
|
232
|
+
for (const step of steps.items) {
|
|
233
|
+
if (!isMap(step)) continue;
|
|
234
|
+
const run = step.get("run", true);
|
|
235
|
+
if (isScalar(run) && /(?:^|\n|&&|;)\s*pnpm\s+(?:run\s+)?checks(?=\s*(?:$|\n|&&|;|#))/.test(String(run.value))) return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/guardrails/sync.ts
|
|
243
|
+
const LEGACY_COMMENT_INSTRUCTION = "- Explain non-obvious constraints and decisions in comments; avoid restating the code. Document public contracts with TSDoc.";
|
|
244
|
+
function managedAgents(current = "", context) {
|
|
245
|
+
const start = current.indexOf(AGENTS_BLOCK_START);
|
|
246
|
+
const end = current.indexOf(AGENTS_BLOCK_END);
|
|
247
|
+
if (start === -1 !== (end === -1) || end < start) throw new Error("AGENTS.md has incomplete guardrails markers. Repair the markers before syncing.");
|
|
248
|
+
const notes = [];
|
|
249
|
+
const clean = (text) => text.split("\n").filter((line) => {
|
|
250
|
+
if (line.replace(/\r$/, "") !== LEGACY_COMMENT_INSTRUCTION) return true;
|
|
251
|
+
notes.push("Removed the legacy comment instruction from AGENTS.md's hand-written part.");
|
|
252
|
+
return false;
|
|
253
|
+
}).join("\n");
|
|
254
|
+
let before = clean(start === -1 ? current : current.slice(0, start));
|
|
255
|
+
const after = start === -1 ? "" : clean(current.slice(end + 34));
|
|
256
|
+
if (context.claude) before = `${before.trimEnd()}${before.trim() ? "\n\n" : ""}${clean(context.claude).trimEnd()}\n\n`;
|
|
257
|
+
const block = agentsBlock(context);
|
|
258
|
+
return {
|
|
259
|
+
content: start === -1 ? `${before}${before && !before.endsWith("\n") ? "\n" : ""}${before && !before.endsWith("\n\n") ? "\n" : ""}${block}` : `${before}${block.trimEnd()}${after}`,
|
|
260
|
+
notes
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const MANAGED_ARTIFACTS = [
|
|
264
|
+
...PRESET_CONFIGS.map((preset) => ({
|
|
265
|
+
path: preset.path,
|
|
266
|
+
profiles: ["full", "template"],
|
|
267
|
+
desired: (current, context) => ({
|
|
268
|
+
content: presetConfig(current, preset, context.tools),
|
|
269
|
+
notes: []
|
|
270
|
+
})
|
|
271
|
+
})),
|
|
272
|
+
{
|
|
273
|
+
path: "lefthook.yml",
|
|
274
|
+
profiles: ["full"],
|
|
275
|
+
desired: (current, context) => ({
|
|
276
|
+
content: lefthook(current, context.manager, context.tools),
|
|
277
|
+
notes: []
|
|
278
|
+
})
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
path: "package.json",
|
|
282
|
+
profiles: ["full"],
|
|
283
|
+
desired: (current, context) => ({
|
|
284
|
+
content: managedPackage(current, context.versions, context),
|
|
285
|
+
notes: []
|
|
286
|
+
})
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
path: ".nvmrc",
|
|
290
|
+
profiles: ["full"],
|
|
291
|
+
desired: () => ({
|
|
292
|
+
content: "24\n",
|
|
293
|
+
notes: []
|
|
294
|
+
})
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
path: ".agents/setup",
|
|
298
|
+
profiles: ["full"],
|
|
299
|
+
desired: () => ({
|
|
300
|
+
content: SETUP_SCRIPT,
|
|
301
|
+
notes: []
|
|
302
|
+
}),
|
|
303
|
+
mode: 493
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
path: "AGENTS.md",
|
|
307
|
+
profiles: ["full", "template"],
|
|
308
|
+
desired: managedAgents
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
path: "CLAUDE.md",
|
|
312
|
+
profiles: ["full"],
|
|
313
|
+
desired: (_current, context) => ({
|
|
314
|
+
content: "@AGENTS.md\n",
|
|
315
|
+
notes: context.claude ? ["Moved CLAUDE.md content into AGENTS.md's hand-written part before the managed block."] : []
|
|
316
|
+
})
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
path: CEILINGS_FILE,
|
|
320
|
+
profiles: ["full"],
|
|
321
|
+
desired: (current, context) => ({
|
|
322
|
+
content: current ?? context.baseline,
|
|
323
|
+
notes: []
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
];
|
|
327
|
+
function planSync(current, modes, context) {
|
|
328
|
+
const plan = [];
|
|
329
|
+
for (const preset of PRESET_CONFIGS) for (const file of preset.legacy) if (current.has(file)) plan.push({
|
|
330
|
+
kind: "error",
|
|
331
|
+
path: file,
|
|
332
|
+
message: `${file}: move its settings into ${preset.path} extending "${context.tools.presets[preset.path]}". Delete ${file}, then rerun antelopejs-sync.`
|
|
333
|
+
});
|
|
334
|
+
for (const artifact of MANAGED_ARTIFACTS) {
|
|
335
|
+
if (!artifact.profiles.includes(context.profile)) continue;
|
|
336
|
+
try {
|
|
337
|
+
const { content, notes } = artifact.desired(current.get(artifact.path), context);
|
|
338
|
+
const changed = current.get(artifact.path) !== content || artifact.mode !== void 0 && (artifact.mode & 73) !== 0 && ((modes.get(artifact.path) ?? 0) & 73) === 0;
|
|
339
|
+
if (notes.length) plan.push({
|
|
340
|
+
kind: "migrated",
|
|
341
|
+
path: artifact.path,
|
|
342
|
+
content,
|
|
343
|
+
mode: artifact.mode,
|
|
344
|
+
notes
|
|
345
|
+
});
|
|
346
|
+
else if (changed) plan.push({
|
|
347
|
+
kind: "write",
|
|
348
|
+
path: artifact.path,
|
|
349
|
+
content,
|
|
350
|
+
mode: artifact.mode
|
|
351
|
+
});
|
|
352
|
+
else plan.push({
|
|
353
|
+
kind: "unchanged",
|
|
354
|
+
path: artifact.path
|
|
355
|
+
});
|
|
356
|
+
} catch (error) {
|
|
357
|
+
plan.push({
|
|
358
|
+
kind: "error",
|
|
359
|
+
path: artifact.path,
|
|
360
|
+
message: error instanceof Error ? error.message : String(error)
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return plan;
|
|
365
|
+
}
|
|
366
|
+
function verificationSkill(root) {
|
|
367
|
+
for (const directory of [
|
|
368
|
+
".agents/skills",
|
|
369
|
+
".claude/skills",
|
|
370
|
+
"skills"
|
|
371
|
+
]) {
|
|
372
|
+
if (!existsSync(join(root, directory))) continue;
|
|
373
|
+
for (const entry of readdirSync(join(root, directory), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
374
|
+
const path = `${directory}/${entry.name}/SKILL.md`;
|
|
375
|
+
if (entry.isDirectory() && entry.name.toLowerCase().includes("verif") && existsSync(join(root, path))) return path;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function repositorySnapshot(root) {
|
|
380
|
+
const current = /* @__PURE__ */ new Map();
|
|
381
|
+
const modes = /* @__PURE__ */ new Map();
|
|
382
|
+
for (const file of [...MANAGED_ARTIFACTS.map(({ path }) => path), ...PRESET_CONFIGS.flatMap(({ legacy }) => legacy)]) {
|
|
383
|
+
const path = join(root, file);
|
|
384
|
+
if (existsSync(path)) {
|
|
385
|
+
current.set(file, readFileSync(path, "utf8"));
|
|
386
|
+
modes.set(file, statSync(path).mode & 511);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
current,
|
|
391
|
+
modes
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function linksTo(root, link, target) {
|
|
395
|
+
const path = join(root, link);
|
|
396
|
+
return lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() === true && resolve(root, readlinkSync(path)) === join(root, target);
|
|
397
|
+
}
|
|
398
|
+
function claudeProse(root, current) {
|
|
399
|
+
if (linksTo(root, "CLAUDE.md", "AGENTS.md") || linksTo(root, "AGENTS.md", "CLAUDE.md")) return "";
|
|
400
|
+
const claude = current.get("CLAUDE.md")?.trim() ?? "";
|
|
401
|
+
if (!claude.includes("\n") && /^(?:@?(?:\.\/)?AGENTS\.md|(?:See |Refer to )?\[AGENTS\.md\]\((?:\.\/)?AGENTS\.md\)(?: for .*)?\.?)$/i.test(claude)) return "";
|
|
402
|
+
return claude;
|
|
403
|
+
}
|
|
404
|
+
function repositoryContext(root, current, profile) {
|
|
405
|
+
const manifest = parseManifest(current.get("package.json") ?? "{}");
|
|
406
|
+
const lint = PRESET_CONFIGS[0];
|
|
407
|
+
const desiredLint = current.get(lint.path) ?? lint.content;
|
|
408
|
+
return {
|
|
409
|
+
profile,
|
|
410
|
+
manager: packageManager(manifest),
|
|
411
|
+
versions: toolingVersions(),
|
|
412
|
+
tools: repositoryTools(manifest),
|
|
413
|
+
typeAware: /\btypeAware["']?\s*:\s*true\b/.test(desiredLint),
|
|
414
|
+
nuxt: Boolean(manifest.dependencies?.nuxt || manifest.devDependencies?.nuxt || readdirSync(root).some((name) => name.startsWith("nuxt.config."))),
|
|
415
|
+
claude: profile === "full" ? claudeProse(root, current) : "",
|
|
416
|
+
baseline: "",
|
|
417
|
+
verificationSkill: verificationSkill(root),
|
|
418
|
+
glossary: ["GLOSSARY.md", "docs/GLOSSARY.md"].find((file) => existsSync(join(root, file)))
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function writeArtifacts(root, artifacts) {
|
|
422
|
+
for (const artifact of artifacts) {
|
|
423
|
+
const path = join(root, artifact.path);
|
|
424
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
425
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) unlinkSync(path);
|
|
426
|
+
writeFileSync(path, artifact.content);
|
|
427
|
+
if (artifact.mode !== void 0) chmodSync(path, artifact.mode);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function syncRepository(root, options = {}) {
|
|
431
|
+
const profile = options.profile ?? "full";
|
|
432
|
+
const { current, modes } = repositorySnapshot(root);
|
|
433
|
+
const manifest = parseManifest(current.get("package.json") ?? "{}");
|
|
434
|
+
const context = repositoryContext(root, current, profile);
|
|
435
|
+
let plan = planSync(current, modes, context);
|
|
436
|
+
const errors = plan.filter((artifact) => artifact.kind === "error").map(({ message }) => message);
|
|
437
|
+
const notes = plan.flatMap((artifact) => artifact.kind === "migrated" ? artifact.notes : []);
|
|
438
|
+
if (profile === "full" && !hasChecksWorkflow(root)) {
|
|
439
|
+
if (options.check) errors.push(MISSING_CHECKS_WORKFLOW);
|
|
440
|
+
else notes.push(MISSING_CHECKS_WORKFLOW);
|
|
441
|
+
}
|
|
442
|
+
if (!errors.length && profile === "full" && !options.check && !current.has("guardrails.ratchet.json")) {
|
|
443
|
+
const lint = PRESET_CONFIGS[0];
|
|
444
|
+
const desired = presetConfig(current.get(lint.path), lint, context.tools);
|
|
445
|
+
context.baseline = formatCeilings(measureBaseline(root, desired === current.get(lint.path) ? void 0 : desired));
|
|
446
|
+
plan = planSync(current, modes, context);
|
|
447
|
+
}
|
|
448
|
+
const writes = plan.filter((artifact) => artifact.kind === "write" || artifact.kind === "migrated");
|
|
449
|
+
if (!options.check && !errors.length) writeArtifacts(root, writes);
|
|
450
|
+
const requiredDependencies = ["lefthook", "knip"];
|
|
451
|
+
if (context.typeAware) requiredDependencies.push("oxlint-tsgolint");
|
|
452
|
+
const dependenciesAdded = profile === "full" && requiredDependencies.some((name) => !manifest.devDependencies?.[name]);
|
|
453
|
+
return {
|
|
454
|
+
artifacts: plan,
|
|
455
|
+
paths: errors.length ? [] : writes.map(({ path }) => path),
|
|
456
|
+
errors,
|
|
457
|
+
notes,
|
|
458
|
+
dependenciesAdded
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/bin/sync.ts
|
|
463
|
+
runCli(() => {
|
|
464
|
+
const { values } = parseArgs({ options: {
|
|
465
|
+
check: { type: "boolean" },
|
|
466
|
+
profile: {
|
|
467
|
+
type: "string",
|
|
468
|
+
default: "full"
|
|
469
|
+
}
|
|
470
|
+
} });
|
|
471
|
+
if (values.profile !== "full" && values.profile !== "template") throw new Error("--profile must be full or template.");
|
|
472
|
+
const result = syncRepository(repositoryRoot(), {
|
|
473
|
+
check: values.check,
|
|
474
|
+
profile: values.profile
|
|
475
|
+
});
|
|
476
|
+
for (const note of result.notes) process.stdout.write(`Note: ${values.check || result.errors.length ? "Planned migration. " : ""}${note}\n`);
|
|
477
|
+
for (const error of result.errors) process.stderr.write(`${error}\n`);
|
|
478
|
+
if (result.errors.length) return 1;
|
|
479
|
+
for (const path of result.paths) process.stdout.write(`${values.check ? "Drift" : "Updated"}: ${path}${values.check ? "; run antelopejs-sync" : ""}\n`);
|
|
480
|
+
if (!result.paths.length) process.stdout.write("Guardrails are in sync.\n");
|
|
481
|
+
if (!values.check && result.dependenciesAdded) process.stdout.write("Added devDependencies. Run install with your package manager before using the hooks or checks.\n");
|
|
482
|
+
return values.check && result.paths.length ? 1 : 0;
|
|
483
|
+
});
|
|
484
|
+
//#endregion
|
|
485
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
//#region src/guardrails/ceilings.ts
|
|
4
|
+
/** The per-repository ratchet file, at the repository root. */
|
|
5
|
+
const CEILINGS_FILE = "guardrails.ratchet.json";
|
|
6
|
+
/**
|
|
7
|
+
* Set by the ratchet when it measures: the oxlint preset then ignores the
|
|
8
|
+
* ceilings and reports every rule, so the counts include the existing debt.
|
|
9
|
+
*/
|
|
10
|
+
const MEASURE_ENV = "ANTELOPEJS_RATCHET_MEASURE";
|
|
11
|
+
/** Largest authored file, in bytes, tests included. */
|
|
12
|
+
const MAX_FILE_BYTES = 2e4;
|
|
13
|
+
const EMPTY_CEILINGS = {
|
|
14
|
+
rules: {},
|
|
15
|
+
oversizeFiles: {}
|
|
16
|
+
};
|
|
17
|
+
/** Finds the nearest git directory or worktree marker, falling back to cwd outside git. */
|
|
18
|
+
function findGitRoot(cwd = process.cwd()) {
|
|
19
|
+
let directory = resolve(cwd);
|
|
20
|
+
while (!existsSync(join(directory, ".git"))) {
|
|
21
|
+
const parent = dirname(directory);
|
|
22
|
+
if (parent === directory) return resolve(cwd);
|
|
23
|
+
directory = parent;
|
|
24
|
+
}
|
|
25
|
+
return directory;
|
|
26
|
+
}
|
|
27
|
+
/** Reads the ceilings at `root`; a repository without the file has no debt. */
|
|
28
|
+
function readCeilings(root) {
|
|
29
|
+
const path = join(root, CEILINGS_FILE);
|
|
30
|
+
if (!existsSync(path)) return EMPTY_CEILINGS;
|
|
31
|
+
return parseCeilings(readFileSync(path, "utf8"), path);
|
|
32
|
+
}
|
|
33
|
+
/** Parses the ceilings file, rejecting anything but non-negative integer counts. */
|
|
34
|
+
function parseCeilings(text, source) {
|
|
35
|
+
const parsed = JSON.parse(text);
|
|
36
|
+
if (parsed === null || Array.isArray(parsed)) throw new Error(`${source}: expected an object with "rules" and "oversizeFiles"`);
|
|
37
|
+
return {
|
|
38
|
+
rules: parseCounts(parsed.rules, `${source}: rules`),
|
|
39
|
+
oversizeFiles: parseCounts(parsed.oversizeFiles, `${source}: oversizeFiles`)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function parseCounts(value, source) {
|
|
43
|
+
if (value === void 0) return {};
|
|
44
|
+
if (value === null || Array.isArray(value)) throw new Error(`${source}: expected an object of counts`);
|
|
45
|
+
const counts = {};
|
|
46
|
+
for (const [key, count] of Object.entries(value)) {
|
|
47
|
+
if (!Number.isInteger(count) || count < 0) throw new Error(`${source}: "${key}" must be a non-negative integer`);
|
|
48
|
+
counts[key] = count;
|
|
49
|
+
}
|
|
50
|
+
return counts;
|
|
51
|
+
}
|
|
52
|
+
function sortedCounts(counts) {
|
|
53
|
+
return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)));
|
|
54
|
+
}
|
|
55
|
+
/** Serializes ceilings with sorted keys, so rewriting an unchanged file is a no-op. */
|
|
56
|
+
function formatCeilings(ceilings) {
|
|
57
|
+
const sorted = {
|
|
58
|
+
rules: sortedCounts(ceilings.rules),
|
|
59
|
+
oversizeFiles: sortedCounts(ceilings.oversizeFiles)
|
|
60
|
+
};
|
|
61
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { formatCeilings as a, findGitRoot as i, MAX_FILE_BYTES as n, readCeilings as o, MEASURE_ENV as r, CEILINGS_FILE as t };
|
package/dist/eslint.mjs
CHANGED
|
@@ -10,13 +10,18 @@ import { createConfigForNuxt } from "@nuxt/eslint-config/flat";
|
|
|
10
10
|
* layers carry today) would fight it on every save.
|
|
11
11
|
*/
|
|
12
12
|
const ANTELOPE_NUXT_RULES = {
|
|
13
|
+
/** Import order follows side-effectful setup and belongs to the formatter. */
|
|
13
14
|
"import/first": "off",
|
|
14
15
|
"import/order": "off",
|
|
16
|
+
/** Single-word pages are intentional; withDefaults supplies prop defaults. */
|
|
15
17
|
"vue/multi-word-component-names": "off",
|
|
16
18
|
"vue/require-default-prop": "off",
|
|
19
|
+
/** CMS blocks and forms require structural types in the interface layer. */
|
|
17
20
|
"@typescript-eslint/no-explicit-any": "off",
|
|
18
21
|
"@typescript-eslint/no-empty-object-type": "off",
|
|
22
|
+
/** Separate event signatures preserve their documentation. */
|
|
19
23
|
"@typescript-eslint/unified-signatures": "off",
|
|
24
|
+
/** Underscores mark intentionally unused bindings. */
|
|
20
25
|
"@typescript-eslint/no-unused-vars": ["error", {
|
|
21
26
|
argsIgnorePattern: "^_",
|
|
22
27
|
varsIgnorePattern: "^_",
|
|
@@ -35,13 +40,28 @@ const ANTELOPE_NUXT_RULES = {
|
|
|
35
40
|
* ```
|
|
36
41
|
*/
|
|
37
42
|
function antelopeNuxtConfig(options = {}) {
|
|
43
|
+
const overrides = {
|
|
44
|
+
...ANTELOPE_NUXT_RULES,
|
|
45
|
+
...options.rules
|
|
46
|
+
};
|
|
38
47
|
return createConfigForNuxt({
|
|
39
48
|
features: { tooling: true },
|
|
40
49
|
dirs: { src: options.dirs ?? [] }
|
|
41
|
-
}).
|
|
42
|
-
|
|
43
|
-
...
|
|
44
|
-
|
|
50
|
+
}).onResolved((configs) => configs.map((config) => {
|
|
51
|
+
if (config.rules === void 0) return config;
|
|
52
|
+
const rules = { ...config.rules };
|
|
53
|
+
for (const [name, original] of Object.entries(rules)) {
|
|
54
|
+
const rule = overrides[name] === void 0 ? original : overrides[name];
|
|
55
|
+
if (rule === null) delete rules[name];
|
|
56
|
+
else if (rule === "warn" || rule === 1) rules[name] = "error";
|
|
57
|
+
else if (Array.isArray(rule) && (rule[0] === "warn" || rule[0] === 1)) rules[name] = ["error", ...rule.slice(1)];
|
|
58
|
+
else rules[name] = rule;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
...config,
|
|
62
|
+
rules
|
|
63
|
+
};
|
|
64
|
+
}));
|
|
45
65
|
}
|
|
46
66
|
//#endregion
|
|
47
67
|
export { ANTELOPE_NUXT_RULES, antelopeNuxtConfig };
|