@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
package/tools/doc-parity.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { FLOW_SCHEMA_VERSION, FLOW_LAGGING_KIT_CONTRACT } from './orchestration-
|
|
|
44
44
|
import { FLOW_BOOKKEEPING_FLOOR_RESIDUAL } from './set-flow.mjs';
|
|
45
45
|
import { FLOW_ARMED_HALVES_HEADER } from './procedures.mjs';
|
|
46
46
|
import { RECEIPT_DEADLINE_CONTRACT } from './receipt-deadline.mjs';
|
|
47
|
+
import { DISPATCH_CONTRACT } from './dispatch.mjs';
|
|
47
48
|
// The coverage vocabulary leaf: a CLOSED value set the gates contract doc must enumerate.
|
|
48
49
|
import { COVERAGE } from './coverage-state.mjs';
|
|
49
50
|
|
|
@@ -60,6 +61,7 @@ const PROCEDURES_DOC = 'references/modes/procedures.md';
|
|
|
60
61
|
const SET_FLOW_DOC = 'references/modes/set-flow.md';
|
|
61
62
|
const RECEIPT_DEADLINE_DOC = 'references/modes/receipt-deadline.md';
|
|
62
63
|
const GATES_DOC = 'references/modes/gates.md';
|
|
64
|
+
const DISPATCH_DOC = 'references/modes/dispatch.md';
|
|
63
65
|
|
|
64
66
|
// A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
|
|
65
67
|
const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
@@ -152,6 +154,11 @@ export const BINDINGS = Object.freeze([
|
|
|
152
154
|
// satisfaction — is the tool's identity; a mode doc silently drifting off it would re-open the
|
|
153
155
|
// #50 misclassification this runner exists to close.
|
|
154
156
|
valueBinding('receipt-deadline-contract', RECEIPT_DEADLINE_CONTRACT, RECEIPT_DEADLINE_CONTRACT, [RECEIPT_DEADLINE_DOC]),
|
|
157
|
+
// The delegation engine's contract sentence (delegation Plan 1 Phase 3): the FORM-only limit and
|
|
158
|
+
// the aggregator's refusals are exactly what a reader must not be able to mis-learn from the mode
|
|
159
|
+
// doc — a doc that softened either would promise a judgment the checker never makes, or a number
|
|
160
|
+
// the aggregator refuses to compute.
|
|
161
|
+
valueBinding('dispatch-contract', DISPATCH_CONTRACT, DISPATCH_CONTRACT, [DISPATCH_DOC]),
|
|
155
162
|
// The runner's `coverage=` summary vocabulary (Decision 8): the gates contract doc enumerates the
|
|
156
163
|
// CLOSED value set, so a renamed or added value fails here instead of leaving the doc describing
|
|
157
164
|
// a vocabulary the runner no longer speaks. One binding per value — the set is small and closed.
|
|
@@ -206,8 +213,9 @@ qualifier every settings-derived runtime claim carries, the setup refresh degrad
|
|
|
206
213
|
orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), the
|
|
207
214
|
worktrees cleanup-ownership rule, the worktrees include-identity rule, the worktrees
|
|
208
215
|
resume-verify rule, the flow tolerate contract (the accepted flow schema version + the
|
|
209
|
-
lagging-kit sentence, procedures.md),
|
|
210
|
-
(
|
|
216
|
+
lagging-kit sentence, procedures.md), the receipt-deadline arrival contract, the dispatch engine's
|
|
217
|
+
FORM-only + aggregate-refusal contract (dispatch.md), and the runner's closed coverage= summary
|
|
218
|
+
vocabulary (gates.md) — to
|
|
211
219
|
the exact token its references/modes/*.md contract must carry, and
|
|
212
220
|
asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
|
|
213
221
|
or an absent token FAILS CLOSED.
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// exec-producer.mjs — the GIT-side metric producer for an exec return (delegation Plan 2, Phase 1).
|
|
2
|
+
// It answers the two questions a return record cannot answer for itself: which objects the delegated
|
|
3
|
+
// run touched and how many bytes they represent, and what the canonical integration bundle is. It
|
|
4
|
+
// never writes, never appends and never spawns anything but git reads.
|
|
5
|
+
//
|
|
6
|
+
// dispatch-record.mjs:74-76 named this module before it existed: "the metric's byte domains are
|
|
7
|
+
// computed over STRUCTURED inputs handed in by a producer; the git-side producer is Plan 2, and it
|
|
8
|
+
// owns minting a stable objectId".
|
|
9
|
+
//
|
|
10
|
+
// ONE DOMAIN, THREE LAYERS (D7). The enumeration walks HEAD → index → worktree, exactly the layers
|
|
11
|
+
// the canonical payload concatenates — never the collapsed HEAD → worktree view. The difference is
|
|
12
|
+
// not academic: a staged change reverted in the worktree keeps its bytes in the payload (and so in
|
|
13
|
+
// the DENOMINATOR) while HEAD→worktree shows nothing at all, so the object would vanish from the
|
|
14
|
+
// NUMERATOR and the two halves of one ratio would describe different change sets. The diff bytes are
|
|
15
|
+
// computeFingerprintPayload's own bytes, imported rather than rebuilt.
|
|
16
|
+
//
|
|
17
|
+
// ONE OBJECT, ONE ENTRY. An object touched in both layers is a single entry, and its pre-image is
|
|
18
|
+
// always the HEAD blob — never the index blob: the numerator answers what the delegate could have
|
|
19
|
+
// spared the ORCHESTRATOR, measured against the dispatch baseline, and a metric-eligible dispatch
|
|
20
|
+
// starts from a clean tree where HEAD is that baseline.
|
|
21
|
+
//
|
|
22
|
+
// RENAMES ARE NOT RECONSTRUCTED, AND THAT IS THE DESIGN — a boundary, not an omission. git's OWN
|
|
23
|
+
// rename detection is consumed where git offers it (`--raw -M` sees a staged `git mv` within a layer),
|
|
24
|
+
// but a rename a delegate actually makes — the wrapper's git-write shim blocks every git write verb,
|
|
25
|
+
// so every one arrives as a tracked deletion plus an UNTRACKED creation — is accounted as exactly
|
|
26
|
+
// that: a deletion plus a creation.
|
|
27
|
+
//
|
|
28
|
+
// This module once matched those pairs by blob identity, and the machinery was SUBTRACTED after it
|
|
29
|
+
// produced silent-error edges in four consecutive review rounds: hashing that read through symlinks
|
|
30
|
+
// (and blocked forever on a symlink to a FIFO), abbreviated object names that never matched in a
|
|
31
|
+
// SHA-256 repository, source images taken from the wrong layer, attribute filters keyed to the
|
|
32
|
+
// destination path, a failed probe read as "not a candidate", link targets keyed through a lossy
|
|
33
|
+
// decode, and an ambiguity rule that could not see its own second key. Every one of those was a
|
|
34
|
+
// numerator that was WRONG WITHOUT ANYONE NOTICING — the worst failure mode an accounting path has.
|
|
35
|
+
// An identity heuristic buys accuracy on the ordinary refactor and pays for it in exactly that
|
|
36
|
+
// currency, so the trade was refused.
|
|
37
|
+
//
|
|
38
|
+
// The cost is stated with its DIRECTION, per case, because the direction is NOT the same in all of
|
|
39
|
+
// them — and a blanket "always optimistic" would be a false reassurance about an accounting number.
|
|
40
|
+
// Write O for the pre-image size:
|
|
41
|
+
// • A PLAIN RENAME `a→b` emits `deleted pre:a (O)` + `new new:b (O)` = 2O against a recognised O.
|
|
42
|
+
// OVER-counts, always.
|
|
43
|
+
// • RENAME-THEN-EDIT emits `deleted pre:a (O)` + `new new:b (F')` = O+F' against a recognised O.
|
|
44
|
+
// OVER-counts, always.
|
|
45
|
+
// • RENAME-THEN-RECREATE is INDETERMINATE, and this is the one to be careful about. Moving `a` to
|
|
46
|
+
// `b` and writing a fresh `a` of size F leaves git reporting a MODIFIED `a` beside an untracked
|
|
47
|
+
// `b`, so the emission is `modified pre:a (O)` + `new new:b (O)` = 2O, against a recognised
|
|
48
|
+
// O+F. The sign CROSSES at F = O: with F < O it over-counts, with F > O it UNDER-counts, and the
|
|
49
|
+
// metric then reads pessimistic. There is no lane that could mark just this case ineligible —
|
|
50
|
+
// the evidence is byte-identical to copying `a` to `b` and editing `a` (no git datum separates
|
|
51
|
+
// them: the index stat cache would, but `ino` is legitimately zero under core.checkStat=minimal
|
|
52
|
+
// and on Windows), so a predicate for it cannot be evaluated at this layer, and one broad enough
|
|
53
|
+
// to catch it — any `modified` beside a `new` — would make most returns ineligible.
|
|
54
|
+
// Every object here is one git can PROVE: a tracked path with a recorded blob, or an untracked path
|
|
55
|
+
// that exists. Nothing is paired on a resemblance.
|
|
56
|
+
//
|
|
57
|
+
// CONFIG-HIDDEN PATHS ARE A STATED LIMIT, and the limit is the PAYLOAD's, not this module's. `git
|
|
58
|
+
// diff` honours `diff.ignoreSubmodules` and skips index entries carrying assume-unchanged or
|
|
59
|
+
// skip-worktree, so a path can be changed and stay invisible to both probes below. The kit already
|
|
60
|
+
// owns a probe that sees them — computeWorkingState (core-evidence.mjs:341) forces
|
|
61
|
+
// `--ignore-submodules=none` and folds in flaggedIndexLag — and `isTreeClean` consumes it, so a
|
|
62
|
+
// dispatch opened over such a tree records baselineClean:false and its return is metric-INELIGIBLE by
|
|
63
|
+
// name. What this module must NOT do is force those flags HERE: D7 binds the numerator to the byte
|
|
64
|
+
// domain of computeFingerprintPayload, which uses the same plain `git diff` — forcing them on one
|
|
65
|
+
// side only would let the numerator count objects the denominator cannot see, a worse failure than
|
|
66
|
+
// the blindness. Closing it for real means changing the shared payload, which is a Plan-1-frozen
|
|
67
|
+
// surface the review lane also consumes; that is queued as its own item, and Phase 2's `return` owns
|
|
68
|
+
// the return-time guard.
|
|
69
|
+
//
|
|
70
|
+
// IDENTITY CARRIES ITS DOMAIN (D6). `pre:<path>` for an object with a pre-image (keyed by the name it
|
|
71
|
+
// HAD) and `new:<path>` for a created one. The prefix is load-bearing: a rename a→b beside a
|
|
72
|
+
// re-created `a` would otherwise hand two objects one identity, and computeNumerator refuses a second
|
|
73
|
+
// size under an id it already counted — the whole return would be refused for an ordinary refactor.
|
|
74
|
+
//
|
|
75
|
+
// TYPE BEATS STATUS for the size rule; STATUS decides the identity domain. A gitlink contributes ZERO
|
|
76
|
+
// bytes deliberately — moving one costs this repository no bytes — but the zero belongs to the image
|
|
77
|
+
// actually COUNTED, not to the entry's kind: a regular file or a symlink REPLACED by a gitlink is
|
|
78
|
+
// still emitted as `submodule` (the type matrix is D6's) while keeping its real pre-image size, since
|
|
79
|
+
// paying it zero would drop a whole object's bytes out of the numerator on a type change. Every OTHER
|
|
80
|
+
// unknown size REFUSES — the fail-closed rule is never a silent zero.
|
|
81
|
+
//
|
|
82
|
+
// A TRANSIENT object (staged, then deleted from the worktree) counts its INDEX image once: the
|
|
83
|
+
// delegate authored those bytes, the payload carries them in both layers, and dropping them would
|
|
84
|
+
// credit the delegate with less than it wrote.
|
|
85
|
+
//
|
|
86
|
+
// Fail closed: outside a git work tree, on an unborn branch (no HEAD means no pre-image to attribute
|
|
87
|
+
// bytes against), on any git read that fails, and on any size it cannot establish.
|
|
88
|
+
|
|
89
|
+
import { join } from 'node:path';
|
|
90
|
+
import { createHash } from 'node:crypto';
|
|
91
|
+
import {
|
|
92
|
+
gitBuf, computeFingerprintPayload, isBinaryFile, isNeverCommittableStat, resolveBase,
|
|
93
|
+
} from './core-evidence.mjs';
|
|
94
|
+
import { lstatNoFollowRead } from './fs-read-nofollow.mjs';
|
|
95
|
+
import { frameIntegrationBundle } from './dispatch-record.mjs';
|
|
96
|
+
|
|
97
|
+
const refuse = (reason) => ({ ok: false, reason });
|
|
98
|
+
|
|
99
|
+
const GITLINK_MODE = '160000';
|
|
100
|
+
const SYMLINK_MODE = '120000';
|
|
101
|
+
const ABSENT_MODE = '000000';
|
|
102
|
+
const SUBMODULE_BYTES = 0;
|
|
103
|
+
|
|
104
|
+
export const PRE_IMAGE_ID_PREFIX = 'pre:';
|
|
105
|
+
export const NEW_IMAGE_ID_PREFIX = 'new:';
|
|
106
|
+
|
|
107
|
+
const isAbsentSha = (sha) => sha === undefined || /^0+$/.test(sha);
|
|
108
|
+
|
|
109
|
+
// PATHS ARE BYTES, and this module keys objects BY path. A NUL-delimited stream decoded whole with
|
|
110
|
+
// toString('utf8') folds every invalid byte to U+FFFD, so two DISTINCT paths — `x\xfe.txt` and
|
|
111
|
+
// `x\xff.txt` — arrive as one string and therefore as one objectId: computeNumerator then either
|
|
112
|
+
// refuses the whole return under a duplicate id or counts one object where there were two. So every
|
|
113
|
+
// -z stream is split into BUFFER segments and each name is trusted only if it survives a byte
|
|
114
|
+
// round-trip; a name that does not is a REFUSAL, never a repaired string. (The same defense runs one
|
|
115
|
+
// module down at core-evidence.mjs:199-210 — this is the family's idiom, not a new invention.)
|
|
116
|
+
const splitZBytes = (buf) => {
|
|
117
|
+
const out = [];
|
|
118
|
+
let start = 0;
|
|
119
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
120
|
+
if (buf[i] !== 0) continue;
|
|
121
|
+
out.push(buf.subarray(start, i));
|
|
122
|
+
start = i + 1;
|
|
123
|
+
}
|
|
124
|
+
if (start < buf.length) out.push(buf.subarray(start));
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const decodesExactly = (slice) => Buffer.from(slice.toString('utf8'), 'utf8').equals(slice);
|
|
129
|
+
|
|
130
|
+
const undecodablePath = (where, slice) => refuse(`git reported a path in ${where} whose bytes are not valid UTF-8 (${JSON.stringify(slice.toString('utf8'))}) — decoding it would fold DISTINCT paths onto ONE objectId and silently merge or drop an object, so the enumeration refuses rather than counting on a collapsed identity (fail closed)`);
|
|
131
|
+
|
|
132
|
+
const gitTop = (cwd) => {
|
|
133
|
+
const buf = gitBuf(['rev-parse', '--show-toplevel'], cwd);
|
|
134
|
+
return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const blobSize = (top, sha) => {
|
|
138
|
+
const buf = gitBuf(['cat-file', '-s', sha], top);
|
|
139
|
+
if (buf == null) return null;
|
|
140
|
+
const size = Number(buf.toString('utf8').trim());
|
|
141
|
+
return Number.isSafeInteger(size) && size >= 0 ? size : null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// The raw record grammar, one shape for both layers: ":<srcMode> <dstMode> <srcSha> <dstSha> <status>"
|
|
145
|
+
// followed by one path, or two when the status renames. The META field is ASCII by construction; only
|
|
146
|
+
// the PATH fields carry arbitrary bytes, so only they are round-trip checked.
|
|
147
|
+
const parseRawRecords = (buf, where) => {
|
|
148
|
+
const fields = splitZBytes(buf);
|
|
149
|
+
const records = [];
|
|
150
|
+
const unparseable = () => refuse(`a git raw record in ${where} is unparseable — the enumeration refuses rather than skipping a touched object`);
|
|
151
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
152
|
+
const meta = fields[i].toString('utf8');
|
|
153
|
+
if (meta === '' || meta[0] !== ':') continue;
|
|
154
|
+
const parts = meta.slice(1).split(' ');
|
|
155
|
+
if (parts.length < 5) return unparseable();
|
|
156
|
+
const [srcMode, dstMode, srcSha, dstSha, statusField] = parts;
|
|
157
|
+
const status = statusField[0];
|
|
158
|
+
const renamed = status === 'R' || status === 'C';
|
|
159
|
+
const first = fields[i + 1];
|
|
160
|
+
const second = renamed ? fields[i + 2] : undefined;
|
|
161
|
+
if (first === undefined || (renamed && second === undefined)) return unparseable();
|
|
162
|
+
i += renamed ? 2 : 1;
|
|
163
|
+
if (!decodesExactly(first)) return undecodablePath(where, first);
|
|
164
|
+
if (renamed && !decodesExactly(second)) return undecodablePath(where, second);
|
|
165
|
+
const srcPath = first.toString('utf8');
|
|
166
|
+
records.push({
|
|
167
|
+
srcMode, dstMode, srcSha, dstSha, status,
|
|
168
|
+
srcPath,
|
|
169
|
+
dstPath: renamed ? second.toString('utf8') : srcPath,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, records };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// git's OWN notion of binary, asked per layer: --numstat prints "-" for both counts on a binary path.
|
|
176
|
+
// The record is `<added>TAB<deleted>TAB<path>` — split by the FIRST TWO tabs only, because a path may
|
|
177
|
+
// legally contain tabs of its own and a three-way split silently drops such a record, losing the
|
|
178
|
+
// binary marker and sizing the object by status instead of type. An INCOMPLETE record is refused, not
|
|
179
|
+
// skipped: a marker that could not be read is not a marker that is absent.
|
|
180
|
+
const binaryNames = (top, args, where) => {
|
|
181
|
+
const buf = gitBuf(['diff', ...args, '--no-ext-diff', '--numstat', '-z', '-M'], top);
|
|
182
|
+
return buf == null
|
|
183
|
+
? refuse('git could not read the numstat binary markers (fail closed)')
|
|
184
|
+
: parseNumstatMarkers(buf, where);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Exported as a TEST SEAM, and only for that: both refusals below guard against output git does not
|
|
188
|
+
// produce on demand — a record short of its two separators, and a rename record that promises two
|
|
189
|
+
// path segments and carries fewer. They are the fail-closed arms of this reader, so they are pinned
|
|
190
|
+
// directly rather than left as unreachable prose.
|
|
191
|
+
export const parseNumstatMarkers = (buf, where) => {
|
|
192
|
+
const fields = splitZBytes(buf);
|
|
193
|
+
const names = new Set();
|
|
194
|
+
const TAB = 0x09;
|
|
195
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
196
|
+
const record = fields[i];
|
|
197
|
+
if (record.length === 0) continue;
|
|
198
|
+
const firstTab = record.indexOf(TAB);
|
|
199
|
+
const secondTab = firstTab === -1 ? -1 : record.indexOf(TAB, firstTab + 1);
|
|
200
|
+
if (firstTab === -1 || secondTab === -1) {
|
|
201
|
+
return refuse(`a git numstat record in ${where} carries fewer than the two separators its grammar requires (${JSON.stringify(record.toString('utf8'))}) — the enumeration refuses rather than skipping a marker it could not read`);
|
|
202
|
+
}
|
|
203
|
+
const added = record.subarray(0, firstTab).toString('utf8');
|
|
204
|
+
const deleted = record.subarray(firstTab + 1, secondTab).toString('utf8');
|
|
205
|
+
const isBinary = added === '-' && deleted === '-';
|
|
206
|
+
const pathSlice = record.subarray(secondTab + 1);
|
|
207
|
+
// A rename leaves the path field EMPTY and follows with two separate NUL-terminated names. A
|
|
208
|
+
// record that promises them and does not carry them is REFUSED, not skipped — the same rule the
|
|
209
|
+
// incomplete-record arm above states, and skipping it would drop a marker while claiming to read
|
|
210
|
+
// every one.
|
|
211
|
+
let slices;
|
|
212
|
+
if (pathSlice.length === 0) {
|
|
213
|
+
const from = fields[i + 1];
|
|
214
|
+
const to = fields[i + 2];
|
|
215
|
+
i += 2;
|
|
216
|
+
if (from === undefined || to === undefined) {
|
|
217
|
+
return refuse(`a git numstat rename record in ${where} is missing the path segments its grammar promises — the enumeration refuses rather than skipping a marker it could not read`);
|
|
218
|
+
}
|
|
219
|
+
slices = [from, to];
|
|
220
|
+
} else {
|
|
221
|
+
slices = [pathSlice];
|
|
222
|
+
}
|
|
223
|
+
for (const slice of slices) {
|
|
224
|
+
if (!decodesExactly(slice)) return undecodablePath(where, slice);
|
|
225
|
+
if (isBinary) names.add(slice.toString('utf8'));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { ok: true, names };
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// ── the object model ──────────────────────────────────────────────────────────────────────────────
|
|
232
|
+
// One record per touched object: where it came from in HEAD, where it ended up, and every name it
|
|
233
|
+
// wore in between (the names the binary oracle and the type probes are asked about).
|
|
234
|
+
|
|
235
|
+
const makeObject = ({ headPath, headSha, headMode }) => ({
|
|
236
|
+
headPath, headSha, headMode,
|
|
237
|
+
finalPath: null, finalMode: ABSENT_MODE, finalSource: null,
|
|
238
|
+
indexPath: null, indexSha: null, indexMode: ABSENT_MODE,
|
|
239
|
+
names: new Set([headPath].filter(Boolean)),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const kindForModes = (modes) => {
|
|
243
|
+
if (modes.includes(GITLINK_MODE)) return 'submodule';
|
|
244
|
+
if (modes.includes(SYMLINK_MODE)) return 'symlink';
|
|
245
|
+
return null;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const buildObjects = (staged, unstaged) => {
|
|
249
|
+
const objects = [];
|
|
250
|
+
// HEAD → index. A staged record's DESTINATION name is how the unstaged layer will refer to it.
|
|
251
|
+
const byIndexPath = new Map();
|
|
252
|
+
for (const r of staged) {
|
|
253
|
+
const hasHead = !isAbsentSha(r.srcSha) && r.srcMode !== ABSENT_MODE;
|
|
254
|
+
const object = makeObject({
|
|
255
|
+
headPath: hasHead ? r.srcPath : null,
|
|
256
|
+
headSha: hasHead ? r.srcSha : null,
|
|
257
|
+
headMode: hasHead ? r.srcMode : ABSENT_MODE,
|
|
258
|
+
});
|
|
259
|
+
if (r.status !== 'D') {
|
|
260
|
+
object.finalPath = r.dstPath;
|
|
261
|
+
object.finalMode = r.dstMode;
|
|
262
|
+
object.finalSource = 'index';
|
|
263
|
+
object.indexPath = r.dstPath;
|
|
264
|
+
object.indexSha = r.dstSha;
|
|
265
|
+
object.indexMode = r.dstMode;
|
|
266
|
+
object.names.add(r.dstPath);
|
|
267
|
+
byIndexPath.set(r.dstPath, object);
|
|
268
|
+
}
|
|
269
|
+
objects.push(object);
|
|
270
|
+
}
|
|
271
|
+
// index → worktree. A path absent from the staged layer is unchanged there, so its index blob IS
|
|
272
|
+
// its HEAD blob and the object enters the model here.
|
|
273
|
+
for (const r of unstaged) {
|
|
274
|
+
const known = byIndexPath.get(r.srcPath);
|
|
275
|
+
const object = known ?? makeObject({ headPath: r.srcPath, headSha: r.srcSha, headMode: r.srcMode });
|
|
276
|
+
if (known === undefined) {
|
|
277
|
+
object.indexPath = r.srcPath;
|
|
278
|
+
object.indexSha = r.srcSha;
|
|
279
|
+
object.indexMode = r.srcMode;
|
|
280
|
+
objects.push(object);
|
|
281
|
+
}
|
|
282
|
+
object.names.add(r.srcPath);
|
|
283
|
+
if (r.status === 'D') {
|
|
284
|
+
object.finalPath = null;
|
|
285
|
+
object.finalMode = ABSENT_MODE;
|
|
286
|
+
object.finalSource = null;
|
|
287
|
+
} else {
|
|
288
|
+
object.finalPath = r.dstPath;
|
|
289
|
+
object.finalMode = r.dstMode;
|
|
290
|
+
object.finalSource = 'worktree';
|
|
291
|
+
object.names.add(r.dstPath);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return objects;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ── entry construction ────────────────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
const sizeRefusal = (kind, path) => refuse(`cannot establish the size of the ${kind} path "${path}" — the enumeration refuses rather than counting an unknown as zero (fail closed)`);
|
|
300
|
+
|
|
301
|
+
const entryForObject = (top, object, binary, lstat) => {
|
|
302
|
+
const hasPreImage = object.headPath !== null;
|
|
303
|
+
// An object with no pre-image is identified by the name it was ADDED under: the index is the first
|
|
304
|
+
// place it existed, and a later filesystem move must not re-key it (a transient object, which ends
|
|
305
|
+
// nowhere at all, has only that name to begin with).
|
|
306
|
+
const identityPath = hasPreImage ? object.headPath : (object.indexPath ?? object.finalPath);
|
|
307
|
+
const objectId = `${hasPreImage ? PRE_IMAGE_ID_PREFIX : NEW_IMAGE_ID_PREFIX}${identityPath}`;
|
|
308
|
+
const path = object.finalPath ?? object.headPath ?? object.indexPath;
|
|
309
|
+
const typeKind = kindForModes([object.headMode, object.indexMode, object.finalMode])
|
|
310
|
+
?? ([...object.names].some((n) => binary.has(n)) ? 'binary' : null);
|
|
311
|
+
|
|
312
|
+
if (typeKind !== null) {
|
|
313
|
+
// The COUNTED image decides the size, even when the emitted kind comes from another layer. A
|
|
314
|
+
// gitlink in ANY layer makes the kind `submodule` (TYPE beats STATUS, D6) — but a regular file
|
|
315
|
+
// REPLACED by a gitlink is still counted at its HEAD blob, and paying it the submodule's
|
|
316
|
+
// deliberate zero would drop a whole object's bytes out of the numerator on a type change. Zero
|
|
317
|
+
// belongs to a counted image that is ITSELF a gitlink: that is the case where the move costs this
|
|
318
|
+
// repository no bytes.
|
|
319
|
+
const countedMode = hasPreImage
|
|
320
|
+
? object.headMode
|
|
321
|
+
: (object.finalSource === 'worktree' ? object.finalMode : object.indexMode);
|
|
322
|
+
if (countedMode === GITLINK_MODE) return { ok: true, entry: { kind: typeKind, path, objectId, sizeBytes: SUBMODULE_BYTES } };
|
|
323
|
+
const size = hasPreImage
|
|
324
|
+
? blobSize(top, object.headSha)
|
|
325
|
+
: (object.finalSource === 'worktree' ? lstat(join(top, object.finalPath))?.size ?? null : blobSize(top, object.indexSha));
|
|
326
|
+
if (size == null) return sizeRefusal(typeKind, path);
|
|
327
|
+
return { ok: true, entry: { kind: typeKind, path, objectId, sizeBytes: size } };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!hasPreImage) {
|
|
331
|
+
const size = object.finalSource === 'worktree'
|
|
332
|
+
? lstat(join(top, object.finalPath))?.size ?? null
|
|
333
|
+
: blobSize(top, object.indexSha);
|
|
334
|
+
if (size == null) return sizeRefusal('created', path);
|
|
335
|
+
return { ok: true, entry: { kind: 'new', path, objectId, postImageBytes: size } };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const preImageBytes = blobSize(top, object.headSha);
|
|
339
|
+
if (preImageBytes == null) return sizeRefusal('pre-image of', identityPath);
|
|
340
|
+
if (object.finalPath === null) return { ok: true, entry: { kind: 'deleted', path, objectId, preImageBytes } };
|
|
341
|
+
if (object.finalPath !== object.headPath) {
|
|
342
|
+
return { ok: true, entry: { kind: 'renamed', path: object.finalPath, objectId, preImageBytes, fromPath: object.headPath } };
|
|
343
|
+
}
|
|
344
|
+
return { ok: true, entry: { kind: 'modified', path, objectId, preImageBytes } };
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const entryForUntracked = (top, rel, lstat) => {
|
|
348
|
+
const full = join(top, rel);
|
|
349
|
+
const stat = lstat(full);
|
|
350
|
+
if (isNeverCommittableStat(stat)) return { ok: true, entry: null };
|
|
351
|
+
const objectId = `${NEW_IMAGE_ID_PREFIX}${rel}`;
|
|
352
|
+
if (stat == null || !stat.isFile()) {
|
|
353
|
+
return stat != null && stat.isSymbolicLink()
|
|
354
|
+
? { ok: true, entry: { kind: 'symlink', path: rel, objectId, sizeBytes: stat.size } }
|
|
355
|
+
: { ok: true, entry: { kind: 'non-regular', path: rel, objectId, sizeBytes: 0 } };
|
|
356
|
+
}
|
|
357
|
+
return isBinaryFile(full)
|
|
358
|
+
? { ok: true, entry: { kind: 'binary', path: rel, objectId, sizeBytes: stat.size } }
|
|
359
|
+
: { ok: true, entry: { kind: 'new', path: rel, objectId, postImageBytes: stat.size } };
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// enumerateReturnedObjects(cwd, io?) → { ok: true, entries } | { ok: false, reason }. The entries are
|
|
363
|
+
// the STRUCTURED input computeNumerator consumes; this module never computes the numerator itself, so
|
|
364
|
+
// the closed vocabulary stays the single authority on what a component is. `io.lstat` and
|
|
365
|
+
// `io.untracked` are TEST SEAMS for the arms git cannot be made to produce on demand (an unstatable
|
|
366
|
+
// path, a never-committable class); an injected lstat returning undefined falls through to the real one.
|
|
367
|
+
export const enumerateReturnedObjects = (cwd = process.cwd(), io = {}) => {
|
|
368
|
+
const top = gitTop(cwd);
|
|
369
|
+
if (top == null) return refuse('not inside a git work tree — an exec return is enumerated against a repository (fail closed)');
|
|
370
|
+
if (resolveBase(top) == null) {
|
|
371
|
+
return refuse('the branch is unborn (no HEAD) — there is no pre-image to attribute delegated bytes against (fail closed)');
|
|
372
|
+
}
|
|
373
|
+
// ABSENT and UNREADABLE are different answers. ENOENT means the path vanished between `ls-files`
|
|
374
|
+
// and the stat, which the payload itself records name-only — mirroring it keeps numerator and
|
|
375
|
+
// denominator over ONE object set. Every OTHER errno (EACCES, EIO, ENOTDIR, ELOOP on a parent) is
|
|
376
|
+
// a FAILED PROBE, and a failed probe recorded as a zero-byte non-regular would be an accounting
|
|
377
|
+
// number nobody could tell was wrong. So the failure is captured and the whole enumeration refuses
|
|
378
|
+
// with the path and the code; this module's contract is a refusal OBJECT, never a throw.
|
|
379
|
+
let probeFailure = null;
|
|
380
|
+
const lstat = (path) => {
|
|
381
|
+
try {
|
|
382
|
+
const injected = io.lstat?.(path);
|
|
383
|
+
return injected === undefined ? lstatNoFollowRead(path) : injected;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err?.code === 'ENOENT') return null;
|
|
386
|
+
probeFailure ??= { path: String(path), code: err?.code ?? err?.message ?? 'lstat failed' };
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const refusedProbe = () => (probeFailure === null
|
|
391
|
+
? null
|
|
392
|
+
: refuse(`could not stat "${probeFailure.path}" (${probeFailure.code}) — an unreadable path is a FAILED probe, not an absent one, and counting it as a zero-byte non-regular would put a number nobody can check into the numerator (fail closed)`));
|
|
393
|
+
// --no-abbrev: the raw format ABBREVIATES object names by default, and these names are handed
|
|
394
|
+
// straight to `cat-file -s` to size an image. A full name is read so the value never depends on an
|
|
395
|
+
// abbreviation staying unique in this repository, nor on the repository's object format —
|
|
396
|
+
// `--abbrev=40` is NOT the same instruction, since in a SHA-256 repository it truncates the 64-hex
|
|
397
|
+
// name to 40.
|
|
398
|
+
const stagedRaw = gitBuf(['diff', '--cached', '--raw', '-z', '-M', '--no-abbrev', '--no-ext-diff'], top);
|
|
399
|
+
const unstagedRaw = gitBuf(['diff', '--raw', '-z', '-M', '--no-abbrev', '--no-ext-diff'], top);
|
|
400
|
+
if (stagedRaw == null || unstagedRaw == null) return refuse('git could not read the tracked change set (fail closed)');
|
|
401
|
+
const staged = parseRawRecords(stagedRaw, 'the staged change set');
|
|
402
|
+
if (!staged.ok) return staged;
|
|
403
|
+
const unstaged = parseRawRecords(unstagedRaw, 'the unstaged change set');
|
|
404
|
+
if (!unstaged.ok) return unstaged;
|
|
405
|
+
const stagedBinary = binaryNames(top, ['--cached'], 'the staged binary markers');
|
|
406
|
+
if (!stagedBinary.ok) return stagedBinary;
|
|
407
|
+
const unstagedBinary = binaryNames(top, [], 'the unstaged binary markers');
|
|
408
|
+
if (!unstagedBinary.ok) return unstagedBinary;
|
|
409
|
+
const binary = new Set([...stagedBinary.names, ...unstagedBinary.names]);
|
|
410
|
+
let untracked = io.untracked?.();
|
|
411
|
+
if (untracked === undefined) {
|
|
412
|
+
const buf = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
|
|
413
|
+
if (buf == null) return refuse('git could not read the untracked section (fail closed)');
|
|
414
|
+
const slices = splitZBytes(buf).filter((slice) => slice.length > 0);
|
|
415
|
+
const undecodable = slices.find((slice) => !decodesExactly(slice));
|
|
416
|
+
if (undecodable !== undefined) return undecodablePath('the untracked section', undecodable);
|
|
417
|
+
untracked = slices.map((slice) => slice.toString('utf8'));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Tracked objects and untracked paths are enumerated INDEPENDENTLY: nothing here pairs a deletion
|
|
421
|
+
// with a creation, so no path is ever consumed by another object's identity.
|
|
422
|
+
const objects = buildObjects(staged.records, unstaged.records);
|
|
423
|
+
const entries = [];
|
|
424
|
+
for (const object of objects) {
|
|
425
|
+
const built = entryForObject(top, object, binary, lstat);
|
|
426
|
+
// A recorded probe failure OUTRANKS whatever refusal the entry builder produced: a null size from
|
|
427
|
+
// an unreadable path would otherwise surface as "cannot establish the size", losing the path and
|
|
428
|
+
// the errno this module promised to name.
|
|
429
|
+
if (!built.ok) return refusedProbe() ?? built;
|
|
430
|
+
entries.push(built.entry);
|
|
431
|
+
}
|
|
432
|
+
for (const rel of untracked) {
|
|
433
|
+
const built = entryForUntracked(top, rel, lstat);
|
|
434
|
+
// A recorded probe failure OUTRANKS whatever refusal the entry builder produced: a null size from
|
|
435
|
+
// an unreadable path would otherwise surface as "cannot establish the size", losing the path and
|
|
436
|
+
// the errno this module promised to name.
|
|
437
|
+
if (!built.ok) return refusedProbe() ?? built;
|
|
438
|
+
if (built.entry !== null) entries.push(built.entry);
|
|
439
|
+
}
|
|
440
|
+
// Checked LAST and over the whole walk: a failed stat anywhere refuses the entire enumeration, so
|
|
441
|
+
// no partially-honest entry list can escape on the strength of the paths that happened to work.
|
|
442
|
+
return refusedProbe() ?? { ok: true, entries };
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// computeReturnedDiff(cwd) → the canonical uncommitted-state payload BYTES, or a refusal. This is the
|
|
446
|
+
// diff half of the integration bundle, and it is the very payload computeTreeFingerprint digests — so
|
|
447
|
+
// the bytes the denominator counts and the digest the records bind describe one state. It refuses
|
|
448
|
+
// where the enumeration refuses: one entry point may not answer for a tree the other rejects. That
|
|
449
|
+
// sentence used to hold only for the two SHARED preconditions below, which let an AMBIGUOUS rename
|
|
450
|
+
// refuse the enumeration while the diff still handed back bytes — so the enumeration is run here and
|
|
451
|
+
// its refusal surfaced verbatim. One extra pass, once per return, never in a loop.
|
|
452
|
+
export const computeReturnedDiff = (cwd = process.cwd(), io = {}) => {
|
|
453
|
+
const top = gitTop(cwd);
|
|
454
|
+
if (top == null) return refuse('not inside a git work tree — there is no returned diff to compute (fail closed)');
|
|
455
|
+
if (resolveBase(top) == null) {
|
|
456
|
+
return refuse('the branch is unborn (no HEAD) — there is no pre-image to attribute delegated bytes against (fail closed)');
|
|
457
|
+
}
|
|
458
|
+
const enumerated = enumerateReturnedObjects(cwd, io);
|
|
459
|
+
if (!enumerated.ok) return enumerated;
|
|
460
|
+
// The payload builder guards its lstat but reads untracked file BYTES unguarded, so an unreadable
|
|
461
|
+
// or vanishing untracked file throws out of it. This entry point promises a refusal object, and a
|
|
462
|
+
// caller that framed a bundle around a thrown read would have no bytes and no reason either.
|
|
463
|
+
let payload;
|
|
464
|
+
try {
|
|
465
|
+
payload = computeFingerprintPayload(cwd);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
return refuse(`the canonical payload could not be read (${err?.code ?? err?.message ?? 'read failed'}) — the returned diff is refused rather than framed around a partial read (fail closed)`);
|
|
468
|
+
}
|
|
469
|
+
return payload == null
|
|
470
|
+
? refuse('the canonical payload could not be computed (fail closed)')
|
|
471
|
+
: { ok: true, diff: payload };
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// assembleIntegrationBundle(diff, report) → the framed bundle with its digest and length. The framing
|
|
475
|
+
// itself belongs to the vocabulary; this only carries the two parts to it and hashes the result.
|
|
476
|
+
export const assembleIntegrationBundle = (diff, report) => {
|
|
477
|
+
const bundle = frameIntegrationBundle(diff, report);
|
|
478
|
+
return {
|
|
479
|
+
bundle,
|
|
480
|
+
bundleDigest: createHash('sha256').update(bundle).digest('hex'),
|
|
481
|
+
bundleLength: bundle.length,
|
|
482
|
+
};
|
|
483
|
+
};
|