@sabaiway/agent-workflow-kit 5.4.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.
- package/CHANGELOG.md +130 -0
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +51 -4
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +616 -24
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +700 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +15 -10
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/dispatch.md +29 -0
- package/references/modes/gates.md +6 -3
- package/references/modes/procedures.md +2 -0
- package/references/modes/receipt-deadline.md +3 -3
- package/references/modes/recommendations.md +1 -1
- package/references/modes/velocity.md +1 -0
- package/tools/commands.mjs +7 -0
- package/tools/core-evidence.mjs +37 -3
- package/tools/detect-backends.mjs +5 -4
- package/tools/dispatch-record.mjs +10 -3
- package/tools/dispatch-store.mjs +392 -0
- package/tools/dispatch.mjs +1779 -0
- package/tools/doc-parity.mjs +10 -2
- package/tools/exec-producer.mjs +483 -0
- package/tools/exec-receipt.mjs +263 -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/flow-store.mjs +111 -462
- package/tools/gates-declaration.mjs +13 -1
- package/tools/gates-init.mjs +134 -22
- package/tools/procedures.mjs +64 -5
- package/tools/receipt-deadline.mjs +25 -3
- package/tools/recommendations.mjs +108 -7
- package/tools/release-scan.mjs +33 -0
- package/tools/source-size-check.mjs +320 -0
- package/tools/source-size-config.mjs +244 -0
- package/tools/source-size-core.mjs +53 -0
- package/tools/source-size-gate-cmd.mjs +55 -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/store-append.mjs +444 -0
- package/tools/velocity-profile.mjs +24 -3
|
@@ -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
|
+
};
|