@stdd/plugin 0.9.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/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/extensions/stdd.mjs +77 -0
- package/hooks/claude-hooks.json +28 -0
- package/hooks/codex-hooks.json +28 -0
- package/package.json +38 -0
- package/runtime/adapters/README.md +158 -0
- package/runtime/cli/check.mjs +555 -0
- package/runtime/cli/ci.mjs +190 -0
- package/runtime/cli/claude-hooks.mjs +689 -0
- package/runtime/cli/config.mjs +27 -0
- package/runtime/cli/evidence.mjs +249 -0
- package/runtime/cli/generated-files.mjs +1693 -0
- package/runtime/cli/held-fs.mjs +415 -0
- package/runtime/cli/init.mjs +883 -0
- package/runtime/cli/ledger.mjs +1470 -0
- package/runtime/cli/lib.mjs +909 -0
- package/runtime/cli/path-bytes.mjs +83 -0
- package/runtime/cli/policy.mjs +112 -0
- package/runtime/cli/recorders.mjs +188 -0
- package/runtime/cli/review-fs.mjs +825 -0
- package/runtime/cli/review.mjs +1065 -0
- package/runtime/cli/runtime.mjs +32 -0
- package/runtime/cli/scope.mjs +185 -0
- package/runtime/cli/snapshot.mjs +897 -0
- package/runtime/cli/state-validation.mjs +168 -0
- package/runtime/cli/status.mjs +580 -0
- package/runtime/cli/stdd.mjs +536 -0
- package/runtime/cli/worker-fs.mjs +971 -0
- package/runtime/cli/worker-metadata.mjs +139 -0
- package/runtime/cli/worker.mjs +779 -0
- package/runtime/method/README.md +634 -0
- package/runtime/method/reference-commands.md +147 -0
- package/runtime/method/reference-generated-state.md +151 -0
- package/runtime/method/reference-integration.md +233 -0
- package/runtime/package.json +65 -0
- package/runtime/playbooks/brainstorming.md +46 -0
- package/runtime/playbooks/debugging.md +36 -0
- package/runtime/playbooks/delegate-slice.md +129 -0
- package/runtime/playbooks/finish-change.md +46 -0
- package/runtime/playbooks/implement.md +26 -0
- package/runtime/playbooks/investigation.md +33 -0
- package/runtime/playbooks/managed-playbooks.json +14 -0
- package/runtime/playbooks/planning.md +177 -0
- package/runtime/playbooks/pr-green.md +50 -0
- package/runtime/playbooks/start-change.md +37 -0
- package/runtime/playbooks/worktrees.md +45 -0
- package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
- package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
- package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
- package/runtime/sdk/adapters.mjs +279 -0
- package/runtime/sdk/file-observation.mjs +12 -0
- package/runtime/sdk/index.d.ts +140 -0
- package/runtime/sdk/index.mjs +31 -0
- package/runtime/sdk/native-fs.mjs +1235 -0
- package/runtime/sdk/path.mjs +71 -0
- package/runtime/sdk/text.mjs +42 -0
- package/runtime/sdk/workflow.mjs +294 -0
- package/runtime/templates/deferred-design.md +47 -0
- package/runtime/templates/github-stdd.yml +42 -0
- package/runtime/templates/gitlab-stdd.yml +72 -0
- package/runtime/templates/pr-description.md +35 -0
- package/scripts/adopting-root.mjs +42 -0
- package/scripts/stdd-hook.mjs +72 -0
- package/skills/stdd-brainstorming/SKILL.md +48 -0
- package/skills/stdd-debugging/SKILL.md +38 -0
- package/skills/stdd-delegate-slice/SKILL.md +118 -0
- package/skills/stdd-finish-change/SKILL.md +40 -0
- package/skills/stdd-implement/SKILL.md +28 -0
- package/skills/stdd-investigation/SKILL.md +35 -0
- package/skills/stdd-planning/SKILL.md +165 -0
- package/skills/stdd-pr-green/SKILL.md +52 -0
- package/skills/stdd-start-change/SKILL.md +39 -0
- package/skills/stdd-worktrees/SKILL.md +46 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
// --- checkout, dirty, worker, and review observations ---
|
|
2
|
+
//
|
|
3
|
+
// Owns every filesystem observation the loop compares against itself: the
|
|
4
|
+
// checkout and dirty-path fingerprints, the managed-worker tree walk, and the
|
|
5
|
+
// review material a brief is built from. Bytes leave this module only through a
|
|
6
|
+
// verified descriptor. It has no dependency on the entry module.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { sameFileObservation } from "../sdk/file-observation.mjs";
|
|
13
|
+
import {
|
|
14
|
+
currentTaskPlan,
|
|
15
|
+
isStateExemptPath,
|
|
16
|
+
isTrustedLedgerInternalTemp,
|
|
17
|
+
STATE_EXEMPT,
|
|
18
|
+
} from "./ledger.mjs";
|
|
19
|
+
import { deferredSectionRange, sha256 } from "./lib.mjs";
|
|
20
|
+
import {
|
|
21
|
+
absPathBuf,
|
|
22
|
+
bufferPathIsWithin,
|
|
23
|
+
parentPathBuf,
|
|
24
|
+
pathForMatch,
|
|
25
|
+
realPathBuf,
|
|
26
|
+
splitNul,
|
|
27
|
+
viewPath,
|
|
28
|
+
} from "./path-bytes.mjs";
|
|
29
|
+
import { fail, MAX_SUBPROCESS_BUFFER, subprocessError } from "./runtime.mjs";
|
|
30
|
+
import { readWorkerPathState, sameWorkerState, workerViewPath } from "./worker-fs.mjs";
|
|
31
|
+
import { readWorkerMetadata, WORKER_METADATA_REL } from "./worker-metadata.mjs";
|
|
32
|
+
|
|
33
|
+
export const DIRTY_FINGERPRINT_READ_LIMIT = 40_000;
|
|
34
|
+
|
|
35
|
+
function snapshotStatMetadata(st) {
|
|
36
|
+
return [st.dev, st.ino, st.uid, st.gid, st.mode, st.nlink, st.size, st.mtimeNs, st.ctimeNs].join(":");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function snapshotOwnerIsCurrent(st) {
|
|
40
|
+
return typeof process.getuid !== "function" || st.uid === BigInt(process.getuid());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sameSnapshotFileObservation(left, right) {
|
|
44
|
+
return sameFileObservation(left, right) && left.uid === right.uid && left.gid === right.gid;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unsafeSnapshotFingerprint(kind, st) {
|
|
48
|
+
return `${kind}:${st ? snapshotStatMetadata(st) : "unstattable"}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Historical raw-byte fingerprint, now read only through a verified fd. */
|
|
52
|
+
function fingerprintRawSnapshotDescriptor(descriptor) {
|
|
53
|
+
const hash = createHash("sha256");
|
|
54
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
55
|
+
let position = 0;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const count = fs.readSync(descriptor, chunk, 0, chunk.length, position);
|
|
58
|
+
if (count === 0) break;
|
|
59
|
+
hash.update(chunk.subarray(0, count));
|
|
60
|
+
position += count;
|
|
61
|
+
}
|
|
62
|
+
return `sha256:${hash.digest("hex")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Brief-inspection fingerprint for one already-verified descriptor.
|
|
67
|
+
* Oversized files bind a bounded prefix to identity and change metadata;
|
|
68
|
+
* no inspection descriptor reads beyond the inline bound.
|
|
69
|
+
*/
|
|
70
|
+
function fingerprintBoundedReviewDescriptor(descriptor, opened, retainBytes = false) {
|
|
71
|
+
const oversized = opened.size > BigInt(DIRTY_FINGERPRINT_READ_LIMIT);
|
|
72
|
+
const expected = Number(
|
|
73
|
+
opened.size < BigInt(DIRTY_FINGERPRINT_READ_LIMIT)
|
|
74
|
+
? opened.size
|
|
75
|
+
: BigInt(DIRTY_FINGERPRINT_READ_LIMIT),
|
|
76
|
+
);
|
|
77
|
+
const hash = createHash("sha256");
|
|
78
|
+
if (oversized) {
|
|
79
|
+
hash.update(`stdd-bounded-file-v1:${snapshotStatMetadata(opened)}\n`);
|
|
80
|
+
}
|
|
81
|
+
const retained = retainBytes ? Buffer.alloc(expected) : null;
|
|
82
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, Math.max(expected, 1)));
|
|
83
|
+
let position = 0;
|
|
84
|
+
while (position < expected) {
|
|
85
|
+
const count = fs.readSync(
|
|
86
|
+
descriptor,
|
|
87
|
+
chunk,
|
|
88
|
+
0,
|
|
89
|
+
Math.min(chunk.length, expected - position),
|
|
90
|
+
position,
|
|
91
|
+
);
|
|
92
|
+
if (count === 0) break;
|
|
93
|
+
hash.update(chunk.subarray(0, count));
|
|
94
|
+
if (retained !== null) chunk.copy(retained, position, 0, count);
|
|
95
|
+
position += count;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
fingerprint: `sha256:${hash.digest("hex")}`,
|
|
99
|
+
bytes: retained?.subarray(0, position) ?? null,
|
|
100
|
+
complete: position === expected,
|
|
101
|
+
oversized,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fingerprintDirtyPath(abs, observed, boundedReview) {
|
|
106
|
+
if (observed.isSymbolicLink()) {
|
|
107
|
+
try {
|
|
108
|
+
const target = fs.readlinkSync(abs, "buffer");
|
|
109
|
+
const after = fs.lstatSync(abs, { bigint: true });
|
|
110
|
+
if (!after.isSymbolicLink() || !sameSnapshotFileObservation(observed, after)) {
|
|
111
|
+
return unsafeSnapshotFingerprint("unsafe", observed);
|
|
112
|
+
}
|
|
113
|
+
return sha256(`link:${target.toString("latin1")}`);
|
|
114
|
+
} catch {
|
|
115
|
+
return unsafeSnapshotFingerprint("unsafe", observed);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!observed.isFile() || observed.nlink !== 1n || !snapshotOwnerIsCurrent(observed)) {
|
|
119
|
+
return unsafeSnapshotFingerprint("unsafe", observed);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let descriptor = null;
|
|
123
|
+
try {
|
|
124
|
+
descriptor = fs.openSync(
|
|
125
|
+
abs,
|
|
126
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
|
|
127
|
+
);
|
|
128
|
+
const opened = fs.fstatSync(descriptor, { bigint: true });
|
|
129
|
+
const pathAtOpen = fs.lstatSync(abs, { bigint: true });
|
|
130
|
+
if (
|
|
131
|
+
!opened.isFile() ||
|
|
132
|
+
opened.nlink !== 1n ||
|
|
133
|
+
!snapshotOwnerIsCurrent(opened) ||
|
|
134
|
+
pathAtOpen.isSymbolicLink() ||
|
|
135
|
+
!pathAtOpen.isFile() ||
|
|
136
|
+
pathAtOpen.nlink !== 1n ||
|
|
137
|
+
!snapshotOwnerIsCurrent(pathAtOpen) ||
|
|
138
|
+
!sameSnapshotFileObservation(observed, opened) ||
|
|
139
|
+
!sameSnapshotFileObservation(opened, pathAtOpen)
|
|
140
|
+
) {
|
|
141
|
+
return unsafeSnapshotFingerprint("unsafe", observed);
|
|
142
|
+
}
|
|
143
|
+
const fingerprint = boundedReview
|
|
144
|
+
? fingerprintBoundedReviewDescriptor(descriptor, opened)
|
|
145
|
+
: {
|
|
146
|
+
fingerprint: fingerprintRawSnapshotDescriptor(descriptor),
|
|
147
|
+
complete: true,
|
|
148
|
+
};
|
|
149
|
+
const after = fs.fstatSync(descriptor, { bigint: true });
|
|
150
|
+
const finalPath = fs.lstatSync(abs, { bigint: true });
|
|
151
|
+
if (
|
|
152
|
+
!fingerprint.complete ||
|
|
153
|
+
finalPath.isSymbolicLink() ||
|
|
154
|
+
!finalPath.isFile() ||
|
|
155
|
+
finalPath.nlink !== 1n ||
|
|
156
|
+
!snapshotOwnerIsCurrent(after) ||
|
|
157
|
+
!snapshotOwnerIsCurrent(finalPath) ||
|
|
158
|
+
!sameSnapshotFileObservation(opened, after) ||
|
|
159
|
+
!sameSnapshotFileObservation(after, finalPath)
|
|
160
|
+
) {
|
|
161
|
+
return unsafeSnapshotFingerprint("unsafe", observed);
|
|
162
|
+
}
|
|
163
|
+
return fingerprint.fingerprint;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return unsafeSnapshotFingerprint(err.code === "EACCES" ? "unreadable" : "unsafe", observed);
|
|
166
|
+
} finally {
|
|
167
|
+
if (descriptor !== null) {
|
|
168
|
+
try {
|
|
169
|
+
fs.closeSync(descriptor);
|
|
170
|
+
} catch {
|
|
171
|
+
// The descriptor is no longer part of the snapshot after the verified read.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Content hashes of every dirty (staged, unstaged, untracked) path.
|
|
179
|
+
* Deleted paths and untracked directories hash to null — equality of
|
|
180
|
+
* nulls still distinguishes inherited state from a slice's own edits.
|
|
181
|
+
*/
|
|
182
|
+
export function dirtySnapshot(cwd, { boundedReview = false } = {}) {
|
|
183
|
+
const worker = readWorkerMetadata(cwd);
|
|
184
|
+
if (worker) {
|
|
185
|
+
try {
|
|
186
|
+
return workerDirtySnapshot(worker.root, worker);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
fail(err.message);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// -z: NUL-delimited, no C-style octal quoting — read as raw bytes so a
|
|
192
|
+
// non-UTF-8 filename is never folded to U+FFFD (which would collapse
|
|
193
|
+
// distinct paths and look up files at the wrong location, letting an
|
|
194
|
+
// untracked doc's content change without staling a review).
|
|
195
|
+
// --untracked-files=all: a wholly untracked directory must list every
|
|
196
|
+
// file inside it, or edits there would never change the snapshot
|
|
197
|
+
const tokens = splitNul(
|
|
198
|
+
execFileSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all"], {
|
|
199
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
200
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
// null-prototype: a file literally named `__proto__` (or `constructor`)
|
|
204
|
+
// must be an own data property, not a write through Object.prototype's
|
|
205
|
+
// setter — otherwise its fingerprint vanishes and edits never stale
|
|
206
|
+
const dirty = Object.create(null);
|
|
207
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
208
|
+
const entry = tokens[i];
|
|
209
|
+
if (entry.length === 0) continue;
|
|
210
|
+
const xy = entry.subarray(0, 2).toString("latin1"); // status bytes are ASCII
|
|
211
|
+
const p = pathForMatch(entry.subarray(3)); // latin1, byte-exact key
|
|
212
|
+
// a rename/copy entry is followed by the origin path token — the
|
|
213
|
+
// current path is what the snapshot tracks
|
|
214
|
+
if (/[RC]/.test(xy)) i++;
|
|
215
|
+
if (isStateExemptPath(cwd, p)) continue;
|
|
216
|
+
// the filesystem path is built from raw bytes, not a decoded string,
|
|
217
|
+
// so a non-UTF-8 name is stat/read at its true location
|
|
218
|
+
const abs = absPathBuf(cwd, p);
|
|
219
|
+
// lstat: a stable symlink is fingerprinted by its target PATH — the
|
|
220
|
+
// change is the link itself. Regular bytes are read only through a
|
|
221
|
+
// no-follow, nonblocking descriptor tied back to this observation.
|
|
222
|
+
let st = null;
|
|
223
|
+
let statError = null;
|
|
224
|
+
try {
|
|
225
|
+
st = p.endsWith("/") ? null : fs.lstatSync(abs, { bigint: true });
|
|
226
|
+
} catch (err) {
|
|
227
|
+
statError = err;
|
|
228
|
+
st = null;
|
|
229
|
+
}
|
|
230
|
+
dirty[p] =
|
|
231
|
+
st === null
|
|
232
|
+
? statError && statError.code !== "ENOENT"
|
|
233
|
+
? unsafeSnapshotFingerprint("unreadable", null)
|
|
234
|
+
: null
|
|
235
|
+
: fingerprintDirtyPath(abs, st, boundedReview);
|
|
236
|
+
}
|
|
237
|
+
return dirty;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Bind a recorded loop fact to the exact checkout that produced it. HEAD is
|
|
242
|
+
* included deliberately: committing after verification requires a fresh run
|
|
243
|
+
* on the commit that will actually be reviewed and pushed.
|
|
244
|
+
*/
|
|
245
|
+
export function checkoutSnapshot(cwd) {
|
|
246
|
+
let head;
|
|
247
|
+
try {
|
|
248
|
+
head = execFileSync("git", ["-C", cwd, "rev-parse", "HEAD"], {
|
|
249
|
+
encoding: "utf8",
|
|
250
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
251
|
+
}).trim();
|
|
252
|
+
} catch {
|
|
253
|
+
head = readWorkerMetadata(cwd)?.source.head ?? "(no HEAD)";
|
|
254
|
+
}
|
|
255
|
+
const dirty = dirtySnapshot(cwd);
|
|
256
|
+
return sha256(`${head}\n${JSON.stringify(dirty)}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function workerIgnoredPaths(root, relativePaths, gitDir) {
|
|
260
|
+
if (relativePaths.length === 0) return new Set();
|
|
261
|
+
try {
|
|
262
|
+
const input = Buffer.concat(relativePaths.map((relative) => Buffer.from(`${relative}\0`)));
|
|
263
|
+
const output = execFileSync(
|
|
264
|
+
"git",
|
|
265
|
+
[`--git-dir=${gitDir}`, `--work-tree=${root}`, "check-ignore", "--no-index", "-z", "--stdin"],
|
|
266
|
+
{ cwd: root, input, stdio: ["pipe", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
|
|
267
|
+
);
|
|
268
|
+
return new Set(splitNul(output).map((entry) => entry.toString("utf8")));
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (err.status === 1) return new Set();
|
|
271
|
+
throw new Error(`cannot evaluate sandbox ignore rules: ${err.message}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function workerTreeFiles(root, metadata) {
|
|
276
|
+
const files = new Set();
|
|
277
|
+
const baselinePaths = Object.keys(metadata.baseline.files);
|
|
278
|
+
const baselinePrefixes = new Set();
|
|
279
|
+
for (const baseline of baselinePaths) {
|
|
280
|
+
let prefix = path.posix.dirname(baseline);
|
|
281
|
+
while (prefix !== ".") {
|
|
282
|
+
baselinePrefixes.add(prefix);
|
|
283
|
+
prefix = path.posix.dirname(prefix);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const gitDir = fs.mkdtempSync(path.join(os.tmpdir(), "stdd-worker-ignore-"));
|
|
287
|
+
const walk = (directory, prefix = "") => {
|
|
288
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true, encoding: "buffer" });
|
|
289
|
+
const names = entries.map((entry) => {
|
|
290
|
+
const name = entry.name.toString("utf8");
|
|
291
|
+
if (!Buffer.from(name, "utf8").equals(entry.name)) {
|
|
292
|
+
throw new Error("managed worker sandbox does not support non-UTF-8 paths");
|
|
293
|
+
}
|
|
294
|
+
return name;
|
|
295
|
+
});
|
|
296
|
+
const relatives = names.map((name) => (prefix ? `${prefix}/${name}` : name));
|
|
297
|
+
const ignored = workerIgnoredPaths(root, relatives, gitDir);
|
|
298
|
+
for (let index = 0; index < entries.length; index++) {
|
|
299
|
+
const entry = entries[index];
|
|
300
|
+
const relative = relatives[index];
|
|
301
|
+
const carriesBaseline =
|
|
302
|
+
Object.hasOwn(metadata.baseline.files, relative) || baselinePrefixes.has(relative);
|
|
303
|
+
if (ignored.has(relative) && !carriesBaseline) continue;
|
|
304
|
+
if (relative.split("/").includes(".git")) {
|
|
305
|
+
throw new Error(`managed worker sandbox must not contain .git: ${workerViewPath(relative)}`);
|
|
306
|
+
}
|
|
307
|
+
if (isStateExemptPath(root, relative) || relative === WORKER_METADATA_REL) continue;
|
|
308
|
+
const absolute = path.join(directory, names[index]);
|
|
309
|
+
if (entry.isDirectory()) walk(absolute, relative);
|
|
310
|
+
else files.add(relative);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
execFileSync("git", ["init", "--bare", "-q", gitDir], { stdio: "ignore" });
|
|
315
|
+
walk(root);
|
|
316
|
+
return [...files].sort();
|
|
317
|
+
} finally {
|
|
318
|
+
fs.rmSync(gitDir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function workerCurrentStates(root, metadata) {
|
|
323
|
+
const paths = new Set([...Object.keys(metadata.baseline.files), ...workerTreeFiles(root, metadata)]);
|
|
324
|
+
const states = Object.create(null);
|
|
325
|
+
for (const relative of paths) states[relative] = readWorkerPathState(root, relative).state;
|
|
326
|
+
return states;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function workerDirtySnapshot(root, metadata) {
|
|
330
|
+
const current = workerCurrentStates(root, metadata);
|
|
331
|
+
const dirty = Object.create(null);
|
|
332
|
+
for (const relative of new Set([...Object.keys(metadata.baseline.files), ...Object.keys(current)])) {
|
|
333
|
+
const before = metadata.baseline.files[relative] ?? null;
|
|
334
|
+
const after = current[relative] ?? null;
|
|
335
|
+
if (!sameWorkerState(before, after)) {
|
|
336
|
+
dirty[relative] = after?.hash ?? null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return dirty;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Only the WORKING artifacts are exempt from review evidence — recording
|
|
343
|
+
// events must never invalidate a review. Tracked .stdd/ deliverables
|
|
344
|
+
// (config, generated kit) stay under review like any other file.
|
|
345
|
+
const REVIEW_EXEMPT = STATE_EXEMPT;
|
|
346
|
+
|
|
347
|
+
function reviewExemptPathspecs(cwd) {
|
|
348
|
+
const trustedTemps = [];
|
|
349
|
+
try {
|
|
350
|
+
for (const name of fs.readdirSync(path.join(cwd, ".stdd"))) {
|
|
351
|
+
const relative = `.stdd/${name}`;
|
|
352
|
+
if (isTrustedLedgerInternalTemp(cwd, relative)) trustedTemps.push(relative);
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// An absent/unreadable state directory contributes no trusted exemption.
|
|
356
|
+
}
|
|
357
|
+
return [...REVIEW_EXEMPT, ...trustedTemps].map((p) => `:(exclude,literal)${p}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The diff under review. `strict` aborts on an unresolvable base — a
|
|
362
|
+
* review of an unavailable diff proves nothing and must not be recordable;
|
|
363
|
+
* status/gate callers stay tolerant and get a placeholder instead.
|
|
364
|
+
*/
|
|
365
|
+
function reviewDiff(cwd, baseRef, strict) {
|
|
366
|
+
try {
|
|
367
|
+
return execFileSync(
|
|
368
|
+
"git",
|
|
369
|
+
[
|
|
370
|
+
"-C",
|
|
371
|
+
cwd,
|
|
372
|
+
"diff",
|
|
373
|
+
"--no-ext-diff",
|
|
374
|
+
"--no-textconv",
|
|
375
|
+
"--full-index",
|
|
376
|
+
"--end-of-options",
|
|
377
|
+
baseRef,
|
|
378
|
+
"--",
|
|
379
|
+
".",
|
|
380
|
+
...reviewExemptPathspecs(cwd),
|
|
381
|
+
],
|
|
382
|
+
{
|
|
383
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
384
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
} catch {
|
|
388
|
+
if (strict) {
|
|
389
|
+
fail(
|
|
390
|
+
`cannot diff against "${baseRef}" — fetch the base ref or fix "baseRef" in .stdd/config.json`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return Buffer.from("(unresolvable base)");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The plan as snapshot material: checkbox marks are normalized away — they are
|
|
399
|
+
* claims graded by the ledger — and so is the `## Deferred` section, which
|
|
400
|
+
* holds recorded scope cuts rather than specification. A session that finds
|
|
401
|
+
* something after an approval is told to defer it instead of editing; that
|
|
402
|
+
* move must not destroy the approval it protects. The reviewer still receives
|
|
403
|
+
* the whole plan file in the brief. Editing the plan's words DOES stale a
|
|
404
|
+
* review: the verdict is a comparison against exactly that specification.
|
|
405
|
+
*/
|
|
406
|
+
function normalizedPlanContent(plan) {
|
|
407
|
+
if (plan === null) return "(no plan for the active task)";
|
|
408
|
+
// Line endings are normalized before anything else, and unconditionally:
|
|
409
|
+
// doing it only on the branch that finds a section would make the first
|
|
410
|
+
// `stdd defer` on a CRLF plan look like an edit.
|
|
411
|
+
const lines = plan.replaceAll("\r\n", "\n").split("\n");
|
|
412
|
+
const section = deferredSectionRange(lines);
|
|
413
|
+
const kept =
|
|
414
|
+
section === null ? lines : [...lines.slice(0, section.start), ...lines.slice(section.end)];
|
|
415
|
+
return (
|
|
416
|
+
kept
|
|
417
|
+
.join("\n")
|
|
418
|
+
.replace(/^(\s*[-*+]\s+)\[[ xX]\]/gm, "$1[ ]")
|
|
419
|
+
// Trailing newlines are not specification, and creating the section on
|
|
420
|
+
// a plan that lacked a final newline leaves one behind. Adding a line
|
|
421
|
+
// after the last one still reads as the edit it is.
|
|
422
|
+
.replace(/\n+$/, "")
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Every path whose working-tree content differs from `baseRef`, mapped to a
|
|
428
|
+
* content fingerprint. The set is the union of the tracked diff and the
|
|
429
|
+
* untracked entries of `git status`, which makes it invariant under `git add`
|
|
430
|
+
* and `git commit`: staging moves a path between those two inputs and
|
|
431
|
+
* committing empties the second, but neither changes a byte on disk. Keys stay
|
|
432
|
+
* byte-exact latin1, so a non-UTF-8 name is never folded to U+FFFD and two
|
|
433
|
+
* distinct paths never collapse into one.
|
|
434
|
+
*
|
|
435
|
+
* The executable bit rides along because the fingerprint hashes content only,
|
|
436
|
+
* and git itself distinguishes exactly `100644` from `100755`.
|
|
437
|
+
*/
|
|
438
|
+
function changedContentFingerprints(cwd, baseRef, strict) {
|
|
439
|
+
const paths = new Set(); // latin1, byte-exact keys
|
|
440
|
+
const remember = (p) => {
|
|
441
|
+
if (!isStateExemptPath(cwd, p)) paths.add(p);
|
|
442
|
+
};
|
|
443
|
+
try {
|
|
444
|
+
for (const entry of splitNul(
|
|
445
|
+
execFileSync(
|
|
446
|
+
"git",
|
|
447
|
+
[
|
|
448
|
+
"-C",
|
|
449
|
+
cwd,
|
|
450
|
+
"diff",
|
|
451
|
+
"--name-only",
|
|
452
|
+
"-z",
|
|
453
|
+
// Rename detection makes the path set depend on the index: an
|
|
454
|
+
// unstaged rename reads as a deletion plus an untracked file,
|
|
455
|
+
// and a staged one collapses to the destination alone. Reporting
|
|
456
|
+
// both sides always keeps the set invariant under `git add`.
|
|
457
|
+
"--no-renames",
|
|
458
|
+
"--end-of-options",
|
|
459
|
+
baseRef,
|
|
460
|
+
"--",
|
|
461
|
+
".",
|
|
462
|
+
...reviewExemptPathspecs(cwd),
|
|
463
|
+
],
|
|
464
|
+
{ stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
|
|
465
|
+
),
|
|
466
|
+
)) {
|
|
467
|
+
if (entry.length > 0) remember(pathForMatch(entry));
|
|
468
|
+
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
if (strict) {
|
|
471
|
+
fail(
|
|
472
|
+
`cannot diff against "${baseRef}" — fetch the base ref or fix "baseRef" in .stdd/config.json`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return { "(unresolvable base ref)": subprocessError(err) };
|
|
476
|
+
}
|
|
477
|
+
const statusTokens = splitNul(
|
|
478
|
+
execFileSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all"], {
|
|
479
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
480
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
for (let i = 0; i < statusTokens.length; i++) {
|
|
484
|
+
const entry = statusTokens[i];
|
|
485
|
+
if (entry.length === 0) continue;
|
|
486
|
+
const xy = entry.subarray(0, 2).toString("latin1"); // status bytes are ASCII
|
|
487
|
+
// a rename/copy entry is followed by its origin path token; consume it
|
|
488
|
+
// here or the next iteration would read that path as a status entry
|
|
489
|
+
if (/[RC]/.test(xy)) {
|
|
490
|
+
i++;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (xy === "??") remember(pathForMatch(entry.subarray(3)));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const gitlinks = gitlinkRecords(cwd, baseRef);
|
|
497
|
+
// null-prototype for the same reason dirtySnapshot uses one: a file named
|
|
498
|
+
// `__proto__` must be an own data property, not a write through the setter
|
|
499
|
+
const changed = Object.create(null);
|
|
500
|
+
for (const key of [...paths].sort()) {
|
|
501
|
+
const abs = absPathBuf(cwd, key);
|
|
502
|
+
let st = null;
|
|
503
|
+
let statError = null;
|
|
504
|
+
try {
|
|
505
|
+
st = key.endsWith("/") ? null : fs.lstatSync(abs, { bigint: true });
|
|
506
|
+
} catch (err) {
|
|
507
|
+
statError = err;
|
|
508
|
+
st = null;
|
|
509
|
+
}
|
|
510
|
+
if (st === null) {
|
|
511
|
+
changed[key] =
|
|
512
|
+
statError && statError.code !== "ENOENT" ? unsafeSnapshotFingerprint("unreadable", null) : null;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (st.isDirectory()) {
|
|
516
|
+
// A gitlink differs from base by the commit it points at, which no
|
|
517
|
+
// filesystem fingerprint of the directory can see. Git resolves the
|
|
518
|
+
// worktree side of the pointer, so its raw record is the content.
|
|
519
|
+
changed[key] = gitlinkFingerprint(cwd, key, gitlinks.has(key), gitlinks.get(key), st);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const fingerprint = fingerprintDirtyPath(abs, st, false);
|
|
523
|
+
// A sentinel is stored bare so the strict rejection below still
|
|
524
|
+
// recognizes it by prefix.
|
|
525
|
+
if (fingerprint.startsWith("unsafe:") || fingerprint.startsWith("unreadable:")) {
|
|
526
|
+
changed[key] = fingerprint;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
// The object type leads, because a symlink's fingerprint hashes the
|
|
530
|
+
// string `link:<target>` and a regular file holding exactly those bytes
|
|
531
|
+
// would otherwise be indistinguishable from it. git records exactly
|
|
532
|
+
// 100644 or 100755 and derives that from the OWNER execute bit alone, so
|
|
533
|
+
// group and other execute are not part of what the snapshot compares.
|
|
534
|
+
const kind = st.isSymbolicLink() ? "link" : "blob";
|
|
535
|
+
changed[key] = `${kind}:${fingerprint}:${(st.mode & 0o100n) === 0n ? "-" : "x"}`;
|
|
536
|
+
}
|
|
537
|
+
return changed;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* One gitlink's snapshot record: always the submodule's checked-out HEAD, and
|
|
542
|
+
* never git's raw destination id. That id is the indexed pointer and is
|
|
543
|
+
* all-zeros while the submodule is out of sync, so reading it would give the
|
|
544
|
+
* same worktree pointer two spellings and let `git add` alone stale a review.
|
|
545
|
+
*
|
|
546
|
+
* A directory is only a gitlink when git says so. Without that gate an
|
|
547
|
+
* ordinary directory left where a tracked file used to be would resolve
|
|
548
|
+
* through the parent repository and hash as a plausible pointer instead of
|
|
549
|
+
* being refused. Anything unresolvable stays unsafe, which strict dispatch
|
|
550
|
+
* rejects rather than approving a tree it could not read.
|
|
551
|
+
*/
|
|
552
|
+
function gitlinkFingerprint(cwd, latin1, isGitlink, indexed, st) {
|
|
553
|
+
if (!isGitlink) return unsafeSnapshotFingerprint("unsafe", st);
|
|
554
|
+
const head = submoduleHead(cwd, latin1);
|
|
555
|
+
// One spelling for one pointer. A checked-out submodule answers with its
|
|
556
|
+
// own HEAD, which is what the reviewer's diff shows and what neither
|
|
557
|
+
// staging nor committing can change. Git's own id is the indexed pointer:
|
|
558
|
+
// it is all-zeros while the checkout is out of sync, so it serves only the
|
|
559
|
+
// uninitialized case, where there is no checkout to ask and the recorded
|
|
560
|
+
// pointer is the whole truth.
|
|
561
|
+
// The reviewer's diff carries git's `-dirty` marker for a submodule whose
|
|
562
|
+
// worktree has uncommitted work, so the snapshot carries it too.
|
|
563
|
+
if (head !== null) return `gitlink:${head}:${submoduleIsDirty(cwd, latin1) ? "dirty" : "clean"}`;
|
|
564
|
+
if (indexed) return `gitlink:${indexed}:absent`;
|
|
565
|
+
return unsafeSnapshotFingerprint("unsafe", st);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Whether the submodule checkout has uncommitted work, as git's marker reads it. */
|
|
569
|
+
function submoduleIsDirty(cwd, latin1) {
|
|
570
|
+
const directory = submoduleDirectory(cwd, latin1);
|
|
571
|
+
if (directory === null) return true;
|
|
572
|
+
try {
|
|
573
|
+
return (
|
|
574
|
+
execFileSync("git", ["-C", directory, "status", "--porcelain", "--untracked-files=all"], {
|
|
575
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
576
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
577
|
+
}).length > 0
|
|
578
|
+
);
|
|
579
|
+
} catch {
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The submodule's directory as a string git can take after `-C`, or null when
|
|
586
|
+
* the name is not valid UTF-8 and therefore cannot be handed over without
|
|
587
|
+
* corruption.
|
|
588
|
+
*/
|
|
589
|
+
function submoduleDirectory(cwd, latin1) {
|
|
590
|
+
const bytes = Buffer.from(latin1, "latin1");
|
|
591
|
+
const utf8 = bytes.toString("utf8");
|
|
592
|
+
if (!Buffer.from(utf8, "utf8").equals(bytes)) return null;
|
|
593
|
+
return path.join(cwd, utf8);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The submodule's checked-out HEAD, or null when there is no checkout to ask. */
|
|
597
|
+
function submoduleHead(cwd, latin1) {
|
|
598
|
+
const directory = submoduleDirectory(cwd, latin1);
|
|
599
|
+
if (directory === null) return null;
|
|
600
|
+
try {
|
|
601
|
+
const [head, toplevel] = execFileSync(
|
|
602
|
+
"git",
|
|
603
|
+
["-C", directory, "rev-parse", "HEAD", "--show-toplevel"],
|
|
604
|
+
{ stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
|
|
605
|
+
)
|
|
606
|
+
.toString("utf8")
|
|
607
|
+
.trim()
|
|
608
|
+
.split("\n");
|
|
609
|
+
if (!/^[0-9a-f]{40,}$/.test(head ?? "")) return null;
|
|
610
|
+
// the submodule must be its own repository root; resolving to an
|
|
611
|
+
// enclosing one would report the superproject's HEAD as this pointer
|
|
612
|
+
if (path.resolve(toplevel ?? "") !== path.resolve(directory)) return null;
|
|
613
|
+
return head;
|
|
614
|
+
} catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The changed paths git reports as gitlinks, keyed byte-exactly. Only their
|
|
621
|
+
* identity is taken from here — the pointer itself is read from the submodule
|
|
622
|
+
* checkout, because the id in this record is the indexed one.
|
|
623
|
+
*/
|
|
624
|
+
function gitlinkRecords(cwd, baseRef) {
|
|
625
|
+
const records = new Map();
|
|
626
|
+
let output;
|
|
627
|
+
try {
|
|
628
|
+
output = execFileSync(
|
|
629
|
+
"git",
|
|
630
|
+
[
|
|
631
|
+
"-C",
|
|
632
|
+
cwd,
|
|
633
|
+
"diff",
|
|
634
|
+
"--raw",
|
|
635
|
+
"-z",
|
|
636
|
+
"--no-renames", // same reason as the name-only pass: one path set
|
|
637
|
+
"--end-of-options",
|
|
638
|
+
baseRef,
|
|
639
|
+
"--",
|
|
640
|
+
".",
|
|
641
|
+
...reviewExemptPathspecs(cwd),
|
|
642
|
+
],
|
|
643
|
+
{ stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
|
|
644
|
+
);
|
|
645
|
+
} catch {
|
|
646
|
+
return records;
|
|
647
|
+
}
|
|
648
|
+
const tokens = splitNul(output);
|
|
649
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
650
|
+
const meta = tokens[i].toString("latin1");
|
|
651
|
+
if (!meta.startsWith(":")) continue;
|
|
652
|
+
// :<srcmode> <dstmode> <srcsha> <dstsha> <status>\0<path>[\0<dest>]
|
|
653
|
+
const fields = meta.slice(1).split(" ");
|
|
654
|
+
if (fields.length < 5) continue;
|
|
655
|
+
const [srcMode, dstMode, , dstSha, status] = fields;
|
|
656
|
+
let p = tokens[++i];
|
|
657
|
+
if (p === undefined) break;
|
|
658
|
+
// a rename or copy emits source then destination; the destination is the
|
|
659
|
+
// path that exists now, and it is the one the snapshot tracks
|
|
660
|
+
if (/^[RC]/.test(status)) {
|
|
661
|
+
const destination = tokens[++i];
|
|
662
|
+
if (destination === undefined) break;
|
|
663
|
+
p = destination;
|
|
664
|
+
}
|
|
665
|
+
if (srcMode !== "160000" && dstMode !== "160000") continue;
|
|
666
|
+
records.set(pathForMatch(p), /^0+$/.test(dstSha ?? "") ? null : dstSha);
|
|
667
|
+
}
|
|
668
|
+
return records;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function capturedChangedFiles(cwd, baseRef, strict) {
|
|
672
|
+
try {
|
|
673
|
+
return execFileSync(
|
|
674
|
+
"git",
|
|
675
|
+
[
|
|
676
|
+
"-C",
|
|
677
|
+
cwd,
|
|
678
|
+
"diff",
|
|
679
|
+
"--name-status",
|
|
680
|
+
"-z",
|
|
681
|
+
"--end-of-options",
|
|
682
|
+
baseRef,
|
|
683
|
+
"--",
|
|
684
|
+
".",
|
|
685
|
+
...reviewExemptPathspecs(cwd),
|
|
686
|
+
],
|
|
687
|
+
{ stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
|
|
688
|
+
);
|
|
689
|
+
} catch (err) {
|
|
690
|
+
if (strict) {
|
|
691
|
+
fail(`cannot enumerate changed files — review aborted (git: ${subprocessError(err)})`);
|
|
692
|
+
}
|
|
693
|
+
return Buffer.from("(unresolvable changed-file manifest)");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function capturedPorcelain(cwd) {
|
|
698
|
+
try {
|
|
699
|
+
return execFileSync("git", ["-C", cwd, "status", "--porcelain"], {
|
|
700
|
+
encoding: "utf8",
|
|
701
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
702
|
+
});
|
|
703
|
+
} catch {
|
|
704
|
+
return "(unavailable)";
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function capturedUntrackedFiles(cwd, strict) {
|
|
709
|
+
try {
|
|
710
|
+
return execFileSync("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard", "-z"], {
|
|
711
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
712
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
713
|
+
});
|
|
714
|
+
} catch (err) {
|
|
715
|
+
if (strict) {
|
|
716
|
+
fail(`cannot enumerate untracked files — review aborted (git: ${subprocessError(err)})`);
|
|
717
|
+
}
|
|
718
|
+
return Buffer.from("(unresolvable untracked-file manifest)");
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function captureReviewMaterial(cwd, baseRef, strict = false) {
|
|
723
|
+
const diffBytes = reviewDiff(cwd, baseRef, strict);
|
|
724
|
+
const diff = diffBytes.toString("utf8");
|
|
725
|
+
const dirty = dirtySnapshot(cwd);
|
|
726
|
+
const reviewDirty = dirtySnapshot(cwd, { boundedReview: true });
|
|
727
|
+
const changedContent = changedContentFingerprints(cwd, baseRef, strict);
|
|
728
|
+
if (strict) {
|
|
729
|
+
// A review over bytes that cannot be fingerprinted safely proves
|
|
730
|
+
// nothing. Soft callers retain the metadata sentinel for stale logic,
|
|
731
|
+
// but dispatch/grading rejects unreadable, raced, hard-linked, and
|
|
732
|
+
// non-regular paths — including a committed one that git status calls
|
|
733
|
+
// clean, since the snapshot now reads those too.
|
|
734
|
+
const maps = [dirty, reviewDirty, changedContent];
|
|
735
|
+
const unsafe = [...new Set(maps.flatMap((m) => Object.keys(m)))].filter((p) =>
|
|
736
|
+
maps.some((m) => m[p]?.startsWith?.("unreadable:") || m[p]?.startsWith?.("unsafe:")),
|
|
737
|
+
);
|
|
738
|
+
if (unsafe.length > 0) {
|
|
739
|
+
// the keys are latin1 byte-exact; render them through the one view
|
|
740
|
+
// seam so a non-UTF-8 or control-byte name reads right and cannot
|
|
741
|
+
// inject a line into the message
|
|
742
|
+
fail(
|
|
743
|
+
`file(s) cannot be fingerprinted safely — nothing to review there: ${unsafe
|
|
744
|
+
.map(viewPath)
|
|
745
|
+
.join(", ")}`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const plan = currentTaskPlan(cwd);
|
|
750
|
+
const changedFiles = capturedChangedFiles(cwd, baseRef, strict);
|
|
751
|
+
const porcelain = capturedPorcelain(cwd);
|
|
752
|
+
const untrackedFiles = capturedUntrackedFiles(cwd, strict);
|
|
753
|
+
const snapshot = sha256(`${JSON.stringify(changedContent)}\n${normalizedPlanContent(plan)}`);
|
|
754
|
+
// The durable snapshot deliberately retains its long-standing exemptions
|
|
755
|
+
// (ledger/plan bookkeeping and trusted reset temps). The ephemeral
|
|
756
|
+
// material binding is stricter: every independent read consumed while
|
|
757
|
+
// composing the brief must agree before and after the build.
|
|
758
|
+
const materialBinding = sha256(
|
|
759
|
+
`${snapshot}\n${JSON.stringify(reviewDirty)}\n${sha256(changedFiles)}\n${porcelain}\n${sha256(
|
|
760
|
+
untrackedFiles,
|
|
761
|
+
)}`,
|
|
762
|
+
);
|
|
763
|
+
return {
|
|
764
|
+
snapshot,
|
|
765
|
+
materialBinding,
|
|
766
|
+
diffBytes,
|
|
767
|
+
diff,
|
|
768
|
+
dirty,
|
|
769
|
+
reviewDirty,
|
|
770
|
+
plan,
|
|
771
|
+
changedFiles,
|
|
772
|
+
porcelain,
|
|
773
|
+
untrackedFiles,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Hash of the work under review: the content of every path that differs from
|
|
779
|
+
* baseRef — tracked or untracked, committed or not — plus the plan's text,
|
|
780
|
+
* `.stdd/` excluded, because the ledger and the plan are working artifacts and
|
|
781
|
+
* recording the review itself must not invalidate it. Content, never git's
|
|
782
|
+
* bookkeeping: staging or committing the reviewed work moves no bytes on disk
|
|
783
|
+
* and therefore cannot stale a verdict about those bytes.
|
|
784
|
+
*/
|
|
785
|
+
export function reviewSnapshot(cwd, baseRef, strict = false) {
|
|
786
|
+
return captureReviewMaterial(cwd, baseRef, strict).snapshot;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Inspect one review path through a descriptor. No bytes are returned until
|
|
791
|
+
* the opened inode and its parent have been proven to be stable, regular, and
|
|
792
|
+
* contained by the repository both before and after the bounded read.
|
|
793
|
+
*/
|
|
794
|
+
export function inspectReviewPath(cwd, latin1, realRoot, readLimit = null) {
|
|
795
|
+
const absolute = absPathBuf(cwd, latin1);
|
|
796
|
+
const parent = parentPathBuf(absolute);
|
|
797
|
+
let before;
|
|
798
|
+
let parentBefore;
|
|
799
|
+
try {
|
|
800
|
+
before = fs.lstatSync(absolute, { bigint: true });
|
|
801
|
+
parentBefore = fs.lstatSync(parent, { bigint: true });
|
|
802
|
+
} catch (err) {
|
|
803
|
+
return {
|
|
804
|
+
kind: err.code === "ENOENT" ? "missing" : "unsafe",
|
|
805
|
+
reason: err.code === "ENOENT" ? "missing or deleted" : "metadata could not be read safely",
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
if (
|
|
809
|
+
before.isSymbolicLink() ||
|
|
810
|
+
!before.isFile() ||
|
|
811
|
+
before.nlink !== 1n ||
|
|
812
|
+
parentBefore.isSymbolicLink() ||
|
|
813
|
+
!parentBefore.isDirectory()
|
|
814
|
+
) {
|
|
815
|
+
return {
|
|
816
|
+
kind: "unsafe",
|
|
817
|
+
reason: "symlink, hard-linked, or non-regular filesystem object",
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
let descriptor = null;
|
|
822
|
+
try {
|
|
823
|
+
const realParentBefore = realPathBuf(parent);
|
|
824
|
+
if (!bufferPathIsWithin(realRoot, realParentBefore)) {
|
|
825
|
+
return { kind: "unsafe", reason: "parent resolves outside the repository" };
|
|
826
|
+
}
|
|
827
|
+
descriptor = fs.openSync(
|
|
828
|
+
absolute,
|
|
829
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
|
|
830
|
+
);
|
|
831
|
+
const opened = fs.fstatSync(descriptor, { bigint: true });
|
|
832
|
+
const pathAtOpen = fs.lstatSync(absolute, { bigint: true });
|
|
833
|
+
const parentAtOpen = fs.lstatSync(parent, { bigint: true });
|
|
834
|
+
const realParentAtOpen = realPathBuf(parent);
|
|
835
|
+
const realFileAtOpen = realPathBuf(absolute);
|
|
836
|
+
if (
|
|
837
|
+
!opened.isFile() ||
|
|
838
|
+
opened.nlink !== 1n ||
|
|
839
|
+
pathAtOpen.isSymbolicLink() ||
|
|
840
|
+
!pathAtOpen.isFile() ||
|
|
841
|
+
!sameFileObservation(before, opened) ||
|
|
842
|
+
!sameFileObservation(opened, pathAtOpen) ||
|
|
843
|
+
!sameFileObservation(parentBefore, parentAtOpen) ||
|
|
844
|
+
!bufferPathIsWithin(realRoot, realParentAtOpen) ||
|
|
845
|
+
!bufferPathIsWithin(realRoot, realFileAtOpen)
|
|
846
|
+
) {
|
|
847
|
+
return { kind: "unsafe", reason: "changed or escaped during descriptor inspection" };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
let bytes = null;
|
|
851
|
+
let truncated = false;
|
|
852
|
+
let contentHash = null;
|
|
853
|
+
if (readLimit !== null) {
|
|
854
|
+
const fingerprinted = fingerprintBoundedReviewDescriptor(descriptor, opened, true);
|
|
855
|
+
if (!fingerprinted.complete) {
|
|
856
|
+
return { kind: "unsafe", reason: "changed or ended during bounded descriptor read" };
|
|
857
|
+
}
|
|
858
|
+
bytes = fingerprinted.bytes;
|
|
859
|
+
truncated = fingerprinted.oversized;
|
|
860
|
+
contentHash = fingerprinted.fingerprint;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const after = fs.fstatSync(descriptor, { bigint: true });
|
|
864
|
+
const finalPath = fs.lstatSync(absolute, { bigint: true });
|
|
865
|
+
const finalParent = fs.lstatSync(parent, { bigint: true });
|
|
866
|
+
const realParentAfter = realPathBuf(parent);
|
|
867
|
+
const realFileAfter = realPathBuf(absolute);
|
|
868
|
+
if (
|
|
869
|
+
finalPath.isSymbolicLink() ||
|
|
870
|
+
!finalPath.isFile() ||
|
|
871
|
+
!sameFileObservation(opened, after) ||
|
|
872
|
+
!sameFileObservation(after, finalPath) ||
|
|
873
|
+
!sameFileObservation(parentBefore, finalParent) ||
|
|
874
|
+
!bufferPathIsWithin(realRoot, realParentAfter) ||
|
|
875
|
+
!bufferPathIsWithin(realRoot, realFileAfter)
|
|
876
|
+
) {
|
|
877
|
+
return { kind: "unsafe", reason: "changed or escaped while being inspected" };
|
|
878
|
+
}
|
|
879
|
+
return { kind: "regular", bytes, truncated, contentHash };
|
|
880
|
+
} catch (err) {
|
|
881
|
+
return {
|
|
882
|
+
kind: "unsafe",
|
|
883
|
+
reason:
|
|
884
|
+
err.code === "ELOOP"
|
|
885
|
+
? "became a symlink during inspection"
|
|
886
|
+
: "could not be inspected without following filesystem replacements",
|
|
887
|
+
};
|
|
888
|
+
} finally {
|
|
889
|
+
if (descriptor !== null) {
|
|
890
|
+
try {
|
|
891
|
+
fs.closeSync(descriptor);
|
|
892
|
+
} catch {
|
|
893
|
+
// The descriptor no longer participates in the brief once inspection returns.
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|