silvermoon 0.1.3 → 0.2.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.
@@ -0,0 +1,230 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+
3
+ import {
4
+ ACTIVE_IDEA_STATES,
5
+ IDEA_STATES,
6
+ isValidUlid,
7
+ } from "./ideas.js";
8
+
9
+ const QUERY_STATES = new Set([...IDEA_STATES, "active"]);
10
+ const SORTS = new Set(["newest", "oldest"]);
11
+ const RFC3339 =
12
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-](\d{2}):(\d{2}))$/;
13
+ const CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
14
+
15
+ function usageError(message) {
16
+ const error = new Error(message);
17
+ error.exitCode = 2;
18
+ return error;
19
+ }
20
+
21
+ function normalizeStates(states, all) {
22
+ if (all !== undefined && typeof all !== "boolean") {
23
+ throw usageError("all must be a boolean");
24
+ }
25
+ if (states !== undefined && all === true) {
26
+ throw usageError("state and all are mutually exclusive");
27
+ }
28
+ if (states === undefined) {
29
+ return all === true ? [...IDEA_STATES] : [...ACTIVE_IDEA_STATES];
30
+ }
31
+ const requested = Array.isArray(states) ? states : [states];
32
+ if (requested.length === 0) {
33
+ throw usageError("state must not be empty");
34
+ }
35
+ const selected = new Set();
36
+ for (const state of requested) {
37
+ if (typeof state !== "string" || !QUERY_STATES.has(state)) {
38
+ throw usageError(
39
+ `state must be one of active, ${IDEA_STATES.join(", ")}; received ${JSON.stringify(state)}`,
40
+ );
41
+ }
42
+ for (const expanded of state === "active" ? ACTIVE_IDEA_STATES : [state]) {
43
+ selected.add(expanded);
44
+ }
45
+ }
46
+ return IDEA_STATES.filter((state) => selected.has(state));
47
+ }
48
+
49
+ function normalizeQueryText(query) {
50
+ if (query === undefined || query === null) return null;
51
+ if (typeof query !== "string" || query.trim().length === 0) {
52
+ throw usageError("query must be a non-empty string");
53
+ }
54
+ return query.trim();
55
+ }
56
+
57
+ function normalizeTimestamp(value, name) {
58
+ if (value === undefined || value === null) return null;
59
+ if (typeof value !== "string") {
60
+ throw usageError(`${name} must be an RFC 3339 timestamp`);
61
+ }
62
+ const match = RFC3339.exec(value);
63
+ if (!match) throw usageError(`${name} must be an RFC 3339 timestamp with a timezone`);
64
+ const [, year, month, day, hour, minute, second, , zone, offsetHour, offsetMinute] =
65
+ match;
66
+ const numeric = [year, month, day, hour, minute, second].map(Number);
67
+ if (
68
+ numeric[1] < 1
69
+ || numeric[1] > 12
70
+ || numeric[2] < 1
71
+ || numeric[2] > 31
72
+ || numeric[3] > 23
73
+ || numeric[4] > 59
74
+ || numeric[5] > 59
75
+ || (
76
+ zone !== "Z"
77
+ && (Number(offsetHour) > 23 || Number(offsetMinute) > 59)
78
+ )
79
+ ) {
80
+ throw usageError(`${name} must be a valid RFC 3339 timestamp`);
81
+ }
82
+ const calendar = new Date(0);
83
+ calendar.setUTCFullYear(numeric[0], numeric[1] - 1, numeric[2]);
84
+ calendar.setUTCHours(0, 0, 0, 0);
85
+ if (
86
+ calendar.getUTCFullYear() !== numeric[0]
87
+ || calendar.getUTCMonth() !== numeric[1] - 1
88
+ || calendar.getUTCDate() !== numeric[2]
89
+ ) {
90
+ throw usageError(`${name} must be a valid RFC 3339 timestamp`);
91
+ }
92
+ const timestamp = Date.parse(value);
93
+ if (!Number.isFinite(timestamp)) {
94
+ throw usageError(`${name} must be a valid RFC 3339 timestamp`);
95
+ }
96
+ return new Date(timestamp).toISOString();
97
+ }
98
+
99
+ function normalizeSort(sort) {
100
+ const value = sort ?? "newest";
101
+ if (typeof value !== "string" || !SORTS.has(value)) {
102
+ throw usageError("sort must be newest or oldest");
103
+ }
104
+ return value;
105
+ }
106
+
107
+ function normalizeLimit(limit) {
108
+ if (limit === undefined || limit === null) return null;
109
+ const value = typeof limit === "number"
110
+ ? limit
111
+ : typeof limit === "string" && /^[1-9]\d*$/.test(limit)
112
+ ? Number(limit)
113
+ : Number.NaN;
114
+ if (!Number.isSafeInteger(value) || value <= 0) {
115
+ throw usageError("limit must be a positive integer");
116
+ }
117
+ return value;
118
+ }
119
+
120
+ export function normalizeIdeaQuery({
121
+ all,
122
+ createdBefore,
123
+ createdSince,
124
+ limit,
125
+ query,
126
+ sort,
127
+ states,
128
+ } = {}) {
129
+ const normalized = {
130
+ states: normalizeStates(states, all),
131
+ query: normalizeQueryText(query),
132
+ createdSince: normalizeTimestamp(createdSince, "createdSince"),
133
+ createdBefore: normalizeTimestamp(createdBefore, "createdBefore"),
134
+ sort: normalizeSort(sort),
135
+ limit: normalizeLimit(limit),
136
+ };
137
+ if (
138
+ normalized.createdSince !== null
139
+ && normalized.createdBefore !== null
140
+ && Date.parse(normalized.createdSince) >= Date.parse(normalized.createdBefore)
141
+ ) {
142
+ throw usageError("createdSince must be earlier than createdBefore");
143
+ }
144
+ return normalized;
145
+ }
146
+
147
+ export function ideaCreatedAt(id) {
148
+ if (!isValidUlid(id)) throw new Error(`Cannot decode invalid idea ULID: ${id}`);
149
+ let timestamp = 0;
150
+ for (const character of id.slice(0, 10)) {
151
+ timestamp = timestamp * 32 + CROCKFORD_BASE32.indexOf(character);
152
+ }
153
+ return new Date(timestamp).toISOString();
154
+ }
155
+
156
+ function markdownText(node) {
157
+ if (node.type === "text" || node.type === "inlineCode") return node.value;
158
+ if (node.type === "image") return node.alt ?? "";
159
+ return (node.children ?? []).map(markdownText).join("");
160
+ }
161
+
162
+ export function extractIdeaTitle(source) {
163
+ if (typeof source !== "string") {
164
+ throw new TypeError("Idea document source must be a string");
165
+ }
166
+ const heading = fromMarkdown(source).children.find(
167
+ (node) => node.type === "heading" && node.depth === 1,
168
+ );
169
+ if (!heading) return undefined;
170
+ const title = markdownText(heading).replaceAll(/\s+/g, " ").trim();
171
+ return title.length === 0 ? undefined : title;
172
+ }
173
+
174
+ export function ideaInventoryItem(idea, title) {
175
+ const item = {
176
+ id: idea.id,
177
+ state: idea.state,
178
+ createdAt: ideaCreatedAt(idea.id),
179
+ };
180
+ if (idea.alias !== undefined) item.alias = idea.alias;
181
+ if (title !== undefined) item.title = title;
182
+ return item;
183
+ }
184
+
185
+ function compareText(left, right) {
186
+ return left < right ? -1 : left > right ? 1 : 0;
187
+ }
188
+
189
+ export function queryIdeaInventory(items, input) {
190
+ const query = normalizeIdeaQuery(input);
191
+ const states = new Set(query.states);
192
+ const needle = query.query?.toLowerCase();
193
+ const createdSince = query.createdSince === null
194
+ ? null
195
+ : Date.parse(query.createdSince);
196
+ const createdBefore = query.createdBefore === null
197
+ ? null
198
+ : Date.parse(query.createdBefore);
199
+ const filtered = items.filter((idea) => {
200
+ if (!states.has(idea.state)) return false;
201
+ if (
202
+ needle !== undefined
203
+ && ![idea.id, idea.alias, idea.title]
204
+ .filter((value) => value !== undefined)
205
+ .some((value) => value.toLowerCase().includes(needle))
206
+ ) {
207
+ return false;
208
+ }
209
+ const createdAt = Date.parse(idea.createdAt);
210
+ if (createdSince !== null && createdAt < createdSince) return false;
211
+ return createdBefore === null || createdAt < createdBefore;
212
+ });
213
+ const direction = query.sort === "newest" ? -1 : 1;
214
+ filtered.sort((left, right) => direction * compareText(left.id, right.id));
215
+ const counts = Object.fromEntries(IDEA_STATES.map((state) => [state, 0]));
216
+ for (const idea of filtered) counts[idea.state] += 1;
217
+ const ideas = query.limit === null
218
+ ? filtered
219
+ : filtered.slice(0, query.limit);
220
+ return {
221
+ query,
222
+ summary: {
223
+ matched: filtered.length,
224
+ returned: ideas.length,
225
+ truncated: ideas.length < filtered.length,
226
+ counts,
227
+ },
228
+ ideas,
229
+ };
230
+ }
package/src/ideas.js CHANGED
@@ -8,6 +8,7 @@ export const IDEA_STATES = [
8
8
  "completed",
9
9
  "abandoned",
10
10
  ];
11
+ export const ACTIVE_IDEA_STATES = IDEA_STATES.slice(0, 3);
11
12
 
12
13
  const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
13
14
  const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  diagnosticProblem,
5
5
  } from "./dialogue.js";
6
6
  import { loadConfig } from "./config.js";
7
+ import { createCommandRun } from "./domain.js";
7
8
  import {
8
9
  fetchPrimary,
9
10
  indexSnapshot,
@@ -32,21 +33,22 @@ function targetArgument({ commit, remote, staged, worktree }) {
32
33
  }
33
34
 
34
35
  function failureReport({
35
- intention,
36
+ runtime,
36
37
  outputLanguage,
37
38
  problem,
38
39
  root,
39
40
  version,
40
41
  }) {
41
- return {
42
- intention,
43
- observation: unavailableObservation({
42
+ return runtime.complete(
43
+ unavailableObservation({
44
44
  outputLanguage,
45
45
  problem,
46
46
  root,
47
47
  version,
48
48
  }),
49
- };
49
+ {},
50
+ { factType: "validation.failed" },
51
+ );
50
52
  }
51
53
 
52
54
  async function inspectTree({
@@ -71,8 +73,12 @@ async function inspectTree({
71
73
  );
72
74
  }
73
75
 
74
- function finishCheck(intention, observed) {
75
- return { intention, observation: observed.observation };
76
+ function finishCheck(runtime, observed) {
77
+ return runtime.complete(
78
+ observed.observation,
79
+ {},
80
+ { factType: "validation.completed" },
81
+ );
76
82
  }
77
83
 
78
84
  export async function checkRepository({
@@ -106,6 +112,7 @@ export async function checkRepository({
106
112
  language: canonicalLanguage ?? null,
107
113
  },
108
114
  };
115
+ const runtime = createCommandRun(intention);
109
116
  const repository = runGit(requestedRoot, ["rev-parse", "--show-toplevel"]);
110
117
  if (!repository.ok) {
111
118
  const observed = await observeSnapshot({
@@ -119,7 +126,7 @@ export async function checkRepository({
119
126
  ? { type: "remote", commit: null }
120
127
  : { type: target.type },
121
128
  });
122
- return finishCheck(intention, observed);
129
+ return finishCheck(runtime, observed);
123
130
  }
124
131
  const repositoryRoot = resolve(repository.stdout);
125
132
 
@@ -129,7 +136,7 @@ export async function checkRepository({
129
136
  head = resolveCommit(repositoryRoot, "HEAD");
130
137
  } catch (caught) {
131
138
  return failureReport({
132
- intention,
139
+ runtime,
133
140
  outputLanguage: fallbackOutputLanguage,
134
141
  problem: {
135
142
  type: "head-unavailable",
@@ -148,7 +155,7 @@ export async function checkRepository({
148
155
  );
149
156
  } catch (caught) {
150
157
  return failureReport({
151
- intention,
158
+ runtime,
152
159
  outputLanguage: fallbackOutputLanguage,
153
160
  problem: {
154
161
  type: "snapshot-inspection-failed",
@@ -159,9 +166,7 @@ export async function checkRepository({
159
166
  });
160
167
  }
161
168
  if (!bootstrap.config) {
162
- return {
163
- intention,
164
- observation: {
169
+ return runtime.complete({
165
170
  state: "project-setup-required",
166
171
  observedThrough: "version",
167
172
  root: repositoryRoot,
@@ -170,23 +175,35 @@ export async function checkRepository({
170
175
  problems: bootstrap.diagnostics.map((diagnostic) =>
171
176
  diagnosticProblem(diagnostic, fallbackOutputLanguage)
172
177
  ),
173
- },
174
- };
178
+ });
175
179
  }
176
180
 
177
- let primary;
178
- try {
179
- primary = fetchPrimary(repositoryRoot, bootstrap.config);
180
- } catch (caught) {
181
- const summary = sanitizeGitMessage(caught.message);
181
+ const fetched = await runtime.performAction(
182
+ {
183
+ type: "fetch-primary",
184
+ repository: bootstrap.config.primaryRepository,
185
+ branch: bootstrap.config.primaryBranch,
186
+ },
187
+ () => ({
188
+ commit: fetchPrimary(repositoryRoot, bootstrap.config),
189
+ }),
190
+ (caught) => ({
191
+ problem: {
192
+ type: "primary-fetch-failed",
193
+ summary: sanitizeGitMessage(caught.message),
194
+ },
195
+ }),
196
+ );
197
+ if (fetched.status === "failure") {
182
198
  return failureReport({
183
- intention,
199
+ runtime,
184
200
  outputLanguage: fallbackOutputLanguage,
185
201
  root: repositoryRoot,
186
202
  version: { type: "remote", commit: null },
187
- problem: { type: "primary-fetch-failed", summary },
203
+ problem: fetched.problem,
188
204
  });
189
205
  }
206
+ const primary = fetched.result.commit;
190
207
  try {
191
208
  const observed = await withTemporaryWorktree(
192
209
  repositoryRoot,
@@ -202,10 +219,10 @@ export async function checkRepository({
202
219
  version: { type: "remote", commit: primary },
203
220
  }),
204
221
  );
205
- return finishCheck(intention, observed);
222
+ return finishCheck(runtime, observed);
206
223
  } catch (caught) {
207
224
  return failureReport({
208
- intention,
225
+ runtime,
209
226
  outputLanguage: fallbackOutputLanguage,
210
227
  problem: {
211
228
  type: "snapshot-inspection-failed",
@@ -224,7 +241,7 @@ export async function checkRepository({
224
241
  resolvedCommit = resolveCommit(repositoryRoot, revision);
225
242
  } catch (caught) {
226
243
  return failureReport({
227
- intention,
244
+ runtime,
228
245
  outputLanguage: fallbackOutputLanguage,
229
246
  problem: {
230
247
  type: "commit-unavailable",
@@ -249,10 +266,10 @@ export async function checkRepository({
249
266
  version: { type: "commit", commit: resolvedCommit },
250
267
  }),
251
268
  );
252
- return finishCheck(intention, observed);
269
+ return finishCheck(runtime, observed);
253
270
  } catch (caught) {
254
271
  return failureReport({
255
- intention,
272
+ runtime,
256
273
  outputLanguage: fallbackOutputLanguage,
257
274
  problem: {
258
275
  type: "snapshot-inspection-failed",
@@ -275,10 +292,10 @@ export async function checkRepository({
275
292
  userHome,
276
293
  version: { type: "staged" },
277
294
  });
278
- return finishCheck(intention, observed);
295
+ return finishCheck(runtime, observed);
279
296
  } catch (caught) {
280
297
  return failureReport({
281
- intention,
298
+ runtime,
282
299
  outputLanguage: fallbackOutputLanguage,
283
300
  problem: {
284
301
  type: "snapshot-inspection-failed",
@@ -300,10 +317,10 @@ export async function checkRepository({
300
317
  userHome,
301
318
  version: { type: "worktree" },
302
319
  });
303
- return finishCheck(intention, observed);
320
+ return finishCheck(runtime, observed);
304
321
  } catch (caught) {
305
322
  return failureReport({
306
- intention,
323
+ runtime,
307
324
  outputLanguage: fallbackOutputLanguage,
308
325
  problem: {
309
326
  type: "snapshot-inspection-failed",
@@ -316,15 +333,43 @@ export async function checkRepository({
316
333
  }
317
334
 
318
335
  export {
336
+ ACTIVE_IDEA_STATES,
319
337
  deriveIdeaState,
338
+ IDEA_STATES,
320
339
  isValidUlid,
321
340
  parseIdeaStatus,
322
341
  serializeIdeaStatus,
323
342
  validateIdeaStatus,
324
343
  } from "./ideas.js";
344
+ export {
345
+ extractIdeaTitle,
346
+ ideaCreatedAt,
347
+ ideaInventoryItem,
348
+ normalizeIdeaQuery,
349
+ queryIdeaInventory,
350
+ } from "./idea-query.js";
351
+ export { listIdeas } from "./list-ideas.js";
325
352
  export { createIdea, generateUlid } from "./create-idea.js";
326
353
  export {
327
354
  inspectAdoption,
328
355
  SILVERMOON_VERSION,
329
356
  } from "./adoption.js";
357
+ export {
358
+ CommandRun,
359
+ DOMAIN_MESSAGE_SCHEMA_VERSION,
360
+ DomainInvariantError,
361
+ driveCommand,
362
+ initialInternalObservation,
363
+ projectActions,
364
+ projectIntention,
365
+ projectPublicObservation,
366
+ projectReport,
367
+ reduceObservation,
368
+ replayObservation,
369
+ } from "./domain.js";
370
+ export {
371
+ renderResponse,
372
+ respond,
373
+ } from "./response.js";
374
+ export { TRACE_SCHEMA_VERSION } from "./trace.js";
330
375
  export { whatsNext } from "./whatsnext.js";
package/src/language.js CHANGED
@@ -3,6 +3,14 @@ export const OUTPUT_LANGUAGES = Object.freeze(["en-US", "zh-CN"]);
3
3
 
4
4
  const OUTPUT_LANGUAGE_SET = new Set(OUTPUT_LANGUAGES);
5
5
 
6
+ export function isChinese(language) {
7
+ return language?.toLowerCase().startsWith("zh") ?? false;
8
+ }
9
+
10
+ export function localize(language, english, chinese) {
11
+ return isChinese(language) ? chinese : english;
12
+ }
13
+
6
14
  export function canonicalizeLanguageTag(value) {
7
15
  if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
8
16
  throw new Error("language tag must be a non-empty trimmed string");
@@ -0,0 +1,133 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+
4
+ import { createCommandRun } from "./domain.js";
5
+ import {
6
+ extractIdeaTitle,
7
+ ideaInventoryItem,
8
+ normalizeIdeaQuery,
9
+ queryIdeaInventory,
10
+ } from "./idea-query.js";
11
+ import { localize } from "./language.js";
12
+ import { observeSnapshot } from "./observation.js";
13
+ import { traceAsync } from "./trace.js";
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
+ }
29
+ }
30
+
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],
47
+ },
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
+ ),
54
+ },
55
+ };
56
+ }
57
+
58
+ export async function listIdeas({
59
+ all,
60
+ createdBefore,
61
+ createdSince,
62
+ limit,
63
+ query,
64
+ root = process.cwd(),
65
+ sort,
66
+ states,
67
+ userHome,
68
+ } = {}) {
69
+ const normalizedQuery = normalizeIdeaQuery({
70
+ all,
71
+ createdBefore,
72
+ createdSince,
73
+ limit,
74
+ query,
75
+ sort,
76
+ states,
77
+ });
78
+ const intention = {
79
+ command: "list-ideas",
80
+ args: normalizedQuery,
81
+ };
82
+ const runtime = createCommandRun(intention);
83
+ const observed = await observeSnapshot({
84
+ root,
85
+ userHome,
86
+ version: { type: "worktree" },
87
+ });
88
+ if (!observed.projectReady) {
89
+ return runtime.complete(
90
+ observed.observation,
91
+ { nextSteps: observed.findings.map(({ instruction }) => instruction) },
92
+ { factType: "ideas.inventory-unavailable" },
93
+ );
94
+ }
95
+
96
+ let items;
97
+ 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
+ );
107
+ } catch (caught) {
108
+ const failure = metadataFailureObservation(observed, caught);
109
+ return runtime.complete(
110
+ failure.observation,
111
+ failure.responseContext,
112
+ { factType: "ideas.inventory-unavailable" },
113
+ );
114
+ }
115
+
116
+ const inventory = queryIdeaInventory(items, normalizedQuery);
117
+ const {
118
+ configuration,
119
+ outputLanguage,
120
+ root: repositoryRoot,
121
+ version,
122
+ } = observed.observation;
123
+ return runtime.complete({
124
+ state: "ideas-listed",
125
+ root: repositoryRoot,
126
+ version,
127
+ configuration,
128
+ outputLanguage,
129
+ problems: [],
130
+ summary: inventory.summary,
131
+ ideas: inventory.ideas,
132
+ }, {}, { factType: "ideas.listed" });
133
+ }