@opengsd/gsd-core 1.20.1 → 1.21.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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +234 -126
- package/commands/gsd/autonomous.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +450 -753
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +80 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +13 -11
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +11 -19
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +107 -31
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +91 -41
- package/gsd-core/bin/lib/state.cjs +446 -61
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +97 -17
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +3 -2
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +8 -5
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +97 -17
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
2
|
+
// Read a path's entry out of the commit HEAD names, straight from `.git`.
|
|
3
|
+
//
|
|
4
|
+
// `headTreeEntry(startDir, relPath, limits?)` answers "what mode and object id
|
|
5
|
+
// does the tree of HEAD hold at this path?" without spawning git and without a
|
|
6
|
+
// timer: the verdict depends only on the repository's bytes, never on machine
|
|
7
|
+
// load or the wall clock (#4432).
|
|
8
|
+
//
|
|
9
|
+
// What it reads, in order:
|
|
10
|
+
// 1. the git dir: walk up from startDir to the nearest `.git`; a `.git` FILE
|
|
11
|
+
// (linked worktree, submodule) holds `gitdir: <path>`; a `commondir` file
|
|
12
|
+
// points at the directory that holds the shared objects and refs.
|
|
13
|
+
// 2. HEAD: a `ref:` chain through loose refs, then `packed-refs`, ending at a
|
|
14
|
+
// 40-hex object id (a detached HEAD holds the id directly).
|
|
15
|
+
// 3. objects: a loose object (`objects/xx/yyyy…`, zlib) or an object in a
|
|
16
|
+
// pack (`objects/pack/*.idx` version 2 + `.pack`, including OFS_DELTA and
|
|
17
|
+
// REF_DELTA chains).
|
|
18
|
+
// 4. the commit's `tree`, then each path segment through nested trees.
|
|
19
|
+
//
|
|
20
|
+
// Fail closed: ANY problem — no repository, no commits, a SHA-256 repository,
|
|
21
|
+
// a missing object (shallow/partial clone, objects only in alternates), an
|
|
22
|
+
// unknown index version, corruption, a structural limit — returns null. This
|
|
23
|
+
// module never throws and never answers from a guess. It is a pure reader: it
|
|
24
|
+
// spawns nothing and sets no timer, so every bound below is structural (a
|
|
25
|
+
// count or a byte size), not a duration.
|
|
26
|
+
//
|
|
27
|
+
// Standalone on purpose (node built-ins `fs`, `path`, `zlib` only): hooks are
|
|
28
|
+
// installed as loose files and cannot require `src/`.
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const zlib = require('zlib');
|
|
35
|
+
|
|
36
|
+
const LIMITS = Object.freeze({
|
|
37
|
+
maxRefHops: 8,
|
|
38
|
+
maxParentDirs: 64,
|
|
39
|
+
maxObjectBytes: 1024 * 1024,
|
|
40
|
+
maxDeltaDepth: 50,
|
|
41
|
+
maxRefsFileBytes: 4 * 1024 * 1024,
|
|
42
|
+
maxPackFiles: 64,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
46
|
+
|
|
47
|
+
// Internal bail-out: caught once at the entry point and turned into null.
|
|
48
|
+
class Unreadable extends Error {}
|
|
49
|
+
|
|
50
|
+
function fail(why) {
|
|
51
|
+
throw new Unreadable(why);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveLimits(limits) {
|
|
55
|
+
const out = { ...LIMITS };
|
|
56
|
+
if (limits !== null && typeof limits === 'object') {
|
|
57
|
+
for (const key of Object.keys(LIMITS)) {
|
|
58
|
+
const v = limits[key];
|
|
59
|
+
if (Number.isInteger(v) && v >= 0) out[key] = v;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── bounded file reads ─────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function readExactAt(fd, pos, len) {
|
|
68
|
+
const buf = Buffer.alloc(len);
|
|
69
|
+
const n = fs.readSync(fd, buf, 0, len, pos);
|
|
70
|
+
if (n !== len) fail('short read');
|
|
71
|
+
return buf;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readFileBounded(file, maxBytes) {
|
|
75
|
+
const fd = fs.openSync(file, 'r');
|
|
76
|
+
try {
|
|
77
|
+
const st = fs.fstatSync(fd);
|
|
78
|
+
if (!st.isFile()) fail('not a regular file');
|
|
79
|
+
if (st.size > maxBytes) fail('file too large');
|
|
80
|
+
return st.size === 0 ? Buffer.alloc(0) : readExactAt(fd, 0, st.size);
|
|
81
|
+
} finally {
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readTextOrNull(file, maxBytes) {
|
|
87
|
+
try {
|
|
88
|
+
return readFileBounded(file, maxBytes).toString('utf8');
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof Unreadable) throw err;
|
|
91
|
+
return null; // missing or not a file: the caller decides what that means
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── locating the repository ────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
// realpathSync.native, not the JS realpath: on Windows the JS form leaves an
|
|
98
|
+
// 8.3 short name (RUNNER~1) as written, so the same directory reached through a
|
|
99
|
+
// short and a long name compared unequal and a real linked worktree was refused.
|
|
100
|
+
function sameRealPath(a, b) {
|
|
101
|
+
try {
|
|
102
|
+
return fs.realpathSync.native(a) === fs.realpathSync.native(b);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A `.git` file is followed only when it is the one `git worktree add` wrote:
|
|
109
|
+
// its gitdir is <commondir>/worktrees/<name> and that directory's `gitdir`
|
|
110
|
+
// back-pointer names this very `.git` file. A planted file fails one of the two.
|
|
111
|
+
function linkedWorktree(dir, dotGit, gitDir) {
|
|
112
|
+
const common = readTextOrNull(path.join(gitDir, 'commondir'), 4096);
|
|
113
|
+
if (common === null || common.trim() === '') fail('worktree git dir has no commondir');
|
|
114
|
+
const commonDir = path.resolve(gitDir, common.trim());
|
|
115
|
+
if (!sameRealPath(path.dirname(gitDir), path.join(commonDir, 'worktrees'))) fail('git dir is not a linked worktree');
|
|
116
|
+
const back = readTextOrNull(path.join(gitDir, 'gitdir'), 4096);
|
|
117
|
+
if (back === null || back.trim() === '') fail('worktree git dir has no back-pointer');
|
|
118
|
+
if (!sameRealPath(path.resolve(gitDir, back.trim()), dotGit)) fail('worktree back-pointer names another .git file');
|
|
119
|
+
return { top: dir, gitDir, commonDir };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function locateGit(startDir, limits) {
|
|
123
|
+
let dir = path.resolve(startDir);
|
|
124
|
+
for (let i = 0; i <= limits.maxParentDirs; i++) {
|
|
125
|
+
const dotGit = path.join(dir, '.git');
|
|
126
|
+
let st = null;
|
|
127
|
+
try {
|
|
128
|
+
st = fs.statSync(dotGit);
|
|
129
|
+
} catch {
|
|
130
|
+
st = null;
|
|
131
|
+
}
|
|
132
|
+
if (st !== null) {
|
|
133
|
+
const gitDir = dotGit;
|
|
134
|
+
if (st.isFile()) {
|
|
135
|
+
const text = readTextOrNull(dotGit, 4096);
|
|
136
|
+
const m = text === null ? null : /^gitdir:\s*(.+?)\s*$/m.exec(text);
|
|
137
|
+
if (m === null) fail('unreadable .git file');
|
|
138
|
+
return linkedWorktree(dir, dotGit, path.resolve(dir, m[1]));
|
|
139
|
+
} else if (!st.isDirectory()) {
|
|
140
|
+
fail('unexpected .git entry');
|
|
141
|
+
}
|
|
142
|
+
let commonDir = gitDir;
|
|
143
|
+
const common = readTextOrNull(path.join(gitDir, 'commondir'), 4096);
|
|
144
|
+
if (common !== null && common.trim() !== '') commonDir = path.resolve(gitDir, common.trim());
|
|
145
|
+
return { top: dir, gitDir, commonDir };
|
|
146
|
+
}
|
|
147
|
+
const parent = path.dirname(dir);
|
|
148
|
+
if (parent === dir) break;
|
|
149
|
+
dir = parent;
|
|
150
|
+
}
|
|
151
|
+
return fail('no repository');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function assertSha1Repository(commonDir, limits) {
|
|
155
|
+
const config = readTextOrNull(path.join(commonDir, 'config'), limits.maxRefsFileBytes);
|
|
156
|
+
if (config !== null && /^\s*objectformat\s*=\s*sha256\s*$/im.test(config)) fail('sha256 repository');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── refs ───────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function isSafeRefName(name) {
|
|
162
|
+
return name !== '' && !name.startsWith('/') && !name.includes('\0') && !name.includes('\\') &&
|
|
163
|
+
!name.split('/').some((seg) => seg === '' || seg === '.' || seg === '..');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// packed-refs is read at most once per repository per call, however many ref hops follow.
|
|
167
|
+
function readPackedRefs(base, repo, limits) {
|
|
168
|
+
if (repo.packedRefsCache === undefined) repo.packedRefsCache = new Map();
|
|
169
|
+
if (!repo.packedRefsCache.has(base)) {
|
|
170
|
+
repo.packedRefsCache.set(base, readTextOrNull(path.join(base, 'packed-refs'), limits.maxRefsFileBytes));
|
|
171
|
+
}
|
|
172
|
+
return repo.packedRefsCache.get(base);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function lookupRef(name, repo, limits) {
|
|
176
|
+
if (!isSafeRefName(name)) fail('bad ref name');
|
|
177
|
+
for (const base of repo.gitDir === repo.commonDir ? [repo.gitDir] : [repo.gitDir, repo.commonDir]) {
|
|
178
|
+
const loose = readTextOrNull(path.join(base, ...name.split('/')), 4096);
|
|
179
|
+
if (loose !== null && loose.trim() !== '') return loose.trim();
|
|
180
|
+
}
|
|
181
|
+
for (const base of repo.gitDir === repo.commonDir ? [repo.gitDir] : [repo.commonDir, repo.gitDir]) {
|
|
182
|
+
const packed = readPackedRefs(base, repo, limits);
|
|
183
|
+
if (packed === null) continue;
|
|
184
|
+
for (const line of packed.split('\n')) {
|
|
185
|
+
if (line === '' || line[0] === '#' || line[0] === '^') continue;
|
|
186
|
+
const space = line.indexOf(' ');
|
|
187
|
+
if (space === 40 && line.slice(space + 1).trim() === name) return line.slice(0, 40);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return fail('ref not found');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveHead(repo, limits) {
|
|
194
|
+
const head = readTextOrNull(path.join(repo.gitDir, 'HEAD'), 4096);
|
|
195
|
+
if (head === null) fail('no HEAD');
|
|
196
|
+
let content = head.trim();
|
|
197
|
+
let hops = 0;
|
|
198
|
+
while (content.startsWith('ref:')) {
|
|
199
|
+
hops++;
|
|
200
|
+
if (hops > limits.maxRefHops) fail('too many ref hops');
|
|
201
|
+
content = lookupRef(content.slice(4).trim(), repo, limits);
|
|
202
|
+
}
|
|
203
|
+
if (!HEX40.test(content)) fail('HEAD does not name a SHA-1 object');
|
|
204
|
+
return content;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── objects ────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const TYPE_NAMES = { 1: 'commit', 2: 'tree', 3: 'blob', 4: 'tag' };
|
|
210
|
+
|
|
211
|
+
// Worst-case deflate expansion is 5 bytes per 64 KiB block plus a few bytes of
|
|
212
|
+
// framing; this bound is only a memory cap on how much compressed input to read.
|
|
213
|
+
function compressedCap(inflatedBytes) {
|
|
214
|
+
return inflatedBytes + (inflatedBytes >> 8) + 64;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readLoose(oid, repo, limits) {
|
|
218
|
+
const file = path.join(repo.commonDir, 'objects', oid.slice(0, 2), oid.slice(2));
|
|
219
|
+
let raw;
|
|
220
|
+
try {
|
|
221
|
+
raw = readFileBounded(file, compressedCap(limits.maxObjectBytes));
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err instanceof Unreadable) throw err;
|
|
224
|
+
if (err && err.code === 'ENOENT') return null;
|
|
225
|
+
return fail('unreadable loose object');
|
|
226
|
+
}
|
|
227
|
+
const inflated = zlib.inflateSync(raw, { maxOutputLength: limits.maxObjectBytes });
|
|
228
|
+
const nul = inflated.indexOf(0);
|
|
229
|
+
if (nul === -1) fail('loose object has no header');
|
|
230
|
+
const m = /^(commit|tree|blob|tag) (\d+)$/.exec(inflated.toString('latin1', 0, nul));
|
|
231
|
+
if (m === null) fail('bad loose object header');
|
|
232
|
+
const data = inflated.subarray(nul + 1);
|
|
233
|
+
if (data.length !== Number(m[2])) fail('loose object size mismatch');
|
|
234
|
+
return { type: m[1], data, depth: 0 };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function applyDelta(base, delta, limits) {
|
|
238
|
+
let pos = 0;
|
|
239
|
+
const varint = () => {
|
|
240
|
+
let value = 0;
|
|
241
|
+
let shift = 0;
|
|
242
|
+
for (;;) {
|
|
243
|
+
if (pos >= delta.length) fail('truncated delta');
|
|
244
|
+
const b = delta[pos++];
|
|
245
|
+
value += (b & 0x7f) * 2 ** shift;
|
|
246
|
+
shift += 7;
|
|
247
|
+
if ((b & 0x80) === 0) return value;
|
|
248
|
+
if (shift > 56) fail('delta size overflow');
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const srcSize = varint();
|
|
252
|
+
const dstSize = varint();
|
|
253
|
+
if (srcSize !== base.length) fail('delta base size mismatch');
|
|
254
|
+
if (dstSize > limits.maxObjectBytes) fail('delta result too large');
|
|
255
|
+
const out = Buffer.alloc(dstSize);
|
|
256
|
+
let outPos = 0;
|
|
257
|
+
while (pos < delta.length) {
|
|
258
|
+
const cmd = delta[pos++];
|
|
259
|
+
if ((cmd & 0x80) !== 0) {
|
|
260
|
+
let offset = 0;
|
|
261
|
+
let size = 0;
|
|
262
|
+
for (let i = 0; i < 4; i++) {
|
|
263
|
+
if ((cmd & (1 << i)) !== 0) {
|
|
264
|
+
if (pos >= delta.length) fail('truncated delta copy');
|
|
265
|
+
offset += delta[pos++] * 2 ** (8 * i);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (let i = 0; i < 3; i++) {
|
|
269
|
+
if ((cmd & (0x10 << i)) !== 0) {
|
|
270
|
+
if (pos >= delta.length) fail('truncated delta copy');
|
|
271
|
+
size += delta[pos++] * 2 ** (8 * i);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (size === 0) size = 0x10000;
|
|
275
|
+
if (offset + size > base.length || outPos + size > dstSize) fail('delta copy out of range');
|
|
276
|
+
base.copy(out, outPos, offset, offset + size);
|
|
277
|
+
outPos += size;
|
|
278
|
+
} else if (cmd !== 0) {
|
|
279
|
+
if (pos + cmd > delta.length || outPos + cmd > dstSize) fail('delta insert out of range');
|
|
280
|
+
delta.copy(out, outPos, pos, pos + cmd);
|
|
281
|
+
pos += cmd;
|
|
282
|
+
outPos += cmd;
|
|
283
|
+
} else {
|
|
284
|
+
fail('reserved delta opcode');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (outPos !== dstSize) fail('delta result size mismatch');
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function packEntry(packFd, packSize, offset, level, repo, limits) {
|
|
292
|
+
if (level > limits.maxDeltaDepth) fail('delta chain too deep');
|
|
293
|
+
if (offset < 12 || offset >= packSize) fail('pack offset out of range');
|
|
294
|
+
const header = readExactAt(packFd, offset, Math.min(32, packSize - offset));
|
|
295
|
+
let pos = 0;
|
|
296
|
+
let byte = header[pos++];
|
|
297
|
+
const typeCode = (byte >> 4) & 7;
|
|
298
|
+
let size = byte & 0x0f;
|
|
299
|
+
let shift = 4;
|
|
300
|
+
while ((byte & 0x80) !== 0) {
|
|
301
|
+
if (pos >= header.length || shift > 56) fail('bad pack entry header');
|
|
302
|
+
byte = header[pos++];
|
|
303
|
+
size += (byte & 0x7f) * 2 ** shift;
|
|
304
|
+
shift += 7;
|
|
305
|
+
}
|
|
306
|
+
if (size > limits.maxObjectBytes) fail('pack object too large');
|
|
307
|
+
|
|
308
|
+
let baseOffset = -1;
|
|
309
|
+
let baseOid = null;
|
|
310
|
+
if (typeCode === 6) {
|
|
311
|
+
if (pos >= header.length) fail('bad ofs-delta header');
|
|
312
|
+
byte = header[pos++];
|
|
313
|
+
let rel = byte & 0x7f;
|
|
314
|
+
while ((byte & 0x80) !== 0) {
|
|
315
|
+
if (pos >= header.length) fail('bad ofs-delta header');
|
|
316
|
+
byte = header[pos++];
|
|
317
|
+
rel = (rel + 1) * 128 + (byte & 0x7f);
|
|
318
|
+
}
|
|
319
|
+
baseOffset = offset - rel;
|
|
320
|
+
} else if (typeCode === 7) {
|
|
321
|
+
if (pos + 20 > header.length) fail('bad ref-delta header');
|
|
322
|
+
baseOid = header.toString('hex', pos, pos + 20);
|
|
323
|
+
pos += 20;
|
|
324
|
+
} else if (TYPE_NAMES[typeCode] === undefined) {
|
|
325
|
+
fail('unknown pack object type');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const start = offset + pos;
|
|
329
|
+
const want = Math.min(compressedCap(size), packSize - start);
|
|
330
|
+
if (want <= 0) fail('pack entry has no data');
|
|
331
|
+
const inflated = zlib.inflateSync(readExactAt(packFd, start, want), { maxOutputLength: Math.max(size, 1) });
|
|
332
|
+
if (inflated.length !== size) fail('pack entry size mismatch');
|
|
333
|
+
|
|
334
|
+
if (typeCode !== 6 && typeCode !== 7) return { type: TYPE_NAMES[typeCode], data: inflated, depth: 0 };
|
|
335
|
+
|
|
336
|
+
let base;
|
|
337
|
+
if (typeCode === 6) {
|
|
338
|
+
if (baseOffset < 12) fail('bad ofs-delta base');
|
|
339
|
+
base = packEntry(packFd, packSize, baseOffset, level + 1, repo, limits);
|
|
340
|
+
} else {
|
|
341
|
+
base = readObject(baseOid, repo, limits, level + 1);
|
|
342
|
+
}
|
|
343
|
+
return { type: base.type, data: applyDelta(base.data, inflated, limits), depth: base.depth + 1 };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function idxLookup(idxFd, idxSize, oid) {
|
|
347
|
+
const head = readExactAt(idxFd, 0, 8 + 1024);
|
|
348
|
+
if (head.readUInt32BE(0) !== 0xff744f63 || head.readUInt32BE(4) !== 2) fail('unsupported pack index');
|
|
349
|
+
const fan = (i) => head.readUInt32BE(8 + 4 * i);
|
|
350
|
+
const count = fan(255);
|
|
351
|
+
if (idxSize < 8 + 1024 + count * 28 + 40) fail('truncated pack index');
|
|
352
|
+
const want = Buffer.from(oid, 'hex');
|
|
353
|
+
let lo = want[0] === 0 ? 0 : fan(want[0] - 1);
|
|
354
|
+
let hi = fan(want[0]);
|
|
355
|
+
if (lo > hi || hi > count) fail('corrupt pack index fanout');
|
|
356
|
+
while (lo < hi) {
|
|
357
|
+
const mid = (lo + hi) >>> 1;
|
|
358
|
+
const cmp = Buffer.compare(readExactAt(idxFd, 1032 + mid * 20, 20), want);
|
|
359
|
+
if (cmp === 0) {
|
|
360
|
+
const off32 = readExactAt(idxFd, 1032 + count * 24 + mid * 4, 4).readUInt32BE(0);
|
|
361
|
+
if ((off32 & 0x80000000) === 0) return off32;
|
|
362
|
+
const slot = off32 & 0x7fffffff;
|
|
363
|
+
if (idxSize < 1032 + count * 28 + slot * 8 + 8 + 40) fail('truncated pack index');
|
|
364
|
+
const big = readExactAt(idxFd, 1032 + count * 28 + slot * 8, 8).readBigUInt64BE(0);
|
|
365
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) fail('pack offset too large');
|
|
366
|
+
return Number(big);
|
|
367
|
+
}
|
|
368
|
+
if (cmp < 0) lo = mid + 1;
|
|
369
|
+
else hi = mid;
|
|
370
|
+
}
|
|
371
|
+
return -1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readPacked(oid, repo, limits, level) {
|
|
375
|
+
const packDir = path.join(repo.commonDir, 'objects', 'pack');
|
|
376
|
+
let names;
|
|
377
|
+
try {
|
|
378
|
+
names = fs.readdirSync(packDir).filter((n) => n.endsWith('.idx')).sort();
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
if (names.length > limits.maxPackFiles) fail('too many pack files');
|
|
383
|
+
for (const name of names) {
|
|
384
|
+
const idxFd = fs.openSync(path.join(packDir, name), 'r');
|
|
385
|
+
try {
|
|
386
|
+
const offset = idxLookup(idxFd, fs.fstatSync(idxFd).size, oid);
|
|
387
|
+
if (offset === -1) continue;
|
|
388
|
+
const packFd = fs.openSync(path.join(packDir, `${name.slice(0, -4)}.pack`), 'r');
|
|
389
|
+
try {
|
|
390
|
+
return packEntry(packFd, fs.fstatSync(packFd).size, offset, level, repo, limits);
|
|
391
|
+
} finally {
|
|
392
|
+
fs.closeSync(packFd);
|
|
393
|
+
}
|
|
394
|
+
} finally {
|
|
395
|
+
fs.closeSync(idxFd);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readObject(oid, repo, limits, level) {
|
|
402
|
+
if (!HEX40.test(oid)) fail('bad object id');
|
|
403
|
+
if (level > limits.maxDeltaDepth) fail('delta chain too deep');
|
|
404
|
+
const loose = readLoose(oid, repo, limits);
|
|
405
|
+
if (loose !== null) return loose;
|
|
406
|
+
const packed = readPacked(oid, repo, limits, level);
|
|
407
|
+
if (packed !== null) return packed;
|
|
408
|
+
return fail('object not found');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ─── trees ──────────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
function findInTree(data, name) {
|
|
414
|
+
const want = Buffer.from(name, 'utf8');
|
|
415
|
+
let pos = 0;
|
|
416
|
+
while (pos < data.length) {
|
|
417
|
+
const space = data.indexOf(0x20, pos);
|
|
418
|
+
const nul = space === -1 ? -1 : data.indexOf(0, space + 1);
|
|
419
|
+
if (space === -1 || nul === -1 || nul + 21 > data.length) fail('corrupt tree');
|
|
420
|
+
if (data.subarray(space + 1, nul).equals(want)) {
|
|
421
|
+
return { mode: data.toString('latin1', pos, space), oid: data.toString('hex', nul + 1, nul + 21) };
|
|
422
|
+
}
|
|
423
|
+
pos = nul + 21;
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function lookup(startDir, relPath, limits) {
|
|
429
|
+
if (typeof startDir !== 'string' || startDir === '' || typeof relPath !== 'string' || relPath === '') return null;
|
|
430
|
+
if (startDir.includes('\0') || relPath.includes('\0')) return null;
|
|
431
|
+
const repo = locateGit(startDir, limits);
|
|
432
|
+
const rel = path.relative(repo.top, path.resolve(startDir, relPath));
|
|
433
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null; // allow-handrolled-containment: an installed hook cannot require src/security.cts
|
|
434
|
+
const segments = rel.split(path.sep);
|
|
435
|
+
if (segments.some((seg) => seg === '' || seg === '.' || seg === '..')) return null;
|
|
436
|
+
|
|
437
|
+
assertSha1Repository(repo.commonDir, limits);
|
|
438
|
+
const commit = readObject(resolveHead(repo, limits), repo, limits, 0);
|
|
439
|
+
if (commit.type !== 'commit') fail('HEAD is not a commit');
|
|
440
|
+
const m = /^tree ([0-9a-f]{40})\n/.exec(commit.data.toString('latin1', 0, Math.min(commit.data.length, 64)));
|
|
441
|
+
if (m === null) fail('commit has no tree');
|
|
442
|
+
|
|
443
|
+
let treeOid = m[1];
|
|
444
|
+
for (let i = 0; i < segments.length; i++) {
|
|
445
|
+
const tree = readObject(treeOid, repo, limits, 0);
|
|
446
|
+
if (tree.type !== 'tree') fail('not a tree');
|
|
447
|
+
const entry = findInTree(tree.data, segments[i]);
|
|
448
|
+
if (entry === null) return null;
|
|
449
|
+
if (i === segments.length - 1) return entry;
|
|
450
|
+
if (entry.mode !== '40000') return null;
|
|
451
|
+
treeOid = entry.oid;
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* @param {string} startDir directory the walk up to `.git` starts from
|
|
458
|
+
* @param {string} relPath path relative to startDir (a path below the repo top)
|
|
459
|
+
* @param {Partial<typeof LIMITS>} [limits] structural bounds, for tests
|
|
460
|
+
* @returns {{mode: string, oid: string} | null} the tree entry HEAD holds at the
|
|
461
|
+
* path, or null when it is absent or the repository cannot be read with confidence
|
|
462
|
+
*/
|
|
463
|
+
function headTreeEntry(startDir, relPath, limits) {
|
|
464
|
+
try {
|
|
465
|
+
return lookup(startDir, relPath, resolveLimits(limits));
|
|
466
|
+
} catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Exposed so tests can drive the parsers with hand-built buffers; not a public API.
|
|
472
|
+
const _internal = { applyDelta, idxLookup, packEntry, lookupRef, locateGit, resolveLimits };
|
|
473
|
+
|
|
474
|
+
module.exports = { headTreeEntry, LIMITS, _internal };
|
|
@@ -74,6 +74,10 @@ const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.js
|
|
|
74
74
|
const KEYED_SENTINEL_DIR = path.join('.gsd', 'dispatch-isolation');
|
|
75
75
|
const SAFE_SENTINEL_KEY_RE = /^[A-Za-z0-9._-]{1,100}$/;
|
|
76
76
|
|
|
77
|
+
// #5758: a real sentinel payload is under 300 bytes. Anything larger is not one, and the cap bounds
|
|
78
|
+
// the read so a hostile or runaway record cannot be pulled into the hook.
|
|
79
|
+
const SENTINEL_MAX_BYTES = 4096;
|
|
80
|
+
|
|
77
81
|
/** #5682: strict allow-list for a phase/plan string that becomes a path segment. */
|
|
78
82
|
function isSafeSentinelKey(s) {
|
|
79
83
|
return typeof s === 'string' && SAFE_SENTINEL_KEY_RE.test(s) && s !== '.' && s !== '..';
|
|
@@ -134,10 +138,10 @@ function sentinelPath(cwd) {
|
|
|
134
138
|
/**
|
|
135
139
|
* Resolve the project root a sentinel should be read from/written to, using
|
|
136
140
|
* the SAME derivation gsd-tools.cjs's dispatcher applies to every `--cwd`
|
|
137
|
-
* before invoking a route handler: `
|
|
138
|
-
* (gsd-core/bin/gsd-tools.cjs main()
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
+
* before invoking a route handler: `resolveProjectContext(cwd).projectRoot`
|
|
142
|
+
* (gsd-core/bin/gsd-tools.cjs main() — `record-dispatch-isolation` and
|
|
143
|
+
* `dispatch-isolation` are not in SKIP_ROOT_RESOLUTION, so every write goes
|
|
144
|
+
* through it).
|
|
141
145
|
*
|
|
142
146
|
* #3045 MINOR fix: the guard hooks previously read the sentinel from the raw
|
|
143
147
|
* `data.cwd` / `workspace_roots[i]` the harness reports, with NO equivalent
|
|
@@ -150,7 +154,7 @@ function sentinelPath(cwd) {
|
|
|
150
154
|
* written where it looked. Deriving both sides through this one function
|
|
151
155
|
* closes that divergence.
|
|
152
156
|
*
|
|
153
|
-
* `
|
|
157
|
+
* `resolveProjectContext` is read from the sibling
|
|
154
158
|
* `gsd-core/bin/lib/*.cjs` modules staged alongside these hooks at install
|
|
155
159
|
* time (same pattern the guard hooks already use for
|
|
156
160
|
* capability-registry.cjs/runtime-name-policy.cjs) — two directories up from
|
|
@@ -176,10 +180,10 @@ function resolveSentinelRoot(cwd) {
|
|
|
176
180
|
// cold-tree encounter.
|
|
177
181
|
const { ensureRuntimeBuild } = require('../../gsd-core/bin/ensure-runtime-build.cjs');
|
|
178
182
|
ensureRuntimeBuild();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const {
|
|
182
|
-
return
|
|
183
|
+
// #6043 (ADR-4908): the dispatcher resolves non-skip commands through the
|
|
184
|
+
// same seam, so the reader looks where the writer wrote.
|
|
185
|
+
const { resolveProjectContext } = require('../../gsd-core/bin/lib/project-context.cjs');
|
|
186
|
+
return resolveProjectContext(cwd).projectRoot;
|
|
183
187
|
} catch {
|
|
184
188
|
return cwd;
|
|
185
189
|
}
|
|
@@ -213,21 +217,23 @@ function readSentinel(cwd, { clock = Date, dispatchIds = null } = {}) {
|
|
|
213
217
|
// well-formed and its contents agree with its own filename key; the legacy slot only when it
|
|
214
218
|
// applies to this dispatch. Among FRESH candidates the latest `writtenAt` wins, so a newer
|
|
215
219
|
// phase-level decision is not shadowed by an older plan-scoped one (and vice versa).
|
|
220
|
+
const gsdDir = path.join(root, '.gsd');
|
|
221
|
+
const keyedDirs = [gsdDir, path.join(root, KEYED_SENTINEL_DIR)];
|
|
216
222
|
const keyedCandidates = [];
|
|
217
223
|
if (dispatchIds) {
|
|
218
224
|
const planFile = keyedSentinelPath(root, dispatchIds.phase, dispatchIds.plan);
|
|
219
225
|
if (planFile) {
|
|
220
|
-
const c = parseSentinelFile(planFile, clock);
|
|
226
|
+
const c = parseSentinelFile(planFile, clock, keyedDirs);
|
|
221
227
|
if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === dispatchIds.plan) keyedCandidates.push(c);
|
|
222
228
|
}
|
|
223
229
|
const phaseFile = phaseSentinelPath(root, dispatchIds.phase);
|
|
224
230
|
if (phaseFile) {
|
|
225
|
-
const c = parseSentinelFile(phaseFile, clock);
|
|
231
|
+
const c = parseSentinelFile(phaseFile, clock, keyedDirs);
|
|
226
232
|
if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === null) keyedCandidates.push(c);
|
|
227
233
|
}
|
|
228
234
|
}
|
|
229
235
|
|
|
230
|
-
const legacy = parseSentinelFile(sentinelPath(root), clock);
|
|
236
|
+
const legacy = parseSentinelFile(sentinelPath(root), clock, [gsdDir]);
|
|
231
237
|
const legacyApplies = legacy.present && !legacy.malformed && sentinelAppliesToDispatch(legacy, dispatchIds);
|
|
232
238
|
|
|
233
239
|
const fresh = keyedCandidates.filter((c) => !c.stale);
|
|
@@ -245,14 +251,87 @@ function latestWritten(candidates) {
|
|
|
245
251
|
return candidates.reduce((best, c) => (c.writtenAt > best.writtenAt ? c : best));
|
|
246
252
|
}
|
|
247
253
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
250
|
-
|
|
254
|
+
/**
|
|
255
|
+
* lstat of `dir` when it is a real directory, not a link (lstat does not follow); otherwise null.
|
|
256
|
+
* Never throws.
|
|
257
|
+
*/
|
|
258
|
+
function lstatRealDirectory(dir) {
|
|
251
259
|
try {
|
|
252
|
-
|
|
260
|
+
// bigint: an NTFS file id is 64 bits and a JS number keeps 53, so ids are compared exactly.
|
|
261
|
+
const st = fs.lstatSync(dir, { bigint: true });
|
|
262
|
+
return st.isDirectory() && !st.isSymbolicLink() ? st : null;
|
|
253
263
|
} catch {
|
|
254
|
-
return
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Same filesystem object: equal dev and ino. A zero ino (some Windows volumes) cannot be compared. */
|
|
269
|
+
function sameObject(a, b) {
|
|
270
|
+
return a.ino === 0n || b.ino === 0n || (a.dev === b.dev && a.ino === b.ino);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* #5758: read one sentinel file as a bounded, non-blocking, link-refusing read of a regular file.
|
|
275
|
+
* Returns the text, or null for EVERY refusal (missing, link, FIFO, device, directory, oversized,
|
|
276
|
+
* short read, a hard link to another file, a parent directory that is a link), so a hostile record
|
|
277
|
+
* is exactly as absent as a missing one. `readFileSync` would block forever on a FIFO with no
|
|
278
|
+
* writer, follow a link to an endless device or to a file outside the project, and read without
|
|
279
|
+
* bound. The writers only ever produce a fresh temp file renamed into place (link count 1). A
|
|
280
|
+
* sentinel renamed over between the open and the fstat reads as nlink 0 and is refused for that one
|
|
281
|
+
* dispatch, which is the conservative no-sentinel decision.
|
|
282
|
+
*
|
|
283
|
+
* Parent directories are lstat-checked before the open and re-checked (same dev/ino, still a real
|
|
284
|
+
* directory) after it, and the opened file must be the object the pre-open lstat saw. Node has no
|
|
285
|
+
* openat, so this NARROWS the window for a parent swapped to a link between check and open; it does
|
|
286
|
+
* not close it.
|
|
287
|
+
*/
|
|
288
|
+
function readSentinelText(file, parentDirs) {
|
|
289
|
+
const parents = [];
|
|
290
|
+
for (const dir of parentDirs) {
|
|
291
|
+
const st = lstatRealDirectory(dir);
|
|
292
|
+
if (!st) return null;
|
|
293
|
+
parents.push({ dir, st });
|
|
255
294
|
}
|
|
295
|
+
let fd;
|
|
296
|
+
try {
|
|
297
|
+
const before = fs.lstatSync(file, { bigint: true });
|
|
298
|
+
if (!before.isFile() || before.size > BigInt(SENTINEL_MAX_BYTES)) return null;
|
|
299
|
+
// O_NONBLOCK makes an open of a FIFO swapped in after the lstat return at once; O_NOFOLLOW
|
|
300
|
+
// refuses a link swapped in. Windows lacks both flags (`|| 0`); lstat/fstat still guard.
|
|
301
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
302
|
+
const st = fs.fstatSync(fd, { bigint: true });
|
|
303
|
+
if (!st.isFile() || st.nlink !== 1n || st.size > BigInt(SENTINEL_MAX_BYTES)) return null;
|
|
304
|
+
if (!sameObject(before, st)) return null;
|
|
305
|
+
const size = Number(st.size);
|
|
306
|
+
for (const p of parents) {
|
|
307
|
+
const after = lstatRealDirectory(p.dir);
|
|
308
|
+
if (!after || !sameObject(p.st, after)) return null;
|
|
309
|
+
}
|
|
310
|
+
const buf = Buffer.alloc(size);
|
|
311
|
+
let offset = 0;
|
|
312
|
+
while (offset < size) {
|
|
313
|
+
const n = fs.readSync(fd, buf, offset, size - offset, offset);
|
|
314
|
+
if (n === 0) return null;
|
|
315
|
+
offset += n;
|
|
316
|
+
}
|
|
317
|
+
return buf.toString('utf-8');
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
} finally {
|
|
321
|
+
if (fd !== undefined) {
|
|
322
|
+
try { fs.closeSync(fd); } catch { /* nothing left to release */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parse and validate one sentinel file. Never throws. Shape: see `readSentinel`.
|
|
329
|
+
* `parentDirs` are the directories between the project root and the file that must be real
|
|
330
|
+
* directories (not links); a refusal anywhere reads as `{ present: false }`.
|
|
331
|
+
*/
|
|
332
|
+
function parseSentinelFile(file, clock, parentDirs = []) {
|
|
333
|
+
const raw = readSentinelText(file, parentDirs);
|
|
334
|
+
if (raw === null) return { present: false };
|
|
256
335
|
|
|
257
336
|
let parsed;
|
|
258
337
|
try {
|
|
@@ -424,6 +503,7 @@ module.exports = {
|
|
|
424
503
|
keyedSentinelPath,
|
|
425
504
|
phaseSentinelPath,
|
|
426
505
|
SENTINEL_STALE_MS,
|
|
506
|
+
SENTINEL_MAX_BYTES,
|
|
427
507
|
AGED_NONE_MAX_MS,
|
|
428
508
|
sentinelPath,
|
|
429
509
|
resolveSentinelRoot,
|