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.
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",
@@ -5,6 +5,8 @@ import { deriveIdeaState, isValidUlid, parseIdeaStatus } from "./ideas.js";
5
5
  import { inspectTreePaths, worktreeSnapshot } from "./git.js";
6
6
  import { IDEAS_ROOT, ideaPaths } from "./layout.js";
7
7
 
8
+ const DEFAULT_FILESYSTEM = { lstat, readFile, readdir };
9
+
8
10
  function displayPath(root, path) {
9
11
  return relative(root, path).replaceAll("\\", "/");
10
12
  }
@@ -13,18 +15,18 @@ function error(code, path, message, remediation) {
13
15
  return { code, level: "error", path, message, remediation };
14
16
  }
15
17
 
16
- async function metadata(path) {
18
+ async function metadata(path, filesystem) {
17
19
  try {
18
- return await lstat(path);
20
+ return await filesystem.lstat(path);
19
21
  } catch (caught) {
20
22
  if (caught.code === "ENOENT") return null;
21
23
  throw caught;
22
24
  }
23
25
  }
24
26
 
25
- async function requireDirectory(root, path, diagnostics) {
27
+ async function requireDirectory(root, path, diagnostics, filesystem) {
26
28
  const absolute = resolve(root, path);
27
- const value = await metadata(absolute);
29
+ const value = await metadata(absolute, filesystem);
28
30
  if (!value || !value.isDirectory() || value.isSymbolicLink()) {
29
31
  diagnostics.push(error(
30
32
  "idea.world.invalid-directory",
@@ -37,9 +39,9 @@ async function requireDirectory(root, path, diagnostics) {
37
39
  return true;
38
40
  }
39
41
 
40
- async function requireDocument(root, path, diagnostics) {
42
+ async function requireDocument(root, path, diagnostics, filesystem) {
41
43
  const absolute = resolve(root, path);
42
- const value = await metadata(absolute);
44
+ const value = await metadata(absolute, filesystem);
43
45
  if (!value || !value.isFile() || value.isSymbolicLink()) {
44
46
  diagnostics.push(error(
45
47
  "idea.world.missing-document",
@@ -52,8 +54,8 @@ async function requireDocument(root, path, diagnostics) {
52
54
  return true;
53
55
  }
54
56
 
55
- async function requireLedger(root, path, diagnostics) {
56
- const value = await metadata(resolve(root, path));
57
+ async function requireLedger(root, path, diagnostics, filesystem) {
58
+ const value = await metadata(resolve(root, path), filesystem);
57
59
  if (!value) {
58
60
  diagnostics.push(error(
59
61
  "idea.ledger.missing-file",
@@ -75,11 +77,13 @@ async function requireLedger(root, path, diagnostics) {
75
77
  return true;
76
78
  }
77
79
 
78
- async function rejectSymlinks(root, path, diagnostics) {
80
+ async function rejectSymlinks(root, path, diagnostics, filesystem) {
79
81
  const absolute = resolve(root, path);
80
- for (const entry of await readdir(absolute, { withFileTypes: true })) {
82
+ for (
83
+ const entry of await filesystem.readdir(absolute, { withFileTypes: true })
84
+ ) {
81
85
  const entryPath = `${path}/${entry.name}`;
82
- const value = await lstat(resolve(root, entryPath));
86
+ const value = await filesystem.lstat(resolve(root, entryPath));
83
87
  if (value.isSymbolicLink()) {
84
88
  diagnostics.push(error(
85
89
  "idea.world.symlink",
@@ -89,7 +93,9 @@ async function rejectSymlinks(root, path, diagnostics) {
89
93
  ));
90
94
  continue;
91
95
  }
92
- if (value.isDirectory()) await rejectSymlinks(root, entryPath, diagnostics);
96
+ if (value.isDirectory()) {
97
+ await rejectSymlinks(root, entryPath, diagnostics, filesystem);
98
+ }
93
99
  }
94
100
  }
95
101
 
@@ -109,13 +115,14 @@ function requiredTree(objects, path) {
109
115
 
110
116
  export async function inspectIdeaLayout({
111
117
  config: _config,
118
+ filesystem = DEFAULT_FILESYSTEM,
112
119
  root,
113
120
  gitRoot = root,
114
121
  snapshotTree,
115
122
  }) {
116
123
  const diagnostics = [];
117
124
  const ideasRoot = resolve(root, IDEAS_ROOT);
118
- const ideasRootMetadata = await metadata(ideasRoot);
125
+ const ideasRootMetadata = await metadata(ideasRoot, filesystem);
119
126
  if (!ideasRootMetadata) {
120
127
  return {
121
128
  diagnostics: [error(
@@ -160,7 +167,9 @@ export async function inspectIdeaLayout({
160
167
  const aliases = new Map();
161
168
  const ideas = [];
162
169
  const caseNames = new Map();
163
- const entries = (await readdir(ideasRoot, { withFileTypes: true }))
170
+ const entries = (
171
+ await filesystem.readdir(ideasRoot, { withFileTypes: true })
172
+ )
164
173
  .sort((left, right) => left.name.localeCompare(right.name));
165
174
  const ideaIds = new Set(
166
175
  entries.filter((entry) => isValidUlid(entry.name)).map((entry) => entry.name),
@@ -171,7 +180,18 @@ export async function inspectIdeaLayout({
171
180
  });
172
181
  let worldObjects;
173
182
  try {
174
- worldObjects = inspectTreePaths(gitRoot, resolvedSnapshotTree, worldPaths);
183
+ worldObjects = typeof filesystem.snapshotEntry === "function"
184
+ ? new Map(worldPaths.map((path) => {
185
+ const entry = filesystem.snapshotEntry(path);
186
+ return [
187
+ path,
188
+ {
189
+ object: entry?.object ?? null,
190
+ type: entry?.type ?? "missing",
191
+ },
192
+ ];
193
+ }))
194
+ : inspectTreePaths(gitRoot, resolvedSnapshotTree, worldPaths);
175
195
  } catch (caught) {
176
196
  return {
177
197
  diagnostics: [error(
@@ -185,7 +205,7 @@ export async function inspectIdeaLayout({
185
205
  }
186
206
  for (const entry of entries) {
187
207
  const folderPath = resolve(ideasRoot, entry.name);
188
- const folderMetadata = await lstat(folderPath);
208
+ const folderMetadata = await filesystem.lstat(folderPath);
189
209
  const relativePath = displayPath(root, folderPath);
190
210
  const folded = entry.name.toUpperCase();
191
211
  const existingCase = caseNames.get(folded);
@@ -219,7 +239,9 @@ export async function inspectIdeaLayout({
219
239
  }
220
240
 
221
241
  const paths = ideaPaths(entry.name);
222
- const children = (await readdir(folderPath, { withFileTypes: true }))
242
+ const children = (
243
+ await filesystem.readdir(folderPath, { withFileTypes: true })
244
+ )
223
245
  .sort((left, right) => left.name.localeCompare(right.name));
224
246
  for (const child of children) {
225
247
  if (
@@ -236,7 +258,7 @@ export async function inspectIdeaLayout({
236
258
  "Keep only status.yaml, ledger.md, and outer/ at the idea root; put supporting files in their world.",
237
259
  ));
238
260
  }
239
- await requireLedger(root, paths.ledgerPath, diagnostics);
261
+ await requireLedger(root, paths.ledgerPath, diagnostics, filesystem);
240
262
  const requiredDirectories = [
241
263
  paths.outerPath,
242
264
  paths.innerPath,
@@ -248,14 +270,28 @@ export async function inspectIdeaLayout({
248
270
  paths.ideaDocumentPath,
249
271
  ];
250
272
  const validDirectories = (await Promise.all(
251
- requiredDirectories.map((path) => requireDirectory(root, path, diagnostics)),
273
+ requiredDirectories.map((path) =>
274
+ requireDirectory(root, path, diagnostics, filesystem)
275
+ ),
252
276
  )).every(Boolean);
253
277
  const validDocuments = (await Promise.all(
254
- requiredDocuments.map((path) => requireDocument(root, path, diagnostics)),
278
+ requiredDocuments.map((path) =>
279
+ requireDocument(root, path, diagnostics, filesystem)
280
+ ),
255
281
  )).every(Boolean);
256
- if (validDirectories) await rejectSymlinks(root, paths.outerPath, diagnostics);
282
+ if (validDirectories) {
283
+ await rejectSymlinks(
284
+ root,
285
+ paths.outerPath,
286
+ diagnostics,
287
+ filesystem,
288
+ );
289
+ }
257
290
 
258
- const statusMetadata = await metadata(resolve(root, paths.statusPath));
291
+ const statusMetadata = await metadata(
292
+ resolve(root, paths.statusPath),
293
+ filesystem,
294
+ );
259
295
  if (!statusMetadata || !statusMetadata.isFile() || statusMetadata.isSymbolicLink()) {
260
296
  diagnostics.push(error(
261
297
  "idea.status.invalid-file",
@@ -269,7 +305,7 @@ export async function inspectIdeaLayout({
269
305
  let status;
270
306
  try {
271
307
  status = parseIdeaStatus(
272
- await readFile(resolve(root, paths.statusPath), "utf8"),
308
+ await filesystem.readFile(resolve(root, paths.statusPath), "utf8"),
273
309
  { objectIdLength: repositoryObjectIdLength },
274
310
  );
275
311
  } catch (caught) {
@@ -0,0 +1,48 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+
4
+ import { extractIdeaTitle, ideaInventoryItem } from "./idea-query.js";
5
+ import { localize } from "./language.js";
6
+
7
+ export async function readIdeaInventoryItem(root, idea) {
8
+ const documentPath = idea.worlds.idealRevision.documentPath;
9
+ try {
10
+ const source = await readFile(
11
+ resolve(root, ...documentPath.split("/")),
12
+ "utf8",
13
+ );
14
+ return ideaInventoryItem(idea, extractIdeaTitle(source));
15
+ } catch (caught) {
16
+ throw new Error(
17
+ `Cannot read idea metadata for ${idea.id} at ${documentPath}: ${caught.message}`,
18
+ { cause: caught },
19
+ );
20
+ }
21
+ }
22
+
23
+ export function metadataFailureObservation(observed, caught, command) {
24
+ const { ideas: _ideas, ...base } = observed.observation;
25
+ const problem = {
26
+ type: "idea-metadata-unavailable",
27
+ summary: localize(
28
+ observed.outputLanguage,
29
+ caught.message,
30
+ `无法读取 idea metadata:${caught.message}`,
31
+ ),
32
+ };
33
+ return {
34
+ observation: {
35
+ ...base,
36
+ state: "project-setup-required",
37
+ observedThrough: "configuration",
38
+ problems: [problem],
39
+ },
40
+ responseContext: {
41
+ nextSteps: localize(
42
+ observed.outputLanguage,
43
+ `Repair the reported idea document and retry \`${command}\`.`,
44
+ `修复报告的 idea document 后,重新运行 \`${command}\`。`,
45
+ ),
46
+ },
47
+ };
48
+ }
@@ -107,3 +107,136 @@ export const LEDGER_TEMPLATE = `# Ledger
107
107
 
108
108
  - [ ] **D-AC01:** Criterion title
109
109
  `;
110
+
111
+ const CHINESE_IDEA_TEMPLATE = `# 将标题替换为这个 idea 的具体名称
112
+
113
+ ## 意图
114
+
115
+ <!-- 用一两句话说明期望结果。 -->
116
+
117
+ ## 背景
118
+
119
+ <!-- 描述当前问题、情境或机会。 -->
120
+
121
+ ## 期望结果
122
+
123
+ <!-- 描述应当实现的、对外有意义的状态。 -->
124
+
125
+ ## 范围
126
+
127
+ ### 范围内
128
+
129
+ <!-- 描述这个 idea 包含的内容。 -->
130
+
131
+ ### 范围外
132
+
133
+ <!-- 描述这个 idea 有意排除的相邻工作。 -->
134
+
135
+ ## 约束
136
+
137
+ <!-- 记录重要的产品、仓库、兼容性或操作约束。 -->
138
+
139
+ ## 待解决问题
140
+
141
+ <!-- 记录尚未解决的决定;没有时删除本节。 -->
142
+ `;
143
+
144
+ const CHINESE_IMPLEMENTATION_TEMPLATE = `# Implementation
145
+
146
+ ## Steps
147
+
148
+ <!--
149
+ 为每个步骤分配稳定的 I-Sxx 标识符和三级标题。
150
+ 说明修改内容、边界和重要设计细节。
151
+ 不要在本文档中使用任务列表复选框。
152
+ -->
153
+
154
+ ### I-S01: 步骤标题
155
+
156
+ <!-- 描述该实施步骤。 -->
157
+
158
+ ## Acceptance criteria
159
+
160
+ <!--
161
+ 为每项标准分配稳定的 I-ACxx 标识符和三级标题。
162
+ 同时说明可观察结果及其证明方法。
163
+ 不要创建单独的验证章节,也不要使用任务列表复选框。
164
+ -->
165
+
166
+ ### I-AC01: 标准标题
167
+
168
+ <!-- 描述所需结果以及 Agent 如何证明该结果。 -->
169
+ `;
170
+
171
+ const CHINESE_DEPLOYMENT_TEMPLATE = `# Deployment
172
+
173
+ ## Steps
174
+
175
+ <!--
176
+ 为每个步骤分配稳定的 D-Sxx 标识符和三级标题。
177
+ 描述部署或现实世界验证工作。
178
+ 不要在本文档中使用任务列表复选框。
179
+ -->
180
+
181
+ ### D-S01: 步骤标题
182
+
183
+ <!-- 描述该部署或外部验证步骤。 -->
184
+
185
+ ## Acceptance criteria
186
+
187
+ <!--
188
+ 为每项标准分配稳定的 D-ACxx 标识符和三级标题。
189
+ 同时说明可观察的外部结果及其证明方法。
190
+ 不要创建单独的验证章节,也不要使用任务列表复选框。
191
+ -->
192
+
193
+ ### D-AC01: 标准标题
194
+
195
+ <!-- 描述所需外部结果以及 Agent 如何证明该结果。 -->
196
+ `;
197
+
198
+ const CHINESE_LEDGER_TEMPLATE = `# Ledger
199
+
200
+ ## Implementation
201
+
202
+ ### Implementation steps
203
+
204
+ - [ ] **I-S01:** 步骤标题
205
+
206
+ ### Implementation acceptance criteria
207
+
208
+ - [ ] **I-AC01:** 标准标题
209
+
210
+ ## Deployment
211
+
212
+ ### Deployment steps
213
+
214
+ - [ ] **D-S01:** 步骤标题
215
+
216
+ ### Deployment acceptance criteria
217
+
218
+ - [ ] **D-AC01:** 标准标题
219
+ `;
220
+
221
+ export function ideaTemplates(contentLanguage) {
222
+ const normalized = contentLanguage.toLowerCase();
223
+ if (normalized === "zh" || normalized.startsWith("zh-")) {
224
+ return {
225
+ idea: CHINESE_IDEA_TEMPLATE,
226
+ implementation: CHINESE_IMPLEMENTATION_TEMPLATE,
227
+ deployment: CHINESE_DEPLOYMENT_TEMPLATE,
228
+ ledger: CHINESE_LEDGER_TEMPLATE,
229
+ localized: true,
230
+ templateLanguage: "zh-CN",
231
+ };
232
+ }
233
+ const localized = normalized === "en" || normalized.startsWith("en-");
234
+ return {
235
+ idea: IDEA_TEMPLATE,
236
+ implementation: IMPLEMENTATION_TEMPLATE,
237
+ deployment: DEPLOYMENT_TEMPLATE,
238
+ ledger: LEDGER_TEMPLATE,
239
+ localized,
240
+ templateLanguage: "en-US",
241
+ };
242
+ }