silvermoon 0.0.1 → 0.0.3

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.
@@ -1,439 +1,439 @@
1
- import { lstat, readFile, readdir } from "node:fs/promises";
2
- import { relative, resolve } from "node:path";
3
-
4
- import { deriveIdeaState, isValidUlid, parseIdeaStatus } from "./ideas.js";
5
- import { gitObjectIdLength, runGit, worktreePathTree } from "./git.js";
6
- import { IDEAS_ROOT, ideaPaths } from "./layout.js";
7
-
8
- const REVISION_BINDINGS = {
9
- approvedRevision: "idealRevision",
10
- implementationAcceptedRevision: "implementationRevision",
11
- deploymentAcceptedRevision: "deploymentRevision",
12
- };
13
-
14
- function displayPath(root, path) {
15
- return relative(root, path).replaceAll("\\", "/");
16
- }
17
-
18
- function error(code, path, message, remediation) {
19
- return { code, level: "error", path, message, remediation };
20
- }
21
-
22
- async function metadata(path) {
23
- try {
24
- return await lstat(path);
25
- } catch (caught) {
26
- if (caught.code === "ENOENT") return null;
27
- throw caught;
28
- }
29
- }
30
-
31
- function validateRevisionObjects(root, statusPath, status, diagnostics) {
32
- for (const key of Object.keys(REVISION_BINDINGS)) {
33
- if (!status[key]) continue;
34
- const type = runGit(root, ["cat-file", "-t", status[key]]);
35
- if (!type.ok || type.stdout !== "tree") {
36
- diagnostics.push(error(
37
- "idea.revision.invalid-object",
38
- `${statusPath}#${key}`,
39
- `${key} must resolve to a Git tree object: ${status[key]}`,
40
- "Use the current corresponding world tree object ID from Git.",
41
- ));
42
- }
43
- }
44
- }
45
-
46
- function validateRevisionHistory(root, commit, idea, diagnostics) {
47
- for (const [statusKey, revisionKey] of Object.entries(REVISION_BINDINGS)) {
48
- const revision = idea.status[statusKey];
49
- if (!revision) continue;
50
- const history = runGit(root, [
51
- "log",
52
- "--reverse",
53
- "--format=%H",
54
- "-S",
55
- `${statusKey}: ${revision}`,
56
- commit,
57
- "--",
58
- idea.statusPath,
59
- ]);
60
- const evidenceCommit = history.ok
61
- ? history.stdout.split(/\r?\n/).filter(Boolean)[0]
62
- : null;
63
- if (!evidenceCommit) {
64
- const shallow = runGit(root, ["rev-parse", "--is-shallow-repository"]);
65
- diagnostics.push(error(
66
- shallow.ok && shallow.stdout === "true"
67
- ? "history.incomplete"
68
- : "idea.revision.history-missing",
69
- `${idea.statusPath}#${statusKey}`,
70
- `Primary history does not prove when ${statusKey} recorded ${revision}.`,
71
- "Fetch complete primary history or repair the decision through a forward commit.",
72
- ));
73
- continue;
74
- }
75
- const tree = runGit(root, ["rev-parse", `${evidenceCommit}:${idea.worlds[revisionKey].path}`]);
76
- if (!tree.ok || tree.stdout !== revision) {
77
- diagnostics.push(error(
78
- "idea.revision.history-mismatch",
79
- `${idea.statusPath}#${statusKey}`,
80
- `${statusKey} was not bound to its world tree in commit ${evidenceCommit}.`,
81
- "Record the decision only for the corresponding world tree in the same candidate commit.",
82
- ));
83
- }
84
- }
85
- }
86
-
87
- function validateCandidateRevisions(root, baseRevision, idea, diagnostics, objectIdLength) {
88
- let previous = {};
89
- if (baseRevision) {
90
- const previousSource = runGit(root, ["show", `${baseRevision}:${idea.statusPath}`]);
91
- if (previousSource.ok) {
92
- try {
93
- previous = parseIdeaStatus(previousSource.stdout + "\n", { objectIdLength });
94
- } catch {
95
- previous = {};
96
- }
97
- }
98
- }
99
- for (const [statusKey, revisionKey] of Object.entries(REVISION_BINDINGS)) {
100
- if (
101
- idea.status[statusKey] === previous[statusKey] ||
102
- idea.status[statusKey] === undefined
103
- ) continue;
104
- const expected = idea.revisions[revisionKey];
105
- if (idea.status[statusKey] !== expected) {
106
- diagnostics.push(error(
107
- "idea.revision.candidate-mismatch",
108
- `${idea.statusPath}#${statusKey}`,
109
- `${statusKey} must equal the corresponding world tree in the same candidate snapshot.`,
110
- `Use ${expected} for ${revisionKey} or omit the stale mutation.`,
111
- ));
112
- }
113
- }
114
- }
115
-
116
- async function requireDirectory(root, path, diagnostics) {
117
- const absolute = resolve(root, path);
118
- const value = await metadata(absolute);
119
- if (!value || !value.isDirectory() || value.isSymbolicLink()) {
120
- diagnostics.push(error(
121
- "idea.world.invalid-directory",
122
- path,
123
- `Required world path must be a repository-owned regular directory: ${path}`,
124
- `Create ${path} as a regular directory without symlinks.`,
125
- ));
126
- return false;
127
- }
128
- return true;
129
- }
130
-
131
- async function requireDocument(root, path, diagnostics) {
132
- const absolute = resolve(root, path);
133
- const value = await metadata(absolute);
134
- if (!value || !value.isFile() || value.isSymbolicLink()) {
135
- diagnostics.push(error(
136
- "idea.world.missing-document",
137
- path,
138
- `Required world entry must be a repository-owned regular file: ${path}`,
139
- `Create ${path} as a regular file without symlinks.`,
140
- ));
141
- return false;
142
- }
143
- return true;
144
- }
145
-
146
- async function requireLedger(root, path, diagnostics) {
147
- const value = await metadata(resolve(root, path));
148
- if (!value) {
149
- diagnostics.push(error(
150
- "idea.ledger.missing-file",
151
- path,
152
- `Required idea ledger does not exist: ${path}`,
153
- `Create ${path} as a repository-owned regular file.`,
154
- ));
155
- return false;
156
- }
157
- if (!value.isFile() || value.isSymbolicLink()) {
158
- diagnostics.push(error(
159
- "idea.ledger.invalid-file",
160
- path,
161
- `Required idea ledger must be a repository-owned regular file: ${path}`,
162
- `Replace ${path} with a regular file.`,
163
- ));
164
- return false;
165
- }
166
- return true;
167
- }
168
-
169
- async function rejectSymlinks(root, path, diagnostics) {
170
- const absolute = resolve(root, path);
171
- for (const entry of await readdir(absolute, { withFileTypes: true })) {
172
- const entryPath = `${path}/${entry.name}`;
173
- const value = await lstat(resolve(root, entryPath));
174
- if (value.isSymbolicLink()) {
175
- diagnostics.push(error(
176
- "idea.world.symlink",
177
- entryPath,
178
- `World content must not use symlinks: ${entryPath}`,
179
- "Replace the symlink with repository-owned regular content.",
180
- ));
181
- continue;
182
- }
183
- if (value.isDirectory()) await rejectSymlinks(root, entryPath, diagnostics);
184
- }
185
- }
186
-
187
- function resolveTree({ gitRoot, root, snapshotTree }, path) {
188
- if (snapshotTree) {
189
- const resolved = runGit(gitRoot, ["rev-parse", `${snapshotTree}:${path}`]);
190
- if (!resolved.ok) throw new Error(resolved.stderr || `Cannot resolve ${path}`);
191
- return resolved.stdout;
192
- }
193
- return worktreePathTree(root, path);
194
- }
195
-
196
- export async function inspectIdeaLayout({
197
- baseRevision,
198
- config: _config,
199
- historyCommit,
200
- root,
201
- gitRoot = root,
202
- snapshotTree,
203
- validateCandidate = false,
204
- }) {
205
- const diagnostics = [];
206
- const ideasRoot = resolve(root, IDEAS_ROOT);
207
- const ideasRootMetadata = await metadata(ideasRoot);
208
- if (!ideasRootMetadata) {
209
- return {
210
- diagnostics: [error(
211
- "layout.ideas.missing",
212
- IDEAS_ROOT,
213
- `Silvermoon ideas directory does not exist: ${IDEAS_ROOT}`,
214
- `Create ${IDEAS_ROOT} as a repository-owned directory.`,
215
- )],
216
- ideas: [],
217
- };
218
- }
219
- if (!ideasRootMetadata.isDirectory() || ideasRootMetadata.isSymbolicLink()) {
220
- return {
221
- diagnostics: [error(
222
- "layout.ideas.invalid",
223
- IDEAS_ROOT,
224
- "The Silvermoon ideas path must be a repository-owned regular directory.",
225
- `Replace ${IDEAS_ROOT} with a regular directory.`,
226
- )],
227
- ideas: [],
228
- };
229
- }
230
-
231
- let objectIdLength;
232
- try {
233
- objectIdLength = gitObjectIdLength(gitRoot);
234
- } catch (caught) {
235
- return {
236
- diagnostics: [error(
237
- "git.object-format.unavailable",
238
- IDEAS_ROOT,
239
- caught.message,
240
- "Run Silvermoon inside a Git repository with a supported object format.",
241
- )],
242
- ideas: [],
243
- };
244
- }
245
-
246
- const aliases = new Map();
247
- const ideas = [];
248
- const caseNames = new Map();
249
- for (const entry of await readdir(ideasRoot, { withFileTypes: true })) {
250
- const folderPath = resolve(ideasRoot, entry.name);
251
- const folderMetadata = await lstat(folderPath);
252
- const relativePath = displayPath(root, folderPath);
253
- const folded = entry.name.toUpperCase();
254
- const existingCase = caseNames.get(folded);
255
- if (existingCase && existingCase !== entry.name) {
256
- diagnostics.push(error(
257
- "idea.id.case-collision",
258
- relativePath,
259
- `Idea identities collide by case: ${existingCase} and ${entry.name}.`,
260
- "Keep exactly one canonical uppercase ULID identity.",
261
- ));
262
- } else {
263
- caseNames.set(folded, entry.name);
264
- }
265
- if (!isValidUlid(entry.name)) {
266
- diagnostics.push(error(
267
- "idea.id.invalid",
268
- relativePath,
269
- `Idea folders must use canonical ULID identities: ${entry.name}`,
270
- "Rename the folder to one canonical uppercase ULID.",
271
- ));
272
- continue;
273
- }
274
- if (!folderMetadata.isDirectory() || folderMetadata.isSymbolicLink()) {
275
- diagnostics.push(error(
276
- "idea.folder.invalid",
277
- relativePath,
278
- "Idea must be a repository-owned regular directory.",
279
- "Replace it with a regular directory.",
280
- ));
281
- continue;
282
- }
283
-
284
- const paths = ideaPaths(entry.name);
285
- for (const child of await readdir(folderPath, { withFileTypes: true })) {
286
- if (
287
- child.name === "status.yaml" ||
288
- child.name === "ledger.md" ||
289
- child.name === "outer"
290
- ) continue;
291
- diagnostics.push(error(
292
- child.name.toLowerCase().includes("status")
293
- ? "idea.status.unexpected-file"
294
- : "idea.entry.unexpected",
295
- `${paths.ideaPath}/${child.name}`,
296
- `Unexpected entry at the idea root: ${child.name}`,
297
- "Keep only status.yaml, ledger.md, and outer/ at the idea root; put supporting files in their world.",
298
- ));
299
- }
300
- await requireLedger(root, paths.ledgerPath, diagnostics);
301
- const requiredDirectories = [
302
- paths.outerPath,
303
- paths.innerPath,
304
- paths.idealPath,
305
- ];
306
- const requiredDocuments = [
307
- paths.deploymentDocumentPath,
308
- paths.implementationDocumentPath,
309
- paths.ideaDocumentPath,
310
- ];
311
- const validDirectories = (await Promise.all(
312
- requiredDirectories.map((path) => requireDirectory(root, path, diagnostics)),
313
- )).every(Boolean);
314
- const validDocuments = (await Promise.all(
315
- requiredDocuments.map((path) => requireDocument(root, path, diagnostics)),
316
- )).every(Boolean);
317
- if (validDirectories) await rejectSymlinks(root, paths.outerPath, diagnostics);
318
-
319
- const statusMetadata = await metadata(resolve(root, paths.statusPath));
320
- if (!statusMetadata || !statusMetadata.isFile() || statusMetadata.isSymbolicLink()) {
321
- diagnostics.push(error(
322
- "idea.status.invalid-file",
323
- paths.statusPath,
324
- "Idea status must be a repository-owned regular file named status.yaml.",
325
- `Create ${paths.statusPath} as canonical YAML.`,
326
- ));
327
- continue;
328
- }
329
-
330
- let status;
331
- try {
332
- status = parseIdeaStatus(
333
- await readFile(resolve(root, paths.statusPath), "utf8"),
334
- { objectIdLength },
335
- );
336
- } catch (caught) {
337
- diagnostics.push(error(
338
- "idea.status.invalid",
339
- paths.statusPath,
340
- caught.message,
341
- "Rewrite status.yaml in canonical form.",
342
- ));
343
- continue;
344
- }
345
- if (status.id !== entry.name) {
346
- diagnostics.push(error(
347
- "idea.status.id-mismatch",
348
- `${paths.statusPath}#id`,
349
- `Status id ${status.id} does not match ${entry.name}.`,
350
- "Use the same canonical ULID in the folder and status id.",
351
- ));
352
- }
353
- if (status.alias !== undefined) {
354
- const aliasOwner = aliases.get(status.alias);
355
- if (aliasOwner) {
356
- diagnostics.push(error(
357
- "idea.alias.duplicate",
358
- `${paths.statusPath}#alias`,
359
- `Alias ${status.alias} is shared by ${aliasOwner} and ${entry.name}.`,
360
- "Assign a unique exact case-sensitive alias.",
361
- ));
362
- } else {
363
- aliases.set(status.alias, entry.name);
364
- }
365
- }
366
- if (
367
- !validDirectories ||
368
- !validDocuments ||
369
- diagnostics.some(({ code, path }) =>
370
- code === "idea.world.symlink" && path.startsWith(`${paths.outerPath}/`)
371
- )
372
- ) continue;
373
-
374
- let revisions;
375
- try {
376
- revisions = {
377
- idealRevision: resolveTree({ gitRoot, root, snapshotTree }, paths.idealPath),
378
- implementationRevision: resolveTree(
379
- { gitRoot, root, snapshotTree },
380
- paths.innerPath,
381
- ),
382
- deploymentRevision: resolveTree(
383
- { gitRoot, root, snapshotTree },
384
- paths.outerPath,
385
- ),
386
- };
387
- } catch (caught) {
388
- diagnostics.push(error(
389
- "idea.revision.unavailable",
390
- paths.ideaPath,
391
- caught.message,
392
- "Ensure every world can be represented as a Git tree.",
393
- ));
394
- continue;
395
- }
396
-
397
- validateRevisionObjects(gitRoot, paths.statusPath, status, diagnostics);
398
- const worlds = {
399
- idealRevision: {
400
- name: "Ideal World",
401
- displayName: "道心",
402
- path: paths.idealPath,
403
- documentPath: paths.ideaDocumentPath,
404
- },
405
- implementationRevision: {
406
- name: "Inner World",
407
- displayName: "内景",
408
- path: paths.innerPath,
409
- documentPath: paths.implementationDocumentPath,
410
- },
411
- deploymentRevision: {
412
- name: "Outer World",
413
- displayName: "现世",
414
- path: paths.outerPath,
415
- documentPath: paths.deploymentDocumentPath,
416
- },
417
- };
418
- const idea = {
419
- id: entry.name,
420
- path: folderPath,
421
- relativePath: paths.ideaPath,
422
- revisions,
423
- ...revisions,
424
- state: deriveIdeaState(revisions, status),
425
- status,
426
- statusPath: paths.statusPath,
427
- ledgerPath: paths.ledgerPath,
428
- worlds,
429
- };
430
- if (status.alias !== undefined) idea.alias = status.alias;
431
- if (validateCandidate) {
432
- validateCandidateRevisions(gitRoot, baseRevision, idea, diagnostics, objectIdLength);
433
- }
434
- if (historyCommit) validateRevisionHistory(gitRoot, historyCommit, idea, diagnostics);
435
- ideas.push(idea);
436
- }
437
-
438
- return { diagnostics, ideas };
439
- }
1
+ import { lstat, readFile, readdir } from "node:fs/promises";
2
+ import { relative, resolve } from "node:path";
3
+
4
+ import { deriveIdeaState, isValidUlid, parseIdeaStatus } from "./ideas.js";
5
+ import { gitObjectIdLength, runGit, worktreePathTree } from "./git.js";
6
+ import { IDEAS_ROOT, ideaPaths } from "./layout.js";
7
+
8
+ const REVISION_BINDINGS = {
9
+ approvedRevision: "idealRevision",
10
+ implementationAcceptedRevision: "implementationRevision",
11
+ deploymentAcceptedRevision: "deploymentRevision",
12
+ };
13
+
14
+ function displayPath(root, path) {
15
+ return relative(root, path).replaceAll("\\", "/");
16
+ }
17
+
18
+ function error(code, path, message, remediation) {
19
+ return { code, level: "error", path, message, remediation };
20
+ }
21
+
22
+ async function metadata(path) {
23
+ try {
24
+ return await lstat(path);
25
+ } catch (caught) {
26
+ if (caught.code === "ENOENT") return null;
27
+ throw caught;
28
+ }
29
+ }
30
+
31
+ function validateRevisionObjects(root, statusPath, status, diagnostics) {
32
+ for (const key of Object.keys(REVISION_BINDINGS)) {
33
+ if (!status[key]) continue;
34
+ const type = runGit(root, ["cat-file", "-t", status[key]]);
35
+ if (!type.ok || type.stdout !== "tree") {
36
+ diagnostics.push(error(
37
+ "idea.revision.invalid-object",
38
+ `${statusPath}#${key}`,
39
+ `${key} must resolve to a Git tree object: ${status[key]}`,
40
+ "Use the current corresponding world tree object ID from Git.",
41
+ ));
42
+ }
43
+ }
44
+ }
45
+
46
+ function validateRevisionHistory(root, commit, idea, diagnostics) {
47
+ for (const [statusKey, revisionKey] of Object.entries(REVISION_BINDINGS)) {
48
+ const revision = idea.status[statusKey];
49
+ if (!revision) continue;
50
+ const history = runGit(root, [
51
+ "log",
52
+ "--reverse",
53
+ "--format=%H",
54
+ "-S",
55
+ `${statusKey}: ${revision}`,
56
+ commit,
57
+ "--",
58
+ idea.statusPath,
59
+ ]);
60
+ const evidenceCommit = history.ok
61
+ ? history.stdout.split(/\r?\n/).filter(Boolean)[0]
62
+ : null;
63
+ if (!evidenceCommit) {
64
+ const shallow = runGit(root, ["rev-parse", "--is-shallow-repository"]);
65
+ diagnostics.push(error(
66
+ shallow.ok && shallow.stdout === "true"
67
+ ? "history.incomplete"
68
+ : "idea.revision.history-missing",
69
+ `${idea.statusPath}#${statusKey}`,
70
+ `Primary history does not prove when ${statusKey} recorded ${revision}.`,
71
+ "Fetch complete primary history or repair the decision through a forward commit.",
72
+ ));
73
+ continue;
74
+ }
75
+ const tree = runGit(root, ["rev-parse", `${evidenceCommit}:${idea.worlds[revisionKey].path}`]);
76
+ if (!tree.ok || tree.stdout !== revision) {
77
+ diagnostics.push(error(
78
+ "idea.revision.history-mismatch",
79
+ `${idea.statusPath}#${statusKey}`,
80
+ `${statusKey} was not bound to its world tree in commit ${evidenceCommit}.`,
81
+ "Record the decision only for the corresponding world tree in the same candidate commit.",
82
+ ));
83
+ }
84
+ }
85
+ }
86
+
87
+ function validateCandidateRevisions(root, baseRevision, idea, diagnostics, objectIdLength) {
88
+ let previous = {};
89
+ if (baseRevision) {
90
+ const previousSource = runGit(root, ["show", `${baseRevision}:${idea.statusPath}`]);
91
+ if (previousSource.ok) {
92
+ try {
93
+ previous = parseIdeaStatus(previousSource.stdout + "\n", { objectIdLength });
94
+ } catch {
95
+ previous = {};
96
+ }
97
+ }
98
+ }
99
+ for (const [statusKey, revisionKey] of Object.entries(REVISION_BINDINGS)) {
100
+ if (
101
+ idea.status[statusKey] === previous[statusKey] ||
102
+ idea.status[statusKey] === undefined
103
+ ) continue;
104
+ const expected = idea.revisions[revisionKey];
105
+ if (idea.status[statusKey] !== expected) {
106
+ diagnostics.push(error(
107
+ "idea.revision.candidate-mismatch",
108
+ `${idea.statusPath}#${statusKey}`,
109
+ `${statusKey} must equal the corresponding world tree in the same candidate snapshot.`,
110
+ `Use ${expected} for ${revisionKey} or omit the stale mutation.`,
111
+ ));
112
+ }
113
+ }
114
+ }
115
+
116
+ async function requireDirectory(root, path, diagnostics) {
117
+ const absolute = resolve(root, path);
118
+ const value = await metadata(absolute);
119
+ if (!value || !value.isDirectory() || value.isSymbolicLink()) {
120
+ diagnostics.push(error(
121
+ "idea.world.invalid-directory",
122
+ path,
123
+ `Required world path must be a repository-owned regular directory: ${path}`,
124
+ `Create ${path} as a regular directory without symlinks.`,
125
+ ));
126
+ return false;
127
+ }
128
+ return true;
129
+ }
130
+
131
+ async function requireDocument(root, path, diagnostics) {
132
+ const absolute = resolve(root, path);
133
+ const value = await metadata(absolute);
134
+ if (!value || !value.isFile() || value.isSymbolicLink()) {
135
+ diagnostics.push(error(
136
+ "idea.world.missing-document",
137
+ path,
138
+ `Required world entry must be a repository-owned regular file: ${path}`,
139
+ `Create ${path} as a regular file without symlinks.`,
140
+ ));
141
+ return false;
142
+ }
143
+ return true;
144
+ }
145
+
146
+ async function requireLedger(root, path, diagnostics) {
147
+ const value = await metadata(resolve(root, path));
148
+ if (!value) {
149
+ diagnostics.push(error(
150
+ "idea.ledger.missing-file",
151
+ path,
152
+ `Required idea ledger does not exist: ${path}`,
153
+ `Create ${path} as a repository-owned regular file.`,
154
+ ));
155
+ return false;
156
+ }
157
+ if (!value.isFile() || value.isSymbolicLink()) {
158
+ diagnostics.push(error(
159
+ "idea.ledger.invalid-file",
160
+ path,
161
+ `Required idea ledger must be a repository-owned regular file: ${path}`,
162
+ `Replace ${path} with a regular file.`,
163
+ ));
164
+ return false;
165
+ }
166
+ return true;
167
+ }
168
+
169
+ async function rejectSymlinks(root, path, diagnostics) {
170
+ const absolute = resolve(root, path);
171
+ for (const entry of await readdir(absolute, { withFileTypes: true })) {
172
+ const entryPath = `${path}/${entry.name}`;
173
+ const value = await lstat(resolve(root, entryPath));
174
+ if (value.isSymbolicLink()) {
175
+ diagnostics.push(error(
176
+ "idea.world.symlink",
177
+ entryPath,
178
+ `World content must not use symlinks: ${entryPath}`,
179
+ "Replace the symlink with repository-owned regular content.",
180
+ ));
181
+ continue;
182
+ }
183
+ if (value.isDirectory()) await rejectSymlinks(root, entryPath, diagnostics);
184
+ }
185
+ }
186
+
187
+ function resolveTree({ gitRoot, root, snapshotTree }, path) {
188
+ if (snapshotTree) {
189
+ const resolved = runGit(gitRoot, ["rev-parse", `${snapshotTree}:${path}`]);
190
+ if (!resolved.ok) throw new Error(resolved.stderr || `Cannot resolve ${path}`);
191
+ return resolved.stdout;
192
+ }
193
+ return worktreePathTree(root, path);
194
+ }
195
+
196
+ export async function inspectIdeaLayout({
197
+ baseRevision,
198
+ config: _config,
199
+ historyCommit,
200
+ root,
201
+ gitRoot = root,
202
+ snapshotTree,
203
+ validateCandidate = false,
204
+ }) {
205
+ const diagnostics = [];
206
+ const ideasRoot = resolve(root, IDEAS_ROOT);
207
+ const ideasRootMetadata = await metadata(ideasRoot);
208
+ if (!ideasRootMetadata) {
209
+ return {
210
+ diagnostics: [error(
211
+ "layout.ideas.missing",
212
+ IDEAS_ROOT,
213
+ `Silvermoon ideas directory does not exist: ${IDEAS_ROOT}`,
214
+ `Create ${IDEAS_ROOT} as a repository-owned directory.`,
215
+ )],
216
+ ideas: [],
217
+ };
218
+ }
219
+ if (!ideasRootMetadata.isDirectory() || ideasRootMetadata.isSymbolicLink()) {
220
+ return {
221
+ diagnostics: [error(
222
+ "layout.ideas.invalid",
223
+ IDEAS_ROOT,
224
+ "The Silvermoon ideas path must be a repository-owned regular directory.",
225
+ `Replace ${IDEAS_ROOT} with a regular directory.`,
226
+ )],
227
+ ideas: [],
228
+ };
229
+ }
230
+
231
+ let objectIdLength;
232
+ try {
233
+ objectIdLength = gitObjectIdLength(gitRoot);
234
+ } catch (caught) {
235
+ return {
236
+ diagnostics: [error(
237
+ "git.object-format.unavailable",
238
+ IDEAS_ROOT,
239
+ caught.message,
240
+ "Run Silvermoon inside a Git repository with a supported object format.",
241
+ )],
242
+ ideas: [],
243
+ };
244
+ }
245
+
246
+ const aliases = new Map();
247
+ const ideas = [];
248
+ const caseNames = new Map();
249
+ for (const entry of await readdir(ideasRoot, { withFileTypes: true })) {
250
+ const folderPath = resolve(ideasRoot, entry.name);
251
+ const folderMetadata = await lstat(folderPath);
252
+ const relativePath = displayPath(root, folderPath);
253
+ const folded = entry.name.toUpperCase();
254
+ const existingCase = caseNames.get(folded);
255
+ if (existingCase && existingCase !== entry.name) {
256
+ diagnostics.push(error(
257
+ "idea.id.case-collision",
258
+ relativePath,
259
+ `Idea identities collide by case: ${existingCase} and ${entry.name}.`,
260
+ "Keep exactly one canonical uppercase ULID identity.",
261
+ ));
262
+ } else {
263
+ caseNames.set(folded, entry.name);
264
+ }
265
+ if (!isValidUlid(entry.name)) {
266
+ diagnostics.push(error(
267
+ "idea.id.invalid",
268
+ relativePath,
269
+ `Idea folders must use canonical ULID identities: ${entry.name}`,
270
+ "Rename the folder to one canonical uppercase ULID.",
271
+ ));
272
+ continue;
273
+ }
274
+ if (!folderMetadata.isDirectory() || folderMetadata.isSymbolicLink()) {
275
+ diagnostics.push(error(
276
+ "idea.folder.invalid",
277
+ relativePath,
278
+ "Idea must be a repository-owned regular directory.",
279
+ "Replace it with a regular directory.",
280
+ ));
281
+ continue;
282
+ }
283
+
284
+ const paths = ideaPaths(entry.name);
285
+ for (const child of await readdir(folderPath, { withFileTypes: true })) {
286
+ if (
287
+ child.name === "status.yaml" ||
288
+ child.name === "ledger.md" ||
289
+ child.name === "outer"
290
+ ) continue;
291
+ diagnostics.push(error(
292
+ child.name.toLowerCase().includes("status")
293
+ ? "idea.status.unexpected-file"
294
+ : "idea.entry.unexpected",
295
+ `${paths.ideaPath}/${child.name}`,
296
+ `Unexpected entry at the idea root: ${child.name}`,
297
+ "Keep only status.yaml, ledger.md, and outer/ at the idea root; put supporting files in their world.",
298
+ ));
299
+ }
300
+ await requireLedger(root, paths.ledgerPath, diagnostics);
301
+ const requiredDirectories = [
302
+ paths.outerPath,
303
+ paths.innerPath,
304
+ paths.idealPath,
305
+ ];
306
+ const requiredDocuments = [
307
+ paths.deploymentDocumentPath,
308
+ paths.implementationDocumentPath,
309
+ paths.ideaDocumentPath,
310
+ ];
311
+ const validDirectories = (await Promise.all(
312
+ requiredDirectories.map((path) => requireDirectory(root, path, diagnostics)),
313
+ )).every(Boolean);
314
+ const validDocuments = (await Promise.all(
315
+ requiredDocuments.map((path) => requireDocument(root, path, diagnostics)),
316
+ )).every(Boolean);
317
+ if (validDirectories) await rejectSymlinks(root, paths.outerPath, diagnostics);
318
+
319
+ const statusMetadata = await metadata(resolve(root, paths.statusPath));
320
+ if (!statusMetadata || !statusMetadata.isFile() || statusMetadata.isSymbolicLink()) {
321
+ diagnostics.push(error(
322
+ "idea.status.invalid-file",
323
+ paths.statusPath,
324
+ "Idea status must be a repository-owned regular file named status.yaml.",
325
+ `Create ${paths.statusPath} as canonical YAML.`,
326
+ ));
327
+ continue;
328
+ }
329
+
330
+ let status;
331
+ try {
332
+ status = parseIdeaStatus(
333
+ await readFile(resolve(root, paths.statusPath), "utf8"),
334
+ { objectIdLength },
335
+ );
336
+ } catch (caught) {
337
+ diagnostics.push(error(
338
+ "idea.status.invalid",
339
+ paths.statusPath,
340
+ caught.message,
341
+ "Rewrite status.yaml in canonical form.",
342
+ ));
343
+ continue;
344
+ }
345
+ if (status.id !== entry.name) {
346
+ diagnostics.push(error(
347
+ "idea.status.id-mismatch",
348
+ `${paths.statusPath}#id`,
349
+ `Status id ${status.id} does not match ${entry.name}.`,
350
+ "Use the same canonical ULID in the folder and status id.",
351
+ ));
352
+ }
353
+ if (status.alias !== undefined) {
354
+ const aliasOwner = aliases.get(status.alias);
355
+ if (aliasOwner) {
356
+ diagnostics.push(error(
357
+ "idea.alias.duplicate",
358
+ `${paths.statusPath}#alias`,
359
+ `Alias ${status.alias} is shared by ${aliasOwner} and ${entry.name}.`,
360
+ "Assign a unique exact case-sensitive alias.",
361
+ ));
362
+ } else {
363
+ aliases.set(status.alias, entry.name);
364
+ }
365
+ }
366
+ if (
367
+ !validDirectories ||
368
+ !validDocuments ||
369
+ diagnostics.some(({ code, path }) =>
370
+ code === "idea.world.symlink" && path.startsWith(`${paths.outerPath}/`)
371
+ )
372
+ ) continue;
373
+
374
+ let revisions;
375
+ try {
376
+ revisions = {
377
+ idealRevision: resolveTree({ gitRoot, root, snapshotTree }, paths.idealPath),
378
+ implementationRevision: resolveTree(
379
+ { gitRoot, root, snapshotTree },
380
+ paths.innerPath,
381
+ ),
382
+ deploymentRevision: resolveTree(
383
+ { gitRoot, root, snapshotTree },
384
+ paths.outerPath,
385
+ ),
386
+ };
387
+ } catch (caught) {
388
+ diagnostics.push(error(
389
+ "idea.revision.unavailable",
390
+ paths.ideaPath,
391
+ caught.message,
392
+ "Ensure every world can be represented as a Git tree.",
393
+ ));
394
+ continue;
395
+ }
396
+
397
+ validateRevisionObjects(gitRoot, paths.statusPath, status, diagnostics);
398
+ const worlds = {
399
+ idealRevision: {
400
+ name: "Ideal World",
401
+ displayName: "道心",
402
+ path: paths.idealPath,
403
+ documentPath: paths.ideaDocumentPath,
404
+ },
405
+ implementationRevision: {
406
+ name: "Inner World",
407
+ displayName: "内景",
408
+ path: paths.innerPath,
409
+ documentPath: paths.implementationDocumentPath,
410
+ },
411
+ deploymentRevision: {
412
+ name: "Outer World",
413
+ displayName: "现世",
414
+ path: paths.outerPath,
415
+ documentPath: paths.deploymentDocumentPath,
416
+ },
417
+ };
418
+ const idea = {
419
+ id: entry.name,
420
+ path: folderPath,
421
+ relativePath: paths.ideaPath,
422
+ revisions,
423
+ ...revisions,
424
+ state: deriveIdeaState(revisions, status),
425
+ status,
426
+ statusPath: paths.statusPath,
427
+ ledgerPath: paths.ledgerPath,
428
+ worlds,
429
+ };
430
+ if (status.alias !== undefined) idea.alias = status.alias;
431
+ if (validateCandidate) {
432
+ validateCandidateRevisions(gitRoot, baseRevision, idea, diagnostics, objectIdLength);
433
+ }
434
+ if (historyCommit) validateRevisionHistory(gitRoot, historyCommit, idea, diagnostics);
435
+ ideas.push(idea);
436
+ }
437
+
438
+ return { diagnostics, ideas };
439
+ }