@sabaiway/agent-workflow-kit 5.5.0 → 5.7.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/CHANGELOG.md +122 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +7 -1
- package/references/modes/doc-parity.md +1 -1
- package/references/modes/gates.md +20 -4
- package/references/modes/procedures.md +2 -0
- package/references/modes/recommendations.md +4 -1
- package/references/modes/review-state.md +1 -1
- package/references/modes/setup.md +18 -2
- package/references/modes/upgrade.md +38 -18
- package/references/modes/velocity.md +1 -0
- package/references/scripts/migrate-gates-branches.test.mjs +146 -1
- package/references/scripts/migrate-gates.mjs +295 -60
- package/references/scripts/migrate-gates.test.mjs +206 -14
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/gates.json +1 -1
- package/tools/ack-write.mjs +20 -11
- package/tools/atomic-write.mjs +71 -18
- package/tools/checker-claim.mjs +100 -0
- package/tools/coverage-producer.mjs +43 -6
- package/tools/direct-run.mjs +76 -0
- package/tools/doc-parity.mjs +34 -3
- package/tools/engine-source.mjs +12 -8
- package/tools/ensure-configs.mjs +141 -0
- package/tools/ensure-ops.mjs +284 -0
- package/tools/ensure-vocabulary.mjs +71 -0
- package/tools/flow-check-cores.mjs +253 -0
- package/tools/flow-check-git-lane.mjs +56 -0
- package/tools/flow-check-rungs.mjs +330 -0
- package/tools/flow-check.mjs +23 -611
- package/tools/gates-declaration.mjs +36 -11
- package/tools/gates-init.mjs +140 -25
- package/tools/hide-footprint.mjs +21 -3
- package/tools/lens-region.mjs +74 -23
- package/tools/orchestration-config.mjs +5 -3
- package/tools/orchestration-write.mjs +7 -0
- package/tools/procedures.mjs +64 -5
- package/tools/recommendations.mjs +384 -34
- package/tools/refresh-parity.mjs +263 -0
- package/tools/run-gates.mjs +8 -5
- package/tools/setup-backends.mjs +88 -77
- package/tools/source-size-check.mjs +310 -0
- package/tools/source-size-config.mjs +244 -0
- package/tools/source-size-core.mjs +59 -0
- package/tools/source-size-gate-cmd.mjs +27 -0
- package/tools/source-size-judge.mjs +114 -0
- package/tools/source-size-refusal.mjs +70 -0
- package/tools/source-size-report.mjs +254 -0
- package/tools/source-size-scope.mjs +145 -0
- package/tools/tracked-tree-census.mjs +102 -0
- package/tools/upgrade-runlist.mjs +92 -0
- package/tools/velocity-profile.mjs +24 -3
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// source-size-check.mjs — the source-size practice's CLI and its writer half. The pure read core
|
|
3
|
+
// (config, scope, counting, the canonical gate-cmd matcher) lives in source-size-core.mjs, the
|
|
4
|
+
// verdict in source-size-judge.mjs and its wording in source-size-report.mjs; all three are imported
|
|
5
|
+
// here. NOTHING in the read graph imports THIS module, so the advisor surfaces can ask about the
|
|
6
|
+
// practice without ever reaching a writer (D-18).
|
|
7
|
+
//
|
|
8
|
+
// What --check judges: every in-scope file against the config's `defaults`, and every RECORDED size
|
|
9
|
+
// against the ratchet instead — a record may not grow, may not sit above what the tree measures, and
|
|
10
|
+
// may not outlive its file. Each declared root carries the same ratchet over its summed lines, so
|
|
11
|
+
// splitting one big file into six buys no headroom.
|
|
12
|
+
//
|
|
13
|
+
// What --write-baseline does: regenerates the machine keys from the tree, and ONLY the machine keys.
|
|
14
|
+
// The authored half is copied through with its VALUES and their ORDER preserved exactly; the file
|
|
15
|
+
// itself is canonically serialized, so its formatting becomes the writer's (that is what makes a
|
|
16
|
+
// regeneration of an unchanged tree reproduce the same bytes). A regeneration that RAISES any value takes
|
|
17
|
+
// --reason "<text>" — the checker cannot invent the human's reason — and that string lands verbatim
|
|
18
|
+
// in the entry it raises. A pure tighten needs none: shrinking is progress. The printed old→new
|
|
19
|
+
// delta is the durable record where docs/ai is git-hidden; it is what the commit message carries.
|
|
20
|
+
//
|
|
21
|
+
// What --adopt does: mints the record and declares the gate — the whole adoption as ONE consented
|
|
22
|
+
// line, because the alternative is a two-step ceremony whose halves can be left half-done. It
|
|
23
|
+
// composes the two writers it already has (this module's mint, the fill's consented apply restricted
|
|
24
|
+
// to this one id) and owns no write of its own.
|
|
25
|
+
//
|
|
26
|
+
// Exit codes: 0 green / 1 violation or refusal / 2 usage, config or enumeration error.
|
|
27
|
+
// Dependency-free, Node >= 22. No side effects on import.
|
|
28
|
+
|
|
29
|
+
import { resolve } from 'node:path';
|
|
30
|
+
import { assertDocsAiDeployment, writeDocsAiFileAtomic } from './atomic-write.mjs';
|
|
31
|
+
import { isDirectRun, sameFile } from './direct-run.mjs';
|
|
32
|
+
import {
|
|
33
|
+
AUTHORED_KEYS,
|
|
34
|
+
SOURCE_SIZE_CONFIG_REL,
|
|
35
|
+
SOURCE_SIZE_GATE_ID,
|
|
36
|
+
loadSourceSizeConfig,
|
|
37
|
+
matchesSourceSizeGate,
|
|
38
|
+
reasonDefect,
|
|
39
|
+
scopeFail,
|
|
40
|
+
} from './source-size-core.mjs';
|
|
41
|
+
import { changesFor, isRaise, judgeTree, ownEntry } from './source-size-judge.mjs';
|
|
42
|
+
import { GATES_REL, loadDeclaration } from './gates-declaration.mjs';
|
|
43
|
+
import { applyFill } from './gates-init.mjs';
|
|
44
|
+
import {
|
|
45
|
+
absentRefusalLines,
|
|
46
|
+
adoptAbsentRefusalLines,
|
|
47
|
+
checkReportLines,
|
|
48
|
+
gateAlreadyDeclaredLines,
|
|
49
|
+
gateDeclaredLines,
|
|
50
|
+
gateRefusedLines,
|
|
51
|
+
reasonRequiredLines,
|
|
52
|
+
recordNoLongerHoldsLines,
|
|
53
|
+
recordRecognizedLines,
|
|
54
|
+
unmintedRefusalLines,
|
|
55
|
+
writtenLines,
|
|
56
|
+
} from './source-size-report.mjs';
|
|
57
|
+
|
|
58
|
+
const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
|
|
59
|
+
const RECORD_NOUN = 'the source-size record';
|
|
60
|
+
|
|
61
|
+
export const runCheck = ({ cwd, deps = {} }) => {
|
|
62
|
+
const { state, config, missingMachineKeys } = loadSourceSizeConfig(cwd, deps);
|
|
63
|
+
if (state === 'absent') return { code: 1, lines: absentRefusalLines(cwd) };
|
|
64
|
+
if (state !== 'minted') return { code: 1, lines: unmintedRefusalLines(cwd, { state, missing: missingMachineKeys }) };
|
|
65
|
+
const verdict = judgeTree(cwd, config, deps);
|
|
66
|
+
return { code: verdict.findings.length === 0 ? 0 : 1, lines: checkReportLines({ cwd, config, verdict }) };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── the writer ────────────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
// planRecord computes what the regeneration WOULD write, before any reason is applied — so a raise
|
|
72
|
+
// can be refused without ever building an entry that carries no reason. It reads the JUDGE's
|
|
73
|
+
// projection, never its own: the checker's verdict and the file the writer produces are then two
|
|
74
|
+
// renderings of one computation, and neither can promise what the other would not do.
|
|
75
|
+
const planRecord = ({ config, verdict }) => {
|
|
76
|
+
const oldBaseline = config.baseline ?? {};
|
|
77
|
+
const oldAggregate = config.aggregate ?? {};
|
|
78
|
+
const files = verdict.scope.files.map((rel) => {
|
|
79
|
+
const next = verdict.projected.get(rel);
|
|
80
|
+
const recorded = ownEntry(oldBaseline, rel);
|
|
81
|
+
return { rel, next, recorded, changes: changesFor(rel, next, recorded) };
|
|
82
|
+
});
|
|
83
|
+
const roots = config.roots.map((root) => ({
|
|
84
|
+
root,
|
|
85
|
+
lines: verdict.rootLines.get(root) ?? 0,
|
|
86
|
+
recorded: ownEntry(oldAggregate, root),
|
|
87
|
+
}));
|
|
88
|
+
const deltas = [
|
|
89
|
+
...files.flatMap(({ changes }) => changes),
|
|
90
|
+
// A record whose file left scope is REMOVED, never kept: that is what makes a split or a rename
|
|
91
|
+
// visible in the delta instead of silently surviving as headroom.
|
|
92
|
+
...Object.entries(oldBaseline)
|
|
93
|
+
.filter(([rel]) => !verdict.measured.has(rel))
|
|
94
|
+
.flatMap(([rel, recorded]) => changesFor(rel, {}, recorded)),
|
|
95
|
+
...roots
|
|
96
|
+
.filter(({ lines, recorded }) => (recorded ? recorded.lines : null) !== lines)
|
|
97
|
+
.map(({ root, lines, recorded }) => ({ target: root, dimension: 'aggregate lines', from: recorded ? recorded.lines : null, to: lines })),
|
|
98
|
+
...Object.entries(oldAggregate)
|
|
99
|
+
.filter(([root]) => !config.roots.includes(root))
|
|
100
|
+
.map(([root, recorded]) => ({ target: root, dimension: 'aggregate lines', from: recorded.lines, to: null })),
|
|
101
|
+
];
|
|
102
|
+
return { files, roots, deltas, raises: deltas.filter(isRaise) };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// The reason of an entry the regeneration did NOT raise is the one already recorded — a tighten
|
|
106
|
+
// rewrites a number, never the human sentence that justified it.
|
|
107
|
+
const materialize = ({ files, roots }, reason) => ({
|
|
108
|
+
baseline: Object.fromEntries(
|
|
109
|
+
files
|
|
110
|
+
.filter(({ next }) => Object.keys(next).length > 0)
|
|
111
|
+
.map(({ rel, next, recorded, changes }) => [rel, {
|
|
112
|
+
...next,
|
|
113
|
+
reason: changes.some(isRaise) ? reason : recorded.reason,
|
|
114
|
+
}]),
|
|
115
|
+
),
|
|
116
|
+
aggregate: Object.fromEntries(
|
|
117
|
+
roots.map(({ root, lines, recorded }) => [root, {
|
|
118
|
+
lines,
|
|
119
|
+
reason: !recorded || lines > recorded.lines ? reason : recorded.reason,
|
|
120
|
+
}]),
|
|
121
|
+
),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// The file is CANONICALLY serialized: the authored VALUES and the ORDER the human wrote them in are
|
|
125
|
+
// preserved exactly, their formatting is not — this file is machine-maintained and the writer owns
|
|
126
|
+
// half of it, so one deterministic rendering is what makes "regenerate an unchanged tree and get the
|
|
127
|
+
// same bytes" true at all.
|
|
128
|
+
const serialize = (parsed, machineKeys) => {
|
|
129
|
+
const authored = Object.fromEntries(Object.keys(parsed).filter((key) => AUTHORED_KEYS.includes(key)).map((key) => [key, parsed[key]]));
|
|
130
|
+
return `${JSON.stringify({ ...authored, ...machineKeys }, null, 2)}\n`;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const runWriteBaseline = ({ cwd, reason, deps = {} }) => {
|
|
134
|
+
// Checked BEFORE the config is read, so a project that was never deployed hears about the
|
|
135
|
+
// deployment rather than about a file it has no place to put.
|
|
136
|
+
assertDocsAiDeployment(cwd, deps, { stop: scopeFail, rel: SOURCE_SIZE_CONFIG_REL, noun: RECORD_NOUN });
|
|
137
|
+
const { state, config, parsed, text } = loadSourceSizeConfig(cwd, deps);
|
|
138
|
+
if (state === 'absent') return { code: 1, lines: absentRefusalLines(cwd) };
|
|
139
|
+
const verdict = judgeTree(cwd, config, deps);
|
|
140
|
+
const plan = planRecord({ config, verdict });
|
|
141
|
+
if (plan.raises.length > 0 && reason === undefined) return { code: 1, lines: reasonRequiredLines(cwd, plan.deltas) };
|
|
142
|
+
const body = serialize(parsed, materialize(plan, reason));
|
|
143
|
+
// Whether anything was written is decided by the BYTES, not by the delta count: completing a
|
|
144
|
+
// hand-edited half record changes the file while raising nothing at all.
|
|
145
|
+
const changed = body !== text;
|
|
146
|
+
if (changed) writeDocsAiFileAtomic(cwd, SOURCE_SIZE_CONFIG_REL, body, deps, { stop: scopeFail, noun: RECORD_NOUN });
|
|
147
|
+
return { code: 0, lines: writtenLines({ cwd, deltas: plan.deltas, reason: plan.raises.length > 0 ? reason : undefined, changed }) };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// ── the adoption verb (D-16) ──────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
// Declaring the gate is delegated to the FILL's own consented apply, restricted to this one id: the
|
|
153
|
+
// fill owns every rule about what a written declaration may look like (placement, id collisions, the
|
|
154
|
+
// coverage invariant, the atomic write), and a second writer here would be a second set of those
|
|
155
|
+
// rules — the one that drifts. `--adopt` is therefore a composition, never a re-implementation.
|
|
156
|
+
//
|
|
157
|
+
// The already-declared arm is checked FIRST and through the practice's own matcher: an earlier
|
|
158
|
+
// partial run leaves a minted record and a declared gate, and re-running must converge rather than
|
|
159
|
+
// collide. A gate that merely CARRIES the id without being this checker does not count — it reaches
|
|
160
|
+
// the fill and collides there, loudly, which is the honest answer to a squatter.
|
|
161
|
+
// The READ is inside the try with the write, deliberately: by the time this runs the record is
|
|
162
|
+
// already minted, so ANY failure here — a malformed declaration the reader throws on, just as much
|
|
163
|
+
// as a collision the fill refuses — must still report both halves. Letting the read escape would
|
|
164
|
+
// surface a bare error carrying neither the mint that succeeded nor the exit contract this tool
|
|
165
|
+
// documents.
|
|
166
|
+
const declareGate = (cwd, deps) => {
|
|
167
|
+
try {
|
|
168
|
+
applyFill({ cwd, onlyIds: [SOURCE_SIZE_GATE_ID] }, deps);
|
|
169
|
+
return { code: 0, lines: gateDeclaredLines(GATES_REL, SOURCE_SIZE_GATE_ID) };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { code: 1, lines: gateRefusedLines(GATES_REL, err?.message ?? String(err)) };
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Is the canonical gate ALREADY there? Asked through the practice's own matcher, so a gate that
|
|
176
|
+
// merely carries the id — running something else entirely — never reads as adopted; it reaches the
|
|
177
|
+
// fill and collides there, loudly, which is the honest answer to a squatter.
|
|
178
|
+
//
|
|
179
|
+
// An unreadable declaration is NOT a verdict here. This read is a shortcut, and aborting on it would
|
|
180
|
+
// report an outcome before the record was settled; the fill re-reads the same file and refuses
|
|
181
|
+
// authoritatively AFTER, so the partial report names both halves truthfully.
|
|
182
|
+
const gateIsDeclared = (cwd, deps) => {
|
|
183
|
+
try {
|
|
184
|
+
const declaration = loadDeclaration(cwd, deps);
|
|
185
|
+
return (declaration.outcome === 'loaded' ? declaration.gates : []).some((gate) => matchesSourceSizeGate(gate.cmd, cwd));
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// A MINTED record is RECOGNIZED, never regenerated. Adoption carries a PINNED reason — the advisor
|
|
192
|
+
// renders it as a fixed string, and its item keeps firing while the gate is undeclared — so a
|
|
193
|
+
// re-run after a partial adoption would let that one sentence raise whatever the tree grew in the
|
|
194
|
+
// meantime. That is exactly the laundering the reason requirement exists to prevent, so the verb
|
|
195
|
+
// asks the checker instead: a record that no longer holds is a ratchet question with its own
|
|
196
|
+
// reasoned lane, and it must be answered BEFORE a gate is declared over it — declaring one that is
|
|
197
|
+
// certain to red the matrix is what the offer rules refuse everywhere else.
|
|
198
|
+
const recognizeRecord = ({ cwd, deps }) => {
|
|
199
|
+
const verdict = runCheck({ cwd, deps });
|
|
200
|
+
if (verdict.code !== 0) return { code: verdict.code, lines: [...verdict.lines, ...recordNoLongerHoldsLines(GATES_REL)] };
|
|
201
|
+
return { code: 0, lines: recordRecognizedLines(cwd) };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// --adopt = settle the record, then declare the gate. The order is not a preference: the fill offers
|
|
205
|
+
// the gate ONLY over a minted config (declaring it earlier would declare a gate that refuses), so
|
|
206
|
+
// the record is what makes the declaration offerable at all.
|
|
207
|
+
export const runAdopt = ({ cwd, reason, deps = {} }) => {
|
|
208
|
+
const { state } = loadSourceSizeConfig(cwd, deps);
|
|
209
|
+
if (state === 'absent') return { code: 1, lines: adoptAbsentRefusalLines(cwd) };
|
|
210
|
+
// ADOPTED is asked FIRST, and it is a question about the GATE alone. Idempotence cannot be made
|
|
211
|
+
// conditional on the record still holding: a declared gate reports its own staleness on every run,
|
|
212
|
+
// with the reasoned lane, and stopping here would tell a reader the gate was not declared while it
|
|
213
|
+
// plainly is — which is exactly what re-running the advisor's one-liner on a drifted tree does.
|
|
214
|
+
// Once the gate is there and the record is minted, nothing is left to adopt.
|
|
215
|
+
const declared = gateIsDeclared(cwd, deps);
|
|
216
|
+
if (declared && state === 'minted') return { code: 0, lines: gateAlreadyDeclaredLines(GATES_REL) };
|
|
217
|
+
// Either half carries its OWN self-servable refusals (a raise with no reason, a project with no
|
|
218
|
+
// docs/ai, a record the tree outgrew). They are returned unchanged: re-wording them here would be
|
|
219
|
+
// the second practice this module exists to avoid, and each already names the step that clears it.
|
|
220
|
+
const record = state === 'minted' ? recognizeRecord({ cwd, deps }) : runWriteBaseline({ cwd, reason, deps });
|
|
221
|
+
if (record.code !== 0) return record;
|
|
222
|
+
if (declared) return { code: 0, lines: [...record.lines, ...gateAlreadyDeclaredLines(GATES_REL)] };
|
|
223
|
+
const gate = declareGate(cwd, deps);
|
|
224
|
+
return { code: gate.code, lines: [...record.lines, ...gate.lines] };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
const MODES = Object.freeze(['--check', '--write-baseline', '--adopt']);
|
|
230
|
+
|
|
231
|
+
const HELP = `source-size-check — the declared source-size practice (agent-workflow family).
|
|
232
|
+
|
|
233
|
+
Usage:
|
|
234
|
+
node source-size-check.mjs --check [--cwd <project-root>]
|
|
235
|
+
node source-size-check.mjs --write-baseline [--reason "<text>"] [--cwd <project-root>]
|
|
236
|
+
node source-size-check.mjs --adopt [--reason "<text>"] [--cwd <project-root>]
|
|
237
|
+
|
|
238
|
+
Judges every in-scope file against ${SOURCE_SIZE_CONFIG_REL}: git-tracked files under a declared
|
|
239
|
+
root carrying a declared extension, minus the excluded path-segment prefixes. Scope is DECLARED,
|
|
240
|
+
never guessed — with the config absent the check REFUSES and prints the exact file to author.
|
|
241
|
+
Symlinks and submodule gitlinks are skipped by kind; an unmerged index, a non-UTF-8 in-scope path,
|
|
242
|
+
an unverifiable in-scope file and an empty declared scope are refusals, never silent greens.
|
|
243
|
+
|
|
244
|
+
Counting: lines, and the longest line in BYTES. A terminator never counts (the CR of a CRLF
|
|
245
|
+
included); a last line with no final newline still counts; an empty file is 0 lines.
|
|
246
|
+
|
|
247
|
+
A file carrying a recorded baseline entry is recorded DEBT: it is judged against the record, which
|
|
248
|
+
may not grow, may not sit above the measured size, and may not outlive its file. Each declared root
|
|
249
|
+
carries the same ratchet over its summed lines.
|
|
250
|
+
|
|
251
|
+
--write-baseline regenerates the machine keys (baseline, aggregate) from the tree. The authored keys
|
|
252
|
+
keep their values and their order; the file is canonically serialized, so its formatting is the
|
|
253
|
+
writer's. A regeneration that RAISES any recorded value needs --reason; the string is recorded in
|
|
254
|
+
the entry it raised. A pure tighten needs none.
|
|
255
|
+
|
|
256
|
+
--adopt is the ONE-line adoption: it mints the record and declares the source-size gate (and NOTHING
|
|
257
|
+
else) in docs/ai/gates.json. It is idempotent on an already-adopted project. With the config absent
|
|
258
|
+
it refuses with the exact file to author — that authoring is the practice's single manual step, and
|
|
259
|
+
the refusal says so. A refused declaration exits nonzero and reports both halves: what was minted and
|
|
260
|
+
what was not declared.
|
|
261
|
+
|
|
262
|
+
Exit codes: 0 green; 1 violation or refusal; 2 usage, config or enumeration error.`;
|
|
263
|
+
|
|
264
|
+
const takeOption = (argv, flag) => {
|
|
265
|
+
const at = argv.indexOf(flag);
|
|
266
|
+
if (at === -1) return { rest: argv, value: undefined };
|
|
267
|
+
if (argv[at + 1] === undefined) throw usageFail(`${flag} needs a value`);
|
|
268
|
+
return { rest: [...argv.slice(0, at), ...argv.slice(at + 2)], value: argv[at + 1] };
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const main = (argv, ctx = {}) => {
|
|
272
|
+
try {
|
|
273
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
274
|
+
const cwdOption = takeOption(argv, '--cwd');
|
|
275
|
+
const reasonOption = takeOption(cwdOption.rest, '--reason');
|
|
276
|
+
// Resolved BEFORE the run and before anything is rendered: every path this run names is then
|
|
277
|
+
// meaningful from any directory, not only from the one that invoked it.
|
|
278
|
+
const cwd = resolve(ctx.cwd ?? process.cwd(), cwdOption.value ?? '.');
|
|
279
|
+
// Counted over the ARGUMENTS, not over the mode list: filtering the list collapses repeats, so
|
|
280
|
+
// `--adopt --adopt` read as exactly one mode and a WRITE ran under an argument list this very
|
|
281
|
+
// guard had just called invalid.
|
|
282
|
+
const modes = reasonOption.rest.filter((arg) => MODES.includes(arg));
|
|
283
|
+
if (modes.length === 0) throw usageFail(`nothing to do — pass one of ${MODES.join(', ')} (see --help)`);
|
|
284
|
+
if (modes.length > 1) throw usageFail(`pass exactly ONE mode, got: ${modes.join(', ')}`);
|
|
285
|
+
const unknown = reasonOption.rest.filter((arg) => !MODES.includes(arg));
|
|
286
|
+
if (unknown.length > 0) throw usageFail(`unknown argument: ${unknown[0]}`);
|
|
287
|
+
if (reasonOption.value !== undefined) {
|
|
288
|
+
if (modes[0] === '--check') throw usageFail('--reason belongs to --write-baseline and --adopt — a check records nothing');
|
|
289
|
+
const defect = reasonDefect(reasonOption.value);
|
|
290
|
+
if (defect) throw usageFail(defect);
|
|
291
|
+
}
|
|
292
|
+
const deps = ctx.deps ?? {};
|
|
293
|
+
const run = { '--check': runCheck, '--write-baseline': runWriteBaseline, '--adopt': runAdopt }[modes[0]];
|
|
294
|
+
const { code, lines } = run({ cwd, reason: reasonOption.value, deps });
|
|
295
|
+
return { code, stdout: lines.join('\n'), stderr: '' };
|
|
296
|
+
} catch (err) {
|
|
297
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `source-size-check: ${err.message}` };
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// The realpath-compare direct-run predicate now lives in tools/direct-run.mjs (the fix this file
|
|
302
|
+
// carried first, extracted so every module shares one implementation); `sameFile` stays exported here
|
|
303
|
+
// because this module's tests bind it as a seam.
|
|
304
|
+
export { sameFile };
|
|
305
|
+
if (isDirectRun(import.meta.url)) {
|
|
306
|
+
const result = main(process.argv.slice(2));
|
|
307
|
+
if (result.stdout) process.stdout.write(result.stdout.endsWith('\n') ? result.stdout : `${result.stdout}\n`);
|
|
308
|
+
if (result.stderr) process.stderr.write(result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`);
|
|
309
|
+
process.exitCode = result.code;
|
|
310
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// source-size-config.mjs — the practice's declaration: what docs/ai/source-size.json may say, which
|
|
2
|
+
// of its four states it is in, and how it is read back. Nothing here touches the tree.
|
|
3
|
+
//
|
|
4
|
+
// • CONFIG STATES — ABSENT (no entry at the path) / AUTHORED (authored keys, machine keys absent) /
|
|
5
|
+
// INCOMPLETE (one machine key without the other — a hand-edited half, which routes to the mint
|
|
6
|
+
// lane) / MINTED (both machine keys). A malformed or unknown-keyed config is a loud STOP
|
|
7
|
+
// (exit 2), never a guess, and the template placeholders are REFUSED until replaced, so a
|
|
8
|
+
// printed authoring template can never be pasted into an empty-green scope.
|
|
9
|
+
// • A RECORDED size is debt, not permission: every entry carries a reason, and the reason lands
|
|
10
|
+
// verbatim in the JSON, the commit message and the release CHANGELOG — so it is validated as the
|
|
11
|
+
// single line those three surfaces can carry.
|
|
12
|
+
//
|
|
13
|
+
// Dependency-free, Node >= 22. No side effects on import.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
16
|
+
import { isAbsolute } from 'node:path';
|
|
17
|
+
import { SOURCE_SIZE_CONFIG_REL, configFail, configPathFor, isLineUnsafe } from './source-size-refusal.mjs';
|
|
18
|
+
|
|
19
|
+
export const SOURCE_SIZE_SCHEMA = 1;
|
|
20
|
+
export const SOURCE_SIZE_DEFAULTS = Object.freeze({ maxLines: 400, maxLineBytes: 1000 });
|
|
21
|
+
export const REASON_MAX_BYTES = 300;
|
|
22
|
+
// The reason a FIRST mint records: every value is new, so every value is a raise, and "this is what
|
|
23
|
+
// the tree already carried when the practice arrived" is the honest sentence for all of them.
|
|
24
|
+
export const INITIAL_ADOPTION_REASON = 'initial adoption';
|
|
25
|
+
|
|
26
|
+
export const AUTHORED_KEYS = Object.freeze(['_README', 'schema', 'defaults', 'roots', 'exclude', 'extensions']);
|
|
27
|
+
export const MACHINE_KEYS = Object.freeze(['baseline', 'aggregate']);
|
|
28
|
+
const ENTRY_KEYS = Object.freeze(['lines', 'maxLineBytes', 'reason']);
|
|
29
|
+
|
|
30
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
31
|
+
// A template placeholder — the authoring refusal prints angle-bracketed values on purpose so the
|
|
32
|
+
// printed file is INERT until a human replaces them; the validator is what makes that promise true.
|
|
33
|
+
const PLACEHOLDER_RE = /^<.*>$/;
|
|
34
|
+
const BAD_SEGMENTS = new Set(['', '.', '..']);
|
|
35
|
+
|
|
36
|
+
const declaredPathDefect = (value, what) => {
|
|
37
|
+
if (typeof value !== 'string' || value.length === 0) return `${what} must be a non-empty string`;
|
|
38
|
+
if (PLACEHOLDER_RE.test(value)) {
|
|
39
|
+
return `${what} still carries the authoring placeholder ${value} — replace it with a real value; the practice never guesses its own scope`;
|
|
40
|
+
}
|
|
41
|
+
if (isAbsolute(value) || value.startsWith('/')) return `${what} "${value}" must be repo-relative, never absolute`;
|
|
42
|
+
if (value.split('/').some((s) => BAD_SEGMENTS.has(s))) return `${what} "${value}" must carry no empty, "." or ".." path segment`;
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const extensionDefect = (value) => {
|
|
47
|
+
if (typeof value !== 'string' || value.length === 0) return 'an "extensions" entry must be a non-empty string';
|
|
48
|
+
if (PLACEHOLDER_RE.test(value)) {
|
|
49
|
+
return `an "extensions" entry still carries the authoring placeholder ${value} — replace it with a real extension; the kit ships NO default file-type list, because a fixed one silently exempts every unlisted language`;
|
|
50
|
+
}
|
|
51
|
+
if (!value.startsWith('.') || value.length < 2 || value.includes('/')) {
|
|
52
|
+
return `an "extensions" entry must look like ".mjs", got ${JSON.stringify(value)}`;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const positiveIntDefect = (value, what) =>
|
|
58
|
+
Number.isSafeInteger(value) && value > 0 ? null : `${what} must be a positive integer, got ${JSON.stringify(value)}`;
|
|
59
|
+
|
|
60
|
+
const nonNegativeIntDefect = (value, what) =>
|
|
61
|
+
Number.isSafeInteger(value) && value >= 0 ? null : `${what} must be a non-negative integer, got ${JSON.stringify(value)}`;
|
|
62
|
+
|
|
63
|
+
// A reason lands VERBATIM in the JSON entry, the commit message and the release CHANGELOG, so it is
|
|
64
|
+
// a single line under a byte cap — an empty, multiline or control-byte reason is refused (D-3a). It
|
|
65
|
+
// is the ONE value no escaper may rescue: the three destinations would each need different bytes, so
|
|
66
|
+
// the line-safety boundary refuses it at the door instead of rendering it safely.
|
|
67
|
+
export const reasonDefect = (reason) => {
|
|
68
|
+
if (typeof reason !== 'string' || reason.length === 0) return 'a reason must be a non-empty string';
|
|
69
|
+
if (isLineUnsafe(reason)) {
|
|
70
|
+
return 'a reason must be ONE line with no control bytes (it is copied verbatim into JSON, the commit message and the CHANGELOG)';
|
|
71
|
+
}
|
|
72
|
+
const bytes = Buffer.byteLength(reason, 'utf8');
|
|
73
|
+
if (bytes > REASON_MAX_BYTES) return `a reason must be at most ${REASON_MAX_BYTES} UTF-8 bytes, got ${bytes}`;
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Whole-segment prefix containment: "a/b" contains "a/b" and "a/b/c", never "a/bc".
|
|
78
|
+
export const segmentPrefixOf = (prefix, path) => path === prefix || path.startsWith(`${prefix}/`);
|
|
79
|
+
|
|
80
|
+
// practiceFacts(config) → the numbers every surface that SPEAKS for the practice states: the caps,
|
|
81
|
+
// how much is declared, and how much is recorded. Derived in ONE place so the plan-time render and
|
|
82
|
+
// the checker's own green line can never state two different counts of the same tree.
|
|
83
|
+
// `recordedFiles` / `aggregateLines` are null while that machine half is absent — "not recorded at
|
|
84
|
+
// all" is a different fact from "recorded as zero", and the two states read differently to a human.
|
|
85
|
+
export const practiceFacts = (config) => ({
|
|
86
|
+
maxLines: config.defaults.maxLines,
|
|
87
|
+
maxLineBytes: config.defaults.maxLineBytes,
|
|
88
|
+
roots: config.roots.length,
|
|
89
|
+
recordedFiles: config.baseline === null ? null : Object.keys(config.baseline).length,
|
|
90
|
+
aggregateLines: config.aggregate === null
|
|
91
|
+
? null
|
|
92
|
+
: Object.values(config.aggregate).reduce((sum, entry) => sum + entry.lines, 0),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// `requiredDimensions: 'any'` — a per-file record may pin EITHER dimension or both, because only the
|
|
96
|
+
// dimension that actually violated should be recorded: an entry pinning a dimension that was never
|
|
97
|
+
// over the cap makes the ratchet refuse later changes nobody chose. `'lines'` — a root budget has
|
|
98
|
+
// exactly one dimension, so it is required there.
|
|
99
|
+
const validateEntryMap = (map, what, requiredDimensions, extra) => {
|
|
100
|
+
if (!isPlainObject(map)) throw configFail(`"${what}" must be an object`);
|
|
101
|
+
for (const [key, entry] of Object.entries(map)) {
|
|
102
|
+
if (!isPlainObject(entry)) throw configFail(`"${what}"."${key}" must be an object`);
|
|
103
|
+
const unknown = Object.keys(entry).filter((k) => !ENTRY_KEYS.includes(k));
|
|
104
|
+
if (unknown.length > 0) throw configFail(`"${what}"."${key}" carries unknown key(s): ${unknown.join(', ')}`);
|
|
105
|
+
for (const dimension of ['lines', 'maxLineBytes']) {
|
|
106
|
+
if (!Object.hasOwn(entry, dimension)) continue;
|
|
107
|
+
const defect = nonNegativeIntDefect(entry[dimension], `"${what}"."${key}".${dimension}`);
|
|
108
|
+
if (defect) throw configFail(defect);
|
|
109
|
+
}
|
|
110
|
+
if (requiredDimensions === 'lines' && !Object.hasOwn(entry, 'lines')) {
|
|
111
|
+
throw configFail(nonNegativeIntDefect(entry.lines, `"${what}"."${key}".lines`));
|
|
112
|
+
}
|
|
113
|
+
if (requiredDimensions === 'any' && !Object.hasOwn(entry, 'lines') && !Object.hasOwn(entry, 'maxLineBytes')) {
|
|
114
|
+
throw configFail(`"${what}"."${key}" must record at least one of "lines" or "maxLineBytes" — an entry that pins no dimension records nothing`);
|
|
115
|
+
}
|
|
116
|
+
const reason = reasonDefect(entry.reason);
|
|
117
|
+
if (reason) throw configFail(`"${what}"."${key}".reason: ${reason}`);
|
|
118
|
+
if (extra) extra(key, entry);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// validateSourceSizeConfig(parsed) → the normalized config. THROWS configFail (exit 2) on anything it
|
|
123
|
+
// cannot judge — an unknown key included, because a typo'd key would otherwise disarm a rule.
|
|
124
|
+
export const validateSourceSizeConfig = (parsed) => {
|
|
125
|
+
if (!isPlainObject(parsed)) throw configFail(`${SOURCE_SIZE_CONFIG_REL} must contain a JSON object`);
|
|
126
|
+
const known = new Set([...AUTHORED_KEYS, ...MACHINE_KEYS]);
|
|
127
|
+
const unknown = Object.keys(parsed).filter((k) => !known.has(k));
|
|
128
|
+
if (unknown.length > 0) {
|
|
129
|
+
throw configFail(`${SOURCE_SIZE_CONFIG_REL} carries unknown key(s): ${unknown.join(', ')} — known keys are ${[...known].join(', ')}`);
|
|
130
|
+
}
|
|
131
|
+
if (parsed.schema !== SOURCE_SIZE_SCHEMA) {
|
|
132
|
+
throw configFail(`"schema" must be ${SOURCE_SIZE_SCHEMA}, got ${JSON.stringify(parsed.schema)}`);
|
|
133
|
+
}
|
|
134
|
+
if (!isPlainObject(parsed.defaults)) throw configFail('"defaults" must be an object carrying maxLines and maxLineBytes');
|
|
135
|
+
const unknownDefaults = Object.keys(parsed.defaults).filter((k) => !['maxLines', 'maxLineBytes'].includes(k));
|
|
136
|
+
if (unknownDefaults.length > 0) throw configFail(`"defaults" carries unknown key(s): ${unknownDefaults.join(', ')}`);
|
|
137
|
+
for (const key of ['maxLines', 'maxLineBytes']) {
|
|
138
|
+
const defect = positiveIntDefect(parsed.defaults[key], `"defaults".${key}`);
|
|
139
|
+
if (defect) throw configFail(defect);
|
|
140
|
+
}
|
|
141
|
+
for (const key of ['roots', 'extensions']) {
|
|
142
|
+
if (!Array.isArray(parsed[key]) || parsed[key].length === 0) {
|
|
143
|
+
throw configFail(`"${key}" must be a non-empty array — scope is DECLARED, never guessed, so an empty "${key}" is a misdeclaration rather than an empty green`);
|
|
144
|
+
}
|
|
145
|
+
for (const value of parsed[key]) {
|
|
146
|
+
const defect = key === 'roots' ? declaredPathDefect(value, 'a "roots" entry') : extensionDefect(value);
|
|
147
|
+
if (defect) throw configFail(defect);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// A root declared twice is not "overlapping itself" by the rule below (the rule compares distinct
|
|
151
|
+
// values), yet it double-counts everywhere a root is ITERATED rather than keyed — the printed
|
|
152
|
+
// delta most visibly, which is the durable record of a regeneration.
|
|
153
|
+
const declaredRoots = new Set();
|
|
154
|
+
for (const root of parsed.roots) {
|
|
155
|
+
if (declaredRoots.has(root)) throw configFail(`"roots" declares "${root}" twice — a duplicated root double-counts its files wherever roots are iterated`);
|
|
156
|
+
declaredRoots.add(root);
|
|
157
|
+
}
|
|
158
|
+
for (const outer of parsed.roots) {
|
|
159
|
+
for (const inner of parsed.roots) {
|
|
160
|
+
if (outer !== inner && segmentPrefixOf(outer, inner)) {
|
|
161
|
+
throw configFail(`"roots" entries overlap: "${inner}" sits inside "${outer}" — an overlapping root double-counts its files in the aggregate`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (Object.hasOwn(parsed, 'exclude')) {
|
|
166
|
+
if (!Array.isArray(parsed.exclude)) throw configFail('"exclude" must be an array of literal path prefixes');
|
|
167
|
+
for (const value of parsed.exclude) {
|
|
168
|
+
const defect = declaredPathDefect(value, 'an "exclude" entry');
|
|
169
|
+
if (defect) throw configFail(defect);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (Object.hasOwn(parsed, 'baseline')) {
|
|
173
|
+
validateEntryMap(parsed.baseline, 'baseline', 'any', (key) => {
|
|
174
|
+
const defect = declaredPathDefect(key, 'a "baseline" key');
|
|
175
|
+
if (defect) throw configFail(defect);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (Object.hasOwn(parsed, 'aggregate')) {
|
|
179
|
+
validateEntryMap(parsed.aggregate, 'aggregate', 'lines', (key, entry) => {
|
|
180
|
+
if (Object.hasOwn(entry, 'maxLineBytes')) {
|
|
181
|
+
throw configFail(`"aggregate"."${key}" carries maxLineBytes — the aggregate budgets LINES only; summing per-file longest-line bytes has no meaning as a budget`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
schema: parsed.schema,
|
|
187
|
+
defaults: { ...parsed.defaults },
|
|
188
|
+
roots: [...parsed.roots],
|
|
189
|
+
exclude: Object.hasOwn(parsed, 'exclude') ? [...parsed.exclude] : [],
|
|
190
|
+
extensions: [...parsed.extensions],
|
|
191
|
+
baseline: Object.hasOwn(parsed, 'baseline') ? { ...parsed.baseline } : null,
|
|
192
|
+
aggregate: Object.hasOwn(parsed, 'aggregate') ? { ...parsed.aggregate } : null,
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// loadSourceSizeConfig(cwd) → { state, path, config, parsed, text, missingMachineKeys }. The states:
|
|
197
|
+
// ABSENT (no file) / AUTHORED (no machine key) / INCOMPLETE (one machine key without the other — a
|
|
198
|
+
// hand-edited half) / MINTED (both). `config` is null only in the ABSENT state; every other state
|
|
199
|
+
// carries a fully validated config, INCOMPLETE included: the key that IS there is judged by the same
|
|
200
|
+
// rules as ever. `parsed` and `text` are the file as written — the writer copies the authored VALUES
|
|
201
|
+
// (and their order) from `parsed`, and compares its own bytes against `text` to know whether it
|
|
202
|
+
// actually changed anything.
|
|
203
|
+
export const loadSourceSizeConfig = (cwd, deps = {}) => {
|
|
204
|
+
const read = deps.readFile ?? readFileSync;
|
|
205
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
206
|
+
const path = configPathFor(cwd);
|
|
207
|
+
// ABSENT means no ENTRY at the path, which only an lstat can answer: reading through a DANGLING
|
|
208
|
+
// SYMLINK fails with the same code as a missing file, and calling that "absent" would tell the
|
|
209
|
+
// reader to author a file the path already holds — through the very link that is broken. The
|
|
210
|
+
// sibling loaders draw the line here for the same reason (orchestration-config.mjs:262-273).
|
|
211
|
+
try {
|
|
212
|
+
lstat(path);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (err && err.code === 'ENOENT') return { state: 'absent', path, config: null };
|
|
215
|
+
throw configFail(`${path} could not be read (${err.message})`);
|
|
216
|
+
}
|
|
217
|
+
let raw;
|
|
218
|
+
try {
|
|
219
|
+
raw = read(path, 'utf8');
|
|
220
|
+
} catch (err) {
|
|
221
|
+
throw configFail(`${path} could not be read (${err.message})`);
|
|
222
|
+
}
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(raw);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
throw configFail(`${path} is not valid JSON (${err.message}) — fix it by hand; a malformed config is a STOP, never a guess`);
|
|
228
|
+
}
|
|
229
|
+
const config = validateSourceSizeConfig(parsed);
|
|
230
|
+
const present = MACHINE_KEYS.filter((key) => Object.hasOwn(parsed, key));
|
|
231
|
+
// MINTED means the WHOLE machine half. A file carrying one machine key without the other is a
|
|
232
|
+
// state no regenerator produces — it was hand-edited into it — so it is INCOMPLETE and routes to
|
|
233
|
+
// the mint lane, which writes both. Refusing it as a config error would deadlock the only
|
|
234
|
+
// self-service lane, because the regenerator reads its config through this very function.
|
|
235
|
+
const state = present.length === MACHINE_KEYS.length ? 'minted' : present.length === 0 ? 'authored' : 'incomplete';
|
|
236
|
+
return {
|
|
237
|
+
state,
|
|
238
|
+
path,
|
|
239
|
+
config,
|
|
240
|
+
parsed,
|
|
241
|
+
text: raw,
|
|
242
|
+
missingMachineKeys: MACHINE_KEYS.filter((key) => !present.includes(key)),
|
|
243
|
+
};
|
|
244
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// source-size-core.mjs — the PURE READ core of the source-size practice (D-18): the ONE import point
|
|
2
|
+
// for every surface that must ask about the practice without reaching a writer. It owns no logic and
|
|
3
|
+
// no write API, and spawns only a read-only git query, so the read-graph purity suite
|
|
4
|
+
// (test/read-graph-purity.test.mjs) stays true however the halves behind it move.
|
|
5
|
+
//
|
|
6
|
+
// The halves, each holding ONE rule set and each within the cap this practice declares:
|
|
7
|
+
// • source-size-refusal.mjs — the two exit classes, and the absolute config path every refusal names
|
|
8
|
+
// • source-size-config.mjs — the config file: its grammar, its four states, its reader
|
|
9
|
+
// • source-size-scope.mjs — which files are judged (D-6) and how big each one is (D-7)
|
|
10
|
+
// • source-size-gate-cmd.mjs — whether a declared gate cmd IS this checker (the canonical matcher),
|
|
11
|
+
// and which of the three tool claims it makes when it is not
|
|
12
|
+
//
|
|
13
|
+
// Re-export only: a consumer imports the practice, never a particular half, so a later split moves
|
|
14
|
+
// code without touching a single call site.
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
SOURCE_SIZE_CONFIG_REL,
|
|
18
|
+
SOURCE_SIZE_STOP,
|
|
19
|
+
SOURCE_SIZE_WHY,
|
|
20
|
+
configFail,
|
|
21
|
+
configPathFor,
|
|
22
|
+
escapeForLine,
|
|
23
|
+
isLineUnsafe,
|
|
24
|
+
jsonForLine,
|
|
25
|
+
scopeFail,
|
|
26
|
+
} from './source-size-refusal.mjs';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
AUTHORED_KEYS,
|
|
30
|
+
INITIAL_ADOPTION_REASON,
|
|
31
|
+
MACHINE_KEYS,
|
|
32
|
+
REASON_MAX_BYTES,
|
|
33
|
+
SOURCE_SIZE_DEFAULTS,
|
|
34
|
+
SOURCE_SIZE_SCHEMA,
|
|
35
|
+
loadSourceSizeConfig,
|
|
36
|
+
practiceFacts,
|
|
37
|
+
reasonDefect,
|
|
38
|
+
segmentPrefixOf,
|
|
39
|
+
validateSourceSizeConfig,
|
|
40
|
+
} from './source-size-config.mjs';
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
countBytes,
|
|
44
|
+
enumerateIndex,
|
|
45
|
+
measureFile,
|
|
46
|
+
resolveScope,
|
|
47
|
+
} from './source-size-scope.mjs';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
SOURCE_SIZE_GATE_ID,
|
|
51
|
+
SOURCE_SIZE_TOOL_PATH,
|
|
52
|
+
classifySourceSizeGate,
|
|
53
|
+
dqUnsafePath,
|
|
54
|
+
matchesSourceSizeGate,
|
|
55
|
+
} from './source-size-gate-cmd.mjs';
|
|
56
|
+
|
|
57
|
+
// The claim vocabulary itself — a consumer naming an outcome imports the name, never a string
|
|
58
|
+
// literal it could misspell into a silently-never-true comparison.
|
|
59
|
+
export { CHECKER_CLAIM } from './checker-claim.mjs';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// source-size-gate-cmd.mjs — whether a declared gate cmd IS this checker, and — when it is not —
|
|
2
|
+
// WHICH of the three claims it makes instead. The shape, admissibility and realpath screens live in
|
|
3
|
+
// checker-claim.mjs (the ONE home, twinned into the standalone migration); this module owns only
|
|
4
|
+
// the binding to THIS tool. Mirrors the SHAPE of the review-dependent matcher
|
|
5
|
+
// (gates-declaration.mjs) without joining either of its arrays: this gate is neither a final core
|
|
6
|
+
// check nor review-dependent.
|
|
7
|
+
//
|
|
8
|
+
// Dependency-free, Node >= 22. No side effects on import.
|
|
9
|
+
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { CHECKER_CLAIM, checkerClaimTool, classifyCheckerClaim, dqUnsafePath } from './checker-claim.mjs';
|
|
12
|
+
|
|
13
|
+
export { dqUnsafePath };
|
|
14
|
+
|
|
15
|
+
export const SOURCE_SIZE_GATE_ID = 'source-size';
|
|
16
|
+
export const SOURCE_SIZE_TOOL_PATH = fileURLToPath(new URL('./source-size-check.mjs', import.meta.url));
|
|
17
|
+
|
|
18
|
+
const SOURCE_SIZE_TOOL = checkerClaimTool('source-size-check.mjs', SOURCE_SIZE_TOOL_PATH);
|
|
19
|
+
|
|
20
|
+
// The three-outcome read: `canonical` (this copy), `tool-elsewhere` (the same invocation shape
|
|
21
|
+
// resolving to another real copy of the checker — a vendored deployment, not an absence), or
|
|
22
|
+
// `not-the-tool`.
|
|
23
|
+
export const classifySourceSizeGate = (cmd, projectDir) => classifyCheckerClaim(SOURCE_SIZE_TOOL, cmd, projectDir);
|
|
24
|
+
|
|
25
|
+
// The boolean surface every existing consumer already asks through — "is this gate MY checker?" —
|
|
26
|
+
// kept exactly as narrow as it was: only the canonical claim answers yes.
|
|
27
|
+
export const matchesSourceSizeGate = (cmd, projectDir) => classifySourceSizeGate(cmd, projectDir) === CHECKER_CLAIM.CANONICAL;
|