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.
package/src/list-ideas.js CHANGED
@@ -1,71 +1,129 @@
1
- import { readFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
3
-
4
1
  import { createCommandRun } from "./domain.js";
5
2
  import {
6
- extractIdeaTitle,
7
3
  ideaInventoryItem,
8
4
  normalizeIdeaQuery,
9
5
  queryIdeaInventory,
10
6
  } from "./idea-query.js";
11
- import { localize } from "./language.js";
7
+ import {
8
+ metadataFailureObservation,
9
+ readIdeaInventoryItem,
10
+ } from "./idea-metadata.js";
11
+ import { canonicalizeOutputLanguage } from "./language.js";
12
12
  import { observeSnapshot } from "./observation.js";
13
13
  import { traceAsync } from "./trace.js";
14
14
 
15
- async function readInventoryItem(root, idea) {
16
- const documentPath = idea.worlds.idealRevision.documentPath;
17
- try {
18
- const source = await readFile(
19
- resolve(root, ...documentPath.split("/")),
20
- "utf8",
21
- );
22
- return ideaInventoryItem(idea, extractIdeaTitle(source));
23
- } catch (caught) {
24
- throw new Error(
25
- `Cannot read idea metadata for ${idea.id} at ${documentPath}: ${caught.message}`,
26
- { cause: caught },
27
- );
28
- }
15
+ function withoutTitleQuery(query, { limit = query.limit } = {}) {
16
+ return { ...query, limit, query: null };
29
17
  }
30
18
 
31
- function metadataFailureObservation(observed, caught) {
32
- const { ideas: _ideas, ...base } = observed.observation;
33
- const problem = {
34
- type: "idea-metadata-unavailable",
35
- summary: localize(
36
- observed.outputLanguage,
37
- caught.message,
38
- `无法读取 idea metadata:${caught.message}`,
39
- ),
40
- };
41
- return {
42
- observation: {
43
- ...base,
44
- state: "project-setup-required",
45
- observedThrough: "configuration",
46
- problems: [problem],
19
+ function matchesIdentity(item, needle) {
20
+ return [item.id, item.alias]
21
+ .filter((value) => value !== undefined)
22
+ .some((value) => value.toLowerCase().includes(needle));
23
+ }
24
+
25
+ async function queryObservedInventory({
26
+ ideas,
27
+ metadataReader,
28
+ normalizedQuery,
29
+ root,
30
+ }) {
31
+ const ideasById = new Map(ideas.map((idea) => [idea.id, idea]));
32
+ const baseItems = ideas.map((idea) => ideaInventoryItem(idea));
33
+ const candidates = queryIdeaInventory(
34
+ baseItems,
35
+ withoutTitleQuery(normalizedQuery, { limit: null }),
36
+ ).ideas;
37
+
38
+ return traceAsync(
39
+ "ideas.inventory-metadata",
40
+ {
41
+ candidateCount: candidates.length,
42
+ ideaCount: ideas.length,
47
43
  },
48
- responseContext: {
49
- nextSteps: localize(
50
- observed.outputLanguage,
51
- "Repair the reported idea document and retry `silvermoon list-ideas`.",
52
- "修复报告的 idea document 后,重新运行 `silvermoon list-ideas`。",
53
- ),
44
+ async () => {
45
+ const loadedItems = new Map();
46
+ let queryCandidateCount = 0;
47
+ let titleReadCount = 0;
48
+
49
+ const readItems = async (items) => {
50
+ const unread = items.filter(({ id }) => !loadedItems.has(id));
51
+ titleReadCount += unread.length;
52
+ const loaded = await Promise.all(unread.map(({ id }) => {
53
+ const idea = ideasById.get(id);
54
+ if (idea === undefined) {
55
+ throw new Error(`Missing observed idea metadata for ${id}`);
56
+ }
57
+ return metadataReader(root, idea);
58
+ }));
59
+ for (const item of loaded) loadedItems.set(item.id, item);
60
+ };
61
+
62
+ let matchingItems = candidates;
63
+ if (normalizedQuery.query !== null) {
64
+ const needle = normalizedQuery.query.toLowerCase();
65
+ const directMatchIds = new Set(
66
+ candidates
67
+ .filter((item) => matchesIdentity(item, needle))
68
+ .map(({ id }) => id),
69
+ );
70
+ const titleCandidates = candidates.filter(
71
+ ({ id }) => !directMatchIds.has(id),
72
+ );
73
+ queryCandidateCount = titleCandidates.length;
74
+ await readItems(titleCandidates);
75
+ matchingItems = candidates
76
+ .filter(({ id }) =>
77
+ directMatchIds.has(id)
78
+ || loadedItems.get(id)?.title?.toLowerCase().includes(needle)
79
+ )
80
+ .map((item) => loadedItems.get(item.id) ?? item);
81
+ }
82
+
83
+ const selected = queryIdeaInventory(
84
+ matchingItems,
85
+ withoutTitleQuery(normalizedQuery),
86
+ );
87
+ await readItems(selected.ideas);
88
+ const inventory = {
89
+ summary: selected.summary,
90
+ ideas: selected.ideas.map(({ id }) => {
91
+ const item = loadedItems.get(id);
92
+ if (item === undefined) {
93
+ throw new Error(`Missing selected idea metadata for ${id}`);
94
+ }
95
+ return item;
96
+ }),
97
+ };
98
+ return { inventory, queryCandidateCount, titleReadCount };
54
99
  },
55
- };
100
+ ({ inventory, queryCandidateCount, titleReadCount }) => ({
101
+ attributes: {
102
+ matchedCount: inventory.summary.matched,
103
+ queryCandidateCount,
104
+ returnedCount: inventory.summary.returned,
105
+ titleReadCount,
106
+ },
107
+ }),
108
+ );
56
109
  }
57
110
 
58
111
  export async function listIdeas({
59
112
  all,
60
113
  createdBefore,
61
114
  createdSince,
115
+ language,
62
116
  limit,
117
+ metadataReader = readIdeaInventoryItem,
63
118
  query,
64
119
  root = process.cwd(),
65
120
  sort,
66
121
  states,
67
122
  userHome,
68
123
  } = {}) {
124
+ const canonicalLanguage = language === undefined
125
+ ? undefined
126
+ : canonicalizeOutputLanguage(language);
69
127
  const normalizedQuery = normalizeIdeaQuery({
70
128
  all,
71
129
  createdBefore,
@@ -77,11 +135,15 @@ export async function listIdeas({
77
135
  });
78
136
  const intention = {
79
137
  command: "list-ideas",
80
- args: normalizedQuery,
138
+ args: {
139
+ ...normalizedQuery,
140
+ language: canonicalLanguage ?? null,
141
+ },
81
142
  };
82
143
  const runtime = createCommandRun(intention);
83
144
  const observed = await observeSnapshot({
84
145
  root,
146
+ outputLanguage: canonicalLanguage,
85
147
  userHome,
86
148
  version: { type: "worktree" },
87
149
  });
@@ -93,19 +155,20 @@ export async function listIdeas({
93
155
  );
94
156
  }
95
157
 
96
- let items;
158
+ let queried;
97
159
  try {
98
- items = await traceAsync(
99
- "ideas.inventory-metadata",
100
- { ideaCount: observed.layout.ideas.length },
101
- () => Promise.all(
102
- observed.layout.ideas.map((idea) =>
103
- readInventoryItem(observed.observation.root, idea)
104
- ),
105
- ),
106
- );
160
+ queried = await queryObservedInventory({
161
+ ideas: observed.layout.ideas,
162
+ metadataReader,
163
+ normalizedQuery,
164
+ root: observed.observation.root,
165
+ });
107
166
  } catch (caught) {
108
- const failure = metadataFailureObservation(observed, caught);
167
+ const failure = metadataFailureObservation(
168
+ observed,
169
+ caught,
170
+ "silvermoon list-ideas",
171
+ );
109
172
  return runtime.complete(
110
173
  failure.observation,
111
174
  failure.responseContext,
@@ -113,7 +176,7 @@ export async function listIdeas({
113
176
  );
114
177
  }
115
178
 
116
- const inventory = queryIdeaInventory(items, normalizedQuery);
179
+ const { inventory } = queried;
117
180
  const {
118
181
  configuration,
119
182
  outputLanguage,
@@ -132,6 +132,7 @@ export function repositoryProblemObservation(observation, problems) {
132
132
  async function observeSnapshotInternal({
133
133
  allowMissingIdeas = false,
134
134
  contentRoot,
135
+ filesystem,
135
136
  gitRoot,
136
137
  ideaLanguage,
137
138
  outputLanguage: requestedOutputLanguage,
@@ -147,7 +148,12 @@ async function observeSnapshotInternal({
147
148
  const adoption = await traceAsync(
148
149
  "adoption.inspect",
149
150
  {},
150
- () => inspectAdoption({ contentRoot, root }),
151
+ () => inspectAdoption({
152
+ contentRoot,
153
+ filesystem,
154
+ repositoryRoot: gitRoot,
155
+ root,
156
+ }),
151
157
  );
152
158
  const user = await traceAsync(
153
159
  "user-config.load",
@@ -233,6 +239,7 @@ async function observeSnapshotInternal({
233
239
  {},
234
240
  () => inspectIdeaLayout({
235
241
  config: adoption.config,
242
+ filesystem,
236
243
  gitRoot: gitRoot ?? adoption.root,
237
244
  root: contentRoot ?? adoption.root,
238
245
  snapshotTree,
@@ -257,6 +264,7 @@ async function observeSnapshotInternal({
257
264
 
258
265
  const guidanceDiagnostics = projectOnly
259
266
  ? (await inspectAllGuidance({
267
+ filesystem,
260
268
  gitRoot: gitRoot ?? adoption.root,
261
269
  snapshotTree,
262
270
  })).diagnostics
package/src/repository.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { createHash } from "node:crypto";
2
- import { spawnSync } from "node:child_process";
3
2
 
4
3
  const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
5
4
  const ENCODED_SEPARATOR = /%(?:2f|5c)/i;
@@ -58,6 +57,7 @@ export function validBranchName(value) {
58
57
  typeof value !== "string" ||
59
58
  value.length === 0 ||
60
59
  value === "@" ||
60
+ value === "HEAD" ||
61
61
  value.startsWith("-") ||
62
62
  value.startsWith("refs/") ||
63
63
  value.startsWith("remotes/") ||
@@ -74,13 +74,7 @@ export function validBranchName(value) {
74
74
  const structurallyValid = value
75
75
  .split("/")
76
76
  .every((component) => component && !component.startsWith(".") && !component.endsWith(".lock"));
77
- if (!structurallyValid) return false;
78
-
79
- const checked = spawnSync("git", ["check-ref-format", "--branch", value], {
80
- encoding: "utf8",
81
- windowsHide: true,
82
- });
83
- return checked.status === 0;
77
+ return structurallyValid;
84
78
  }
85
79
 
86
80
  export function effectiveSourceRepository(config, record) {
package/src/response.js CHANGED
@@ -163,13 +163,13 @@ function dialogueResponse(intention, internalObservation, language) {
163
163
  summary: choices.length === 0
164
164
  ? localize(
165
165
  language,
166
- "No active ideas are available.",
167
- "当前没有 active idea。",
166
+ "Current state: navigation-ready. No active ideas are available.",
167
+ "当前状态:navigation-ready。当前没有 active idea。",
168
168
  )
169
169
  : localize(
170
170
  language,
171
- `${choices.length} active idea(s) are available.`,
172
- `当前有 ${choices.length} 个 active idea。`,
171
+ `Current state: navigation-ready. ${choices.length} active idea(s) are available.`,
172
+ `当前状态:navigation-ready。当前有 ${choices.length} 个 active idea。`,
173
173
  ),
174
174
  choices,
175
175
  nextSteps,
@@ -304,34 +304,85 @@ function markdownTableCell(value) {
304
304
  .replaceAll(/\r?\n/g, " ");
305
305
  }
306
306
 
307
- function renderIdeaList(response, language) {
307
+ function renderMarkdownTable(headers, rows) {
308
+ return [
309
+ `| ${headers.join(" | ")} |`,
310
+ `| ${headers.map(() => "---").join(" | ")} |`,
311
+ ...rows.map((row) =>
312
+ `| ${row.map(markdownTableCell).join(" | ")} |`
313
+ ),
314
+ ];
315
+ }
316
+
317
+ function formatDate(timestamp, language, now) {
318
+ const date = new Date(timestamp);
319
+ if (!Number.isFinite(date.getTime()) || !Number.isFinite(now.getTime())) {
320
+ throw new TypeError("Cannot render an invalid timestamp");
321
+ }
322
+ const duration = now.getTime() - date.getTime();
323
+ const elapsed = Math.abs(duration);
324
+ const minute = 60_000;
325
+ const hour = 60 * minute;
326
+ const day = 24 * hour;
327
+ if (elapsed > 7 * day) {
328
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
329
+ }
330
+ if (elapsed < minute) {
331
+ return duration < 0
332
+ ? localize(language, "soon", "马上")
333
+ : localize(language, "just now", "刚刚");
334
+ }
335
+ const [amount, unit, chineseUnit] = elapsed < hour
336
+ ? [Math.floor(elapsed / minute), "m", "分钟"]
337
+ : elapsed < day
338
+ ? [Math.floor(elapsed / hour), "h", "小时"]
339
+ : [Math.floor(elapsed / day), "d", "天"];
340
+ return duration < 0
341
+ ? localize(language, `in ${amount}${unit}`, `${amount}${chineseUnit}后`)
342
+ : localize(language, `${amount}${unit} ago`, `${amount}${chineseUnit}前`);
343
+ }
344
+
345
+ function renderIdeaTable(ideas, language, now) {
308
346
  const headers = [
309
- localize(language, "ID", "ID"),
310
- localize(language, "Alias", "Alias"),
347
+ "Alias / ID",
311
348
  localize(language, "State", "状态"),
312
349
  localize(language, "Created", "创建时间"),
313
350
  localize(language, "Title", "标题"),
314
351
  ];
352
+ return renderMarkdownTable(
353
+ headers,
354
+ ideas.map((idea) => [
355
+ idea.alias ?? idea.id,
356
+ idea.state,
357
+ formatDate(idea.createdAt, language, now),
358
+ idea.title,
359
+ ]),
360
+ );
361
+ }
362
+
363
+ function renderIdeaList(response, language, now) {
315
364
  const counts = Object.entries(response.inventory.counts)
365
+ .filter(([, count]) => count > 0)
316
366
  .map(([state, count]) => `${state}=${count}`)
317
- .join(", ");
318
- const lines = [
319
- `${response.summary} ${localize(language, "Counts", "计数")}: ${counts}.`,
320
- "",
321
- `| ${headers.join(" | ")} |`,
322
- `| ${headers.map(() => "---").join(" | ")} |`,
323
- ];
324
- for (const idea of response.items) {
367
+ .join(localize(language, ", ", ","));
368
+ const lines = [counts.length === 0
369
+ ? response.summary
370
+ : localize(
371
+ language,
372
+ `${response.summary} Counts: ${counts}.`,
373
+ `${response.summary}计数:${counts}。`,
374
+ )];
375
+ if (response.query?.createdSince) {
376
+ lines.push(
377
+ `- ${localize(language, "Created since", "创建时间下界")}: ${formatDate(response.query.createdSince, language, now)}`,
378
+ );
379
+ }
380
+ if (response.query?.createdBefore) {
325
381
  lines.push(
326
- `| ${[
327
- idea.id,
328
- idea.alias,
329
- idea.state,
330
- idea.createdAt,
331
- idea.title,
332
- ].map(markdownTableCell).join(" | ")} |`,
382
+ `- ${localize(language, "Created before", "创建时间上界")}: ${formatDate(response.query.createdBefore, language, now)}`,
333
383
  );
334
384
  }
385
+ lines.push("", ...renderIdeaTable(response.items, language, now));
335
386
  if (response.items.length === 0) {
336
387
  lines.push(
337
388
  "",
@@ -341,56 +392,17 @@ function renderIdeaList(response, language) {
341
392
  return lines.join("\n");
342
393
  }
343
394
 
344
- export function renderResponse(response) {
395
+ export function renderResponse(response, { now = new Date() } = {}) {
345
396
  const language = response.language ?? DEFAULT_LANGUAGE;
346
397
  if (response.kind === "idea-list") {
347
398
  return [
348
399
  `## ${responseTitle(response)}`,
349
400
  "",
350
- renderIdeaList(response, language),
401
+ renderIdeaList(response, language, now),
351
402
  ].join("\n");
352
403
  }
353
404
  const lines = [`## ${responseTitle(response)}`, "", response.summary];
354
405
 
355
- if (response.inventory) {
356
- const counts = Object.entries(response.inventory.counts)
357
- .map(([state, count]) => `${state}=${count}`)
358
- .join(", ");
359
- lines.push(
360
- "",
361
- `- ${localize(language, "States", "状态")}: ${response.query.states
362
- .map(codeSpan)
363
- .join(", ")}`,
364
- );
365
- if (response.query.query !== null) {
366
- lines.push(
367
- `- ${localize(language, "Query", "查询")}: ${codeSpan(response.query.query)}`,
368
- );
369
- }
370
- if (response.query.createdSince !== null) {
371
- lines.push(
372
- `- ${localize(language, "Created since", "创建时间下界")}: ${codeSpan(response.query.createdSince)}`,
373
- );
374
- }
375
- if (response.query.createdBefore !== null) {
376
- lines.push(
377
- `- ${localize(language, "Created before", "创建时间上界")}: ${codeSpan(response.query.createdBefore)}`,
378
- );
379
- }
380
- lines.push(
381
- `- ${localize(language, "Sort", "排序")}: ${codeSpan(response.query.sort)}`,
382
- `- ${localize(language, "Limit", "上限")}: ${response.query.limit === null
383
- ? localize(language, "none", "无")
384
- : codeSpan(response.query.limit)}`,
385
- `- ${localize(language, "Matched", "匹配")}: ${response.inventory.matched}`,
386
- `- ${localize(language, "Returned", "返回")}: ${response.inventory.returned}`,
387
- `- ${localize(language, "Truncated", "已截断")}: ${response.inventory.truncated
388
- ? localize(language, "yes", "是")
389
- : localize(language, "no", "否")}`,
390
- `- ${localize(language, "Counts", "计数")}: ${counts}`,
391
- );
392
- }
393
-
394
406
  if (response.validation) {
395
407
  const target = response.validation.target.type === "commit"
396
408
  ? `${response.validation.target.type} ${response.validation.target.revision}`
@@ -432,40 +444,22 @@ export function renderResponse(response) {
432
444
  );
433
445
  }
434
446
 
435
- if (response.choices?.length > 0) {
447
+ if (response.choices) {
436
448
  lines.push(
437
449
  "",
438
- `### ${localize(language, "Available ideas", "可用 ideas")}`,
450
+ `### ${localize(language, "Active ideas", "Active ideas")}`,
439
451
  "",
440
452
  );
441
- for (const idea of response.choices) {
442
- const alias = idea.alias === undefined ? "" : ` (${idea.alias})`;
443
- lines.push(`- ${codeSpan(idea.id)}${alias} ${idea.state}`);
444
- }
445
- }
446
-
447
- if (response.items) {
448
- lines.push("", `### ${localize(language, "Results", "结果")}`, "");
449
- if (response.items.length === 0) {
450
- lines.push(localize(language, "No ideas matched.", "没有匹配的 idea。"));
453
+ if (response.choices.length === 0) {
454
+ lines.push(
455
+ localize(
456
+ language,
457
+ "No active ideas are available.",
458
+ "当前没有 active idea。",
459
+ ),
460
+ );
451
461
  } else {
452
- for (const idea of response.items) {
453
- const details = [
454
- `${localize(language, "State", "状态")}=${codeSpan(idea.state)}`,
455
- `${localize(language, "Created", "创建时间")}=${codeSpan(idea.createdAt)}`,
456
- ];
457
- if (idea.alias !== undefined) {
458
- details.push(
459
- `${localize(language, "Alias", "Alias")}=${codeSpan(idea.alias)}`,
460
- );
461
- }
462
- if (idea.title !== undefined) {
463
- details.push(
464
- `${localize(language, "Title", "标题")}=${codeSpan(idea.title)}`,
465
- );
466
- }
467
- lines.push(`- ${codeSpan(idea.id)}: ${details.join("; ")}`);
468
- }
462
+ lines.push(...renderIdeaTable(response.choices, language, now));
469
463
  }
470
464
  }
471
465
 
@@ -475,9 +469,13 @@ export function renderResponse(response) {
475
469
  `### ${localize(language, "Issues to address", "需要处理的问题")}`,
476
470
  "",
477
471
  );
478
- for (const problem of response.problems) {
479
- lines.push(`- [${problem.type}] ${problem.summary}`);
480
- }
472
+ lines.push(...renderMarkdownTable(
473
+ [
474
+ localize(language, "Type", "类型"),
475
+ localize(language, "Summary", "概要"),
476
+ ],
477
+ response.problems.map(({ type, summary }) => [type, summary]),
478
+ ));
481
479
  }
482
480
 
483
481
  if (response.nextSteps?.length > 0) {
@@ -0,0 +1,27 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import { traceSync } from "./trace.js";
4
+
5
+ export function runSubprocess(
6
+ command,
7
+ args,
8
+ options,
9
+ {
10
+ attributes = {},
11
+ spanName = "process.command",
12
+ spawn = spawnSync,
13
+ } = {},
14
+ ) {
15
+ return traceSync(
16
+ spanName,
17
+ attributes,
18
+ () => spawn(command, args, options),
19
+ (completed) => ({
20
+ attributes: {
21
+ exitCode: completed.status,
22
+ signal: completed.signal,
23
+ },
24
+ status: completed.status === 0 ? "ok" : "error",
25
+ }),
26
+ );
27
+ }
package/src/trace.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { constants } from "node:fs";
4
- import { access, lstat, writeFile } from "node:fs/promises";
4
+ import { access, lstat, open } from "node:fs/promises";
5
5
  import { dirname, resolve } from "node:path";
6
6
 
7
7
  export const TRACE_SCHEMA_VERSION = 2;
@@ -217,6 +217,41 @@ export function traceSync(name, attributes, callback, summarize = () => ({})) {
217
217
  );
218
218
  }
219
219
 
220
+ async function flushTrace(path, trace) {
221
+ const span = trace.start("trace.flush", null, {});
222
+ let handle;
223
+ let failure;
224
+ let spanEnded = false;
225
+ try {
226
+ handle = await open(path, "wx");
227
+ await handle.writeFile(trace.serialize(), { encoding: "utf8" });
228
+ await handle.sync();
229
+ trace.end(span, "ok", { eventCount: trace.events.length + 1 });
230
+ spanEnded = true;
231
+ await handle.appendFile(
232
+ `${JSON.stringify(trace.events.at(-1))}\n`,
233
+ { encoding: "utf8" },
234
+ );
235
+ await handle.sync();
236
+ } catch (caught) {
237
+ if (!spanEnded) {
238
+ trace.end(span, "error", { errorName: errorName(caught) });
239
+ }
240
+ failure = caught;
241
+ }
242
+
243
+ if (handle) {
244
+ try {
245
+ await handle.close();
246
+ } catch (caught) {
247
+ failure = failure
248
+ ? new AggregateError([failure, caught], `Cannot close trace file: ${path}`)
249
+ : caught;
250
+ }
251
+ }
252
+ if (failure) throw failure;
253
+ }
254
+
220
255
  export async function withTraceFile(file, name, attributes, callback) {
221
256
  if (file === undefined) return callback();
222
257
 
@@ -236,7 +271,7 @@ export async function withTraceFile(file, name, attributes, callback) {
236
271
 
237
272
  let traceError;
238
273
  try {
239
- await writeFile(path, trace.serialize(), { encoding: "utf8", flag: "wx" });
274
+ await flushTrace(path, trace);
240
275
  } catch (caught) {
241
276
  traceError = caught;
242
277
  }