@sabaiway/agent-workflow-kit 5.5.0 → 5.6.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.
@@ -0,0 +1,55 @@
1
+ // source-size-gate-cmd.mjs — whether a declared gate cmd IS this checker. Mirrors the SHAPE of the
2
+ // review-dependent matcher (gates-declaration.mjs) without joining either of its arrays: this gate is
3
+ // neither a final core check nor review-dependent.
4
+ //
5
+ // Dependency-free, Node >= 22. No side effects on import.
6
+
7
+ import { realpathSync } from 'node:fs';
8
+ import { isAbsolute, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ // STRICT full command — `node` + ONE (quoted or bare) path token + the exact basename + ` --check` +
12
+ // END — and the token must realpath-resolve to THIS kit's own checker, so an id squatter never
13
+ // matches.
14
+ //
15
+ // Separators are PLAIN SPACES, not \s: a newline between the tokens is not a command a runner would
16
+ // execute as written. The token is screened by the rules of the quoting it actually carries, because
17
+ // the two halves are interpreted differently and a single screen would be wrong for one of them:
18
+ // • QUOTED — double quotes survive most bytes, so only what breaks OUT of them is refused.
19
+ // • BARE — anything the shell may split, expand or glob makes the executed command different
20
+ // from the string, so a bare token is admitted only from a known-safe alphabet.
21
+ // Either way the point is the same: a path that resolves literally here while the shell would read
22
+ // it differently must never be called canonical, or the matcher certifies a command that never runs.
23
+ export const dqUnsafePath = (text) => [...text].some((ch) => {
24
+ const code = ch.codePointAt(0);
25
+ return ch === '"' || ch === '$' || code === 96 || code === 92 || code === 13 || code === 10;
26
+ });
27
+
28
+ // Stated as the bytes the shell ACTS on, not as an alphabet of blessed ones: an allow-list refuses
29
+ // perfectly ordinary paths (`@`, `+`, `,`, `%`, `=`, anything non-ASCII) that the shell passes
30
+ // through verbatim, and refusing a command that really is canonical is its own defect. Whitespace
31
+ // and ASCII control bytes are refused too — a bare token cannot contain them and still be one token.
32
+ const SHELL_ACTIVE_BARE = new Set([...'"\'\\$|&;<>(){}[]*?!#~^`']);
33
+ const bareTokenSafe = (text) => text.length > 0 && ![...text].some((ch) => {
34
+ const code = ch.codePointAt(0);
35
+ return code <= 0x20 || code === 0x7f || SHELL_ACTIVE_BARE.has(ch);
36
+ });
37
+
38
+ const CHECK_CMD_RE = /^node +(?:"((?:[^"]*[/\\])?source-size-check\.mjs)"|((?:[^\s"]*[/\\])?source-size-check\.mjs)) +--check$/;
39
+ export const SOURCE_SIZE_GATE_ID = 'source-size';
40
+ export const SOURCE_SIZE_TOOL_PATH = fileURLToPath(new URL('./source-size-check.mjs', import.meta.url));
41
+
42
+ export const matchesSourceSizeGate = (cmd, projectDir) => {
43
+ if (typeof cmd !== 'string') return false;
44
+ const match = CHECK_CMD_RE.exec(cmd.trim());
45
+ if (!match) return false;
46
+ const token = match[1] ?? match[2];
47
+ const admissible = match[1] !== undefined ? !dqUnsafePath(token) : bareTokenSafe(token);
48
+ if (!admissible) return false;
49
+ const abs = isAbsolute(token) ? token : join(projectDir, token);
50
+ try {
51
+ return realpathSync(abs) === realpathSync(SOURCE_SIZE_TOOL_PATH);
52
+ } catch {
53
+ return false; // unresolvable → never canonical (fail closed)
54
+ }
55
+ };
@@ -0,0 +1,114 @@
1
+ // source-size-judge.mjs — the verdict as FACTS, never as words, and the ONE projection everything
2
+ // else reads. Measures every in-scope file once, projects the record the regenerator WOULD write for
3
+ // it, and states each difference against what is recorded today. The rendering half turns those
4
+ // differences into words; the writer half turns the very same projection into the file — so the
5
+ // checker can never demand something the regenerator would not do, nor pass a tree the regenerator
6
+ // would rewrite.
7
+ //
8
+ // The ratchet, stated once (D-3, D-4): a recorded size is DEBT, not permission. It may not grow
9
+ // (that is a raise, and a raise is reasoned); it may not sit ABOVE what the tree measures (a stale
10
+ // record is headroom nobody earned); it may not outlive the violation it records (a file back under
11
+ // the cap keeps no record); and a record whose file is gone is an error, because that is what makes
12
+ // a split or a rename visible instead of silent. The per-root budget rides the same rules over the
13
+ // summed LINES of the root — splitting one file into six buys no headroom at all.
14
+ //
15
+ // Dependency-free, Node >= 22. No writes, no side effects on import.
16
+
17
+ import { measureFile, resolveScope } from './source-size-scope.mjs';
18
+
19
+ export const DIMENSIONS = Object.freeze(['lines', 'maxLineBytes']);
20
+
21
+ // The measured dimension names ARE the baseline-entry keys; `defaults` spells the line cap
22
+ // differently, so the two vocabularies are bridged in exactly one place.
23
+ export const DEFAULT_KEY = Object.freeze({ lines: 'maxLines', maxLineBytes: 'maxLineBytes' });
24
+
25
+ // THE projection: a record exists for a dimension exactly while that dimension violates the declared
26
+ // default. An entry pinning a dimension that was never over the cap would make the ratchet refuse
27
+ // later changes nobody chose, so the entry appears with the violation and disappears with it.
28
+ export const recordFor = (measured, defaults) => Object.fromEntries(
29
+ DIMENSIONS.filter((dimension) => measured[dimension] > defaults[DEFAULT_KEY[dimension]])
30
+ .map((dimension) => [dimension, measured[dimension]]),
31
+ );
32
+
33
+ // A change is a RAISE when it puts a number where there was none, or a bigger one where there was a
34
+ // smaller — the whole class a human's reason is required for. Everything else lowers or removes.
35
+ export const isRaise = ({ from, to }) => to !== null && (from === null || to > from);
36
+
37
+ // A recorded entry is read by OWN key only. A declared root may legitimately be named like an
38
+ // Object.prototype member ("constructor", "toString"), and a plain lookup would then answer with an
39
+ // INHERITED value — which reads as "already recorded", so the raise goes unnoticed and the entry is
40
+ // written with no reason at all, producing a config this tool's own validator refuses.
41
+ export const ownEntry = (map, key) => (Object.hasOwn(map, key) ? map[key] : undefined);
42
+
43
+ // changesFor(target, projected, recorded) → the per-dimension old→new pairs that actually differ.
44
+ // The refusal, the printed delta and the reason an entry ends up with all read THIS.
45
+ export const changesFor = (target, projected, recorded) => DIMENSIONS
46
+ .map((dimension) => ({
47
+ target,
48
+ dimension,
49
+ from: recorded && Object.hasOwn(recorded, dimension) ? recorded[dimension] : null,
50
+ to: Object.hasOwn(projected, dimension) ? projected[dimension] : null,
51
+ }))
52
+ .filter(({ from, to }) => from !== to);
53
+
54
+ const GROWTH_KINDS = new Set(['over-default', 'grew', 'aggregate-grew', 'aggregate-unrecorded']);
55
+
56
+ // D-9: the unmechanizable question — real decomposition, or the same coupling spread thinner? — is
57
+ // asked exactly where a record went DOWN or disappeared, and the checker itself raises it there.
58
+ const LOWERED_KINDS = new Set(['stale', 'record-obsolete', 'entry-gone']);
59
+
60
+ export const hasGrowth = (findings) => findings.some((f) => GROWTH_KINDS.has(f.kind));
61
+ export const hasLoweredRecord = (findings) => findings.some((f) => LOWERED_KINDS.has(f.kind));
62
+
63
+ const findingOfChange = (rel, { dimension, from, to }, measured, defaults) => {
64
+ const allowed = defaults[DEFAULT_KEY[dimension]];
65
+ if (from === null) return { kind: 'over-default', rel, dimension, actual: to, allowed };
66
+ // `to === null` — the file is back under the cap, so the projection records nothing. The measured
67
+ // value is what the reader needs to see; the record is simply obsolete.
68
+ if (to === null) return { kind: 'record-obsolete', rel, dimension, actual: measured[dimension], allowed, recorded: from };
69
+ return to > from
70
+ ? { kind: 'grew', rel, dimension, actual: to, recorded: from }
71
+ : { kind: 'stale', rel, dimension, actual: to, recorded: from };
72
+ };
73
+
74
+ const judgeAggregate = (rootLines, aggregate) => {
75
+ const findings = [];
76
+ for (const [root, actual] of rootLines) {
77
+ if (!Object.hasOwn(aggregate, root)) {
78
+ findings.push({ kind: 'aggregate-unrecorded', root, actual });
79
+ continue;
80
+ }
81
+ const value = aggregate[root].lines;
82
+ if (actual > value) findings.push({ kind: 'aggregate-grew', root, actual, recorded: value });
83
+ else if (actual < value) findings.push({ kind: 'aggregate-stale', root, actual, recorded: value });
84
+ }
85
+ // The recorded set must MIRROR the declared roots: a budget nobody declares any more is a leftover
86
+ // that hides its own root's disappearance, and deleting an entry must never be a way to disarm it.
87
+ for (const root of Object.keys(aggregate)) {
88
+ if (!rootLines.has(root)) findings.push({ kind: 'aggregate-root-gone', root, recorded: aggregate[root].lines });
89
+ }
90
+ return findings;
91
+ };
92
+
93
+ // judgeTree(cwd, config) → { scope, measured, projected, rootLines, findings }. EVERY in-scope file
94
+ // is measured, recorded or not: the fail-closed scope rule has no baseline exception, so an
95
+ // unreadable or non-UTF-8 file refuses even when its size is recorded debt.
96
+ export const judgeTree = (cwd, config, deps = {}) => {
97
+ const scope = resolveScope(cwd, config, deps);
98
+ const measured = new Map(scope.files.map((rel) => [rel, measureFile(cwd, rel, deps)]));
99
+ const projected = new Map(scope.files.map((rel) => [rel, recordFor(measured.get(rel), config.defaults)]));
100
+ const baseline = config.baseline ?? {};
101
+ const findings = [];
102
+ for (const rel of scope.files) {
103
+ findings.push(...changesFor(rel, projected.get(rel), ownEntry(baseline, rel))
104
+ .map((change) => findingOfChange(rel, change, measured.get(rel), config.defaults)));
105
+ }
106
+ for (const rel of Object.keys(baseline)) {
107
+ if (!measured.has(rel)) findings.push({ kind: 'entry-gone', rel, recorded: baseline[rel] });
108
+ }
109
+ const rootLines = new Map(
110
+ [...scope.perRoot].map(([root, files]) => [root, files.reduce((sum, rel) => sum + measured.get(rel).lines, 0)]),
111
+ );
112
+ findings.push(...judgeAggregate(rootLines, config.aggregate ?? {}));
113
+ return { scope, measured, projected, rootLines, findings };
114
+ };
@@ -0,0 +1,70 @@
1
+ // source-size-refusal.mjs — how the source-size practice STOPS, and what every stop must name. The
2
+ // leaf every other half reads, so a refusal raised deep in the scope walk carries the same contract
3
+ // as one raised by the config reader:
4
+ // • exit 2 — the INPUTS are unusable (usage, a malformed config, a failed enumeration); the
5
+ // practice could not judge anything, and no step it could name would change that.
6
+ // • exit 1 — the practice REFUSED: a violation, a stale record, an unverifiable in-scope source
7
+ // file, or a declared state the reader must move on (an absent or unminted config). Every one of
8
+ // these carries a step the reader can perform.
9
+ // • every refusal names the config of the project it ACTUALLY judged, absolute: under a foreign or
10
+ // relative --cwd a repo-relative name points at whatever directory the reader happens to be in.
11
+ // • the WHY rides the RENDERED refusals only — the ones the report composes (absent / unminted /
12
+ // check-FAIL / reason-required). The thrown exit-1 scope refusals (an unverifiable in-scope
13
+ // source file, a non-UTF-8 path, an unmerged index, an empty declared scope) and the exit-2
14
+ // config, usage and enumeration errors do NOT carry it: a sentence about module size explains
15
+ // nothing about a tree that could not be judged at all. That same sentence is what every other
16
+ // surface speaking for the practice quotes (D-17), so it lives here, in the leaf every half
17
+ // already reads: a practice explained in three slightly different sentences is three practices.
18
+ //
19
+ // Dependency-free, Node >= 22. No side effects on import.
20
+
21
+ import { resolve } from 'node:path';
22
+
23
+ export const SOURCE_SIZE_CONFIG_REL = 'docs/ai/source-size.json';
24
+
25
+ // Quoted VERBATIM by every surface that explains the practice: the plan-time render, the checker's
26
+ // rendered refusals, the constraints row a grounded review payload carries. The checker's GREEN line
27
+ // states the practice's FACTS instead — caps, recorded count, aggregate — and never this sentence.
28
+ export const SOURCE_SIZE_WHY = 'A module you can hold whole is the unit of review, test pairing and safe edit; the caps turn size drift into recorded, reasoned debt instead of invisible growth.';
29
+
30
+ export const configPathFor = (cwd) => resolve(cwd, 'docs', 'ai', 'source-size.json');
31
+
32
+ export const SOURCE_SIZE_STOP = 'SOURCE_SIZE_STOP';
33
+
34
+ // ── the line-safety boundary ───────────────────────────────────────────────────────
35
+ // ONE definition of what may never reach a rendered line — C0, DEL, C1, the two Unicode line
36
+ // separators, and a LONE surrogate — and every consumer DERIVED from it. The set is stated once
37
+ // because the alternative was demonstrated across review rounds: each surface guarded part of it,
38
+ // and each partial guard read as complete until a different character walked through it.
39
+ //
40
+ // The lone surrogate is here for a reason the others are not: it does not break a line, it breaks
41
+ // IDENTITY. Written as UTF-8 it becomes the replacement character, byte for byte identical to a
42
+ // name that really contains one — so two different names would print the same, which is the exact
43
+ // property the escaping exists to prevent. A valid PAIR is an ordinary character and is untouched.
44
+ //
45
+ // Three consumers, three jobs:
46
+ // • escapeForLine — a value going into PROSE. Reversible: the backslash is escaped too, so a real
47
+ // newline and a name that literally spells its escape can never render as the same string.
48
+ // • jsonForLine — a value going into the PASTEABLE suggestion, which must stay valid JSON a
49
+ // human copies back into the config. JSON.stringify already escapes C0, the backslash and lone
50
+ // surrogates, so only DEL/C1/separators survive it; escaping exactly those cannot double-escape
51
+ // anything, and the result still JSON.parses back to the original name.
52
+ // • isLineUnsafe — the predicate, for the two values no escaper may touch: a reason (copied
53
+ // VERBATIM into three different files, so it is refused at the door instead — and a
54
+ // non-well-formed one could not land verbatim anywhere) and a rendered command (escapes would
55
+ // change the path the shell receives, so the command is withheld).
56
+ const LINE_UNSAFE_CLASS = '\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029';
57
+ const LONE_SURROGATE = '[\\ud800-\\udbff](?![\\udc00-\\udfff])|(?<![\\ud800-\\udbff])[\\udc00-\\udfff]';
58
+ const UNSAFE = new RegExp(`[${LINE_UNSAFE_CLASS}]|${LONE_SURROGATE}`, 'g');
59
+ const ESCAPED = new RegExp(`[\\\\${LINE_UNSAFE_CLASS}]|${LONE_SURROGATE}`, 'g');
60
+ const asEscape = (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`;
61
+
62
+ export const isLineUnsafe = (text) => new RegExp(`[${LINE_UNSAFE_CLASS}]|${LONE_SURROGATE}`).test(String(text));
63
+ export const escapeForLine = (text) => String(text).replace(ESCAPED, (ch) => (ch === '\\' ? '\\\\' : asEscape(ch)));
64
+ export const jsonForLine = (value) => JSON.stringify(value).replace(UNSAFE, asEscape);
65
+
66
+ const stopWith = (exitCode) => (message) =>
67
+ Object.assign(new Error(`[agent-workflow-kit] ${escapeForLine(message)}`), { code: SOURCE_SIZE_STOP, exitCode });
68
+
69
+ export const configFail = stopWith(2);
70
+ export const scopeFail = stopWith(1);
@@ -0,0 +1,254 @@
1
+ // source-size-report.mjs — the words a verdict is delivered in. Every refusal this practice prints
2
+ // must name the file, the actual value, the allowed value AND a step the reader can perform against
3
+ // THIS build — never a bare "too big" and never a command that does not exist yet.
4
+ //
5
+ // Two render contracts, honestly separated (D-3a):
6
+ // • TIGHTEN — the tree shrank below what is recorded. Shrinking is progress, so the regenerator is
7
+ // printed EXACTLY as it should be pasted; no reason is needed and none is asked for.
8
+ // • GROWTH — the regeneration would RAISE a recorded value. The checker cannot invent the human's
9
+ // reason, so the command is printed as a TEMPLATE carrying the placeholder, and the requirement
10
+ // is stated rather than implied.
11
+ // The exception both share: on a project path that does not survive double-quoting, NO command is
12
+ // rendered (a rendered one could run somewhere else) — the parameters, the reason requirement and
13
+ // the manual lane are stated instead.
14
+ //
15
+ // The standing echo (D-17 U4) rides the same two renders: a PASS states the practice in one line, so
16
+ // the enforced path itself keeps the caps in front of the reader, and every refusal CLOSES with the
17
+ // canonical WHY sentence — a cap whose reason has to be looked up is a cap that reads as arbitrary.
18
+ //
19
+ // Dependency-free, Node >= 22. No writes, no side effects on import.
20
+
21
+ import { SOURCE_SIZE_WHY, configPathFor, escapeForLine, isLineUnsafe, jsonForLine } from './source-size-refusal.mjs';
22
+ import { INITIAL_ADOPTION_REASON, SOURCE_SIZE_DEFAULTS, SOURCE_SIZE_SCHEMA, practiceFacts } from './source-size-config.mjs';
23
+ import { SOURCE_SIZE_TOOL_PATH, dqUnsafePath } from './source-size-gate-cmd.mjs';
24
+ import { hasGrowth, hasLoweredRecord, isRaise } from './source-size-judge.mjs';
25
+
26
+ // The project directory is project-controlled too, so the path every refusal names enters its line
27
+ // escaped like any other such value. configPathFor itself stays literal — it also builds the real
28
+ // filesystem path the writer opens.
29
+ const namedConfig = (cwd) => escapeForLine(configPathFor(cwd));
30
+
31
+ export const GROWTH_REASON_PLACEHOLDER = '<why this size is accepted>';
32
+
33
+ // The authoring template (D-5): GENERIC, and INERT by construction — its roots/extensions carry
34
+ // placeholders the validator refuses, so it can never be pasted into an empty-green scope.
35
+ export const authoringTemplate = () => JSON.stringify({
36
+ _README: 'Source-size practice: declared scope + thresholds + the recorded ratchet. Authored keys: schema, defaults, roots, exclude, extensions. Machine keys: baseline, aggregate — each recorded entry carries a reason, and a recorded size is debt, not permission. Strict JSON — unknown keys refused.',
37
+ schema: SOURCE_SIZE_SCHEMA,
38
+ defaults: { ...SOURCE_SIZE_DEFAULTS },
39
+ roots: ['<a directory this practice covers>'],
40
+ exclude: [],
41
+ extensions: ['<.an-extension-this-practice-covers>'],
42
+ }, null, 2);
43
+
44
+ // A rendered command carries the RESOLVED tool path and an explicit quoted --cwd, so it means the
45
+ // same thing from any directory. It is WITHHELD on either of two grounds, and the render says WHICH
46
+ // one fired: a reader told their path "does not survive double-quoting" when it quotes perfectly
47
+ // goes off to fix the wrong thing. The grounds are made DISJOINT by testing line-safety FIRST — a
48
+ // newline satisfies both, and a path that cannot be printed at all is not a quoting question. The
49
+ // recovery lane is identical either way; only the diagnosis differs.
50
+ const WITHHELD_SHELL = 'source-size: no paste-ready command is printed — this project\'s path does not survive double-quoting, so a rendered command could run somewhere other than the project it names.';
51
+ const WITHHELD_LINE = 'source-size: no paste-ready command is printed — this project\'s path carries a character that cannot appear in a rendered line, and escaping it would change the path the shell receives.';
52
+ // The withheld fallback is the reader's ONLY remaining instruction, so it has to be the instruction
53
+ // for what they were actually doing. Sending an adoption to the regenerator would leave them with a
54
+ // record and no gate — half an adoption, handed over as the way out — so the lane is per MODE.
55
+ const WITHHELD_LANE = 'Run the regenerator yourself with the working directory set to this project (source-size-check.mjs --write-baseline, plus --reason "<text>" for any raise), or record each size by hand below.';
56
+ const WITHHELD_ADOPT_LANE = `Run source-size-check.mjs --adopt --reason "${INITIAL_ADOPTION_REASON}" yourself, with the working directory set to this project — it mints the record and declares the gate in one step.`;
57
+
58
+ // → { command, withheld }: exactly one of the two is non-null. ONE renderer for every mode this tool
59
+ // prints, so a withhold can never apply to some of its own commands and not others.
60
+ const rendered = (cwd, mode, reason) => {
61
+ const paths = [SOURCE_SIZE_TOOL_PATH, cwd];
62
+ if (paths.some(isLineUnsafe)) return { command: null, withheld: WITHHELD_LINE };
63
+ if (paths.some(dqUnsafePath)) return { command: null, withheld: WITHHELD_SHELL };
64
+ return { command: `node "${SOURCE_SIZE_TOOL_PATH}" ${mode} --cwd "${cwd}"${reason === undefined ? '' : ` --reason "${reason}"`}`, withheld: null };
65
+ };
66
+ const regenerator = (cwd, reason) => rendered(cwd, '--write-baseline', reason);
67
+
68
+ const SPLIT_QUALITY_FOCUS = 'source-size: REVIEW FOCUS — a recorded size went DOWN or disappeared: check that this is real decomposition and not the same coupling spread across more files (paste this line into the review dispatch focus).';
69
+
70
+ // Every refusal THIS module renders closes with the canonical WHY. The two thrown classes do not
71
+ // carry it — the exit-1 scope refusals (an unverifiable in-scope source file, a non-UTF-8 path, an
72
+ // unmerged index, an empty declared scope) and the exit-2 config, usage and enumeration errors: both
73
+ // are about a tree the practice could not judge at all, where a sentence about module size explains
74
+ // nothing.
75
+ const WHY_LINE = `source-size: WHY — ${SOURCE_SIZE_WHY}`;
76
+ const refusal = (lines) => [...lines, WHY_LINE];
77
+
78
+ // The standing summary (D-17 U4) — one line, from the CONFIG alone. There is no headroom to report:
79
+ // the ratchet refuses actual > recorded and actual < recorded alike, so a recorded aggregate is EXACT
80
+ // by construction, and saying that is the honest form of "how much room is left". Reached only on a
81
+ // MINTED config (a check refuses every other state before it renders anything).
82
+ const practiceLine = (config) => {
83
+ const facts = practiceFacts(config);
84
+ return `source-size: practice — caps ${facts.maxLines} lines · ${facts.maxLineBytes} bytes per line over ${facts.roots} declared root(s) · ${facts.recordedFiles} file(s) carry a recorded size (debt, not permission) · aggregate ${facts.aggregateLines} line(s), EXACT: growth takes a reasoned bump, never free headroom.`;
85
+ };
86
+
87
+ export const absentRefusalLines = (cwd) => refusal([
88
+ `source-size: REFUSED — ${namedConfig(cwd)} is absent, so the scope of this practice is undeclared.`,
89
+ 'Scope is DECLARED, never guessed: the kit ships no default root list and no default file-type list, because a fixed one would silently exempt every unlisted language. Authoring this file is the ONE manual step of the practice.',
90
+ 'Create it with this content, replacing every placeholder value:',
91
+ authoringTemplate(),
92
+ ]);
93
+
94
+ // Both not-yet-MINTED states route to the same lane — the regenerator writes the machine half — so
95
+ // they differ only in what they say happened: AUTHORED is the state a human creates, INCOMPLETE is a
96
+ // machine half no regenerator produces, which means the file was hand-edited into it.
97
+ export const unmintedRefusalLines = (cwd, { state, missing }) => {
98
+ const { command, withheld } = regenerator(cwd, INITIAL_ADOPTION_REASON);
99
+ return refusal([
100
+ state === 'incomplete'
101
+ ? `source-size: REFUSED — ${namedConfig(cwd)} is INCOMPLETE: it carries a machine half no regenerator produces (missing ${missing.map((key) => `"${key}"`).join(', ')}), so the ratchet holds only part of this tree.`
102
+ : `source-size: REFUSED — ${namedConfig(cwd)} is AUTHORED but not yet MINTED (it records no size yet), so there is nothing for the ratchet to hold.`,
103
+ 'Mint it — the regenerator records what this tree already carries; recording a value for the first time is a raise, so it takes a reason:',
104
+ command === null ? `${withheld} Run source-size-check.mjs --write-baseline --reason "${INITIAL_ADOPTION_REASON}" with the working directory set to this project.` : ` ${command}`,
105
+ ]);
106
+ };
107
+
108
+ // Every finding names a path or a root the PROJECT chose, and each one crosses the line-safety
109
+ // boundary ONCE — in lineSafeFinding, before any branch sees it. Nine branches each remembering to
110
+ // escape would be nine chances to forget, and the tenth branch nobody has written yet would start
111
+ // out forgetting; here a branch cannot render an unsafe value even if it tries. The PASTEABLE
112
+ // suggestion goes through the boundary's JSON consumer instead — same set, different serialization,
113
+ // because those are bytes a human copies back into the config.
114
+ const lineSafeFinding = (finding) => ({
115
+ ...finding,
116
+ ...(finding.rel === undefined ? {} : { rel: escapeForLine(finding.rel) }),
117
+ ...(finding.root === undefined ? {} : { root: escapeForLine(finding.root) }),
118
+ });
119
+
120
+ const FINDING_LINE = Object.freeze({
121
+ 'over-default': (f) => `${f.rel}: ${f.dimension} ${f.actual} exceeds the declared default ${f.allowed}`,
122
+ grew: (f) => `${f.rel}: ${f.dimension} ${f.actual} exceeds its recorded baseline ${f.recorded} — recorded debt is not permission to grow`,
123
+ stale: (f) => `${f.rel}: ${f.dimension} ${f.actual} is under the recorded ${f.recorded} — the baseline is STALE, tighten it`,
124
+ 'record-obsolete': (f) => `${f.rel}: ${f.dimension} ${f.actual} no longer exceeds the declared default ${f.allowed} — the record must go`,
125
+ 'entry-gone': (f) => `${f.rel}: recorded in "baseline" but no longer in scope — split, renamed or deleted; the record must go`,
126
+ 'aggregate-grew': (f) => `${f.root}: aggregate lines ${f.actual} exceeds the recorded budget ${f.recorded} — splitting a file buys no aggregate headroom`,
127
+ 'aggregate-stale': (f) => `${f.root}: aggregate lines ${f.actual} is under the recorded budget ${f.recorded} — the budget is STALE, tighten it`,
128
+ 'aggregate-unrecorded': (f) => `${f.root}: declared root carries NO recorded aggregate budget (${f.actual} lines measured) — the record must mirror the declared roots exactly`,
129
+ 'aggregate-root-gone': (f) => `${f.root}: recorded in "aggregate" but no longer a declared root — the record must mirror the declared roots exactly`,
130
+ });
131
+
132
+ // The suggested entry is the WHOLE projected record, not the dimensions that happen to have changed:
133
+ // an entry printed from the findings alone would drop a recorded dimension that stayed put, and
134
+ // pasting it would fail the very next check. The projection already carries exactly the dimensions
135
+ // that violate the declared defaults and nothing else.
136
+ const suggestedEntryLines = (verdict) => {
137
+ const rels = [...new Set(verdict.findings.filter((f) => f.kind === 'over-default' || f.kind === 'grew').map((f) => f.rel))];
138
+ return rels.map((rel) => {
139
+ const parts = Object.entries(verdict.projected.get(rel)).map(([dimension, value]) => `"${dimension}": ${value}`);
140
+ return ` ${jsonForLine(rel)}: { ${[...parts, `"reason": "${GROWTH_REASON_PLACEHOLDER}"`].join(', ')} }`;
141
+ });
142
+ };
143
+
144
+ const servableStep = (cwd, verdict) => {
145
+ const growth = hasGrowth(verdict.findings);
146
+ const entries = suggestedEntryLines(verdict);
147
+ const { command, withheld } = regenerator(cwd, growth ? GROWTH_REASON_PLACEHOLDER : undefined);
148
+ const byHand = entries.length === 0 ? [] : ['Or record each size by hand under "baseline" — the validator accepts exactly these bytes:', ...entries];
149
+ if (command === null) return [withheld, WITHHELD_LANE, ...entries];
150
+ return [
151
+ growth
152
+ ? 'This regeneration RAISES a recorded value, so the reason is REQUIRED — it is recorded in the entry it raises, and it is what the commit message and the CHANGELOG restate:'
153
+ : 'Nothing here grows — regenerate the record (no reason needed; shrinking is progress):',
154
+ ` ${command}`,
155
+ ...byHand,
156
+ ];
157
+ };
158
+
159
+ export const checkReportLines = ({ cwd, config, verdict }) => {
160
+ const lines = verdict.scope.emptyRoots.map(
161
+ (rel) => `source-size: NOTE — the declared root "${escapeForLine(rel)}" matches no tracked file with a declared extension`);
162
+ if (verdict.findings.length === 0) {
163
+ lines.push(`source-size: PASS — ${verdict.scope.files.length} in-scope file(s) within the declared caps`, practiceLine(config));
164
+ return lines;
165
+ }
166
+ lines.push(`source-size: FAIL — ${verdict.findings.length} finding(s) against ${namedConfig(cwd)}:`);
167
+ for (const finding of verdict.findings) lines.push(` ${FINDING_LINE[finding.kind](lineSafeFinding(finding))}`);
168
+ lines.push(...servableStep(cwd, verdict));
169
+ if (hasLoweredRecord(verdict.findings)) lines.push(SPLIT_QUALITY_FOCUS);
170
+ return refusal(lines);
171
+ };
172
+
173
+ // The delta is the DURABLE RECORD on a deployment whose docs/ai is git-hidden: it is what the commit
174
+ // message carries and what the release CHANGELOG restates. It is printed WHOLE — a refusal that
175
+ // showed only the raises would drop the tightens and removals riding the same regeneration from the
176
+ // record it promises, so the raises are MARKED instead of filtered.
177
+ export const deltaLines = (deltas) => deltas.map(
178
+ ({ target, dimension, from, to }) =>
179
+ ` ${escapeForLine(target)}: ${dimension} ${from ?? 'none'} → ${to ?? 'none'}${isRaise({ from, to }) ? ' (raise)' : ''}`);
180
+
181
+ export const reasonRequiredLines = (cwd, deltas) => {
182
+ const { command, withheld } = regenerator(cwd, GROWTH_REASON_PLACEHOLDER);
183
+ const raises = deltas.filter(isRaise).length;
184
+ return refusal([
185
+ `source-size: REFUSED — this regeneration RAISES ${raises} recorded value(s), and a raise takes a reason (it lands verbatim in the entry it raises, in the commit message and in the CHANGELOG). The whole old→new it would write:`,
186
+ ...deltaLines(deltas),
187
+ command === null ? `${withheld} ${WITHHELD_LANE}` : ` ${command}`,
188
+ ]);
189
+ };
190
+
191
+ // ── --adopt (D-16) ────────────────────────────────────────────────────────────────────────────────
192
+ // The verb touches TWO files, so it reports the two halves separately, ALWAYS. A failure in the
193
+ // second must never read as a failure of the first: a reader told only "adopt failed" would re-run a
194
+ // mint that already succeeded, or hand-write a record that is already on disk. Every line below
195
+ // therefore names what WAS done before what was not.
196
+
197
+ // ABSENT config. This says the one thing the check refusal need not: authoring the scope is the
198
+ // EXPECTED first step of adoption, not a failure — the template and the WHY are the same ones the
199
+ // check prints, because a second wording of the same refusal would be a second practice.
200
+ export const adoptAbsentRefusalLines = (cwd) => {
201
+ const { command, withheld } = rendered(cwd, '--adopt', INITIAL_ADOPTION_REASON);
202
+ return refusal([
203
+ `source-size: --adopt REFUSED — ${namedConfig(cwd)} is absent, so this practice has no declared scope yet.`,
204
+ 'Authoring that file is the ONE manual step of the practice, and reaching it here is EXPECTED, not a failure: the kit ships no default root list and no default file-type list, because a fixed one would silently exempt every unlisted language.',
205
+ 'Create it with this content, replacing every placeholder value:',
206
+ authoringTemplate(),
207
+ 'Then re-run this same verb — it mints the record and declares the gate in one step:',
208
+ command === null ? `${withheld} ${WITHHELD_ADOPT_LANE}` : ` ${command}`,
209
+ ]);
210
+ };
211
+
212
+ // Byte-identity cannot tell a recognition from a regeneration that rewrote the same bytes, and the
213
+ // two differ exactly when the tree has moved — so the run SAYS which one it did.
214
+ export const recordRecognizedLines = (cwd) => [
215
+ `source-size: the record in ${namedConfig(cwd)} already holds for this tree — recognized, not regenerated (adoption never rewrites a minted record).`,
216
+ ];
217
+
218
+ // The record the tree outgrew. The checker's own refusal has already printed the numbers and the
219
+ // reasoned lane; this line adds the ONE thing that refusal cannot know — that a gate was on its way
220
+ // in and did not land, so nothing was half-armed.
221
+ export const recordNoLongerHoldsLines = (rel) => [
222
+ `source-size: --adopt STOPPED before declaring — the ${escapeForLine(rel)} gate was NOT declared, because the recorded sizes above no longer hold and the gate would refuse on every run.`,
223
+ 'Settle the record with its own reason first (the command above), then re-run --adopt: the settled record is recognized and only the gate is declared.',
224
+ ];
225
+
226
+ export const gateAlreadyDeclaredLines = (rel) => [
227
+ `source-size: the ${escapeForLine(rel)} gate is already declared — nothing to declare; --adopt converged.`,
228
+ ];
229
+
230
+ export const gateDeclaredLines = (rel, id) => [
231
+ `source-size: gate "${escapeForLine(id)}" declared in ${escapeForLine(rel)} — the practice now runs with the matrix.`,
232
+ ];
233
+
234
+ // The PARTIAL outcome, and the reason it is not simply an error: the record IS minted and the tree
235
+ // IS judgeable — only the declaration step was refused, and a re-run converges from here without
236
+ // re-doing the mint.
237
+ export const gateRefusedLines = (rel, message) => refusal([
238
+ `source-size: --adopt INCOMPLETE — the record was minted, the gate was NOT declared in ${escapeForLine(rel)}.`,
239
+ ` ${escapeForLine(message)}`,
240
+ 'Fix what the line above names and re-run the same verb: the minted record is recognized, so the re-run only declares the gate.',
241
+ ]);
242
+
243
+ // `changed` is decided by comparing the serialized bytes with the file's own — never by the delta
244
+ // count alone: completing a half-written machine record changes the file while raising nothing, and
245
+ // reporting that as "unchanged" would hide a write that happened.
246
+ export const writtenLines = ({ cwd, deltas, reason, changed }) => {
247
+ if (!changed) return [`source-size: baseline unchanged — ${namedConfig(cwd)} already records this tree`];
248
+ if (deltas.length === 0) return [`source-size: baseline rewritten — no recorded value changed; ${namedConfig(cwd)} was completed or re-serialized`];
249
+ return [
250
+ `source-size: baseline regenerated — ${deltas.length} change(s) in ${namedConfig(cwd)}:`,
251
+ ...deltaLines(deltas),
252
+ ...(reason === undefined ? [] : [`reason: ${reason}`]),
253
+ ];
254
+ };
@@ -0,0 +1,145 @@
1
+ // source-size-scope.mjs — which files the practice judges (D-6) and how big each one is (D-7). The
2
+ // two rules ride together because they meet in measureFile: the same strict-UTF-8 decoder decides
3
+ // both whether a PATH can be addressed losslessly and whether a file's BYTES can be judged, and both
4
+ // refuse through the same fail-closed lane.
5
+ //
6
+ // • SCOPE (fail-closed) — git-tracked files under a declared root carrying a declared extension,
7
+ // minus the excluded path-segment prefixes. Symlinks and submodule gitlinks are skipped BY KIND.
8
+ // An unmerged index, a non-UTF-8 in-scope FILENAME, an unverifiable in-scope file and an empty
9
+ // declared scope are REFUSALS (exit 1); a failed enumeration is exit 2. The enumeration is
10
+ // NUL-delimited and every path test runs on BYTES, so a tracked path carrying a tab or a newline
11
+ // is judged byte-exactly instead of being mangled by line splitting.
12
+ // • COUNTING — lines, and the longest line in BYTES. Terminators never count, CR included.
13
+ //
14
+ // Dependency-free, Node >= 22. No side effects on import; the only child process is a read-only git
15
+ // query, so the read-graph purity suite stays true.
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { configFail, configPathFor, scopeFail } from './source-size-refusal.mjs';
21
+
22
+ const LF = 0x0a;
23
+ const CR = 0x0d;
24
+ const TAB = 0x09;
25
+ const SLASH = 0x2f;
26
+
27
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
28
+ const SYMLINK_MODE = '120000';
29
+ const GITLINK_MODE = '160000';
30
+
31
+ // Raw bytes in, raw bytes out: `ls-files -s -z` emits mode, object and stage, then a TAB, then the
32
+ // path, then a NUL — and the path half is never decoded before it has been matched, so a name
33
+ // carrying a tab or a newline survives intact (splitting on lines would mangle it).
34
+ const parseIndexEntries = (buf) => {
35
+ const entries = [];
36
+ let start = 0;
37
+ while (start < buf.length) {
38
+ let end = buf.indexOf(0, start);
39
+ if (end === -1) end = buf.length;
40
+ const record = buf.subarray(start, end);
41
+ const tab = record.indexOf(TAB);
42
+ if (tab !== -1) {
43
+ const head = record.subarray(0, tab).toString('utf8').split(' ');
44
+ entries.push({ mode: head[0], stage: Number(head[2]), path: record.subarray(tab + 1) });
45
+ }
46
+ start = end + 1;
47
+ }
48
+ return entries;
49
+ };
50
+
51
+ // A path whose bytes are not valid UTF-8 round-trips to something DIFFERENT (the replacement char is
52
+ // lossy) — that is the whole test, and it is exact.
53
+ const decodeStrict = (buf) => {
54
+ const text = buf.toString('utf8');
55
+ return Buffer.from(text, 'utf8').equals(buf) ? text : null;
56
+ };
57
+
58
+ const bufSegmentPrefix = (path, prefix) =>
59
+ path.length >= prefix.length &&
60
+ path.subarray(0, prefix.length).equals(prefix) &&
61
+ (path.length === prefix.length || path[prefix.length] === SLASH);
62
+
63
+ const bufEndsWith = (path, suffix) =>
64
+ path.length >= suffix.length && path.subarray(path.length - suffix.length).equals(suffix);
65
+
66
+ export const enumerateIndex = (cwd, deps = {}) => {
67
+ const spawn = deps.spawn ?? spawnSync;
68
+ const result = spawn('git', ['ls-files', '-s', '-z'], { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
69
+ if (result.error || result.status !== 0) {
70
+ const why = result.error ? result.error.message : `git exited ${result.status}`;
71
+ throw configFail(`the git index could not be enumerated in ${cwd} (${why}) — the declared scope is unknown, so nothing is judged`);
72
+ }
73
+ return parseIndexEntries(result.stdout);
74
+ };
75
+
76
+ // resolveScope(cwd, config) → { files: [rel…] sorted, perRoot: Map<root, [rel…]>, emptyRoots: [root…] }.
77
+ // Every exclusion is BY RULE (root / extension / exclude prefix) or BY KIND (symlink, gitlink); an
78
+ // in-scope path that cannot be addressed losslessly is a refusal, never a quiet skip.
79
+ export const resolveScope = (cwd, config, deps = {}) => {
80
+ const entries = enumerateIndex(cwd, deps);
81
+ const unmerged = entries.filter((e) => e.stage !== 0);
82
+ if (unmerged.length > 0) {
83
+ throw scopeFail(`the git index is UNMERGED (${unmerged.length} conflict-stage entr(ies)) — an ambiguous index cannot be judged; resolve the conflict, then re-run`);
84
+ }
85
+ const roots = config.roots.map((rel) => ({ rel, buf: Buffer.from(rel, 'utf8') }));
86
+ const excludes = config.exclude.map((rel) => Buffer.from(rel, 'utf8'));
87
+ const extensions = config.extensions.map((ext) => Buffer.from(ext, 'utf8'));
88
+ const perRoot = new Map(config.roots.map((rel) => [rel, []]));
89
+ const files = [];
90
+ for (const entry of entries) {
91
+ const root = roots.find((r) => bufSegmentPrefix(entry.path, r.buf));
92
+ if (!root) continue;
93
+ if (!extensions.some((ext) => bufEndsWith(entry.path, ext))) continue;
94
+ if (excludes.some((ex) => bufSegmentPrefix(entry.path, ex))) continue;
95
+ if (entry.mode === SYMLINK_MODE || entry.mode === GITLINK_MODE) continue;
96
+ const rel = decodeStrict(entry.path);
97
+ if (rel === null) {
98
+ throw scopeFail(`an in-scope tracked path's NAME is not valid UTF-8 (bytes ${entry.path.toString('hex')}) — it cannot be addressed losslessly, so nothing is judged; rename it, or add its prefix to "exclude" in ${configPathFor(cwd)}`);
99
+ }
100
+ files.push(rel);
101
+ perRoot.get(root.rel).push(rel);
102
+ }
103
+ if (files.length === 0) {
104
+ throw scopeFail(`the declared scope matches ZERO tracked files (roots: ${config.roots.join(', ')}; extensions: ${config.extensions.join(', ')}) — an empty scope is a misdeclaration, never an empty green; either widen "roots" / "extensions" in ${configPathFor(cwd)}, or track a file the declared scope covers`);
105
+ }
106
+ files.sort();
107
+ for (const list of perRoot.values()) list.sort();
108
+ return { files, perRoot, emptyRoots: [...perRoot].filter(([, list]) => list.length === 0).map(([rel]) => rel) };
109
+ };
110
+
111
+ // countBytes(buf) → { lines, maxLineBytes }. A terminator never counts (the CR of a CRLF included);
112
+ // a last line with no final newline still counts; an empty file is 0 lines.
113
+ export const countBytes = (buf) => {
114
+ let lines = 0;
115
+ let maxLineBytes = 0;
116
+ let start = 0;
117
+ const widen = (from, to) => {
118
+ lines += 1;
119
+ if (to - from > maxLineBytes) maxLineBytes = to - from;
120
+ };
121
+ for (let i = 0; i < buf.length; i += 1) {
122
+ if (buf[i] !== LF) continue;
123
+ widen(start, i > start && buf[i - 1] === CR ? i - 1 : i);
124
+ start = i + 1;
125
+ }
126
+ if (start < buf.length) widen(start, buf.length);
127
+ return { lines, maxLineBytes };
128
+ };
129
+
130
+ // measureFile(cwd, rel) → { lines, maxLineBytes }. The judged bytes are the WORKTREE bytes of an
131
+ // index-visible path; an in-scope file the checker cannot verify is exit 1 naming the exclude lane,
132
+ // never a silent or green skip.
133
+ export const measureFile = (cwd, rel, deps = {}) => {
134
+ const read = deps.readFile ?? readFileSync;
135
+ let buf;
136
+ try {
137
+ buf = read(join(cwd, rel));
138
+ } catch (err) {
139
+ throw scopeFail(`${rel}: in-scope but unverifiable (${err.message}) — a file the checker cannot read is never a silent skip; fix it, or add its prefix to "exclude" in ${configPathFor(cwd)}`);
140
+ }
141
+ if (decodeStrict(buf) === null) {
142
+ throw scopeFail(`${rel}: in-scope but not valid UTF-8 — its size cannot be judged; add its prefix to "exclude" in ${configPathFor(cwd)}`);
143
+ }
144
+ return countBytes(buf);
145
+ };