silvermoon 0.2.1 → 0.3.0-rc.1

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.
@@ -10,10 +10,7 @@ import {
10
10
  import { createCommandRun } from "./domain.js";
11
11
  import { inspectPhaseGuidance } from "./guidance.js";
12
12
  import {
13
- DEPLOYMENT_TEMPLATE,
14
- IDEA_TEMPLATE,
15
- IMPLEMENTATION_TEMPLATE,
16
- LEDGER_TEMPLATE,
13
+ ideaTemplates,
17
14
  } from "./idea-templates.js";
18
15
  import { isValidUlid, serializeIdeaStatus } from "./ideas.js";
19
16
  import { canonicalizeLanguageTag } from "./language.js";
@@ -209,6 +206,7 @@ async function cleanupScaffold(cleanupPlan, operations) {
209
206
 
210
207
  async function createScaffold({
211
208
  canonicalLanguage,
209
+ contentLanguage,
212
210
  generateId,
213
211
  operations,
214
212
  repositoryRoot,
@@ -263,11 +261,12 @@ async function createScaffold({
263
261
  paths.innerPath,
264
262
  paths.idealPath,
265
263
  ];
264
+ const templates = ideaTemplates(contentLanguage);
266
265
  const files = [
267
- [paths.ideaDocumentPath, IDEA_TEMPLATE],
268
- [paths.implementationDocumentPath, IMPLEMENTATION_TEMPLATE],
269
- [paths.deploymentDocumentPath, DEPLOYMENT_TEMPLATE],
270
- [paths.ledgerPath, LEDGER_TEMPLATE],
266
+ [paths.ideaDocumentPath, templates.idea],
267
+ [paths.implementationDocumentPath, templates.implementation],
268
+ [paths.deploymentDocumentPath, templates.deployment],
269
+ [paths.ledgerPath, templates.ledger],
271
270
  [paths.statusPath, serializeIdeaStatus({
272
271
  version: 1,
273
272
  id,
@@ -420,6 +419,7 @@ export async function createIdea({
420
419
  },
421
420
  () => createScaffold({
422
421
  canonicalLanguage,
422
+ contentLanguage: observed.contentLanguage,
423
423
  generateId,
424
424
  operations,
425
425
  repositoryRoot,
@@ -467,6 +467,18 @@ export async function createIdea({
467
467
  }
468
468
 
469
469
  const { createdIdea, paths } = completion.result;
470
+ const templates = ideaTemplates(observed.contentLanguage);
471
+ const languageInstruction = templates.localized
472
+ ? localize(
473
+ observed.outputLanguage,
474
+ `Use ${observed.contentLanguage} for all natural-language content in the canonical idea documents and ledger.`,
475
+ `在 canonical idea 文档和 ledger 的所有自然语言内容中使用 ${observed.contentLanguage}。`,
476
+ )
477
+ : localize(
478
+ observed.outputLanguage,
479
+ `Silvermoon has no built-in ${observed.contentLanguage} scaffold, so the new files contain explicit ${templates.templateLanguage} fallback placeholders. Replace all natural-language placeholders with ${observed.contentLanguage} before continuing.`,
480
+ `Silvermoon 没有内置的 ${observed.contentLanguage} 脚手架,因此新文件包含明确的 ${templates.templateLanguage} fallback 占位。继续前,将所有自然语言占位替换为 ${observed.contentLanguage}。`,
481
+ );
470
482
  return runtime.complete(
471
483
  dialogueReadyObservation(readiness.observation, "idea-created", {
472
484
  createdIdea,
@@ -477,8 +489,8 @@ export async function createIdea({
477
489
  {
478
490
  nextSteps: localize(
479
491
  observed.outputLanguage,
480
- `Use ${observed.contentLanguage} for natural-language content while describing the requested Ideal World in ${paths.ideaDocumentPath}; keep the stable IDs and placeholders in ${paths.implementationDocumentPath}, ${paths.deploymentDocumentPath}, and ${paths.ledgerPath} synchronized.`,
481
- `使用 ${observed.contentLanguage} 在 ${paths.ideaDocumentPath} 中描述请求的理想契约,并保持 ${paths.implementationDocumentPath}、${paths.deploymentDocumentPath} 与 ${paths.ledgerPath} 中的稳定 ID 和占位同步。`,
492
+ `${languageInstruction} Describe the requested Ideal World in ${paths.ideaDocumentPath}; keep canonical headings, stable IDs, and placeholders in ${paths.implementationDocumentPath}, ${paths.deploymentDocumentPath}, and ${paths.ledgerPath} synchronized.`,
493
+ `${languageInstruction} 在 ${paths.ideaDocumentPath} 中描述请求的理想契约;保持 ${paths.implementationDocumentPath}、${paths.deploymentDocumentPath} 与 ${paths.ledgerPath} 中的 canonical 标题、稳定 ID 和占位同步。`,
482
494
  ),
483
495
  },
484
496
  );
@@ -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