silvermoon 0.2.2 → 0.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { posix } from "node:path";
3
+
4
+ import {
5
+ inspectTreeLineage,
6
+ readGitBlob,
7
+ readGitBlobs,
8
+ } from "./git.js";
9
+ import { traceSync } from "./trace.js";
10
+
11
+ const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
12
+
13
+ function fileSystemError(code, path, message) {
14
+ return Object.assign(new Error(`${message}: ${path}`), { code, path });
15
+ }
16
+
17
+ class SnapshotMetadata {
18
+ constructor(entry, name) {
19
+ this.entry = entry;
20
+ this.name = name;
21
+ }
22
+
23
+ isDirectory() {
24
+ return this.entry.type === "tree" && this.entry.mode === "040000";
25
+ }
26
+
27
+ isFile() {
28
+ return this.entry.type === "blob"
29
+ && REGULAR_FILE_MODES.has(this.entry.mode);
30
+ }
31
+
32
+ isSymbolicLink() {
33
+ return this.entry.type === "blob" && this.entry.mode === "120000";
34
+ }
35
+ }
36
+
37
+ function normalizedRelativePath(root, path) {
38
+ const normalizedRoot = resolve(root);
39
+ const absolutePath = resolve(path);
40
+ const value = relative(normalizedRoot, absolutePath);
41
+ if (
42
+ value === ""
43
+ || (!isAbsolute(value) && value !== ".." && !value.startsWith(`..${sep}`))
44
+ ) {
45
+ return value.split(sep).join("/");
46
+ }
47
+ throw fileSystemError("ENOENT", path, "Snapshot path is outside the root");
48
+ }
49
+
50
+ function childName(path) {
51
+ const separator = path.lastIndexOf("/");
52
+ return separator < 0 ? path : path.slice(separator + 1);
53
+ }
54
+
55
+ export function createGitSnapshotFileSystem({
56
+ gitRoot,
57
+ preload = () => false,
58
+ root = gitRoot,
59
+ tree,
60
+ }) {
61
+ return traceSync(
62
+ "snapshot.open",
63
+ {},
64
+ () => {
65
+ const entries = inspectTreeLineage(gitRoot, tree, ".");
66
+ const entriesByPath = new Map(entries.map((entry) => [entry.name, entry]));
67
+ const childrenByPath = new Map();
68
+ for (const entry of entries) {
69
+ const parent = posix.dirname(entry.name);
70
+ const key = parent === "." ? "" : parent;
71
+ const children = childrenByPath.get(key) ?? [];
72
+ children.push(entry);
73
+ childrenByPath.set(key, children);
74
+ }
75
+ const preloadObjects = entries
76
+ .filter((entry) =>
77
+ entry.mode === "120000"
78
+ || (entry.type === "blob" && preload(entry))
79
+ )
80
+ .map(({ object }) => object);
81
+ const blobs = readGitBlobs(gitRoot, preloadObjects);
82
+
83
+ async function blob(entry) {
84
+ const cached = blobs.get(entry.object);
85
+ if (cached !== undefined) return cached;
86
+ const loaded = readGitBlob(gitRoot, entry.object);
87
+ blobs.set(entry.object, loaded);
88
+ return loaded;
89
+ }
90
+
91
+ async function resolveEntry(path, { followFinal = true } = {}) {
92
+ const requested = normalizedRelativePath(root, path);
93
+ if (requested === "") {
94
+ return {
95
+ entry: {
96
+ mode: "040000",
97
+ name: "",
98
+ object: tree,
99
+ size: null,
100
+ type: "tree",
101
+ },
102
+ path: "",
103
+ };
104
+ }
105
+
106
+ let parts = requested.split("/");
107
+ let resolvedParts = [];
108
+ const followed = new Set();
109
+ for (let index = 0; index < parts.length; index += 1) {
110
+ const candidate = [...resolvedParts, parts[index]].join("/");
111
+ const entry = entriesByPath.get(candidate);
112
+ if (entry === undefined) {
113
+ throw fileSystemError("ENOENT", path, "Snapshot path does not exist");
114
+ }
115
+ const shouldFollow = entry.mode === "120000"
116
+ && (followFinal || index < parts.length - 1);
117
+ if (!shouldFollow) {
118
+ resolvedParts.push(parts[index]);
119
+ continue;
120
+ }
121
+ if (followed.has(candidate) || followed.size >= 40) {
122
+ throw fileSystemError("ELOOP", path, "Snapshot symlink cycle");
123
+ }
124
+ followed.add(candidate);
125
+ const target = (await blob(entry)).toString("utf8");
126
+ if (
127
+ target.includes("\0")
128
+ || target.startsWith("/")
129
+ || /^[A-Za-z]:[\\/]/.test(target)
130
+ ) {
131
+ throw fileSystemError(
132
+ "ENOENT",
133
+ path,
134
+ "Snapshot symlink target is outside the root",
135
+ );
136
+ }
137
+ const normalizedTarget = posix.normalize(
138
+ posix.join(posix.dirname(candidate), target.replaceAll("\\", "/")),
139
+ );
140
+ if (
141
+ normalizedTarget === ".."
142
+ || normalizedTarget.startsWith("../")
143
+ ) {
144
+ throw fileSystemError(
145
+ "ENOENT",
146
+ path,
147
+ "Snapshot symlink target is outside the root",
148
+ );
149
+ }
150
+ parts = [
151
+ ...normalizedTarget.split("/").filter(Boolean),
152
+ ...parts.slice(index + 1),
153
+ ];
154
+ resolvedParts = [];
155
+ index = -1;
156
+ }
157
+ const resolvedPath = resolvedParts.join("/");
158
+ return { entry: entriesByPath.get(resolvedPath), path: resolvedPath };
159
+ }
160
+
161
+ const filesystem = {
162
+ async lstat(path) {
163
+ const resolved = await resolveEntry(path, { followFinal: false });
164
+ return new SnapshotMetadata(resolved.entry, childName(resolved.path));
165
+ },
166
+ async readFile(path, encoding) {
167
+ const resolved = await resolveEntry(path);
168
+ const metadata = new SnapshotMetadata(
169
+ resolved.entry,
170
+ childName(resolved.path),
171
+ );
172
+ if (!metadata.isFile()) {
173
+ throw fileSystemError("EISDIR", path, "Snapshot path is not a file");
174
+ }
175
+ const content = await blob(resolved.entry);
176
+ return encoding === undefined ? Buffer.from(content) : content.toString(encoding);
177
+ },
178
+ async readdir(path, options = {}) {
179
+ const resolved = await resolveEntry(path);
180
+ const metadata = new SnapshotMetadata(
181
+ resolved.entry,
182
+ childName(resolved.path),
183
+ );
184
+ if (!metadata.isDirectory()) {
185
+ throw fileSystemError("ENOTDIR", path, "Snapshot path is not a directory");
186
+ }
187
+ const children = childrenByPath.get(resolved.path) ?? [];
188
+ if (options.withFileTypes === true) {
189
+ return children.map((entry) =>
190
+ new SnapshotMetadata(entry, childName(entry.name))
191
+ );
192
+ }
193
+ return children.map(({ name }) => childName(name));
194
+ },
195
+ snapshotEntry(path) {
196
+ return entriesByPath.get(path) ?? null;
197
+ },
198
+ snapshotEntries(path) {
199
+ return [...(childrenByPath.get(path) ?? [])];
200
+ },
201
+ async snapshotFile(path) {
202
+ const entry = entriesByPath.get(path);
203
+ if (entry === undefined || entry.type !== "blob") {
204
+ throw fileSystemError("ENOENT", path, "Snapshot blob does not exist");
205
+ }
206
+ return Buffer.from(await blob(entry));
207
+ },
208
+ };
209
+ return filesystem;
210
+ },
211
+ );
212
+ }
package/src/git.js CHANGED
@@ -1,35 +1,32 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
2
  import { mkdtempSync, rmSync } from "node:fs";
4
3
  import { tmpdir } from "node:os";
5
4
  import { join } from "node:path";
6
5
 
7
- import { traceAsync, traceSync } from "./trace.js";
6
+ import { runSubprocess } from "./subprocess.js";
7
+ import { traceAsync } from "./trace.js";
8
8
 
9
9
  let commandObserver = null;
10
10
 
11
11
  function executeGit(root, args, { encoding, env, input } = {}) {
12
12
  if (commandObserver) commandObserver([...args]);
13
- return traceSync(
14
- "git.command",
13
+ return runSubprocess(
14
+ "git",
15
+ root === null ? args : ["-C", root, ...args],
15
16
  {
16
- argumentCount: args.length - 1,
17
- network: args[0] === "fetch" || args[0] === "ls-remote",
18
- subcommand: args[0] ?? null,
19
- },
20
- () => spawnSync("git", ["-C", root, ...args], {
21
17
  encoding,
22
18
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
23
19
  input,
24
20
  windowsHide: true,
25
- }),
26
- (completed) => ({
21
+ },
22
+ {
23
+ spanName: "git.command",
27
24
  attributes: {
28
- exitCode: completed.status,
29
- signal: completed.signal,
25
+ argumentCount: args.length - 1,
26
+ network: args[0] === "fetch" || args[0] === "ls-remote",
27
+ subcommand: args[0] ?? null,
30
28
  },
31
- status: completed.status === 0 ? "ok" : "error",
32
- }),
29
+ },
33
30
  );
34
31
  }
35
32
 
@@ -98,6 +95,24 @@ function parseTreeEntries(source, label) {
98
95
  return entries;
99
96
  }
100
97
 
98
+ function parseLongTreeEntries(source, label) {
99
+ const entries = [];
100
+ for (const record of source.split("\0")) {
101
+ if (!record) continue;
102
+ const match = /^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40}|[0-9a-f]{64}) +(-|(?:0|[1-9]\d*))\t([\s\S]+)$/
103
+ .exec(record);
104
+ if (!match) throw new Error(`Cannot parse ${label} entry`);
105
+ entries.push({
106
+ mode: match[1],
107
+ type: match[2],
108
+ object: match[3],
109
+ size: match[4] === "-" ? null : Number(match[4]),
110
+ name: match[5],
111
+ });
112
+ }
113
+ return entries;
114
+ }
115
+
101
116
  export function inspectTreeEntry(root, tree, path) {
102
117
  const output = requireGit(
103
118
  root,
@@ -121,6 +136,22 @@ export function listTreeEntries(root, tree, path) {
121
136
  return parseTreeEntries(output, `snapshot directory ${path}`);
122
137
  }
123
138
 
139
+ export function inspectTree(root, tree, path) {
140
+ return inspectTreeLineage(root, tree, path)
141
+ .filter((entry) =>
142
+ entry.name === path || entry.name.startsWith(`${path}/`)
143
+ );
144
+ }
145
+
146
+ export function inspectTreeLineage(root, tree, path) {
147
+ const output = requireGit(
148
+ root,
149
+ ["ls-tree", "-r", "-t", "-l", "-z", tree, "--", path],
150
+ `Cannot inspect snapshot tree ${path}`,
151
+ );
152
+ return parseLongTreeEntries(output, `snapshot tree ${path}`);
153
+ }
154
+
124
155
  export function gitObjectSize(root, object) {
125
156
  const output = requireGit(
126
157
  root,
@@ -151,6 +182,54 @@ export function readGitBlob(root, object) {
151
182
  return Buffer.from(result.stdout);
152
183
  }
153
184
 
185
+ export function readGitBlobs(root, objects) {
186
+ const requested = [...new Set(objects)];
187
+ if (requested.length === 0) return new Map();
188
+ const result = executeGit(
189
+ root,
190
+ ["cat-file", "--batch"],
191
+ {
192
+ encoding: null,
193
+ input: Buffer.from(`${requested.join("\n")}\n`, "utf8"),
194
+ },
195
+ );
196
+ if (result.status !== 0) {
197
+ const stderr = Buffer.isBuffer(result.stderr)
198
+ ? result.stderr.toString("utf8")
199
+ : result.stderr;
200
+ throw new Error(
201
+ "Cannot read Git blobs: "
202
+ + `${sanitizeGitMessage(stderr?.trim() || result.error?.message || "Git failed")}`,
203
+ );
204
+ }
205
+
206
+ const source = Buffer.from(result.stdout);
207
+ const blobs = new Map();
208
+ let offset = 0;
209
+ for (const object of requested) {
210
+ const headerEnd = source.indexOf(0x0a, offset);
211
+ if (headerEnd < 0) throw new Error(`Missing Git blob header for ${object}`);
212
+ const header = source.subarray(offset, headerEnd).toString("utf8");
213
+ const match = /^([0-9a-f]{40}|[0-9a-f]{64}) blob (0|[1-9]\d*)$/
214
+ .exec(header);
215
+ if (!match || match[1] !== object) {
216
+ throw new Error(`Cannot parse Git blob header for ${object}`);
217
+ }
218
+ const size = Number(match[2]);
219
+ const contentStart = headerEnd + 1;
220
+ const contentEnd = contentStart + size;
221
+ if (contentEnd >= source.length || source[contentEnd] !== 0x0a) {
222
+ throw new Error(`Incomplete Git blob content for ${object}`);
223
+ }
224
+ blobs.set(object, source.subarray(contentStart, contentEnd));
225
+ offset = contentEnd + 1;
226
+ }
227
+ if (offset !== source.length) {
228
+ throw new Error("Unexpected trailing Git blob batch output");
229
+ }
230
+ return blobs;
231
+ }
232
+
154
233
  export function inspectTreePaths(root, tree, paths) {
155
234
  if (paths.length === 0) return new Map();
156
235
  const input = `${paths.map((path) => `${tree}:${path}`).join("\n")}\n`;
@@ -271,6 +350,88 @@ export function parseWorktreeChanges(source) {
271
350
  return result;
272
351
  }
273
352
 
353
+ export function parseRepositoryStatus(source) {
354
+ let branch = null;
355
+ let head = null;
356
+ for (const record of source.split("\0")) {
357
+ if (record.startsWith("# branch.oid ")) {
358
+ const value = record.slice("# branch.oid ".length);
359
+ if (value !== "(initial)") {
360
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value)) {
361
+ throw new Error(`Unsupported Git HEAD object ID: ${value}`);
362
+ }
363
+ head = value;
364
+ }
365
+ } else if (record.startsWith("# branch.head ")) {
366
+ const value = record.slice("# branch.head ".length);
367
+ branch = value === "(detached)" ? null : value;
368
+ }
369
+ }
370
+ return {
371
+ branch,
372
+ changes: parseWorktreeChanges(source),
373
+ head,
374
+ };
375
+ }
376
+
377
+ function escapeConfigKeyPattern(value) {
378
+ return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
379
+ }
380
+
381
+ function parseConfigRecords(source) {
382
+ const values = new Map();
383
+ for (const record of source.split("\0")) {
384
+ if (!record) continue;
385
+ const separator = record.indexOf("\n");
386
+ if (separator < 1) throw new Error("Cannot parse Git configuration record");
387
+ values.set(record.slice(0, separator), record.slice(separator + 1));
388
+ }
389
+ return values;
390
+ }
391
+
392
+ function inspectBranchConfiguration(root, branch) {
393
+ if (branch === null) {
394
+ return { branch: null, remote: null, repository: null, upstreamBranch: null };
395
+ }
396
+ const escapedBranch = escapeConfigKeyPattern(branch);
397
+ const result = runGit(root, [
398
+ "config",
399
+ "--null",
400
+ "--get-regexp",
401
+ `^(branch\\.${escapedBranch}\\.(remote|merge)|remote\\..*\\.url)$`,
402
+ ]);
403
+ const values = result.ok ? parseConfigRecords(result.stdout) : new Map();
404
+ const remote = values.get(`branch.${branch}.remote`) ?? null;
405
+ const merge = values.get(`branch.${branch}.merge`) ?? null;
406
+ const upstreamBranch = merge?.startsWith("refs/heads/")
407
+ ? merge.slice("refs/heads/".length)
408
+ : null;
409
+ const repository = remote === null || remote === "."
410
+ ? null
411
+ : values.get(`remote.${remote}.url`) ?? null;
412
+ return { branch, remote, repository, upstreamBranch };
413
+ }
414
+
415
+ export function inspectRepositoryState(root) {
416
+ const source = requireGit(
417
+ root,
418
+ [
419
+ "status",
420
+ "--porcelain=v2",
421
+ "--branch",
422
+ "--untracked-files=all",
423
+ "-z",
424
+ ],
425
+ "Cannot inspect repository state",
426
+ );
427
+ const status = parseRepositoryStatus(`${source}\0`);
428
+ return {
429
+ changes: status.changes,
430
+ head: status.head,
431
+ branch: inspectBranchConfiguration(root, status.branch),
432
+ };
433
+ }
434
+
274
435
  export function inspectWorktreeChanges(root) {
275
436
  const source = requireGit(
276
437
  root,
@@ -360,6 +521,39 @@ export function resolveCommit(root, revision) {
360
521
  );
361
522
  }
362
523
 
524
+ export function resolveSnapshot(root, revision) {
525
+ if (typeof revision !== "string" || /[\0\r\n]/.test(revision)) {
526
+ throw new Error(`Cannot resolve snapshot ${JSON.stringify(revision)}`);
527
+ }
528
+ const expressions = [`${revision}^{commit}`, `${revision}^{tree}`];
529
+ const result = runGit(
530
+ root,
531
+ ["cat-file", "--batch-check=%(objectname) %(objecttype)"],
532
+ { input: `${expressions.join("\n")}\n` },
533
+ );
534
+ if (!result.ok) {
535
+ throw new Error(
536
+ `Cannot resolve snapshot ${revision}: `
537
+ + `${sanitizeGitMessage(result.stderr || result.error?.message || "Git failed")}`,
538
+ );
539
+ }
540
+ const lines = result.stdout.split(/\r?\n/);
541
+ if (lines.length !== 2) {
542
+ throw new Error(`Cannot resolve snapshot ${revision}: unexpected Git output`);
543
+ }
544
+ const parsed = lines.map((line, index) => {
545
+ const match = /^([0-9a-f]{40}|[0-9a-f]{64}) (commit|tree)$/.exec(line);
546
+ if (!match) {
547
+ throw new Error(`Cannot resolve ${expressions[index]}`);
548
+ }
549
+ return { object: match[1], type: match[2] };
550
+ });
551
+ if (parsed[0].type !== "commit" || parsed[1].type !== "tree") {
552
+ throw new Error(`Cannot resolve snapshot ${revision}: invalid object types`);
553
+ }
554
+ return { commit: parsed[0].object, tree: parsed[1].object };
555
+ }
556
+
363
557
  export function indexSnapshot(root) {
364
558
  return {
365
559
  tree: requireGit(root, ["write-tree"], "Cannot snapshot the index"),
@@ -391,9 +585,6 @@ export function fetchRepositoryBranch(root, repository, branch) {
391
585
  throw new Error(`Cannot determine fetched commit for branch ${branch}`);
392
586
  }
393
587
  const primary = updates[0][2];
394
- if (!runGit(root, ["cat-file", "-e", `${primary}^{commit}`]).ok) {
395
- throw new Error(`Fetched branch ${branch} does not resolve to a commit`);
396
- }
397
588
  return primary;
398
589
  }
399
590
 
package/src/guidance.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import { TextDecoder } from "node:util";
2
2
 
3
3
  import {
4
- gitObjectSize,
5
- inspectTreeEntry,
6
- listTreeEntries,
7
- readGitBlob,
4
+ inspectTree,
5
+ readGitBlobs,
8
6
  } from "./git.js";
9
7
  import {
10
8
  GUIDANCE_PHASES,
@@ -50,22 +48,20 @@ function unexpectedEntry(name) {
50
48
  );
51
49
  }
52
50
 
53
- function inspectContent(gitRoot, entry, phase) {
51
+ function contentSizeDiagnostic(entry, phase) {
54
52
  const path = phaseGuidancePath(phase);
55
- const size = gitObjectSize(gitRoot, entry.object);
56
- if (size > MAX_PHASE_GUIDANCE_BYTES) {
57
- return {
58
- diagnostic: error(
59
- "guidance.file.too-large",
60
- path,
61
- `Phase guidance exceeds the ${MAX_PHASE_GUIDANCE_BYTES}-byte limit: ${path}`,
62
- `Reduce ${path} to at most ${MAX_PHASE_GUIDANCE_BYTES} UTF-8 bytes.`,
63
- ),
64
- };
65
- }
53
+ if (entry.size <= MAX_PHASE_GUIDANCE_BYTES) return null;
54
+ return error(
55
+ "guidance.file.too-large",
56
+ path,
57
+ `Phase guidance exceeds the ${MAX_PHASE_GUIDANCE_BYTES}-byte limit: ${path}`,
58
+ `Reduce ${path} to at most ${MAX_PHASE_GUIDANCE_BYTES} UTF-8 bytes.`,
59
+ );
60
+ }
66
61
 
67
- const bytes = readGitBlob(gitRoot, entry.object);
68
- if (bytes.length !== size) {
62
+ function inspectContent(bytes, entry, phase) {
63
+ const path = phaseGuidancePath(phase);
64
+ if (bytes.length !== entry.size) {
69
65
  throw new Error(
70
66
  `Git blob ${entry.object} changed size while reading ${path}`,
71
67
  );
@@ -121,11 +117,20 @@ function inspectContent(gitRoot, entry, phase) {
121
117
  }
122
118
 
123
119
  async function inspectGuidanceInternal({
120
+ filesystem,
124
121
  gitRoot,
125
122
  phase,
126
123
  snapshotTree,
127
124
  }) {
128
- const rootEntry = inspectTreeEntry(gitRoot, snapshotTree, GUIDANCE_ROOT);
125
+ const treeEntries = filesystem === undefined
126
+ ? inspectTree(gitRoot, snapshotTree, GUIDANCE_ROOT)
127
+ : null;
128
+ const rootEntry = treeEntries === null
129
+ ? filesystem.snapshotEntry(GUIDANCE_ROOT)
130
+ : treeEntries.find(({ name }) => name === GUIDANCE_ROOT);
131
+ if (rootEntry === undefined) {
132
+ return { state: "absent", diagnostics: [] };
133
+ }
129
134
  if (rootEntry === null) {
130
135
  return { state: "absent", diagnostics: [] };
131
136
  }
@@ -133,7 +138,15 @@ async function inspectGuidanceInternal({
133
138
  return { state: "invalid", diagnostics: [invalidDirectory()] };
134
139
  }
135
140
 
136
- const entries = listTreeEntries(gitRoot, rootEntry.object, GUIDANCE_ROOT);
141
+ const prefix = `${GUIDANCE_ROOT}/`;
142
+ const entries = (
143
+ treeEntries === null
144
+ ? filesystem.snapshotEntries(GUIDANCE_ROOT)
145
+ : treeEntries.filter(({ name }) =>
146
+ name.startsWith(prefix) && !name.slice(prefix.length).includes("/")
147
+ )
148
+ )
149
+ .map((entry) => ({ ...entry, name: entry.name.slice(prefix.length) }));
137
150
  const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
138
151
  const phases = phase === undefined ? GUIDANCE_PHASES : [phase];
139
152
  const diagnostics = [];
@@ -153,6 +166,7 @@ async function inspectGuidanceInternal({
153
166
 
154
167
  let selectedGuidance;
155
168
  let selectedPresent = false;
169
+ const contentEntries = [];
156
170
  for (const candidate of phases) {
157
171
  const path = phaseGuidancePath(candidate);
158
172
  const name = path.slice(`${GUIDANCE_ROOT}/`.length);
@@ -163,8 +177,29 @@ async function inspectGuidanceInternal({
163
177
  diagnostics.push(invalidFile(path));
164
178
  continue;
165
179
  }
166
- const inspected = inspectContent(gitRoot, entry, candidate);
167
- if (inspected.diagnostic) diagnostics.push(inspected.diagnostic);
180
+ const sizeDiagnostic = contentSizeDiagnostic(entry, candidate);
181
+ if (sizeDiagnostic) {
182
+ diagnostics.push(sizeDiagnostic);
183
+ continue;
184
+ }
185
+ contentEntries.push({ candidate, entry });
186
+ }
187
+
188
+ const blobs = filesystem === undefined
189
+ ? readGitBlobs(
190
+ gitRoot,
191
+ contentEntries.map(({ entry }) => entry.object),
192
+ )
193
+ : new Map(await Promise.all(contentEntries.map(async ({ candidate, entry }) => [
194
+ entry.object,
195
+ await filesystem.snapshotFile(phaseGuidancePath(candidate)),
196
+ ])));
197
+ for (const { candidate, entry } of contentEntries) {
198
+ const bytes = blobs.get(entry.object);
199
+ if (bytes === undefined) {
200
+ throw new Error(`Missing Git blob content for ${entry.object}`);
201
+ }
202
+ const inspected = inspectContent(bytes, entry, candidate);
168
203
  if (inspected.diagnostics) diagnostics.push(...inspected.diagnostics);
169
204
  if (phase !== undefined && inspected.guidance) {
170
205
  selectedGuidance = inspected.guidance;
@@ -183,6 +218,7 @@ async function inspectGuidanceInternal({
183
218
  }
184
219
 
185
220
  export async function inspectPhaseGuidance({
221
+ filesystem,
186
222
  gitRoot,
187
223
  phase,
188
224
  snapshotTree,
@@ -194,6 +230,7 @@ export async function inspectPhaseGuidance({
194
230
  async () => {
195
231
  try {
196
232
  return await inspectGuidanceInternal({
233
+ filesystem,
197
234
  gitRoot,
198
235
  phase,
199
236
  snapshotTree,
@@ -214,6 +251,7 @@ export async function inspectPhaseGuidance({
214
251
  }
215
252
 
216
253
  export async function inspectAllGuidance({
254
+ filesystem,
217
255
  gitRoot,
218
256
  snapshotTree,
219
257
  }) {
@@ -222,7 +260,11 @@ export async function inspectAllGuidance({
222
260
  { scope: "all" },
223
261
  async () => {
224
262
  try {
225
- return await inspectGuidanceInternal({ gitRoot, snapshotTree });
263
+ return await inspectGuidanceInternal({
264
+ filesystem,
265
+ gitRoot,
266
+ snapshotTree,
267
+ });
226
268
  } catch (caught) {
227
269
  return {
228
270
  state: "invalid",