@monte3l/groundwork 0.0.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 +23 -0
- package/bin/m3l-groundwork.mjs +10 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +79 -0
- package/dist/caps.d.ts +25 -0
- package/dist/caps.js +69 -0
- package/dist/conflicts.d.ts +12 -0
- package/dist/conflicts.js +77 -0
- package/dist/emit.d.ts +7 -0
- package/dist/emit.js +42 -0
- package/dist/git.d.ts +3 -0
- package/dist/git.js +9 -0
- package/dist/harness/conformance.d.ts +20 -0
- package/dist/harness/conformance.js +18 -0
- package/dist/harness/frontmatter.d.ts +38 -0
- package/dist/harness/frontmatter.js +204 -0
- package/dist/harness/grade.d.ts +4 -0
- package/dist/harness/grade.js +105 -0
- package/dist/harness/rules.d.ts +55 -0
- package/dist/harness/rules.js +580 -0
- package/dist/harness/types.d.ts +32 -0
- package/dist/harness/types.js +9 -0
- package/dist/inventory.d.ts +63 -0
- package/dist/inventory.js +66 -0
- package/dist/jsonc.d.ts +14 -0
- package/dist/jsonc.js +83 -0
- package/dist/main.d.ts +24 -0
- package/dist/main.js +297 -0
- package/dist/merge-json.d.ts +74 -0
- package/dist/merge-json.js +135 -0
- package/dist/mode.d.ts +19 -0
- package/dist/mode.js +53 -0
- package/dist/packs.d.ts +61 -0
- package/dist/packs.js +186 -0
- package/dist/plugin.d.ts +23 -0
- package/dist/plugin.js +79 -0
- package/dist/report.d.ts +4 -0
- package/dist/report.js +323 -0
- package/dist/survey/fs-walk.d.ts +14 -0
- package/dist/survey/fs-walk.js +60 -0
- package/dist/survey/survey-docs.d.ts +4 -0
- package/dist/survey/survey-docs.js +69 -0
- package/dist/survey/survey-harness.d.ts +4 -0
- package/dist/survey/survey-harness.js +121 -0
- package/dist/survey/survey-shape.d.ts +4 -0
- package/dist/survey/survey-shape.js +182 -0
- package/dist/survey/survey-toolchain.d.ts +4 -0
- package/dist/survey/survey-toolchain.js +217 -0
- package/dist/survey/survey.d.ts +5 -0
- package/dist/survey/survey.js +21 -0
- package/dist/survey/types.d.ts +117 -0
- package/dist/survey/types.js +8 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +13 -0
- package/dist/toolchain/conformance.d.ts +20 -0
- package/dist/toolchain/conformance.js +30 -0
- package/dist/toolchain/grade.d.ts +4 -0
- package/dist/toolchain/grade.js +244 -0
- package/dist/toolchain/rules.d.ts +118 -0
- package/dist/toolchain/rules.js +706 -0
- package/dist/toolchain/tsconfig-chain.d.ts +36 -0
- package/dist/toolchain/tsconfig-chain.js +116 -0
- package/dist/toolchain/types.d.ts +27 -0
- package/dist/toolchain/types.js +9 -0
- package/package.json +59 -0
- package/plugin/skills/customize/SKILL.md +305 -0
- package/plugin/src/domain-map.ts +134 -0
- package/plugin/src/index.ts +4 -0
- package/plugin/src/kind-facet-map.ts +174 -0
- package/plugin/src/pack-map.ts +65 -0
- package/templates/core/.claude/agents/Explore.md +43 -0
- package/templates/core/.claude/agents/code-implementer.md +258 -0
- package/templates/core/.claude/agents/code-reviewer.md +163 -0
- package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
- package/templates/core/.claude/agents/test-author.md +211 -0
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
- package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
- package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
- package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
- package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
- package/templates/core/.claude/rules/agent-dispatch.md +121 -0
- package/templates/core/.claude/rules/refactoring.md +52 -0
- package/templates/core/.claude/rules/src.md +114 -0
- package/templates/core/.claude/rules/tests.md +129 -0
- package/templates/core/.claude/settings.json +111 -0
- package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
- package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
- package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
- package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
- package/templates/core/.github/workflows/ci.yml +123 -0
- package/templates/core/.github/workflows/dependency-review.yml +26 -0
- package/templates/core/.github/workflows/security-audit.yml +54 -0
- package/templates/core/.node-version +1 -0
- package/templates/core/.prettierignore +5 -0
- package/templates/core/.prettierrc.json +4 -0
- package/templates/core/CLAUDE.md +127 -0
- package/templates/core/README.md +24 -0
- package/templates/core/_gitignore +19 -0
- package/templates/core/_npmrc +1 -0
- package/templates/core/bin/check-exports.mjs +92 -0
- package/templates/core/bin/check-harness.mjs +27 -0
- package/templates/core/bin/check-node-version.mjs +51 -0
- package/templates/core/bin/check-toolchain.mjs +20 -0
- package/templates/core/bin/lib/agent-roster.mjs +8 -0
- package/templates/core/bin/lib/frontmatter.mjs +210 -0
- package/templates/core/bin/lib/harness-rules.mjs +916 -0
- package/templates/core/bin/lib/protected-paths.mjs +23 -0
- package/templates/core/bin/lib/report.mjs +56 -0
- package/templates/core/bin/lib/signed-range.mjs +178 -0
- package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
- package/templates/core/bin/lib/verify-steps.mjs +131 -0
- package/templates/core/bin/lib/verify-steps.packs.json +1 -0
- package/templates/core/bin/lint-commit.mjs +50 -0
- package/templates/core/bin/strip-claude-trailers.mjs +25 -0
- package/templates/core/bin/verify.mjs +64 -0
- package/templates/core/commitlint.config.js +11 -0
- package/templates/core/docs/research/harness-refresh.md +27 -0
- package/templates/core/docs/research/typescript-refresh.md +32 -0
- package/templates/core/eslint.config.js +105 -0
- package/templates/core/knip.json +6 -0
- package/templates/core/lefthook.yml +39 -0
- package/templates/core/package.json +58 -0
- package/templates/core/pnpm-workspace.yaml +13 -0
- package/templates/core/src/index.ts +12 -0
- package/templates/core/tests/index.test.ts +8 -0
- package/templates/core/tsconfig.base.json +36 -0
- package/templates/core/tsconfig.build.json +10 -0
- package/templates/core/tsconfig.json +11 -0
- package/templates/core/vitest.config.ts +32 -0
- package/templates/packs/README.md +81 -0
- package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
- package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
- package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
- package/templates/packs/harness-extras/pack.json +65 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
- package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
- package/templates/packs/statusline/pack.json +31 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Per-file size ratchet for `src/` and `tests/` — the file scope matches
|
|
4
|
+
* `vitest.config.ts`'s `coverage.include` (`.ts` sources, excluding `index.ts`
|
|
5
|
+
* barrels and `.d.ts` files), since the hazard this guards against is
|
|
6
|
+
* specific to `perFile: true` v8 coverage: a large implementation file binds
|
|
7
|
+
* to every test file that exercises it, and once both grow past a point,
|
|
8
|
+
* retrofitting a split becomes structurally difficult to do in one PR.
|
|
9
|
+
*
|
|
10
|
+
* A flat ceiling is not viable once a project has real debt, so this is a
|
|
11
|
+
* **ratchet**, not a cap: a committed baseline (`bin/file-budget-
|
|
12
|
+
* baseline.json`) is the sparse "debt list" of files that already exceeded
|
|
13
|
+
* their ceiling when this gate was adopted. A baselined file may shrink
|
|
14
|
+
* freely but never **grow** past its recorded size; any file not in the
|
|
15
|
+
* baseline must stay under the ceiling from the start. `--update`
|
|
16
|
+
* regenerates the baseline from current sizes, dropping any entry that no
|
|
17
|
+
* longer exceeds its ceiling and adding any newly-over-ceiling file — an
|
|
18
|
+
* explicit, reviewed-diff social contract: a PR that baselines a new
|
|
19
|
+
* oversized file is asking its reviewer to accept that debt, not silently
|
|
20
|
+
* evading the gate.
|
|
21
|
+
*
|
|
22
|
+
* `ROOTS` defaults to this baseline's flat `src/`/`tests/` layout. If
|
|
23
|
+
* `/customize` or a later refactor moves to a `packages/*` monorepo shape,
|
|
24
|
+
* update `ROOTS` to list each package's `src`/`tests` pair.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* node bin/check-file-budget.mjs # verify (fails on growth/new-over-ceiling)
|
|
28
|
+
* node bin/check-file-budget.mjs --update # rewrite the baseline from current sizes
|
|
29
|
+
* node bin/check-file-budget.mjs --ref <ref> # verify a committed ref instead of the working tree (no checkout/worktree required); incompatible with --update
|
|
30
|
+
*/
|
|
31
|
+
import process from "node:process";
|
|
32
|
+
import {
|
|
33
|
+
readFileSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
readdirSync,
|
|
36
|
+
existsSync,
|
|
37
|
+
realpathSync,
|
|
38
|
+
} from "node:fs";
|
|
39
|
+
import { execFileSync } from "node:child_process";
|
|
40
|
+
import { join, relative } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
import { parseJsonFlag, createReporter, repoRoot } from "./lib/report.mjs";
|
|
43
|
+
|
|
44
|
+
const root = repoRoot();
|
|
45
|
+
const baselineRel = "bin/file-budget-baseline.json";
|
|
46
|
+
const baselinePath = join(root, baselineRel);
|
|
47
|
+
|
|
48
|
+
/** Each root's `src`/`tests` pair to scan, relative to the repo root. */
|
|
49
|
+
export const ROOTS = [{ src: "src", tests: "tests" }];
|
|
50
|
+
|
|
51
|
+
/** Ceiling for a coverage-eligible `src` file not in the baseline. */
|
|
52
|
+
export const SRC_CEILING_BYTES = 25_000;
|
|
53
|
+
/** Ceiling for a `tests` file not in the baseline. */
|
|
54
|
+
export const TEST_CEILING_BYTES = 60_000;
|
|
55
|
+
|
|
56
|
+
/** `error.code` for a caught filesystem error, or `undefined` if it isn't one. */
|
|
57
|
+
function errnoCodeOf(error) {
|
|
58
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
59
|
+
? String(/** @type {{ code: unknown }} */ (error).code)
|
|
60
|
+
: undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Recursively collect files under `dir` for which `matches(relPath)` is
|
|
65
|
+
* true, pruning `dist`/`node_modules` subtrees. A missing `dir` yields no
|
|
66
|
+
* files rather than throwing — a project without a `tests/` directory yet
|
|
67
|
+
* is not an error here.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} dir absolute directory to walk
|
|
70
|
+
* @param {(relPath: string) => boolean} matches called with the path
|
|
71
|
+
* relative to the repo root
|
|
72
|
+
* @returns {string[]} repo-relative paths, sorted
|
|
73
|
+
*/
|
|
74
|
+
export function walkMatching(dir, matches) {
|
|
75
|
+
const results = [];
|
|
76
|
+
const skipDirs = new Set(["dist", "node_modules"]);
|
|
77
|
+
|
|
78
|
+
function recurse(current) {
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
if (errnoCodeOf(cause) === "ENOENT") return;
|
|
84
|
+
throw cause;
|
|
85
|
+
}
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (entry.isDirectory()) {
|
|
88
|
+
if (skipDirs.has(entry.name)) continue;
|
|
89
|
+
recurse(join(current, entry.name));
|
|
90
|
+
} else if (entry.isFile()) {
|
|
91
|
+
const rel = relative(root, join(current, entry.name));
|
|
92
|
+
if (matches(rel)) results.push(rel);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
recurse(dir);
|
|
98
|
+
return results.sort();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* True for a `.ts` file that is part of `vitest.config.ts`'s coverage set —
|
|
103
|
+
* neither a declaration file nor a barrel named exactly `index.ts`.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} relPath repo-relative path
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export function isCoverageEligibleSrcFile(relPath) {
|
|
109
|
+
if (!relPath.endsWith(".ts") || relPath.endsWith(".d.ts")) return false;
|
|
110
|
+
return !relPath.endsWith("/index.ts") && relPath !== "index.ts";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} relPath repo-relative path
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
export function isTestFile(relPath) {
|
|
118
|
+
return relPath.endsWith(".test.ts");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Classify a `git ls-tree`-reported path the same way
|
|
123
|
+
* {@link collectBudgetEntries}'s walk classifies a filesystem path — but
|
|
124
|
+
* from a bare repo-relative string, since `--ref` mode has no directory to
|
|
125
|
+
* walk.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} relPath repo-relative path
|
|
128
|
+
* @returns {"src" | "test" | null}
|
|
129
|
+
*/
|
|
130
|
+
export function classifyRefPath(relPath) {
|
|
131
|
+
for (const { src, tests } of ROOTS) {
|
|
132
|
+
if (relPath.startsWith(`${src}/`) && isCoverageEligibleSrcFile(relPath))
|
|
133
|
+
return "src";
|
|
134
|
+
if (relPath.startsWith(`${tests}/`) && isTestFile(relPath)) return "test";
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {Object} BudgetEntry
|
|
141
|
+
* @property {string} path repo-relative
|
|
142
|
+
* @property {number} bytes current size
|
|
143
|
+
* @property {"src" | "test"} category
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {BudgetEntry} entry
|
|
148
|
+
* @returns {number} the ceiling that applies when `entry.path` is not baselined
|
|
149
|
+
*/
|
|
150
|
+
function ceilingFor(entry) {
|
|
151
|
+
return entry.category === "src" ? SRC_CEILING_BYTES : TEST_CEILING_BYTES;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Collect every file this gate scopes, with its current byte size.
|
|
156
|
+
*
|
|
157
|
+
* @returns {BudgetEntry[]}
|
|
158
|
+
*/
|
|
159
|
+
export function collectBudgetEntries() {
|
|
160
|
+
/** @type {BudgetEntry[]} */
|
|
161
|
+
const entries = [];
|
|
162
|
+
|
|
163
|
+
for (const { src, tests } of ROOTS) {
|
|
164
|
+
for (const rel of walkMatching(
|
|
165
|
+
join(root, src),
|
|
166
|
+
isCoverageEligibleSrcFile,
|
|
167
|
+
)) {
|
|
168
|
+
entries.push({
|
|
169
|
+
path: rel,
|
|
170
|
+
bytes: Buffer.byteLength(readFileSync(join(root, rel)), "utf8"),
|
|
171
|
+
category: "src",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
for (const rel of walkMatching(join(root, tests), isTestFile)) {
|
|
175
|
+
entries.push({
|
|
176
|
+
path: rel,
|
|
177
|
+
bytes: Buffer.byteLength(readFileSync(join(root, rel)), "utf8"),
|
|
178
|
+
category: "test",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* {@link collectBudgetEntries}'s equivalent for a committed ref, read via
|
|
187
|
+
* `git` plumbing instead of `node:fs` — no checkout or worktree required.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} ref a ref resolvable by `git` (branch, tag, SHA, `origin/*`)
|
|
190
|
+
* @returns {BudgetEntry[]}
|
|
191
|
+
* @throws {Error} if `ref` cannot be resolved, or `git` fails for any reason
|
|
192
|
+
*/
|
|
193
|
+
export function collectBudgetEntriesAtRef(ref) {
|
|
194
|
+
const pathspecs = ROOTS.flatMap(({ src, tests }) => [`${src}/`, `${tests}/`]);
|
|
195
|
+
const listing = execFileSync(
|
|
196
|
+
"git",
|
|
197
|
+
["ls-tree", "-r", "--name-only", ref, "--", ...pathspecs],
|
|
198
|
+
{ cwd: root, encoding: "utf8" },
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
/** @type {BudgetEntry[]} */
|
|
202
|
+
const entries = [];
|
|
203
|
+
for (const relPath of listing.split("\n")) {
|
|
204
|
+
if (relPath === "") continue;
|
|
205
|
+
const category = classifyRefPath(relPath);
|
|
206
|
+
if (category === null) continue;
|
|
207
|
+
const size = execFileSync("git", ["cat-file", "-s", `${ref}:${relPath}`], {
|
|
208
|
+
cwd: root,
|
|
209
|
+
encoding: "utf8",
|
|
210
|
+
});
|
|
211
|
+
entries.push({ path: relPath, bytes: Number(size.trim()), category });
|
|
212
|
+
}
|
|
213
|
+
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* {@link readFileSync}/`JSON.parse` of `bin/file-budget-baseline.json`, but
|
|
218
|
+
* against a committed ref instead of the working tree. A baseline absent at
|
|
219
|
+
* `ref` yields `{}`; a present-but-invalid baseline's `JSON.parse` failure
|
|
220
|
+
* propagates uncaught, same as the working-tree path.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} ref a ref resolvable by `git`
|
|
223
|
+
* @returns {Record<string, number>}
|
|
224
|
+
*/
|
|
225
|
+
export function readBaselineAtRef(ref) {
|
|
226
|
+
try {
|
|
227
|
+
execFileSync("git", ["cat-file", "-e", `${ref}:${baselineRel}`], {
|
|
228
|
+
cwd: root,
|
|
229
|
+
encoding: "utf8",
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
const text = execFileSync("git", ["show", `${ref}:${baselineRel}`], {
|
|
235
|
+
cwd: root,
|
|
236
|
+
encoding: "utf8",
|
|
237
|
+
});
|
|
238
|
+
return JSON.parse(text);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Read `--ref <value>` out of an argv array.
|
|
243
|
+
*
|
|
244
|
+
* @param {string[]} argv
|
|
245
|
+
* @returns {string | undefined}
|
|
246
|
+
*/
|
|
247
|
+
export function parseRefArg(argv) {
|
|
248
|
+
const i = argv.indexOf("--ref");
|
|
249
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Compare current entries against the committed baseline.
|
|
254
|
+
*
|
|
255
|
+
* @param {BudgetEntry[]} entries
|
|
256
|
+
* @param {Record<string, number>} baseline path -> recorded byte ceiling
|
|
257
|
+
* @returns {{ violations: Array<{ path: string, bytes: number, limit: number, baselined: boolean }> }}
|
|
258
|
+
*/
|
|
259
|
+
export function checkBudget(entries, baseline) {
|
|
260
|
+
const violations = [];
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
const recorded = baseline[entry.path];
|
|
263
|
+
if (recorded !== undefined) {
|
|
264
|
+
if (entry.bytes > recorded) {
|
|
265
|
+
violations.push({
|
|
266
|
+
path: entry.path,
|
|
267
|
+
bytes: entry.bytes,
|
|
268
|
+
limit: recorded,
|
|
269
|
+
baselined: true,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const ceiling = ceilingFor(entry);
|
|
275
|
+
if (entry.bytes > ceiling) {
|
|
276
|
+
violations.push({
|
|
277
|
+
path: entry.path,
|
|
278
|
+
bytes: entry.bytes,
|
|
279
|
+
limit: ceiling,
|
|
280
|
+
baselined: false,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { violations };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Build the regenerated baseline: every entry currently over its ceiling,
|
|
289
|
+
* keyed to its exact current size. Entries that no longer exceed their
|
|
290
|
+
* ceiling (shrunk, or deleted) are dropped — the baseline only ever tracks
|
|
291
|
+
* live debt.
|
|
292
|
+
*
|
|
293
|
+
* @param {BudgetEntry[]} entries
|
|
294
|
+
* @returns {Record<string, number>} key-sorted
|
|
295
|
+
*/
|
|
296
|
+
export function buildBaseline(entries) {
|
|
297
|
+
/** @type {Record<string, number>} */
|
|
298
|
+
const next = {};
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
if (entry.bytes > ceilingFor(entry)) next[entry.path] = entry.bytes;
|
|
301
|
+
}
|
|
302
|
+
return Object.fromEntries(
|
|
303
|
+
Object.entries(next).sort(([a], [b]) => a.localeCompare(b)),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// `import.meta.url` is symlink-resolved but `process.argv[1]` is not, so
|
|
308
|
+
// comparing them directly is false under any symlinked path and the gate would
|
|
309
|
+
// never run -- exiting 0, a green check that checked nothing.
|
|
310
|
+
function isEntryPoint() {
|
|
311
|
+
try {
|
|
312
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (isEntryPoint()) {
|
|
319
|
+
const argv = process.argv.slice(2);
|
|
320
|
+
const reporter = createReporter(parseJsonFlag(argv));
|
|
321
|
+
const ref = parseRefArg(argv);
|
|
322
|
+
|
|
323
|
+
if (ref !== undefined && argv.includes("--update")) {
|
|
324
|
+
reporter.fail(
|
|
325
|
+
"--update cannot be combined with --ref -- there is no committed " +
|
|
326
|
+
"blob to write a regenerated baseline into. Run --update on a " +
|
|
327
|
+
"checked-out working tree instead.",
|
|
328
|
+
);
|
|
329
|
+
reporter.finish();
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let entries;
|
|
334
|
+
try {
|
|
335
|
+
entries = ref ? collectBudgetEntriesAtRef(ref) : collectBudgetEntries();
|
|
336
|
+
} catch (cause) {
|
|
337
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
338
|
+
reporter.fail(
|
|
339
|
+
ref
|
|
340
|
+
? `Could not scan the tracked roots at ${ref}: ${message}`
|
|
341
|
+
: `Could not scan ${relative(root, root)}: ${message}`,
|
|
342
|
+
);
|
|
343
|
+
reporter.finish();
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (argv.includes("--update")) {
|
|
348
|
+
const next = buildBaseline(entries);
|
|
349
|
+
writeFileSync(baselinePath, `${JSON.stringify(next, null, 2)}\n`);
|
|
350
|
+
const count = Object.keys(next).length;
|
|
351
|
+
reporter.ok(
|
|
352
|
+
`updated ${baselineRel} (${count} ${count === 1 ? "entry" : "entries"})`,
|
|
353
|
+
);
|
|
354
|
+
reporter.finish();
|
|
355
|
+
process.exit(0);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @type {Record<string, number>} */
|
|
359
|
+
let baseline = {};
|
|
360
|
+
if (ref !== undefined) {
|
|
361
|
+
try {
|
|
362
|
+
baseline = readBaselineAtRef(ref);
|
|
363
|
+
} catch (cause) {
|
|
364
|
+
reporter.fail(
|
|
365
|
+
`Could not parse ${baselineRel} at ${ref}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
366
|
+
);
|
|
367
|
+
reporter.finish();
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
} else if (existsSync(baselinePath)) {
|
|
371
|
+
try {
|
|
372
|
+
baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
|
|
373
|
+
} catch (cause) {
|
|
374
|
+
reporter.fail(
|
|
375
|
+
`Could not parse ${baselineRel}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
376
|
+
);
|
|
377
|
+
reporter.finish();
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const { violations } = checkBudget(entries, baseline);
|
|
383
|
+
for (const v of violations) {
|
|
384
|
+
if (v.baselined) {
|
|
385
|
+
reporter.fail(
|
|
386
|
+
`${v.path}: ${v.bytes} bytes -- grew past its baselined ceiling of ${v.limit} ` +
|
|
387
|
+
`(bin/file-budget-baseline.json). Split the file before adding more to it.`,
|
|
388
|
+
);
|
|
389
|
+
} else {
|
|
390
|
+
reporter.fail(
|
|
391
|
+
`${v.path}: ${v.bytes} bytes -- exceeds the ${v.limit}-byte ceiling and is not in the ` +
|
|
392
|
+
`baseline. Split it, or if the size is deliberate, run ` +
|
|
393
|
+
`\`node bin/check-file-budget.mjs --update\` and explain why in the PR body.`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (violations.length > 0) {
|
|
399
|
+
reporter.finish();
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
reporter.ok(
|
|
404
|
+
`${entries.length} file(s) checked against the size ratchet -- none exceed their limit.`,
|
|
405
|
+
);
|
|
406
|
+
reporter.finish();
|
|
407
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "harness-extras",
|
|
4
|
+
"description": "Compaction handoff hooks, a read-only Bash guard, a type-design analyzer agent, and a file-budget gate -- cut from the baseline purely to fit its hard caps, not because they failed the generalization test.",
|
|
5
|
+
"modes": ["fresh", "adopt"],
|
|
6
|
+
"budget": {
|
|
7
|
+
"agents": 1,
|
|
8
|
+
"skills": 0,
|
|
9
|
+
"hooks": 3,
|
|
10
|
+
"workflows": 0,
|
|
11
|
+
"scripts": 0
|
|
12
|
+
},
|
|
13
|
+
"requires": {
|
|
14
|
+
"paths": ["bin/lib/agent-roster.mjs", "bin/lib/report.mjs"]
|
|
15
|
+
},
|
|
16
|
+
"wiring": {
|
|
17
|
+
"settings": {
|
|
18
|
+
"PreCompact": [
|
|
19
|
+
{
|
|
20
|
+
"hooks": [
|
|
21
|
+
{
|
|
22
|
+
"type": "command",
|
|
23
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/write-compact-handoff.mjs\"",
|
|
24
|
+
"timeout": 30
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"SessionStart": [
|
|
30
|
+
{
|
|
31
|
+
"matcher": "compact|resume|startup",
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/reinject-compact-handoff.mjs\"",
|
|
36
|
+
"timeout": 30
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"PreToolUse": [
|
|
42
|
+
{
|
|
43
|
+
"matcher": "Bash",
|
|
44
|
+
"hooks": [
|
|
45
|
+
{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-readonly-bash.mjs\"",
|
|
48
|
+
"timeout": 30
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"packageScripts": {},
|
|
55
|
+
"verifySteps": [
|
|
56
|
+
{
|
|
57
|
+
"id": "file-budget",
|
|
58
|
+
"group": "build",
|
|
59
|
+
"name": "Check file budget",
|
|
60
|
+
"cmd": ["node", "bin/check-file-budget.mjs"]
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"adoptNotes": "check-file-budget.mjs's ROOTS default assumes a flat src/+tests/ layout and its ceilings are this baseline's defaults -- in an adopted project, re-point ROOTS at the project's real source layout (or drop the gate) before wiring it. The hooks and the agent have no such dependency and install anywhere a .claude/ directory exists. The verify step assumes a bin/verify.mjs-shaped gate runner; if the project has none, install the other artifacts and report the gate as not installable rather than inventing one."
|
|
65
|
+
}
|