@oevery/rsp 3.2.0 → 4.0.0
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/README.md +25 -17
- package/README.zh-CN.md +25 -17
- package/dist/chunk-HFSTLCCK.mjs +3968 -0
- package/dist/cli.mjs +3510 -2446
- package/dist/entry-RIJQ2X46.mjs +1932 -0
- package/package.json +32 -19
- package/skills/rsp/SKILL.md +42 -68
- package/skills/rsp/references/closeout.md +38 -0
- package/skills/rsp/references/context-migration.md +22 -0
- package/skills/rsp/references/control-outcome.md +5 -0
- package/skills/rsp/references/coordination-recovery.md +25 -0
- package/skills/rsp/references/coordination-review.md +15 -0
- package/skills/rsp/references/coordination.md +59 -0
- package/skills/rsp/references/durable-review.md +74 -9
- package/skills/rsp/references/focus-continuation.md +13 -0
- package/skills/rsp/references/groups-dependencies.md +6 -4
- package/skills/rsp/references/response-language.md +13 -0
- package/skills/rsp/references/setup-repair.md +37 -7
- package/skills/rsp/references/work-model.md +27 -0
- package/skills/rsp-commit/SKILL.md +82 -22
- package/skills/rsp-commit/references/message.md +34 -0
- package/skills/rsp-doc/SKILL.md +65 -0
- package/skills/rsp-doc/agents/openai.yaml +3 -0
- package/skills/rsp-doc/references/artifact-lenses.md +43 -0
- package/skills/rsp-doc/references/release-communication.md +39 -0
- package/skills/rsp-doc/references/skill-authoring.md +36 -0
- package/skills/rsp-implement/SKILL.md +62 -21
- package/skills/rsp-implement/references/diagnosis.md +10 -0
- package/skills/rsp-implement/references/finding-resolution.md +21 -0
- package/skills/rsp-implement/references/test-first.md +7 -0
- package/skills/rsp-review/SKILL.md +44 -46
- package/skills/rsp-review/references/code-review.md +23 -7
- package/skills/rsp-review/references/document-review.md +25 -8
- package/skills/rsp-review/references/release-communication.md +21 -0
- package/skills/{rsp-structural-audit/references/structural-lenses.md → rsp-review/references/structural-review.md} +5 -5
- package/skills/rsp-shape/SKILL.md +24 -45
- package/skills/rsp-shape/references/complex-shaping.md +3 -1
- package/skills/rsp-shape/references/deep-clarification.md +4 -4
- package/skills/rsp-shape/references/domain-modeling.md +13 -0
- package/skills/rsp-shape/references/module-seams.md +13 -0
- package/skills/rsp-shape/references/planning.md +42 -0
- package/skills/{rsp-design → rsp-shape}/references/reversible-exploration.md +2 -2
- package/skills/rsp-verify/SKILL.md +72 -0
- package/dist/chunk-JOMFFQRG.mjs +0 -99
- package/dist/chunk-JYDEJEM4.mjs +0 -358
- package/dist/chunk-SEGJLWRE.mjs +0 -1158
- package/dist/chunk-ZORUJTVS.mjs +0 -1967
- package/dist/entry-I22OUIW5.mjs +0 -888
- package/dist/entry-RG6HREUN.mjs +0 -174
- package/rules/rsp-rules.md +0 -47
- package/skills/rsp/references/managed-routing.md +0 -44
- package/skills/rsp/references/release-operations.md +0 -9
- package/skills/rsp-design/SKILL.md +0 -53
- package/skills/rsp-design/references/domain-modeling.md +0 -11
- package/skills/rsp-design/references/module-seams.md +0 -11
- package/skills/rsp-diagnose/SKILL.md +0 -46
- package/skills/rsp-manage/SKILL.md +0 -96
- package/skills/rsp-manage/references/closeout.md +0 -11
- package/skills/rsp-manage/references/interruption-recovery.md +0 -7
- package/skills/rsp-manage/references/review-convergence.md +0 -7
- package/skills/rsp-release-docs/SKILL.md +0 -77
- package/skills/rsp-release-docs/references/convention-discovery.md +0 -65
- package/skills/rsp-release-docs/references/evidence-and-surfaces.md +0 -21
- package/skills/rsp-release-docs/references/output-contracts.md +0 -97
- package/skills/rsp-release-docs/references/publication-lifecycle.md +0 -24
- package/skills/rsp-resolve-findings/SKILL.md +0 -61
- package/skills/rsp-structural-audit/SKILL.md +0 -71
- package/skills/rsp-tdd/SKILL.md +0 -48
|
@@ -0,0 +1,3968 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/config.ts
|
|
4
|
+
import { readFile } from "fs/promises";
|
|
5
|
+
import { dirname as dirname2, join as join3, resolve as resolve2 } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import { stringify } from "yaml";
|
|
9
|
+
|
|
10
|
+
// src/core/markdown.mjs
|
|
11
|
+
import { fromHtml } from "hast-util-from-html";
|
|
12
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
13
|
+
import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
|
|
14
|
+
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
15
|
+
import { frontmatter } from "micromark-extension-frontmatter";
|
|
16
|
+
import { gfm } from "micromark-extension-gfm";
|
|
17
|
+
var htmlTokenAnalyses = /* @__PURE__ */ new WeakMap();
|
|
18
|
+
var matters = ["yaml", { type: "yaml", fence: { open: "---", close: "..." } }];
|
|
19
|
+
var extensions = [gfm(), frontmatter(matters)];
|
|
20
|
+
var mdastExtensions = [gfmFromMarkdown(), frontmatterFromMarkdown(matters)];
|
|
21
|
+
function parseMarkdown(input, options = {}) {
|
|
22
|
+
if (typeof input !== "string")
|
|
23
|
+
return input;
|
|
24
|
+
const syntax = options.frontmatter === false ? { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] } : { extensions, mdastExtensions };
|
|
25
|
+
return { source: input, root: fromMarkdown(input, syntax), start: 0, end: input.length };
|
|
26
|
+
}
|
|
27
|
+
function walkMarkdown(node, visit) {
|
|
28
|
+
if (visit(node) === false)
|
|
29
|
+
return;
|
|
30
|
+
if ("children" in node) {
|
|
31
|
+
for (const child of node.children)
|
|
32
|
+
walkMarkdown(child, visit);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function markdownNodeSource(input, node) {
|
|
36
|
+
const { source } = parseMarkdown(input);
|
|
37
|
+
return source.slice(node.position?.start.offset ?? 0, node.position?.end.offset ?? 0);
|
|
38
|
+
}
|
|
39
|
+
function isMarkdownComment(node) {
|
|
40
|
+
return node.type === "html" && !readHtmlToken(node, node.value).maskedSource.trim();
|
|
41
|
+
}
|
|
42
|
+
function htmlComments(value) {
|
|
43
|
+
if (!value.includes("<!--"))
|
|
44
|
+
return [];
|
|
45
|
+
const comments = [];
|
|
46
|
+
function visit(node, topLevel) {
|
|
47
|
+
if (node.type === "comment" && node.position)
|
|
48
|
+
comments.push({ start: node.position.start.offset ?? 0, end: node.position.end.offset ?? value.length, topLevel });
|
|
49
|
+
if ("children" in node)
|
|
50
|
+
node.children.forEach((child) => visit(child, node.type === "root"));
|
|
51
|
+
}
|
|
52
|
+
visit(fromHtml(value, { fragment: true }), false);
|
|
53
|
+
return comments;
|
|
54
|
+
}
|
|
55
|
+
function readHtmlToken(node, value) {
|
|
56
|
+
let analyses = htmlTokenAnalyses.get(node);
|
|
57
|
+
const cached = analyses?.get(value);
|
|
58
|
+
if (cached)
|
|
59
|
+
return cached;
|
|
60
|
+
const comments = htmlComments(value);
|
|
61
|
+
const analysis = {
|
|
62
|
+
comments,
|
|
63
|
+
maskedSource: maskSourceRanges(value, comments.map((comment) => [comment.start, comment.end]), 0, value.length)
|
|
64
|
+
};
|
|
65
|
+
if (!analyses) {
|
|
66
|
+
analyses = /* @__PURE__ */ new Map();
|
|
67
|
+
htmlTokenAnalyses.set(node, analyses);
|
|
68
|
+
}
|
|
69
|
+
analyses.set(value, analysis);
|
|
70
|
+
return analysis;
|
|
71
|
+
}
|
|
72
|
+
function markdownTopLevelComments(input) {
|
|
73
|
+
const document = parseMarkdown(input);
|
|
74
|
+
return document.root.children.flatMap((node) => {
|
|
75
|
+
if (node.type !== "html")
|
|
76
|
+
return [];
|
|
77
|
+
const source = markdownNodeSource(document, node);
|
|
78
|
+
const offset = node.position?.start.offset ?? document.start;
|
|
79
|
+
return readHtmlToken(node, source).comments.filter((comment) => comment.topLevel).map((comment) => ({
|
|
80
|
+
start: offset + comment.start,
|
|
81
|
+
end: offset + comment.end,
|
|
82
|
+
source: source.slice(comment.start, comment.end)
|
|
83
|
+
}));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function maskSourceRanges(source, ranges, start, end) {
|
|
87
|
+
let result = "";
|
|
88
|
+
let offset = start;
|
|
89
|
+
for (const [from, until] of ranges.sort((a, b) => a[0] - b[0])) {
|
|
90
|
+
result += source.slice(offset, from);
|
|
91
|
+
result += source.slice(from, until).replace(/[^\r\n]/g, " ");
|
|
92
|
+
offset = until;
|
|
93
|
+
}
|
|
94
|
+
return result + source.slice(offset, end);
|
|
95
|
+
}
|
|
96
|
+
function markdownText(node) {
|
|
97
|
+
if (node.type === "yaml")
|
|
98
|
+
return "";
|
|
99
|
+
if (node.type === "html")
|
|
100
|
+
return readHtmlToken(node, node.value).maskedSource;
|
|
101
|
+
if (node.type === "image" || node.type === "imageReference")
|
|
102
|
+
return node.alt ?? "";
|
|
103
|
+
if ("value" in node)
|
|
104
|
+
return node.value;
|
|
105
|
+
if ("children" in node) {
|
|
106
|
+
const separator = ["root", "list", "listItem", "blockquote"].includes(node.type) ? "\n" : "";
|
|
107
|
+
return node.children.map((child) => markdownText(child)).filter(Boolean).join(separator);
|
|
108
|
+
}
|
|
109
|
+
return node.type === "break" ? "\n" : "";
|
|
110
|
+
}
|
|
111
|
+
function markdownSection(document, nodes, start, end) {
|
|
112
|
+
return { source: document.source, root: { type: "root", children: nodes }, start, end };
|
|
113
|
+
}
|
|
114
|
+
function visibleMarkdownSource(input, options = {}) {
|
|
115
|
+
const document = parseMarkdown(input);
|
|
116
|
+
const visibleNodes = /* @__PURE__ */ new Set();
|
|
117
|
+
function markVisibleContent(node) {
|
|
118
|
+
if (node.type === "yaml" || node.type === "code" && !options.includeCode)
|
|
119
|
+
return false;
|
|
120
|
+
let visible = false;
|
|
121
|
+
if (node.type === "image" || node.type === "imageReference" || node.type === "footnoteReference")
|
|
122
|
+
visible = true;
|
|
123
|
+
else if (node.type === "html")
|
|
124
|
+
visible = !isMarkdownComment(node);
|
|
125
|
+
else if ("value" in node)
|
|
126
|
+
visible = Boolean(node.value.trim());
|
|
127
|
+
else if ("children" in node)
|
|
128
|
+
visible = node.children.map(markVisibleContent).some(Boolean);
|
|
129
|
+
if (visible)
|
|
130
|
+
visibleNodes.add(node);
|
|
131
|
+
return visible;
|
|
132
|
+
}
|
|
133
|
+
markVisibleContent(document.root);
|
|
134
|
+
const ranges = [];
|
|
135
|
+
walkMarkdown(document.root, (node) => {
|
|
136
|
+
if (!visibleNodes.has(node)) {
|
|
137
|
+
const start = Math.max(document.start, node.position?.start.offset ?? document.start);
|
|
138
|
+
const end = Math.min(document.end, node.position?.end.offset ?? document.end);
|
|
139
|
+
if (end > start)
|
|
140
|
+
ranges.push([start, end]);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (node.type === "html") {
|
|
144
|
+
const nodeStart = node.position?.start.offset ?? document.start;
|
|
145
|
+
for (const comment of readHtmlToken(node, markdownNodeSource(document, node)).comments)
|
|
146
|
+
ranges.push([nodeStart + comment.start, nodeStart + comment.end]);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return maskSourceRanges(document.source, ranges, document.start, document.end);
|
|
151
|
+
}
|
|
152
|
+
function visibleMarkdownLines(input) {
|
|
153
|
+
return visibleMarkdownSource(input).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
154
|
+
}
|
|
155
|
+
function markdownListItems(input) {
|
|
156
|
+
const document = parseMarkdown(input);
|
|
157
|
+
const items = [];
|
|
158
|
+
walkMarkdown(document.root, (node) => {
|
|
159
|
+
if (node.type === "blockquote" || node.type === "footnoteDefinition")
|
|
160
|
+
return false;
|
|
161
|
+
if (node.type === "listItem") {
|
|
162
|
+
const source = markdownNodeSource(document, node);
|
|
163
|
+
items.push({ node, line: source.split(/\r?\n/, 1)[0].trim() });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return items;
|
|
167
|
+
}
|
|
168
|
+
function markdownFrontmatter(input) {
|
|
169
|
+
const document = parseMarkdown(input);
|
|
170
|
+
const node = document.root.children[0];
|
|
171
|
+
if (node?.type !== "yaml" || node.position?.start.offset !== 0)
|
|
172
|
+
return null;
|
|
173
|
+
const raw = markdownNodeSource(document, node);
|
|
174
|
+
const lines = raw.split(/\r?\n/);
|
|
175
|
+
if (lines[0] !== "---" || !["---", "..."].includes(lines.at(-1) ?? ""))
|
|
176
|
+
return null;
|
|
177
|
+
let end = node.position.end.offset ?? raw.length;
|
|
178
|
+
if (document.source.slice(end, end + 2) === "\r\n")
|
|
179
|
+
end += 2;
|
|
180
|
+
else if (document.source[end] === "\n")
|
|
181
|
+
end++;
|
|
182
|
+
return { node, value: node.value, end };
|
|
183
|
+
}
|
|
184
|
+
function markdownProse(input) {
|
|
185
|
+
const document = parseMarkdown(input);
|
|
186
|
+
const fragments = [];
|
|
187
|
+
walkMarkdown(document.root, (node) => {
|
|
188
|
+
if (node.type === "text" || node.type === "html")
|
|
189
|
+
fragments.push({ value: markdownText(node), line: node.position?.start.line ?? 1 });
|
|
190
|
+
});
|
|
191
|
+
return fragments;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/core/document-model.ts
|
|
195
|
+
var CHANGE_DOCUMENT_SCHEMA = {
|
|
196
|
+
titlePrefix: "# Change:",
|
|
197
|
+
sections: [
|
|
198
|
+
{ id: "proposal", heading: "Proposal" },
|
|
199
|
+
{ id: "spec", heading: "Spec" },
|
|
200
|
+
{ id: "design", heading: "Design" },
|
|
201
|
+
{ id: "tasks", heading: "Tasks" },
|
|
202
|
+
{ id: "verify", heading: "Verify" },
|
|
203
|
+
{ id: "blockers", heading: "Blockers" }
|
|
204
|
+
]
|
|
205
|
+
};
|
|
206
|
+
var GROUP_BRIEF_DOCUMENT_SCHEMA = {
|
|
207
|
+
titlePrefix: "# Change Group:",
|
|
208
|
+
sections: [
|
|
209
|
+
{ id: "goal", heading: "Goal" },
|
|
210
|
+
{ id: "scope", heading: "Scope" },
|
|
211
|
+
{ id: "sharedConstraints", heading: "Shared Constraints" },
|
|
212
|
+
{ id: "slices", heading: "Slices" },
|
|
213
|
+
{ id: "completionConditions", heading: "Completion Conditions" },
|
|
214
|
+
{ id: "durableOutcomes", heading: "Durable Outcomes" },
|
|
215
|
+
{ id: "blockers", heading: "Blockers" }
|
|
216
|
+
]
|
|
217
|
+
};
|
|
218
|
+
var DocumentSectionCardinalityError = class extends Error {
|
|
219
|
+
constructor(sectionId, count) {
|
|
220
|
+
super(`document must contain exactly one ${sectionId} section; found ${count}`);
|
|
221
|
+
this.sectionId = sectionId;
|
|
222
|
+
this.count = count;
|
|
223
|
+
this.name = "DocumentSectionCardinalityError";
|
|
224
|
+
}
|
|
225
|
+
sectionId;
|
|
226
|
+
count;
|
|
227
|
+
};
|
|
228
|
+
function parseRspDocument(input, schema) {
|
|
229
|
+
const markdown = parseMarkdown(input);
|
|
230
|
+
const source = markdown.source;
|
|
231
|
+
const definitionsByHeading = new Map(schema.sections.map((definition) => [definition.heading, definition]));
|
|
232
|
+
const observedHeadings = collectLevelTwoHeadings(markdown);
|
|
233
|
+
const sections = /* @__PURE__ */ new Map();
|
|
234
|
+
const unknownHeadings = [];
|
|
235
|
+
for (let index = 0; index < observedHeadings.length; index++) {
|
|
236
|
+
const observed = observedHeadings[index];
|
|
237
|
+
const definition = definitionsByHeading.get(observed.heading);
|
|
238
|
+
if (!definition) {
|
|
239
|
+
unknownHeadings.push(observed.heading);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const nextHeading = observedHeadings[index + 1];
|
|
243
|
+
const bodyEnd = nextHeading ? lineBreakStart(source, nextHeading.start) : source.length;
|
|
244
|
+
const section = {
|
|
245
|
+
id: definition.id,
|
|
246
|
+
heading: definition.heading,
|
|
247
|
+
canonical: observed.canonical,
|
|
248
|
+
bodyEnd,
|
|
249
|
+
markdown: markdownSection(markdown, markdown.root.children.filter((node) => (node.position?.start.offset ?? 0) >= observed.end && (node.position?.end.offset ?? source.length) <= bodyEnd), observed.end, bodyEnd)
|
|
250
|
+
};
|
|
251
|
+
const matches = sections.get(definition.id) ?? [];
|
|
252
|
+
matches.push(section);
|
|
253
|
+
sections.set(definition.id, matches);
|
|
254
|
+
}
|
|
255
|
+
const missingSections = schema.sections.filter((definition) => !sections.has(definition.id)).map((definition) => definition.id);
|
|
256
|
+
const duplicateSections = schema.sections.filter((definition) => (sections.get(definition.id)?.length ?? 0) > 1).map((definition) => definition.id);
|
|
257
|
+
const titleOccurrences = collectDocumentTitles(markdown, schema.titlePrefix);
|
|
258
|
+
const titles = titleOccurrences.map((title) => title.value);
|
|
259
|
+
return {
|
|
260
|
+
source,
|
|
261
|
+
markdown,
|
|
262
|
+
schema,
|
|
263
|
+
title: titles[0] ?? null,
|
|
264
|
+
titles,
|
|
265
|
+
titleOccurrences,
|
|
266
|
+
sections,
|
|
267
|
+
unknownHeadings,
|
|
268
|
+
missingSections,
|
|
269
|
+
duplicateSections
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function getDocumentTitles(document, compatibility = "broad") {
|
|
273
|
+
if (compatibility === "broad")
|
|
274
|
+
return document.titles;
|
|
275
|
+
return document.titleOccurrences.filter((title) => compatibility === "identity" ? title.identityCompatible : title.spaced).map((title) => title.value);
|
|
276
|
+
}
|
|
277
|
+
function getDocumentTitle(document, compatibility = "broad") {
|
|
278
|
+
return getDocumentTitles(document, compatibility)[0] ?? null;
|
|
279
|
+
}
|
|
280
|
+
function getDocumentSections(document, sectionId) {
|
|
281
|
+
return document.sections.get(sectionId) ?? [];
|
|
282
|
+
}
|
|
283
|
+
function getDocumentSection(document, sectionId) {
|
|
284
|
+
return getDocumentSections(document, sectionId)[0];
|
|
285
|
+
}
|
|
286
|
+
function getDocumentSectionMarkdown(document, sectionId) {
|
|
287
|
+
return getDocumentSection(document, sectionId)?.markdown ?? markdownSection(document.markdown, [], 0, 0);
|
|
288
|
+
}
|
|
289
|
+
function getDocumentSectionDefinitionByHeading(schema, heading) {
|
|
290
|
+
return schema.sections.find((candidate) => candidate.heading === heading);
|
|
291
|
+
}
|
|
292
|
+
function getCanonicalSectionHeadings(schema) {
|
|
293
|
+
return schema.sections.map((section) => section.heading);
|
|
294
|
+
}
|
|
295
|
+
function renderDocumentSectionHeading(schema, sectionId) {
|
|
296
|
+
const definition = schema.sections.find((candidate) => candidate.id === sectionId);
|
|
297
|
+
if (!definition)
|
|
298
|
+
throw new Error(`unknown document section: ${sectionId}`);
|
|
299
|
+
return `## ${definition.heading}`;
|
|
300
|
+
}
|
|
301
|
+
function renderDocumentTitle(schema, title) {
|
|
302
|
+
return `${schema.titlePrefix} ${title}`;
|
|
303
|
+
}
|
|
304
|
+
function appendDocumentSectionItem(document, sectionId, item) {
|
|
305
|
+
const matches = getDocumentSections(document, sectionId);
|
|
306
|
+
if (matches.length !== 1)
|
|
307
|
+
throw new DocumentSectionCardinalityError(sectionId, matches.length);
|
|
308
|
+
const insertAt = matches[0].bodyEnd;
|
|
309
|
+
const before = document.source.slice(0, insertAt);
|
|
310
|
+
const separator = before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
|
|
311
|
+
return `${before}${separator}${item}
|
|
312
|
+
${document.source.slice(insertAt)}`;
|
|
313
|
+
}
|
|
314
|
+
function collectLevelTwoHeadings(document) {
|
|
315
|
+
const headings = [];
|
|
316
|
+
for (const node of document.root.children) {
|
|
317
|
+
if (node.type !== "heading" || node.depth !== 2 || node.position?.start.column !== 1)
|
|
318
|
+
continue;
|
|
319
|
+
const raw = markdownNodeSource(document, node);
|
|
320
|
+
if (!raw.startsWith("## ") || raw.includes("\n"))
|
|
321
|
+
continue;
|
|
322
|
+
const value = raw.slice(3);
|
|
323
|
+
const heading = value.trimEnd();
|
|
324
|
+
headings.push({
|
|
325
|
+
heading,
|
|
326
|
+
canonical: heading === value,
|
|
327
|
+
start: node.position.start.offset,
|
|
328
|
+
end: node.position.end.offset
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return headings;
|
|
332
|
+
}
|
|
333
|
+
function collectDocumentTitles(document, prefix) {
|
|
334
|
+
const titles = [];
|
|
335
|
+
for (const node of document.root.children) {
|
|
336
|
+
if (node.type !== "heading" || node.depth !== 1)
|
|
337
|
+
continue;
|
|
338
|
+
const start = node.position.start.offset;
|
|
339
|
+
const lineStart = document.source.lastIndexOf("\n", start - 1) + 1;
|
|
340
|
+
const line = document.source.slice(lineStart, node.position.end.offset);
|
|
341
|
+
const trimmed = line.trim();
|
|
342
|
+
if (!trimmed.startsWith(prefix))
|
|
343
|
+
continue;
|
|
344
|
+
const identity = line.startsWith(prefix) ? line.slice(prefix.length).match(/^\s+(\S+)\s*$/) : null;
|
|
345
|
+
titles.push({
|
|
346
|
+
value: trimmed.slice(prefix.length).trim(),
|
|
347
|
+
identityCompatible: identity !== null,
|
|
348
|
+
spaced: line.startsWith(`${prefix} `)
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return titles;
|
|
352
|
+
}
|
|
353
|
+
function lineBreakStart(source, headingStart) {
|
|
354
|
+
if (headingStart > 0 && source[headingStart - 1] === "\n")
|
|
355
|
+
return headingStart - 1;
|
|
356
|
+
return headingStart;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/core/yaml.mjs
|
|
360
|
+
import { parseDocument } from "yaml";
|
|
361
|
+
function parseYamlDocument(source) {
|
|
362
|
+
const document = parseDocument(source, { keepSourceTokens: true });
|
|
363
|
+
if (document.errors.length)
|
|
364
|
+
throw new Error(document.errors.map((error) => error.message).join("; "));
|
|
365
|
+
return document;
|
|
366
|
+
}
|
|
367
|
+
function yamlMapping(document) {
|
|
368
|
+
const value = document.toJS();
|
|
369
|
+
if (value === null || value === void 0)
|
|
370
|
+
return {};
|
|
371
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
372
|
+
throw new Error("YAML document must be a mapping/object");
|
|
373
|
+
return value;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/core/content.ts
|
|
377
|
+
function parseYamlText(text) {
|
|
378
|
+
return yamlMapping(parseYamlDocument(text));
|
|
379
|
+
}
|
|
380
|
+
function parseFrontmatter(content) {
|
|
381
|
+
const matter = markdownFrontmatter(content);
|
|
382
|
+
return matter ? parseYamlText(matter.value) : null;
|
|
383
|
+
}
|
|
384
|
+
function checkboxItem(line) {
|
|
385
|
+
const marker = line.match(/^[-*+]\s+\[([ /x-])\]/i);
|
|
386
|
+
return marker ? { state: marker[1].toLowerCase(), line } : null;
|
|
387
|
+
}
|
|
388
|
+
function collectCheckboxItems(content) {
|
|
389
|
+
return markdownListItems(content).flatMap(({ line }) => {
|
|
390
|
+
const item = checkboxItem(line);
|
|
391
|
+
return item ? [item] : [];
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
function countCheckboxItems(items) {
|
|
395
|
+
const count = { todo: 0, progress: 0, done: 0, dropped: 0, total: items.length };
|
|
396
|
+
for (const { state } of items) {
|
|
397
|
+
if (state === " ")
|
|
398
|
+
count.todo++;
|
|
399
|
+
else if (state === "/")
|
|
400
|
+
count.progress++;
|
|
401
|
+
else if (state === "x")
|
|
402
|
+
count.done++;
|
|
403
|
+
else
|
|
404
|
+
count.dropped++;
|
|
405
|
+
}
|
|
406
|
+
return count;
|
|
407
|
+
}
|
|
408
|
+
function countCheckboxes(content) {
|
|
409
|
+
return countCheckboxItems(collectCheckboxItems(content));
|
|
410
|
+
}
|
|
411
|
+
function collectVerifyCheckboxes(content) {
|
|
412
|
+
const document = parseMarkdown(content);
|
|
413
|
+
const buckets = { required: [], optional: [], unclassified: [] };
|
|
414
|
+
let current = "unclassified";
|
|
415
|
+
let sawClassificationHeading = false;
|
|
416
|
+
walkMarkdown(document.root, (node) => {
|
|
417
|
+
if (node.type === "blockquote" || node.type === "footnoteDefinition")
|
|
418
|
+
return false;
|
|
419
|
+
if (node.type === "heading" && node.depth === 3 && node.position?.start.column === 1) {
|
|
420
|
+
const heading = markdownNodeSource(document, node).match(/^###\s+(Required|Optional)\s*$/i);
|
|
421
|
+
if (heading) {
|
|
422
|
+
current = heading[1].toLowerCase();
|
|
423
|
+
sawClassificationHeading = true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (node.type === "listItem") {
|
|
427
|
+
const line = markdownNodeSource(document, node).split(/\r?\n/, 1)[0].trim();
|
|
428
|
+
const item = checkboxItem(line);
|
|
429
|
+
if (item)
|
|
430
|
+
buckets[current].push(item);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
return { ...buckets, legacy: buckets.unclassified.length > 0 || !sawClassificationHeading };
|
|
434
|
+
}
|
|
435
|
+
function summarizeVerifyCheckboxes(buckets) {
|
|
436
|
+
return {
|
|
437
|
+
required: countCheckboxItems([...buckets.required, ...buckets.unclassified]),
|
|
438
|
+
optional: countCheckboxItems(buckets.optional),
|
|
439
|
+
unclassified: countCheckboxItems(buckets.unclassified),
|
|
440
|
+
legacy: buckets.legacy
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function openCheckboxLines(items) {
|
|
444
|
+
return items.filter((item) => item.state === " " || item.state === "/").map((item) => item.line);
|
|
445
|
+
}
|
|
446
|
+
function getOpenCheckboxes(content) {
|
|
447
|
+
return openCheckboxLines(collectCheckboxItems(content));
|
|
448
|
+
}
|
|
449
|
+
function changeDocument(content) {
|
|
450
|
+
return typeof content === "string" ? parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA) : content;
|
|
451
|
+
}
|
|
452
|
+
function hasMeaningfulBlockerBody(body) {
|
|
453
|
+
return readBlockerEntries(body).length > 0;
|
|
454
|
+
}
|
|
455
|
+
function getVisibleBodyLines(body) {
|
|
456
|
+
return visibleMarkdownLines(body);
|
|
457
|
+
}
|
|
458
|
+
function hasMeaningfulBlockers(content) {
|
|
459
|
+
return hasMeaningfulBlockerBody(getDocumentSectionMarkdown(changeDocument(content), "blockers"));
|
|
460
|
+
}
|
|
461
|
+
function isEmptyBlockerLine(line) {
|
|
462
|
+
const normalized = line.trim();
|
|
463
|
+
return /^none[.。]?$/i.test(normalized) || /^[-*]\s*(?:none[.。]?)?$/i.test(normalized);
|
|
464
|
+
}
|
|
465
|
+
var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
|
|
466
|
+
function readBlockerEntries(input) {
|
|
467
|
+
const document = parseMarkdown(input);
|
|
468
|
+
const entries = [];
|
|
469
|
+
const nodeLines = (node, start = node.position.start.offset) => getVisibleBodyLines(
|
|
470
|
+
markdownSection(document, [node], start, node.position.end.offset)
|
|
471
|
+
);
|
|
472
|
+
const text = (line) => line.replace(/^[-*]\s+/, "").trim();
|
|
473
|
+
const appendExternal = (lines) => {
|
|
474
|
+
for (const line of lines) {
|
|
475
|
+
if (!isEmptyBlockerLine(line))
|
|
476
|
+
entries.push({ kind: "external", line, text: text(line) });
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const visit = (node) => {
|
|
480
|
+
if (node.type === "code")
|
|
481
|
+
return;
|
|
482
|
+
if (node.type !== "list") {
|
|
483
|
+
appendExternal(nodeLines(node));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
for (const item of node.children) {
|
|
487
|
+
for (const [index, child] of item.children.entries()) {
|
|
488
|
+
if (child.type === "list" || child.type === "code") {
|
|
489
|
+
visit(child);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const lines = nodeLines(child, index === 0 ? item.position.start.offset : child.position.start.offset);
|
|
493
|
+
if (index === 0 && child.type === "paragraph") {
|
|
494
|
+
const line = lines[0] ?? "";
|
|
495
|
+
const dependency = line.match(REQUIRES_RE);
|
|
496
|
+
if (dependency) {
|
|
497
|
+
entries.push({ kind: "dependency", line, text: text(line), name: dependency[1], reason: dependency[2] });
|
|
498
|
+
lines.shift();
|
|
499
|
+
} else if (/^[-*]\s+requires\b/i.test(line)) {
|
|
500
|
+
entries.push({ kind: "malformed-dependency", line, text: text(line) });
|
|
501
|
+
lines.shift();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
appendExternal(lines);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
document.root.children.forEach(visit);
|
|
509
|
+
return entries;
|
|
510
|
+
}
|
|
511
|
+
function detectDeltaSections(content) {
|
|
512
|
+
const body = getDocumentSectionMarkdown(changeDocument(content), "spec");
|
|
513
|
+
const labels = new Set(body.root.children.filter((node) => node.type === "heading" && node.depth === 3).map((node) => markdownText(node).trim().toUpperCase()));
|
|
514
|
+
return { added: labels.has("ADDED"), modified: labels.has("MODIFIED"), removed: labels.has("REMOVED") };
|
|
515
|
+
}
|
|
516
|
+
function parseScenarios(content) {
|
|
517
|
+
const document = parseMarkdown(content);
|
|
518
|
+
const scenarios = [];
|
|
519
|
+
let scenario = null;
|
|
520
|
+
for (const node of document.root.children) {
|
|
521
|
+
if (node.type === "heading" && node.depth <= 4) {
|
|
522
|
+
const title = markdownText(node);
|
|
523
|
+
scenario = (node.depth === 3 || node.depth === 4) && /^Scenario:\s*\S/i.test(title) ? { heading: title.slice(title.indexOf(":") + 1).trim(), steps: [] } : null;
|
|
524
|
+
if (scenario)
|
|
525
|
+
scenarios.push(scenario);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (scenario && node.type === "list") {
|
|
529
|
+
for (const item of node.children) {
|
|
530
|
+
const line = markdownNodeSource(document, item).split(/\r?\n/, 1)[0].trim();
|
|
531
|
+
const step = line.replace(/^[-*+]\s+/, "");
|
|
532
|
+
if (/^(?:GIVEN|WHEN|THEN|AND|BUT)\b/i.test(step))
|
|
533
|
+
scenario.steps.push(step);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return scenarios.filter((item) => item.steps.length > 0);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/core/decisions.ts
|
|
541
|
+
import { existsSync } from "fs";
|
|
542
|
+
import { mkdir, readdir as readdir2, realpath, stat } from "fs/promises";
|
|
543
|
+
import { dirname, isAbsolute, join as join2, posix, relative, resolve, sep } from "path";
|
|
544
|
+
|
|
545
|
+
// src/core/managed-path.ts
|
|
546
|
+
import { lstatSync } from "fs";
|
|
547
|
+
import { readdir, writeFile } from "fs/promises";
|
|
548
|
+
import { join } from "path";
|
|
549
|
+
var ManagedPathError = class extends Error {
|
|
550
|
+
constructor(path, message) {
|
|
551
|
+
super(message);
|
|
552
|
+
this.path = path;
|
|
553
|
+
this.name = "ManagedPathError";
|
|
554
|
+
}
|
|
555
|
+
path;
|
|
556
|
+
};
|
|
557
|
+
function inspectManagedDirectory(path, label, options = {}) {
|
|
558
|
+
try {
|
|
559
|
+
const stats = lstatSync(path);
|
|
560
|
+
if (stats.isDirectory())
|
|
561
|
+
return { exists: true, issue: null };
|
|
562
|
+
return {
|
|
563
|
+
exists: true,
|
|
564
|
+
issue: new ManagedPathError(path, `${label} must be a real directory: ${path}`)
|
|
565
|
+
};
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (error.code === "ENOENT") {
|
|
568
|
+
if (options.allowMissing)
|
|
569
|
+
return { exists: false, issue: null };
|
|
570
|
+
return {
|
|
571
|
+
exists: false,
|
|
572
|
+
issue: new ManagedPathError(path, `${label} must be a real directory: ${path}`)
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
576
|
+
return {
|
|
577
|
+
exists: false,
|
|
578
|
+
issue: new ManagedPathError(path, `unable to inspect ${label} ${path}: ${message}`)
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function requireManagedDirectory(path, label, options = {}) {
|
|
583
|
+
const inspection = inspectManagedDirectory(path, label, options);
|
|
584
|
+
if (inspection.issue)
|
|
585
|
+
throw inspection.issue;
|
|
586
|
+
return inspection.exists;
|
|
587
|
+
}
|
|
588
|
+
function inspectManagedFile(path, label, options = {}) {
|
|
589
|
+
try {
|
|
590
|
+
const stats = lstatSync(path);
|
|
591
|
+
if (stats.isFile())
|
|
592
|
+
return { exists: true, issue: null };
|
|
593
|
+
return {
|
|
594
|
+
exists: true,
|
|
595
|
+
issue: new ManagedPathError(path, `${label} must be a regular file: ${path}`)
|
|
596
|
+
};
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (error.code === "ENOENT") {
|
|
599
|
+
if (options.allowMissing)
|
|
600
|
+
return { exists: false, issue: null };
|
|
601
|
+
return {
|
|
602
|
+
exists: false,
|
|
603
|
+
issue: new ManagedPathError(path, `${label} must be a regular file: ${path}`)
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
607
|
+
return {
|
|
608
|
+
exists: false,
|
|
609
|
+
issue: new ManagedPathError(path, `unable to inspect ${label} ${path}: ${message}`)
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function requireManagedFile(path, label, options = {}) {
|
|
614
|
+
const inspection = inspectManagedFile(path, label, options);
|
|
615
|
+
if (inspection.issue)
|
|
616
|
+
throw inspection.issue;
|
|
617
|
+
return inspection.exists;
|
|
618
|
+
}
|
|
619
|
+
async function ensureManagedFile(path, content, label) {
|
|
620
|
+
if (requireManagedFile(path, label, { allowMissing: true }))
|
|
621
|
+
return false;
|
|
622
|
+
await writeFile(path, content, { flag: "wx" });
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
async function writeManagedFile(path, content, label) {
|
|
626
|
+
const exists = requireManagedFile(path, label, { allowMissing: true });
|
|
627
|
+
if (exists)
|
|
628
|
+
await writeFile(path, content);
|
|
629
|
+
else
|
|
630
|
+
await writeFile(path, content, { flag: "wx" });
|
|
631
|
+
}
|
|
632
|
+
function resolveManagedDirectoryChain(root, segments, label) {
|
|
633
|
+
requireManagedDirectory(root, `${label} root`);
|
|
634
|
+
let current = root;
|
|
635
|
+
for (const segment of segments) {
|
|
636
|
+
if (!segment || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))
|
|
637
|
+
throw new ManagedPathError(current, `${label} contains an invalid directory segment: ${segment}`);
|
|
638
|
+
current = join(current, segment);
|
|
639
|
+
requireManagedDirectory(current, label, { allowMissing: true });
|
|
640
|
+
}
|
|
641
|
+
return current;
|
|
642
|
+
}
|
|
643
|
+
async function inspectManagedFileTree(root, label, options = {}) {
|
|
644
|
+
const result = { rootExists: false, files: [], issues: [] };
|
|
645
|
+
const rootInspection = inspectManagedDirectory(root, `${label} root`, options);
|
|
646
|
+
if (rootInspection.issue) {
|
|
647
|
+
result.issues.push(rootInspection.issue);
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
if (!rootInspection.exists)
|
|
651
|
+
return result;
|
|
652
|
+
result.rootExists = true;
|
|
653
|
+
await collectManagedFiles(root, label, result, options.includeHidden);
|
|
654
|
+
result.files.sort();
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
async function collectManagedFiles(directory, label, result, includeHidden = false) {
|
|
658
|
+
let entries;
|
|
659
|
+
try {
|
|
660
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
661
|
+
} catch (error) {
|
|
662
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
663
|
+
result.issues.push(new ManagedPathError(directory, `unable to inspect the ${label} tree at ${directory}: ${message}`));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
667
|
+
if (!includeHidden && entry.name.startsWith("."))
|
|
668
|
+
continue;
|
|
669
|
+
const path = join(directory, entry.name);
|
|
670
|
+
if (entry.isDirectory()) {
|
|
671
|
+
await collectManagedFiles(path, label, result, includeHidden);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (entry.isFile()) {
|
|
675
|
+
result.files.push(path);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
result.issues.push(new ManagedPathError(path, `unsupported entry in the ${label} tree: ${path}`));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/core/decisions.ts
|
|
683
|
+
var DEFAULT_DECISION_RECORDS_PATH = ".rsp/specs/decisions";
|
|
684
|
+
function normalizeDecisionRecordsPath(pathValue) {
|
|
685
|
+
return posix.normalize(pathValue.trim().replace(/\\/g, "/")).replace(/^\.\//, "").replace(/\/$/, "");
|
|
686
|
+
}
|
|
687
|
+
function validateDecisionRecordsPath(pathValue) {
|
|
688
|
+
if (typeof pathValue !== "string" || pathValue.trim() === "")
|
|
689
|
+
return 'config.yaml field "decisions.path" must be a non-empty string';
|
|
690
|
+
const raw = pathValue.trim();
|
|
691
|
+
const portable = raw.replace(/\\/g, "/");
|
|
692
|
+
if (isAbsolute(raw) || posix.isAbsolute(portable) || /^[a-z]:[\\/]/i.test(raw))
|
|
693
|
+
return 'config.yaml field "decisions.path" must be project-relative';
|
|
694
|
+
const normalized = normalizeDecisionRecordsPath(raw);
|
|
695
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../"))
|
|
696
|
+
return 'config.yaml field "decisions.path" must stay inside the Host Project';
|
|
697
|
+
if ((normalized === ".rsp" || normalized.startsWith(".rsp/")) && normalized !== DEFAULT_DECISION_RECORDS_PATH)
|
|
698
|
+
return `config.yaml field "decisions.path" must use ${DEFAULT_DECISION_RECORDS_PATH} or a path outside .rsp/`;
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
function getDecisionRecordsConfigIssue(config) {
|
|
702
|
+
if (!("decisions" in config))
|
|
703
|
+
return null;
|
|
704
|
+
const decisions = config.decisions;
|
|
705
|
+
if (!decisions || typeof decisions !== "object" || Array.isArray(decisions))
|
|
706
|
+
return 'config.yaml field "decisions" must be a mapping with one path';
|
|
707
|
+
const keys = Object.keys(decisions);
|
|
708
|
+
if (keys.length !== 1 || keys[0] !== "path")
|
|
709
|
+
return 'config.yaml field "decisions" supports only "path"';
|
|
710
|
+
return validateDecisionRecordsPath(decisions.path);
|
|
711
|
+
}
|
|
712
|
+
function resolveDecisionRecordsPath(config) {
|
|
713
|
+
const configured = config.decisions?.path;
|
|
714
|
+
return configured && validateDecisionRecordsPath(configured) === null ? normalizeDecisionRecordsPath(configured) : DEFAULT_DECISION_RECORDS_PATH;
|
|
715
|
+
}
|
|
716
|
+
async function validateDecisionRecordsFilesystemPath(pathValue, cwd = process.cwd()) {
|
|
717
|
+
const projectRoot = await realpath(cwd);
|
|
718
|
+
const targetPath = resolve(projectRoot, pathValue);
|
|
719
|
+
let existingAncestor = targetPath;
|
|
720
|
+
while (!existsSync(existingAncestor)) {
|
|
721
|
+
const parent = dirname(existingAncestor);
|
|
722
|
+
if (parent === existingAncestor)
|
|
723
|
+
return `Decision Record path "${pathValue}" has no accessible Host Project ancestor`;
|
|
724
|
+
existingAncestor = parent;
|
|
725
|
+
}
|
|
726
|
+
const resolvedAncestor = await realpath(existingAncestor);
|
|
727
|
+
const rel = relative(projectRoot, resolvedAncestor);
|
|
728
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
729
|
+
return `Decision Record path "${pathValue}" resolves outside the Host Project through ${existingAncestor}`;
|
|
730
|
+
if (existsSync(targetPath) && !(await stat(targetPath)).isDirectory())
|
|
731
|
+
return `Decision Record path "${pathValue}" must be a directory`;
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/core/config.ts
|
|
736
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
737
|
+
var PKG_ROOT = join3(__dirname, "..");
|
|
738
|
+
var RSP_DIR = ".rsp";
|
|
739
|
+
var RSP_RULES_PATH = join3(RSP_DIR, "rsp-rules.md");
|
|
740
|
+
var OBSOLETE_RSP_RULES_PATH = join3(RSP_DIR, "rules", "rsp-rules.md");
|
|
741
|
+
var CHANGES_DIR = join3(RSP_DIR, "changes");
|
|
742
|
+
var FOCUS_DIR = join3(RSP_DIR, "focus.d");
|
|
743
|
+
var MAX_FOCUS_CAPSULE_BYTES = 4096;
|
|
744
|
+
var ARCHIVES_DIR = join3(RSP_DIR, "archives");
|
|
745
|
+
var LOCK_PATH = join3(RSP_DIR, ".lock");
|
|
746
|
+
var CONFIG_PATH = join3(RSP_DIR, "config.yaml");
|
|
747
|
+
var VALID_KINDS = ["feature", "fix", "refactor", "docs", "ops", "research"];
|
|
748
|
+
var DEFAULT_REQUIRED_SECTIONS = getCanonicalSectionHeadings(CHANGE_DOCUMENT_SCHEMA);
|
|
749
|
+
var DEFAULT_WORKFLOW_POLICY = { delegation: "off", closeout: "ask" };
|
|
750
|
+
var INVALID_CONFIG_WORKFLOW_POLICY = { delegation: "off", closeout: "manual" };
|
|
751
|
+
var CONFIG_DEFAULTS = {
|
|
752
|
+
kinds: [],
|
|
753
|
+
decisions: { path: ".rsp/specs/decisions" },
|
|
754
|
+
language: { default: "en" },
|
|
755
|
+
workflow: { delegation: "off", closeout: "ask" }
|
|
756
|
+
};
|
|
757
|
+
function generateConfigTemplate(config = {}) {
|
|
758
|
+
const kinds = config.kinds ?? CONFIG_DEFAULTS.kinds;
|
|
759
|
+
const decisions = { ...CONFIG_DEFAULTS.decisions, ...config.decisions };
|
|
760
|
+
const workflow = { ...CONFIG_DEFAULTS.workflow, ...config.workflow };
|
|
761
|
+
const language = { ...CONFIG_DEFAULTS.language, ...config.language };
|
|
762
|
+
const kindsYaml = stringify(kinds).trimEnd();
|
|
763
|
+
const renderedKinds = kinds.length === 0 ? `kinds: ${kindsYaml}` : `kinds:
|
|
764
|
+
${kindsYaml.split("\n").map((line) => ` ${line}`).join("\n")}`;
|
|
765
|
+
const artifactsLine = language.artifacts ? ` artifacts: ${stringify(language.artifacts).trim()}` : " # artifacts: zh-CN";
|
|
766
|
+
const commitLine = language.commit ? ` commit: ${stringify(language.commit).trim()}` : " # commit: zh-CN";
|
|
767
|
+
return `# RSP project configuration
|
|
768
|
+
# An empty kinds list retains the built-in defaults.
|
|
769
|
+
# A non-empty kinds list replaces the built-in defaults; it does not extend them.
|
|
770
|
+
# Every entry must be a unique non-empty string.
|
|
771
|
+
#
|
|
772
|
+
# Built-in defaults:
|
|
773
|
+
# kinds: ${VALID_KINDS.join(", ")}
|
|
774
|
+
#
|
|
775
|
+
${renderedKinds}
|
|
776
|
+
|
|
777
|
+
# Decision Records default to .rsp/specs/decisions.
|
|
778
|
+
decisions:
|
|
779
|
+
path: ${stringify(decisions.path).trim()}
|
|
780
|
+
|
|
781
|
+
# Automatic delegation is off; explicit worker requests remain allowed.
|
|
782
|
+
# Closeout applies only to established final delivery: ask, auto, or manual.
|
|
783
|
+
# Ordinary completion and checkpoint commits keep work open. No setting grants Git authority.
|
|
784
|
+
workflow:
|
|
785
|
+
delegation: ${workflow.delegation}
|
|
786
|
+
closeout: ${workflow.closeout}
|
|
787
|
+
|
|
788
|
+
# Set one shared project default for durable artifact and commit prose.
|
|
789
|
+
# Response language remains user/session-owned and is never read from this project mapping.
|
|
790
|
+
language:
|
|
791
|
+
default: ${stringify(language.default).trim()}
|
|
792
|
+
${artifactsLine}
|
|
793
|
+
${commitLine}
|
|
794
|
+
`;
|
|
795
|
+
}
|
|
796
|
+
var DELEGATION_POLICIES = ["off", "auto"];
|
|
797
|
+
var CLOSEOUT_POLICIES = ["ask", "auto", "manual"];
|
|
798
|
+
var _configCache = null;
|
|
799
|
+
async function loadRspConfig() {
|
|
800
|
+
const inspection = await inspectRspConfig();
|
|
801
|
+
if (inspection.issues.length > 0)
|
|
802
|
+
throw new Error(inspection.issues.join("; "));
|
|
803
|
+
return inspection.config;
|
|
804
|
+
}
|
|
805
|
+
async function inspectRspConfig(cwd = process.cwd()) {
|
|
806
|
+
cwd = resolve2(cwd);
|
|
807
|
+
if (_configCache?.cwd === cwd)
|
|
808
|
+
return _configCache.inspection;
|
|
809
|
+
const configPath = join3(cwd, CONFIG_PATH);
|
|
810
|
+
const configFile = inspectManagedFile(configPath, "config file", { allowMissing: true });
|
|
811
|
+
if (configFile.issue)
|
|
812
|
+
throw configFile.issue;
|
|
813
|
+
if (!configFile.exists) {
|
|
814
|
+
const inspection2 = { config: {}, issues: [] };
|
|
815
|
+
_configCache = { cwd, inspection: inspection2 };
|
|
816
|
+
return inspection2;
|
|
817
|
+
}
|
|
818
|
+
const raw = await readFile(configPath, "utf-8");
|
|
819
|
+
const parsed = parseYamlText(raw);
|
|
820
|
+
const issues = validateRspConfig(parsed);
|
|
821
|
+
const decisions = parsed.decisions && typeof parsed.decisions === "object" && !Array.isArray(parsed.decisions) ? parsed.decisions : void 0;
|
|
822
|
+
const decisionPath = decisions?.path;
|
|
823
|
+
const workflowValue = parsed.workflow && typeof parsed.workflow === "object" && !Array.isArray(parsed.workflow) ? parsed.workflow : void 0;
|
|
824
|
+
const languageValue = parsed.language && typeof parsed.language === "object" && !Array.isArray(parsed.language) ? parsed.language : void 0;
|
|
825
|
+
const delegation = DELEGATION_POLICIES.includes(workflowValue?.delegation) ? workflowValue?.delegation : void 0;
|
|
826
|
+
const closeout = CLOSEOUT_POLICIES.includes(workflowValue?.closeout) ? workflowValue?.closeout : void 0;
|
|
827
|
+
const kinds = Array.isArray(parsed.kinds) && parsed.kinds.every((value) => typeof value === "string" && value.trim() !== "") ? parsed.kinds.map((value) => value.trim()) : void 0;
|
|
828
|
+
const decisionRecordsIssue = getDecisionRecordsConfigIssue(parsed);
|
|
829
|
+
const inspection = {
|
|
830
|
+
config: {
|
|
831
|
+
kinds,
|
|
832
|
+
decisions: decisionRecordsIssue === null && validateDecisionRecordsPath(decisionPath) === null ? { path: normalizeDecisionRecordsPath(String(decisionPath)) } : void 0,
|
|
833
|
+
workflow: workflowValue ? { delegation, closeout } : void 0,
|
|
834
|
+
language: parseLanguageConfig(languageValue)
|
|
835
|
+
},
|
|
836
|
+
issues
|
|
837
|
+
};
|
|
838
|
+
_configCache = { cwd, inspection };
|
|
839
|
+
return inspection;
|
|
840
|
+
}
|
|
841
|
+
function validateRspConfig(parsed) {
|
|
842
|
+
const issues = [];
|
|
843
|
+
const supported = /* @__PURE__ */ new Set(["kinds", "decisions", "workflow", "language"]);
|
|
844
|
+
for (const key of Object.keys(parsed)) {
|
|
845
|
+
if (key === "manage") {
|
|
846
|
+
issues.push('config.yaml field "manage" requires explicit migration: remove manage and choose workflow.delegation (off | auto) and workflow.closeout (ask | auto | manual); recommended defaults are off/ask. Old local closeout no longer grants Git authority. rsp update will not convert it automatically');
|
|
847
|
+
} else if (key === "required_sections") {
|
|
848
|
+
issues.push('config.yaml field "required_sections" is no longer supported');
|
|
849
|
+
} else if (!supported.has(key)) {
|
|
850
|
+
issues.push(`config.yaml field "${key}" is not supported`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if ("kinds" in parsed) {
|
|
854
|
+
if (!Array.isArray(parsed.kinds)) {
|
|
855
|
+
issues.push('config.yaml field "kinds" must be a YAML list');
|
|
856
|
+
} else if (!parsed.kinds.every((value) => typeof value === "string" && value.trim() !== "")) {
|
|
857
|
+
issues.push('config.yaml field "kinds" must contain only non-empty strings');
|
|
858
|
+
} else {
|
|
859
|
+
const kinds = parsed.kinds.map((value) => value.trim());
|
|
860
|
+
const duplicates = [...new Set(kinds.filter((value, index) => kinds.indexOf(value) !== index))];
|
|
861
|
+
if (duplicates.length > 0)
|
|
862
|
+
issues.push(`config.yaml field "kinds" contains duplicate entries: ${duplicates.join(", ")}`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const decisionRecordsIssue = getDecisionRecordsConfigIssue(parsed);
|
|
866
|
+
if (decisionRecordsIssue)
|
|
867
|
+
issues.push(decisionRecordsIssue);
|
|
868
|
+
if ("workflow" in parsed) {
|
|
869
|
+
if (!parsed.workflow || typeof parsed.workflow !== "object" || Array.isArray(parsed.workflow)) {
|
|
870
|
+
issues.push('config.yaml field "workflow" must be a YAML mapping');
|
|
871
|
+
} else {
|
|
872
|
+
const workflow = parsed.workflow;
|
|
873
|
+
const supportedWorkflowFields = /* @__PURE__ */ new Set(["delegation", "closeout"]);
|
|
874
|
+
for (const key of Object.keys(workflow)) {
|
|
875
|
+
if (!supportedWorkflowFields.has(key))
|
|
876
|
+
issues.push(`config.yaml field "workflow.${key}" is not supported`);
|
|
877
|
+
}
|
|
878
|
+
if ("delegation" in workflow && !DELEGATION_POLICIES.includes(workflow.delegation))
|
|
879
|
+
issues.push('config.yaml field "workflow.delegation" must be one of: off, auto');
|
|
880
|
+
if ("closeout" in workflow && !CLOSEOUT_POLICIES.includes(workflow.closeout))
|
|
881
|
+
issues.push('config.yaml field "workflow.closeout" must be one of: ask, auto, manual');
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if ("language" in parsed) {
|
|
885
|
+
if (!parsed.language || typeof parsed.language !== "object" || Array.isArray(parsed.language)) {
|
|
886
|
+
issues.push('config.yaml field "language" must be a YAML mapping');
|
|
887
|
+
} else {
|
|
888
|
+
const language = parsed.language;
|
|
889
|
+
const supportedLanguageFields = /* @__PURE__ */ new Set(["default", "artifacts", "commit"]);
|
|
890
|
+
for (const key of Object.keys(language)) {
|
|
891
|
+
if (!supportedLanguageFields.has(key))
|
|
892
|
+
issues.push(`config.yaml field "language.${key}" is not supported`);
|
|
893
|
+
}
|
|
894
|
+
if (!("default" in language))
|
|
895
|
+
issues.push('config.yaml field "language.default" is required when language is configured');
|
|
896
|
+
for (const key of supportedLanguageFields) {
|
|
897
|
+
if (key in language && canonicalizeLanguageTag(language[key]) === null)
|
|
898
|
+
issues.push(`config.yaml field "language.${key}" must be a non-empty valid BCP 47 language tag`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return issues;
|
|
903
|
+
}
|
|
904
|
+
function clearConfigCache() {
|
|
905
|
+
_configCache = null;
|
|
906
|
+
}
|
|
907
|
+
function resolveKinds(config) {
|
|
908
|
+
return config.kinds && config.kinds.length > 0 ? config.kinds : [...VALID_KINDS];
|
|
909
|
+
}
|
|
910
|
+
function resolveRequiredSections(_config) {
|
|
911
|
+
return [...DEFAULT_REQUIRED_SECTIONS];
|
|
912
|
+
}
|
|
913
|
+
function resolveWorkflowPolicy(config, options = {}) {
|
|
914
|
+
if (options.configValid === false)
|
|
915
|
+
return { ...INVALID_CONFIG_WORKFLOW_POLICY };
|
|
916
|
+
return {
|
|
917
|
+
delegation: config.workflow?.delegation ?? DEFAULT_WORKFLOW_POLICY.delegation,
|
|
918
|
+
closeout: config.workflow?.closeout ?? DEFAULT_WORKFLOW_POLICY.closeout
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function resolveLanguagePolicy(config, options = {}) {
|
|
922
|
+
if (options.configValid === false || !config.language)
|
|
923
|
+
return { artifacts: null, commit: null };
|
|
924
|
+
return {
|
|
925
|
+
artifacts: config.language.artifacts ?? config.language.default,
|
|
926
|
+
commit: config.language.commit ?? config.language.default
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
function parseLanguageConfig(value) {
|
|
930
|
+
if (!value)
|
|
931
|
+
return void 0;
|
|
932
|
+
const defaultLanguage = canonicalizeLanguageTag(value.default);
|
|
933
|
+
if (defaultLanguage === null)
|
|
934
|
+
return void 0;
|
|
935
|
+
const artifacts = canonicalizeLanguageTag(value.artifacts);
|
|
936
|
+
const commit = canonicalizeLanguageTag(value.commit);
|
|
937
|
+
return {
|
|
938
|
+
default: defaultLanguage,
|
|
939
|
+
artifacts: artifacts ?? void 0,
|
|
940
|
+
commit: commit ?? void 0
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
function canonicalizeLanguageTag(value) {
|
|
944
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
945
|
+
return null;
|
|
946
|
+
try {
|
|
947
|
+
return Intl.getCanonicalLocales(value.trim())[0] ?? null;
|
|
948
|
+
} catch {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
async function getVersion() {
|
|
953
|
+
const { version } = JSON.parse(await readFile(join3(PKG_ROOT, "package.json"), "utf-8"));
|
|
954
|
+
return version;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/status/inspect.ts
|
|
958
|
+
import { readFile as readFile5, stat as stat2 } from "fs/promises";
|
|
959
|
+
|
|
960
|
+
// src/core/change-group.ts
|
|
961
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
962
|
+
import { dirname as dirname4, join as join6, relative as relative4 } from "path";
|
|
963
|
+
|
|
964
|
+
// src/core/filesystem.ts
|
|
965
|
+
import { existsSync as existsSync2 } from "fs";
|
|
966
|
+
import { readdir as readdir3, readFile as readFile2, rmdir } from "fs/promises";
|
|
967
|
+
import { basename, dirname as dirname3, join as join4, relative as relative2, sep as sep2 } from "path";
|
|
968
|
+
|
|
969
|
+
// src/core/output.ts
|
|
970
|
+
import pc2 from "picocolors";
|
|
971
|
+
function toErrorMessage(error) {
|
|
972
|
+
return error instanceof Error ? error.message : String(error);
|
|
973
|
+
}
|
|
974
|
+
function emitJson(data, options = {}) {
|
|
975
|
+
const indentation = options.compact ? void 0 : 2;
|
|
976
|
+
process.stdout.write(`${JSON.stringify(data, null, indentation)}
|
|
977
|
+
`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/core/filesystem.ts
|
|
981
|
+
async function walkMarkdownFiles(dir, options = {}) {
|
|
982
|
+
const all = await walkFiles(dir, options);
|
|
983
|
+
return all.filter((f) => f.endsWith(".md"));
|
|
984
|
+
}
|
|
985
|
+
async function inspectUnsupportedRules() {
|
|
986
|
+
const rulesDir = join4(RSP_DIR, "rules");
|
|
987
|
+
const diagnostics = [];
|
|
988
|
+
const rawEntries = await walkAllEntries(rulesDir, diagnostics);
|
|
989
|
+
const entries = rawEntries.filter((entry) => entry.path !== OBSOLETE_RSP_RULES_PATH).map((entry) => ({ path: normalizeLogicalPath(relative2(rulesDir, entry.path)), kind: entry.kind }));
|
|
990
|
+
return {
|
|
991
|
+
entries,
|
|
992
|
+
diagnostics,
|
|
993
|
+
directoryRemaining: entries.length > 0
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
async function walkAllEntries(dir, diagnostics) {
|
|
997
|
+
const entries = [];
|
|
998
|
+
try {
|
|
999
|
+
const items = await readdir3(dir, { withFileTypes: true });
|
|
1000
|
+
for (const item of items) {
|
|
1001
|
+
const path = join4(dir, item.name);
|
|
1002
|
+
const kind = item.isDirectory() ? "directory" : "file";
|
|
1003
|
+
entries.push({ path, kind });
|
|
1004
|
+
if (kind === "directory")
|
|
1005
|
+
entries.push(...await walkAllEntries(path, diagnostics));
|
|
1006
|
+
}
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
if (error.code !== "ENOENT") {
|
|
1009
|
+
diagnostics.push({ code: "walk_failed", operation: "readdir", path: dir, message: toErrorMessage(error) });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return entries;
|
|
1013
|
+
}
|
|
1014
|
+
async function walkFiles(dir, options = {}) {
|
|
1015
|
+
const files = [];
|
|
1016
|
+
try {
|
|
1017
|
+
const items = await readdir3(dir, { withFileTypes: true });
|
|
1018
|
+
for (const item of items) {
|
|
1019
|
+
if (item.name.startsWith("."))
|
|
1020
|
+
continue;
|
|
1021
|
+
const filePath = join4(dir, item.name);
|
|
1022
|
+
if (item.isDirectory())
|
|
1023
|
+
files.push(...await walkFiles(filePath, options));
|
|
1024
|
+
else
|
|
1025
|
+
files.push(filePath);
|
|
1026
|
+
}
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
if (error.code !== "ENOENT") {
|
|
1029
|
+
options.onError?.({ code: "walk_failed", operation: "readdir", path: dir, message: toErrorMessage(error) });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return files;
|
|
1033
|
+
}
|
|
1034
|
+
function normalizeLogicalPath(pathValue) {
|
|
1035
|
+
return pathValue.split(sep2).join("/").replace(/\\/g, "/");
|
|
1036
|
+
}
|
|
1037
|
+
var SPEC_NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
|
|
1038
|
+
function isValidSpecName(name) {
|
|
1039
|
+
return SPEC_NAME_RE.test(name);
|
|
1040
|
+
}
|
|
1041
|
+
function inspectRspInitialization() {
|
|
1042
|
+
const designPath = join4(RSP_DIR, "specs", "design.md");
|
|
1043
|
+
const design = inspectManagedFile(designPath, "design Spec", { allowMissing: true });
|
|
1044
|
+
if (design.issue)
|
|
1045
|
+
return `${design.issue.message}
|
|
1046
|
+
Check path types and permissions; init/update do not repair existing user content.`;
|
|
1047
|
+
if (design.exists)
|
|
1048
|
+
return void 0;
|
|
1049
|
+
return existsSync2(RSP_DIR) ? `${designPath} is missing.
|
|
1050
|
+
Restore your design file, or run rsp init to create missing scaffolding.` : "RSP is not initialized in this project\n Run: rsp init";
|
|
1051
|
+
}
|
|
1052
|
+
function guardRspInitialized() {
|
|
1053
|
+
const issue = inspectRspInitialization();
|
|
1054
|
+
if (issue) {
|
|
1055
|
+
console.error(` ${pc.red("Error:")} ${issue}`);
|
|
1056
|
+
process.exit(1);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
async function cleanupEmptyParentDirs(path, stopDir) {
|
|
1060
|
+
let parent = dirname3(path);
|
|
1061
|
+
while (parent !== stopDir) {
|
|
1062
|
+
try {
|
|
1063
|
+
const remaining = await readdir3(parent);
|
|
1064
|
+
if (remaining.length === 0)
|
|
1065
|
+
await rmdir(parent);
|
|
1066
|
+
else
|
|
1067
|
+
break;
|
|
1068
|
+
} catch {
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
parent = dirname3(parent);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
async function detectProjectName() {
|
|
1075
|
+
try {
|
|
1076
|
+
if (existsSync2("package.json")) {
|
|
1077
|
+
const raw = await readFile2("package.json", "utf-8");
|
|
1078
|
+
const pkg = JSON.parse(raw);
|
|
1079
|
+
if (pkg.name && pkg.name !== "")
|
|
1080
|
+
return pkg.name;
|
|
1081
|
+
}
|
|
1082
|
+
} catch {
|
|
1083
|
+
}
|
|
1084
|
+
return basename(process.cwd());
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/core/work-ref.ts
|
|
1088
|
+
import { Buffer } from "buffer";
|
|
1089
|
+
import { lstatSync as lstatSync2, readdirSync } from "fs";
|
|
1090
|
+
import { readdir as readdir4 } from "fs/promises";
|
|
1091
|
+
import { isAbsolute as isAbsolute2, join as join5, relative as relative3 } from "path";
|
|
1092
|
+
var WORK_SEGMENT_RE = /^[\p{Ll}\p{Lo}\p{Lm}\p{M}\p{Nd}]+(?:-[\p{Ll}\p{Lo}\p{Lm}\p{M}\p{Nd}]+)*$/u;
|
|
1093
|
+
var WORK_SEGMENT_MAX_CODE_POINTS = 100;
|
|
1094
|
+
var WORK_SEGMENT_MAX_UTF8_BYTES = 200;
|
|
1095
|
+
var GROUP_BRIEF_FILENAME = "00-brief.md";
|
|
1096
|
+
var WorkRefError = class extends Error {
|
|
1097
|
+
constructor(code, message, input) {
|
|
1098
|
+
super(message);
|
|
1099
|
+
this.code = code;
|
|
1100
|
+
this.input = input;
|
|
1101
|
+
this.name = "WorkRefError";
|
|
1102
|
+
}
|
|
1103
|
+
code;
|
|
1104
|
+
input;
|
|
1105
|
+
};
|
|
1106
|
+
function normalizeWorkRefSegment(segment) {
|
|
1107
|
+
const normalized = segment.normalize("NFC");
|
|
1108
|
+
if (!WORK_SEGMENT_RE.test(normalized) || [...normalized].length > WORK_SEGMENT_MAX_CODE_POINTS || Buffer.byteLength(normalized, "utf8") > WORK_SEGMENT_MAX_UTF8_BYTES) {
|
|
1109
|
+
throw new WorkRefError(
|
|
1110
|
+
"invalid_work_ref",
|
|
1111
|
+
`work identity segment "${segment}" must contain at most ${WORK_SEGMENT_MAX_CODE_POINTS} lowercase or caseless Unicode letters, marks, or decimal numbers separated only by internal hyphens and fit within ${WORK_SEGMENT_MAX_UTF8_BYTES} UTF-8 bytes`,
|
|
1112
|
+
segment
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
return normalized;
|
|
1116
|
+
}
|
|
1117
|
+
function normalizeExecutableWorkRef(name) {
|
|
1118
|
+
return normalizeWorkRefName(name, false);
|
|
1119
|
+
}
|
|
1120
|
+
function isCanonicalExecutableWorkRef(name) {
|
|
1121
|
+
try {
|
|
1122
|
+
return normalizeExecutableWorkRef(name) === name;
|
|
1123
|
+
} catch {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
function isCanonicalWorkRefSegment(segment) {
|
|
1128
|
+
try {
|
|
1129
|
+
return normalizeWorkRefSegment(segment) === segment;
|
|
1130
|
+
} catch {
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function resolveWorkRef(name, options = {}) {
|
|
1135
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
1136
|
+
const normalizedName = normalizeWorkRefName(name, true);
|
|
1137
|
+
if (options.canonical && normalizedName !== name) {
|
|
1138
|
+
throw new WorkRefError(
|
|
1139
|
+
"invalid_work_ref",
|
|
1140
|
+
`stored work identity "${name}" must use Unicode NFC normalization`,
|
|
1141
|
+
name
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
const segments = normalizedName.split("/");
|
|
1145
|
+
const isGroupBrief = segments.length === 2 && segments[1] === "brief";
|
|
1146
|
+
assertValidWorkRoot(changesDir, normalizedName);
|
|
1147
|
+
assertNoNormalizationCollision(changesDir, segments, normalizedName, isGroupBrief);
|
|
1148
|
+
assertNoIdentityCollision(changesDir, segments, normalizedName, isGroupBrief ? GROUP_BRIEF_FILENAME : void 0);
|
|
1149
|
+
const path = join5(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
|
|
1150
|
+
const ref = segments.length === 1 ? { kind: "change", name: normalizedName, path, group: null } : isGroupBrief ? { kind: "group-brief", name: normalizedName, path, group: segments[0] } : { kind: "group-change", name: normalizedName, path, group: segments[0] };
|
|
1151
|
+
if (ref.kind === "group-change" && options.executable !== false)
|
|
1152
|
+
assertGroupBrief(changesDir, ref);
|
|
1153
|
+
if (options.executable && ref.kind === "group-brief") {
|
|
1154
|
+
throw new WorkRefError(
|
|
1155
|
+
"non_executable_work_ref",
|
|
1156
|
+
`work identity "${name}" is a Group Brief and cannot be used as an executable Change`,
|
|
1157
|
+
name
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
assertRegularWorkFile(ref, Boolean(options.mustExist));
|
|
1161
|
+
return ref;
|
|
1162
|
+
}
|
|
1163
|
+
function assertGroupBrief(changesDir, ref) {
|
|
1164
|
+
const briefPath = join5(changesDir, ref.group, GROUP_BRIEF_FILENAME);
|
|
1165
|
+
const kind = getPathKind(briefPath, ref.name);
|
|
1166
|
+
if (kind === "missing") {
|
|
1167
|
+
throw new WorkRefError(
|
|
1168
|
+
"group_brief_missing",
|
|
1169
|
+
`Change Group "${ref.group}" requires a Group Brief; run: rsp group create ${ref.group}`,
|
|
1170
|
+
ref.name
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
if (kind !== "file") {
|
|
1174
|
+
throw new WorkRefError(
|
|
1175
|
+
"work_ref_not_file",
|
|
1176
|
+
`Group Brief must be a regular file: ${briefPath}`,
|
|
1177
|
+
ref.name
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
function assertRegularWorkFile(ref, mustExist) {
|
|
1182
|
+
let stats;
|
|
1183
|
+
try {
|
|
1184
|
+
stats = lstatSync2(ref.path);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
if (error.code === "ENOENT") {
|
|
1187
|
+
if (mustExist) {
|
|
1188
|
+
throw new WorkRefError(
|
|
1189
|
+
"work_ref_not_found",
|
|
1190
|
+
`open work file not found: ${ref.path}`,
|
|
1191
|
+
ref.name
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
throw asReadFailure(ref.path, ref.name, error);
|
|
1197
|
+
}
|
|
1198
|
+
if (!stats.isFile()) {
|
|
1199
|
+
throw new WorkRefError(
|
|
1200
|
+
"work_ref_not_file",
|
|
1201
|
+
`open work path is not a regular file: ${ref.path}`,
|
|
1202
|
+
ref.name
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
function resolveWorkRefPath(path, options = {}) {
|
|
1207
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
1208
|
+
const logicalPath = normalizeLogicalPath2(relative3(changesDir, path));
|
|
1209
|
+
if (isAbsolute2(logicalPath) || logicalPath === ".." || logicalPath.startsWith("../") || !logicalPath.endsWith(".md")) {
|
|
1210
|
+
throw new WorkRefError(
|
|
1211
|
+
"invalid_work_ref_path",
|
|
1212
|
+
`work path must be a Markdown file inside ${changesDir}: ${path}`,
|
|
1213
|
+
path
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
const segments = logicalPath.split("/");
|
|
1217
|
+
if (segments.length === 2 && segments[1] === "brief.md") {
|
|
1218
|
+
throw new WorkRefError(
|
|
1219
|
+
"invalid_work_ref_path",
|
|
1220
|
+
`unsupported legacy Group Brief path: ${path}`,
|
|
1221
|
+
path
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
const name = segments.length === 2 && segments[1] === GROUP_BRIEF_FILENAME ? `${segments[0]}/brief` : logicalPath.slice(0, -3);
|
|
1225
|
+
return resolveWorkRef(name, { ...options, canonical: true });
|
|
1226
|
+
}
|
|
1227
|
+
function isExecutableWorkRef(ref) {
|
|
1228
|
+
return ref.kind !== "group-brief";
|
|
1229
|
+
}
|
|
1230
|
+
function resolveFocusMarkerPath(ref, options = {}) {
|
|
1231
|
+
const focusDir = options.focusDir ?? FOCUS_DIR;
|
|
1232
|
+
assertManagedRoot(focusDir, ref.name, "invalid_focus_root", "focus root", true);
|
|
1233
|
+
assertNoManagedNormalizationCollision(focusDir, ref.name.split("/"), ref.name, "invalid_focus_path");
|
|
1234
|
+
if (ref.group) {
|
|
1235
|
+
assertOptionalManagedDirectory(
|
|
1236
|
+
join5(focusDir, ref.group),
|
|
1237
|
+
ref.name,
|
|
1238
|
+
"invalid_focus_path",
|
|
1239
|
+
"focus path"
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
const markerPath = join5(focusDir, ...ref.name.split("/"));
|
|
1243
|
+
try {
|
|
1244
|
+
requireManagedFile(markerPath, "focus marker", { allowMissing: true });
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
if (error instanceof ManagedPathError)
|
|
1247
|
+
throw new WorkRefError("invalid_focus_path", error.message, ref.name);
|
|
1248
|
+
throw error;
|
|
1249
|
+
}
|
|
1250
|
+
return markerPath;
|
|
1251
|
+
}
|
|
1252
|
+
function resolveArchiveDirectory(ref, options = {}) {
|
|
1253
|
+
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1254
|
+
assertManagedRoot(archivesDir, ref.name, "invalid_archive_root", "archive root", true);
|
|
1255
|
+
if (!ref.group)
|
|
1256
|
+
return archivesDir;
|
|
1257
|
+
assertNoManagedNormalizationCollision(archivesDir, [ref.group], ref.name, "invalid_archive_path");
|
|
1258
|
+
const groupPath = join5(archivesDir, ref.group);
|
|
1259
|
+
assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
|
|
1260
|
+
return groupPath;
|
|
1261
|
+
}
|
|
1262
|
+
async function inspectFocusTree(options = {}) {
|
|
1263
|
+
const focusDir = options.focusDir ?? FOCUS_DIR;
|
|
1264
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
1265
|
+
const result = { markers: [], diagnostics: [] };
|
|
1266
|
+
const rootInspection = inspectManagedDirectory(focusDir, "focus root", { allowMissing: true });
|
|
1267
|
+
if (rootInspection.issue) {
|
|
1268
|
+
addDiagnostic(result, new WorkRefError("invalid_focus_root", rootInspection.issue.message, focusDir), focusDir);
|
|
1269
|
+
return result;
|
|
1270
|
+
}
|
|
1271
|
+
if (!rootInspection.exists)
|
|
1272
|
+
return result;
|
|
1273
|
+
const rootEntries = await readDirectory(focusDir, focusDir, result);
|
|
1274
|
+
if (!rootEntries)
|
|
1275
|
+
return result;
|
|
1276
|
+
for (const entry of rootEntries) {
|
|
1277
|
+
const path = join5(focusDir, entry.name);
|
|
1278
|
+
if (entry.name === ".gitkeep" && entry.isFile())
|
|
1279
|
+
continue;
|
|
1280
|
+
if (entry.isDirectory()) {
|
|
1281
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
1282
|
+
addDiagnostic(result, new WorkRefError(
|
|
1283
|
+
"invalid_work_ref",
|
|
1284
|
+
`stored work identity "${entry.name}" must be a safe Unicode NFC segment`,
|
|
1285
|
+
entry.name
|
|
1286
|
+
), path);
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
await inspectFocusGroup(entry.name, path, changesDir, result);
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (!entry.isFile()) {
|
|
1293
|
+
addDiagnostic(result, new WorkRefError(
|
|
1294
|
+
"invalid_focus_path",
|
|
1295
|
+
`focus path must be a regular marker file or real directory: ${path}`,
|
|
1296
|
+
entry.name
|
|
1297
|
+
), path);
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
collectFocusMarker(entry.name, path, changesDir, result);
|
|
1301
|
+
}
|
|
1302
|
+
result.markers.sort((a, b) => a.name.localeCompare(b.name));
|
|
1303
|
+
return result;
|
|
1304
|
+
}
|
|
1305
|
+
async function inspectArchiveTree(options = {}) {
|
|
1306
|
+
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1307
|
+
const result = { rootExists: false, files: [], diagnostics: [] };
|
|
1308
|
+
const rootInspection = inspectManagedDirectory(archivesDir, "archive root", { allowMissing: true });
|
|
1309
|
+
if (rootInspection.issue) {
|
|
1310
|
+
addDiagnostic(result, new WorkRefError("invalid_archive_root", rootInspection.issue.message, archivesDir), archivesDir);
|
|
1311
|
+
return result;
|
|
1312
|
+
}
|
|
1313
|
+
if (!rootInspection.exists)
|
|
1314
|
+
return result;
|
|
1315
|
+
result.rootExists = true;
|
|
1316
|
+
const rootEntries = await readDirectory(archivesDir, archivesDir, result);
|
|
1317
|
+
if (!rootEntries)
|
|
1318
|
+
return result;
|
|
1319
|
+
for (const entry of rootEntries) {
|
|
1320
|
+
const path = join5(archivesDir, entry.name);
|
|
1321
|
+
if (entry.name === "INDEX.md" && entry.isFile())
|
|
1322
|
+
continue;
|
|
1323
|
+
if (entry.isDirectory()) {
|
|
1324
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
1325
|
+
addArchivePathDiagnostic(result, entry.name, path);
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
await inspectArchiveGroup(entry.name, path, result);
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1332
|
+
addArchivePathDiagnostic(result, entry.name, path);
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
result.files.push(path);
|
|
1336
|
+
}
|
|
1337
|
+
result.files.sort();
|
|
1338
|
+
return result;
|
|
1339
|
+
}
|
|
1340
|
+
async function inspectWorkTree(options = {}) {
|
|
1341
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
1342
|
+
const result = { changes: [], briefs: [], diagnostics: [] };
|
|
1343
|
+
try {
|
|
1344
|
+
assertValidWorkRoot(changesDir, changesDir);
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
if (!(error instanceof WorkRefError))
|
|
1347
|
+
throw error;
|
|
1348
|
+
addDiagnostic(result, error, changesDir);
|
|
1349
|
+
return result;
|
|
1350
|
+
}
|
|
1351
|
+
const rootEntries = await readDirectory(changesDir, changesDir, result);
|
|
1352
|
+
if (!rootEntries)
|
|
1353
|
+
return result;
|
|
1354
|
+
const rootDirectories = new Set(rootEntries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
|
|
1355
|
+
const rootMarkdownNames = new Set(rootEntries.filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name.slice(0, -3)));
|
|
1356
|
+
const collidingGroups = new Set([...rootDirectories].filter((name) => rootMarkdownNames.has(name)));
|
|
1357
|
+
for (const entry of rootEntries) {
|
|
1358
|
+
const path = join5(changesDir, entry.name);
|
|
1359
|
+
if (entry.name === ".gitkeep" && entry.isFile())
|
|
1360
|
+
continue;
|
|
1361
|
+
if (entry.isDirectory()) {
|
|
1362
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
1363
|
+
addDiagnostic(result, new WorkRefError(
|
|
1364
|
+
"invalid_work_ref",
|
|
1365
|
+
`stored work identity "${entry.name}" must be a safe Unicode NFC segment`,
|
|
1366
|
+
entry.name
|
|
1367
|
+
), path);
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
const colliding = collidingGroups.has(entry.name);
|
|
1371
|
+
if (colliding) {
|
|
1372
|
+
addDiagnostic(result, new WorkRefError(
|
|
1373
|
+
"work_ref_collision",
|
|
1374
|
+
`work identity "${entry.name}" is claimed by both a file and a directory`,
|
|
1375
|
+
entry.name
|
|
1376
|
+
), path);
|
|
1377
|
+
}
|
|
1378
|
+
await inspectGroupDirectory(entry.name, path, changesDir, result, colliding);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1382
|
+
addInvalidEntryDiagnostic(result, entry.name, path);
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
const name = entry.name.slice(0, -3);
|
|
1386
|
+
if (collidingGroups.has(name)) {
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
collectResolvedPath(path, changesDir, result);
|
|
1390
|
+
}
|
|
1391
|
+
result.changes.sort((a, b) => a.name.localeCompare(b.name));
|
|
1392
|
+
result.briefs.sort((a, b) => a.name.localeCompare(b.name));
|
|
1393
|
+
return result;
|
|
1394
|
+
}
|
|
1395
|
+
async function inspectGroupDirectory(group, groupPath, changesDir, result, colliding) {
|
|
1396
|
+
const entries = await readDirectory(groupPath, group, result);
|
|
1397
|
+
if (!entries)
|
|
1398
|
+
return;
|
|
1399
|
+
const briefEntry = entries.find((entry) => entry.name === GROUP_BRIEF_FILENAME);
|
|
1400
|
+
if (!briefEntry) {
|
|
1401
|
+
addDiagnostic(result, new WorkRefError(
|
|
1402
|
+
"group_brief_missing",
|
|
1403
|
+
`Change Group "${group}" requires a Group Brief; run: rsp group create ${group}`,
|
|
1404
|
+
group
|
|
1405
|
+
), join5(groupPath, GROUP_BRIEF_FILENAME));
|
|
1406
|
+
} else if (!briefEntry.isFile()) {
|
|
1407
|
+
addDiagnostic(result, new WorkRefError(
|
|
1408
|
+
"work_ref_not_file",
|
|
1409
|
+
`Group Brief must be a regular file: ${join5(groupPath, GROUP_BRIEF_FILENAME)}`,
|
|
1410
|
+
group
|
|
1411
|
+
), join5(groupPath, GROUP_BRIEF_FILENAME));
|
|
1412
|
+
}
|
|
1413
|
+
for (const entry of entries) {
|
|
1414
|
+
const input = `${group}/${entry.name}`;
|
|
1415
|
+
const path = join5(groupPath, entry.name);
|
|
1416
|
+
if (entry.name === GROUP_BRIEF_FILENAME && !entry.isFile())
|
|
1417
|
+
continue;
|
|
1418
|
+
if (entry.name === "brief.md") {
|
|
1419
|
+
addInvalidEntryDiagnostic(result, input, path);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
if (entry.isDirectory()) {
|
|
1423
|
+
addDiagnostic(result, new WorkRefError(
|
|
1424
|
+
"unsupported_work_depth",
|
|
1425
|
+
`work identity "${input}" exceeds the supported one-level Change Group depth`,
|
|
1426
|
+
input
|
|
1427
|
+
), path);
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1431
|
+
addInvalidEntryDiagnostic(result, input, path);
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
if (!colliding)
|
|
1435
|
+
collectResolvedPath(path, changesDir, result);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
async function inspectFocusGroup(group, groupPath, changesDir, result) {
|
|
1439
|
+
const entries = await readDirectory(groupPath, group, result);
|
|
1440
|
+
if (!entries)
|
|
1441
|
+
return;
|
|
1442
|
+
for (const entry of entries) {
|
|
1443
|
+
const input = `${group}/${entry.name}`;
|
|
1444
|
+
const path = join5(groupPath, entry.name);
|
|
1445
|
+
if (entry.isDirectory()) {
|
|
1446
|
+
addDiagnostic(result, new WorkRefError(
|
|
1447
|
+
"unsupported_work_depth",
|
|
1448
|
+
`work identity "${input}" exceeds the supported one-level Change Group depth`,
|
|
1449
|
+
input
|
|
1450
|
+
), path);
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
if (!entry.isFile()) {
|
|
1454
|
+
addDiagnostic(result, new WorkRefError(
|
|
1455
|
+
"invalid_focus_path",
|
|
1456
|
+
`focus path must be a regular marker file: ${path}`,
|
|
1457
|
+
input
|
|
1458
|
+
), path);
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
collectFocusMarker(input, path, changesDir, result);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
async function inspectArchiveGroup(group, groupPath, result) {
|
|
1465
|
+
const entries = await readDirectory(groupPath, group, result);
|
|
1466
|
+
if (!entries)
|
|
1467
|
+
return;
|
|
1468
|
+
for (const entry of entries) {
|
|
1469
|
+
const input = `${group}/${entry.name}`;
|
|
1470
|
+
const path = join5(groupPath, entry.name);
|
|
1471
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1472
|
+
addArchivePathDiagnostic(result, input, path);
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
result.files.push(path);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
function collectFocusMarker(input, path, changesDir, result) {
|
|
1479
|
+
try {
|
|
1480
|
+
const ref = resolveWorkRef(input, { changesDir, canonical: true, executable: true });
|
|
1481
|
+
result.markers.push({ name: ref.name, path });
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
if (!(error instanceof WorkRefError))
|
|
1484
|
+
throw error;
|
|
1485
|
+
addDiagnostic(result, error, path);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function collectResolvedPath(path, changesDir, result) {
|
|
1489
|
+
try {
|
|
1490
|
+
const ref = resolveWorkRefPath(path, { changesDir });
|
|
1491
|
+
if (isExecutableWorkRef(ref))
|
|
1492
|
+
result.changes.push(ref);
|
|
1493
|
+
else
|
|
1494
|
+
result.briefs.push(ref);
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
if (!(error instanceof WorkRefError))
|
|
1497
|
+
throw error;
|
|
1498
|
+
addDiagnostic(result, error, path);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
async function readDirectory(path, input, result) {
|
|
1502
|
+
try {
|
|
1503
|
+
return (await readdir4(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
1504
|
+
} catch (error) {
|
|
1505
|
+
addDiagnostic(result, asReadFailure(path, input, error), path);
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
function addInvalidEntryDiagnostic(result, input, path) {
|
|
1510
|
+
addDiagnostic(result, new WorkRefError(
|
|
1511
|
+
"invalid_work_ref_path",
|
|
1512
|
+
`unsupported entry in the open-work tree: ${path}`,
|
|
1513
|
+
input
|
|
1514
|
+
), path);
|
|
1515
|
+
}
|
|
1516
|
+
function addArchivePathDiagnostic(result, input, path) {
|
|
1517
|
+
addDiagnostic(result, new WorkRefError(
|
|
1518
|
+
"invalid_archive_path",
|
|
1519
|
+
`unsupported entry in the archive tree: ${path}`,
|
|
1520
|
+
input
|
|
1521
|
+
), path);
|
|
1522
|
+
}
|
|
1523
|
+
function addDiagnostic(result, error, path) {
|
|
1524
|
+
if (result.diagnostics.some((item) => item.code === error.code && item.input === error.input))
|
|
1525
|
+
return;
|
|
1526
|
+
result.diagnostics.push({ code: error.code, input: error.input, path, message: error.message });
|
|
1527
|
+
}
|
|
1528
|
+
function normalizeWorkRefName(name, allowGroupBrief) {
|
|
1529
|
+
const segments = name.split("/");
|
|
1530
|
+
if (segments.length > 2) {
|
|
1531
|
+
throw new WorkRefError(
|
|
1532
|
+
"unsupported_work_depth",
|
|
1533
|
+
`work identity "${name}" exceeds the supported one-level Change Group depth`,
|
|
1534
|
+
name
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
if (!name || segments.some((segment) => !segment)) {
|
|
1538
|
+
throw new WorkRefError(
|
|
1539
|
+
"invalid_work_ref",
|
|
1540
|
+
`work identity "${name}" must contain one or two safe Unicode segments`,
|
|
1541
|
+
name
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
const normalizedSegments = segments.map(normalizeWorkRefSegment);
|
|
1545
|
+
const child = normalizedSegments[1];
|
|
1546
|
+
if (segments.length === 2 && child === GROUP_BRIEF_FILENAME.slice(0, -3)) {
|
|
1547
|
+
throw new WorkRefError(
|
|
1548
|
+
"invalid_work_ref",
|
|
1549
|
+
`work identity "${name}" uses the reserved Group Brief filename`,
|
|
1550
|
+
name
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
if (segments.length === 2 && child === "brief" && !allowGroupBrief) {
|
|
1554
|
+
throw new WorkRefError(
|
|
1555
|
+
"non_executable_work_ref",
|
|
1556
|
+
`work identity "${name}" is a Group Brief and cannot be used as an executable Change`,
|
|
1557
|
+
name
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
return normalizedSegments.join("/");
|
|
1561
|
+
}
|
|
1562
|
+
function assertNoNormalizationCollision(changesDir, segments, input, isGroupBrief) {
|
|
1563
|
+
for (let depth = 0; depth < segments.length; depth++) {
|
|
1564
|
+
const parent = join5(changesDir, ...segments.slice(0, depth));
|
|
1565
|
+
const parentKind = getPathKind(parent, input);
|
|
1566
|
+
if (parentKind === "missing")
|
|
1567
|
+
return;
|
|
1568
|
+
if (parentKind !== "directory")
|
|
1569
|
+
continue;
|
|
1570
|
+
let entries;
|
|
1571
|
+
try {
|
|
1572
|
+
entries = readdirSync(parent, { withFileTypes: true });
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
throw asReadFailure(parent, input, error);
|
|
1575
|
+
}
|
|
1576
|
+
const expected = segments[depth];
|
|
1577
|
+
for (const entry of entries) {
|
|
1578
|
+
let candidate = null;
|
|
1579
|
+
if (entry.isDirectory())
|
|
1580
|
+
candidate = entry.name;
|
|
1581
|
+
else if (entry.isFile() && entry.name.endsWith(".md"))
|
|
1582
|
+
candidate = entry.name.slice(0, -3);
|
|
1583
|
+
if (depth === segments.length - 1 && isGroupBrief && entry.name === GROUP_BRIEF_FILENAME)
|
|
1584
|
+
candidate = "brief";
|
|
1585
|
+
if (candidate && candidate !== expected && candidate.normalize("NFC") === expected) {
|
|
1586
|
+
throw new WorkRefError(
|
|
1587
|
+
"work_ref_collision",
|
|
1588
|
+
`work identity "${input}" collides with non-canonical stored identity "${candidate}" after Unicode NFC normalization`,
|
|
1589
|
+
input
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
function assertNoManagedNormalizationCollision(root, segments, input, code) {
|
|
1596
|
+
for (let depth = 0; depth < segments.length; depth++) {
|
|
1597
|
+
const parent = join5(root, ...segments.slice(0, depth));
|
|
1598
|
+
const parentKind = getPathKind(parent, input);
|
|
1599
|
+
if (parentKind === "missing")
|
|
1600
|
+
return;
|
|
1601
|
+
if (parentKind !== "directory")
|
|
1602
|
+
continue;
|
|
1603
|
+
let entries;
|
|
1604
|
+
try {
|
|
1605
|
+
entries = readdirSync(parent, { withFileTypes: true });
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
throw asReadFailure(parent, input, error);
|
|
1608
|
+
}
|
|
1609
|
+
const expected = segments[depth];
|
|
1610
|
+
const alias = entries.find((entry) => entry.name !== expected && entry.name.normalize("NFC") === expected);
|
|
1611
|
+
if (alias) {
|
|
1612
|
+
throw new WorkRefError(
|
|
1613
|
+
code,
|
|
1614
|
+
`managed identity "${input}" collides with non-canonical stored entry "${alias.name}" after Unicode NFC normalization`,
|
|
1615
|
+
input
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
|
|
1621
|
+
for (let depth = 1; depth <= segments.length; depth++) {
|
|
1622
|
+
const identityPath = join5(changesDir, ...segments.slice(0, depth));
|
|
1623
|
+
const filePath = finalFilename && depth === segments.length ? join5(changesDir, ...segments.slice(0, -1), finalFilename) : `${identityPath}.md`;
|
|
1624
|
+
const fileKind = getPathKind(filePath, input);
|
|
1625
|
+
const identityKind = getPathKind(identityPath, input);
|
|
1626
|
+
const isPrefix = depth < segments.length;
|
|
1627
|
+
const isFlatIdentity = segments.length === 1;
|
|
1628
|
+
const identity = segments.slice(0, depth).join("/");
|
|
1629
|
+
if (fileKind !== "missing" && fileKind !== "file") {
|
|
1630
|
+
throw new WorkRefError(
|
|
1631
|
+
"work_ref_not_file",
|
|
1632
|
+
`open work path is not a regular file: ${filePath}`,
|
|
1633
|
+
input
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
if (fileKind === "file" && identityKind === "directory" || isPrefix && fileKind === "file" || isFlatIdentity && identityKind === "directory") {
|
|
1637
|
+
throw new WorkRefError(
|
|
1638
|
+
"work_ref_collision",
|
|
1639
|
+
`work identity "${identity}" is or would be claimed by both a file and a directory`,
|
|
1640
|
+
input
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
if (isPrefix && identityKind !== "missing" && identityKind !== "directory") {
|
|
1644
|
+
throw new WorkRefError(
|
|
1645
|
+
"invalid_work_ref_path",
|
|
1646
|
+
`open work path must be a real directory: ${identityPath}`,
|
|
1647
|
+
input
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
if (!isPrefix && !isFlatIdentity && identityKind === "directory") {
|
|
1651
|
+
throw new WorkRefError(
|
|
1652
|
+
"unsupported_work_depth",
|
|
1653
|
+
`work identity "${identity}" exceeds the supported one-level Change Group depth`,
|
|
1654
|
+
input
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
if (!isPrefix && identityKind !== "missing" && identityKind !== "directory") {
|
|
1658
|
+
throw new WorkRefError(
|
|
1659
|
+
"invalid_work_ref_path",
|
|
1660
|
+
`unsupported entry in the open-work tree: ${identityPath}`,
|
|
1661
|
+
input
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
function assertValidWorkRoot(changesDir, input) {
|
|
1667
|
+
assertManagedRoot(changesDir, input, "invalid_work_root", "open work root");
|
|
1668
|
+
}
|
|
1669
|
+
function assertManagedRoot(path, input, code, label, allowMissing = false) {
|
|
1670
|
+
try {
|
|
1671
|
+
requireManagedDirectory(path, label, { allowMissing });
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
if (error instanceof ManagedPathError)
|
|
1674
|
+
throw new WorkRefError(code, error.message, input);
|
|
1675
|
+
throw error;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
function assertOptionalManagedDirectory(path, input, code, label) {
|
|
1679
|
+
try {
|
|
1680
|
+
requireManagedDirectory(path, label, { allowMissing: true });
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
if (error instanceof ManagedPathError)
|
|
1683
|
+
throw new WorkRefError(code, error.message, input);
|
|
1684
|
+
throw error;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
function getPathKind(path, input) {
|
|
1688
|
+
try {
|
|
1689
|
+
const stats = lstatSync2(path);
|
|
1690
|
+
if (stats.isFile())
|
|
1691
|
+
return "file";
|
|
1692
|
+
if (stats.isDirectory())
|
|
1693
|
+
return "directory";
|
|
1694
|
+
return "other";
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
if (error.code === "ENOENT")
|
|
1697
|
+
return "missing";
|
|
1698
|
+
throw asReadFailure(path, input, error);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
function asReadFailure(path, input, error) {
|
|
1702
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1703
|
+
return new WorkRefError(
|
|
1704
|
+
"work_tree_read_failed",
|
|
1705
|
+
`unable to inspect open work path ${path}: ${message}`,
|
|
1706
|
+
input
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
function normalizeLogicalPath2(path) {
|
|
1710
|
+
return path.split("\\").join("/");
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// src/core/work-summary.ts
|
|
1714
|
+
var PLACEHOLDER_SUMMARY = "<\u2026>";
|
|
1715
|
+
function extractChangeSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), options = {}) {
|
|
1716
|
+
try {
|
|
1717
|
+
const frontmatter2 = parseFrontmatter(document.markdown);
|
|
1718
|
+
const frontmatterSummary = meaningfulSummary(frontmatter2?.summary, options);
|
|
1719
|
+
if (frontmatterSummary)
|
|
1720
|
+
return frontmatterSummary;
|
|
1721
|
+
} catch {
|
|
1722
|
+
}
|
|
1723
|
+
const proposalLines = visibleSummaryItemLines(getDocumentSectionMarkdown(document, "proposal"));
|
|
1724
|
+
for (const field of ["Outcome", "Summary"]) {
|
|
1725
|
+
const prefix = `- ${field}:`;
|
|
1726
|
+
for (const line of proposalLines) {
|
|
1727
|
+
const summary = line.startsWith(prefix) ? meaningfulSummary(line.slice(prefix.length), options) : null;
|
|
1728
|
+
if (summary)
|
|
1729
|
+
return summary;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return null;
|
|
1733
|
+
}
|
|
1734
|
+
function extractGroupSummary(content, document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA)) {
|
|
1735
|
+
for (const line of visibleSummaryItemLines(getDocumentSectionMarkdown(document, "goal"))) {
|
|
1736
|
+
const trimmed = line.trim();
|
|
1737
|
+
const summary = trimmed.startsWith("- ") || trimmed.startsWith("* ") ? meaningfulSummary(trimmed.slice(2)) : null;
|
|
1738
|
+
if (summary)
|
|
1739
|
+
return summary;
|
|
1740
|
+
}
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
function visibleSummaryItemLines(document) {
|
|
1744
|
+
const source = visibleMarkdownSource(document);
|
|
1745
|
+
return markdownListItems(document).map(({ node }) => source.slice(node.position.start.offset - document.start, node.position.end.offset - document.start).split(/\r?\n/, 1)[0].trim());
|
|
1746
|
+
}
|
|
1747
|
+
function meaningfulSummary(value, options = {}) {
|
|
1748
|
+
if (typeof value !== "string")
|
|
1749
|
+
return null;
|
|
1750
|
+
const summary = value.trim();
|
|
1751
|
+
return summary && (options.preservePlaceholder || summary !== PLACEHOLDER_SUMMARY) ? summary : null;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// src/core/change-group.ts
|
|
1755
|
+
var GROUP_BRIEF_SECTIONS = getCanonicalSectionHeadings(GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
1756
|
+
var GROUP_REOPEN_COMPLETION_PREFIX = "Resolve reopened concern from";
|
|
1757
|
+
async function resolveExecutableChange(name, options = {}) {
|
|
1758
|
+
const ref = resolveWorkRef(name, { ...options, executable: true });
|
|
1759
|
+
if (ref.kind === "group-change")
|
|
1760
|
+
await requireDeclaredGroupSlice(ref);
|
|
1761
|
+
return ref;
|
|
1762
|
+
}
|
|
1763
|
+
function generateGroupBriefContent(name, goal = "") {
|
|
1764
|
+
const placeholder = "<\u2026>";
|
|
1765
|
+
return `---
|
|
1766
|
+
kind: group
|
|
1767
|
+
---
|
|
1768
|
+
|
|
1769
|
+
${renderDocumentTitle(GROUP_BRIEF_DOCUMENT_SCHEMA, name)}
|
|
1770
|
+
|
|
1771
|
+
${groupSectionHeading("goal")}
|
|
1772
|
+
- ${goal || placeholder}
|
|
1773
|
+
|
|
1774
|
+
${groupSectionHeading("scope")}
|
|
1775
|
+
- ${placeholder}
|
|
1776
|
+
|
|
1777
|
+
${groupSectionHeading("sharedConstraints")}
|
|
1778
|
+
- ${placeholder}
|
|
1779
|
+
|
|
1780
|
+
${groupSectionHeading("slices")}
|
|
1781
|
+
<!-- Declare at least two independently executable direct child WorkRefs before creating them; keep their Tasks and evidence in each Change. -->
|
|
1782
|
+
- \`${name}/<change>\`: ${placeholder}
|
|
1783
|
+
|
|
1784
|
+
${groupSectionHeading("completionConditions")}
|
|
1785
|
+
<!-- State shared completion conditions. For Group-level verification, name an Integration: condition and retain its acceptance and evidence here; child passes alone do not prove it. -->
|
|
1786
|
+
- [ ] ${placeholder}
|
|
1787
|
+
|
|
1788
|
+
${groupSectionHeading("durableOutcomes")}
|
|
1789
|
+
<!-- Identify current-fact and lasting-rationale owners only when an update is needed; do not duplicate child results, current Specs or execution history. -->
|
|
1790
|
+
- Current facts: ${placeholder}
|
|
1791
|
+
- Lasting rationale: ${placeholder}
|
|
1792
|
+
|
|
1793
|
+
${groupSectionHeading("blockers")}
|
|
1794
|
+
- none
|
|
1795
|
+
`;
|
|
1796
|
+
}
|
|
1797
|
+
async function inspectChangeGroups(options = {}) {
|
|
1798
|
+
const workTree = options.workTree ?? await inspectWorkTree();
|
|
1799
|
+
const archiveTree = options.archiveTree ?? await inspectArchiveTree();
|
|
1800
|
+
const focusTree = options.focusTree ?? await inspectFocusTree();
|
|
1801
|
+
const diagnostics = [];
|
|
1802
|
+
const groups = [];
|
|
1803
|
+
const archivedSlicesByGroup = /* @__PURE__ */ new Map();
|
|
1804
|
+
const archivedGroups = /* @__PURE__ */ new Set();
|
|
1805
|
+
const archivedGroupReopenEvidence = /* @__PURE__ */ new Map();
|
|
1806
|
+
const archivedGroupBriefPaths = /* @__PURE__ */ new Map();
|
|
1807
|
+
const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
|
|
1808
|
+
for (const path of archiveTree.files) {
|
|
1809
|
+
const archivePath = normalizeLogicalPath(relative4(ARCHIVES_DIR, path));
|
|
1810
|
+
const archiveGroup = archivePath.includes("/") ? archivePath.split("/")[0] : null;
|
|
1811
|
+
if (!archiveGroup || !openGroupNames.has(archiveGroup))
|
|
1812
|
+
continue;
|
|
1813
|
+
try {
|
|
1814
|
+
const content = await readFile3(path, "utf-8");
|
|
1815
|
+
const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1816
|
+
const identity = getDocumentTitle(archivedChange, "identity");
|
|
1817
|
+
if (identity?.includes("/")) {
|
|
1818
|
+
if (!isCanonicalExecutableWorkRef(identity) || !identity.startsWith(`${archiveGroup}/`)) {
|
|
1819
|
+
diagnostics.push({
|
|
1820
|
+
severity: "error",
|
|
1821
|
+
code: "group_archive_identity_mismatch",
|
|
1822
|
+
change: archiveGroup,
|
|
1823
|
+
path,
|
|
1824
|
+
message: `archived Change identity ${identity} does not belong to archive group ${archiveGroup}`
|
|
1825
|
+
});
|
|
1826
|
+
} else {
|
|
1827
|
+
const groupSlices = archivedSlicesByGroup.get(archiveGroup) ?? /* @__PURE__ */ new Set();
|
|
1828
|
+
groupSlices.add(identity);
|
|
1829
|
+
archivedSlicesByGroup.set(archiveGroup, groupSlices);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
const archivedBrief = parseRspDocument(archivedChange.markdown, GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
1833
|
+
if (getDocumentTitle(archivedBrief, "identity") === archiveGroup) {
|
|
1834
|
+
archivedGroups.add(archiveGroup);
|
|
1835
|
+
const evidence = archivedGroupReopenEvidence.get(archiveGroup) ?? /* @__PURE__ */ new Set();
|
|
1836
|
+
for (const item of getGroupReopenEvidence(archivedBrief))
|
|
1837
|
+
evidence.add(item.key);
|
|
1838
|
+
archivedGroupReopenEvidence.set(archiveGroup, evidence);
|
|
1839
|
+
const retainedPaths = archivedGroupBriefPaths.get(archiveGroup) ?? /* @__PURE__ */ new Set();
|
|
1840
|
+
retainedPaths.add(`.rsp/archives/${archivePath}`);
|
|
1841
|
+
archivedGroupBriefPaths.set(archiveGroup, retainedPaths);
|
|
1842
|
+
}
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
diagnostics.push({
|
|
1845
|
+
severity: "error",
|
|
1846
|
+
code: "group_archive_read_failed",
|
|
1847
|
+
change: archiveGroup,
|
|
1848
|
+
path,
|
|
1849
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
for (const brief of workTree.briefs) {
|
|
1854
|
+
let content;
|
|
1855
|
+
try {
|
|
1856
|
+
content = await readFile3(brief.path, "utf-8");
|
|
1857
|
+
} catch (error) {
|
|
1858
|
+
diagnostics.push({
|
|
1859
|
+
severity: "error",
|
|
1860
|
+
code: "group_brief_read_failed",
|
|
1861
|
+
change: brief.group,
|
|
1862
|
+
path: brief.path,
|
|
1863
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1864
|
+
});
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
const document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
1868
|
+
const parsed = parseGroupBrief(brief.group, document);
|
|
1869
|
+
diagnostics.push(...parsed.diagnostics.map((diagnostic) => ({ ...diagnostic, path: brief.path })));
|
|
1870
|
+
if (archivedGroups.has(brief.group) && !hasExplicitGroupReopenEvidence(
|
|
1871
|
+
document,
|
|
1872
|
+
archivedGroupReopenEvidence.get(brief.group) ?? /* @__PURE__ */ new Set(),
|
|
1873
|
+
archivedGroupBriefPaths.get(brief.group) ?? /* @__PURE__ */ new Set()
|
|
1874
|
+
)) {
|
|
1875
|
+
diagnostics.push({
|
|
1876
|
+
severity: "error",
|
|
1877
|
+
code: "group_identity_reopened",
|
|
1878
|
+
change: brief.group,
|
|
1879
|
+
path: brief.path,
|
|
1880
|
+
message: `archived Change Group cannot be reopened: ${brief.group}`
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
for (const diagnostic of archiveTree.diagnostics.filter((item) => {
|
|
1884
|
+
if (item.code === "invalid_archive_root")
|
|
1885
|
+
return true;
|
|
1886
|
+
const archivePath = normalizeLogicalPath(relative4(ARCHIVES_DIR, item.path));
|
|
1887
|
+
return archivePath === brief.group || archivePath.startsWith(`${brief.group}/`);
|
|
1888
|
+
})) {
|
|
1889
|
+
diagnostics.push({
|
|
1890
|
+
severity: "error",
|
|
1891
|
+
code: diagnostic.code,
|
|
1892
|
+
change: brief.group,
|
|
1893
|
+
path: diagnostic.path,
|
|
1894
|
+
message: diagnostic.message
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
for (const diagnostic of focusTree.diagnostics.filter((item) => item.input.startsWith(`${brief.group}/`))) {
|
|
1898
|
+
diagnostics.push({
|
|
1899
|
+
severity: "error",
|
|
1900
|
+
code: "group_focus_invalid",
|
|
1901
|
+
change: brief.group,
|
|
1902
|
+
path: diagnostic.path,
|
|
1903
|
+
message: `invalid focus for ${diagnostic.input}: ${diagnostic.message}`
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
const openChildren = new Set(workTree.changes.filter((ref) => ref.group === brief.group).map((ref) => ref.name));
|
|
1907
|
+
const archivedSlices = archivedSlicesByGroup.get(brief.group) ?? /* @__PURE__ */ new Set();
|
|
1908
|
+
const slices = parsed.slices.map((slice) => {
|
|
1909
|
+
const state = openChildren.has(slice.name) ? "open" : archivedSlices.has(slice.name) ? "archived" : "missing";
|
|
1910
|
+
return { ...slice, state };
|
|
1911
|
+
});
|
|
1912
|
+
const focusedNames = new Set(focusTree.markers.map((marker) => marker.name));
|
|
1913
|
+
for (const slice of slices.filter((slice2) => slice2.state !== "open" && focusedNames.has(slice2.name))) {
|
|
1914
|
+
diagnostics.push({
|
|
1915
|
+
severity: "error",
|
|
1916
|
+
code: "group_focus_invalid",
|
|
1917
|
+
change: brief.group,
|
|
1918
|
+
path: focusTree.markers.find((marker) => marker.name === slice.name)?.path,
|
|
1919
|
+
message: `invalid focus for ${slice.name}: focused group slice is not open`
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
const declared = new Set(slices.map((slice) => slice.name));
|
|
1923
|
+
for (const child of [...openChildren].filter((name) => !declared.has(name)).sort()) {
|
|
1924
|
+
diagnostics.push({
|
|
1925
|
+
severity: "error",
|
|
1926
|
+
code: "group_child_undeclared",
|
|
1927
|
+
change: brief.group,
|
|
1928
|
+
path: brief.path,
|
|
1929
|
+
message: `open child Change is not declared by the Group Brief: ${child}`
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
for (const child of [...archivedSlices].filter((name) => !declared.has(name)).sort()) {
|
|
1933
|
+
diagnostics.push({
|
|
1934
|
+
severity: "error",
|
|
1935
|
+
code: "group_archived_child_undeclared",
|
|
1936
|
+
change: brief.group,
|
|
1937
|
+
path: brief.path,
|
|
1938
|
+
message: `archived child Change is not declared by the Group Brief: ${child}`
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
for (const slice of slices.filter((slice2) => slice2.state === "missing")) {
|
|
1942
|
+
diagnostics.push({
|
|
1943
|
+
severity: "error",
|
|
1944
|
+
code: "group_slice_missing",
|
|
1945
|
+
change: brief.group,
|
|
1946
|
+
path: brief.path,
|
|
1947
|
+
message: `declared group slice is neither open nor archived: ${slice.name}`
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
const warnings = parsed.diagnostics.filter((diagnostic) => diagnostic.severity === "warning").map((diagnostic) => diagnostic.message);
|
|
1951
|
+
const readyToClose = diagnostics.every((diagnostic) => diagnostic.change !== brief.group || diagnostic.severity !== "error") && slices.length >= 2 && slices.every((slice) => slice.state === "archived") && parsed.completion.total > 0 && parsed.completion.done === parsed.completion.total && !parsed.blockers;
|
|
1952
|
+
groups.push({
|
|
1953
|
+
name: brief.group,
|
|
1954
|
+
summary: extractGroupSummary(content, document),
|
|
1955
|
+
path: normalizeLogicalPath(brief.path),
|
|
1956
|
+
slices,
|
|
1957
|
+
completion: parsed.completion,
|
|
1958
|
+
blockers: parsed.blockers,
|
|
1959
|
+
readyToClose,
|
|
1960
|
+
warnings
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
groups.sort((left, right) => left.name.localeCompare(right.name));
|
|
1964
|
+
return { groups, diagnostics };
|
|
1965
|
+
}
|
|
1966
|
+
function getGroupReopenEvidence(content) {
|
|
1967
|
+
const document = typeof content === "string" ? parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA) : content;
|
|
1968
|
+
const completion = getDocumentSectionMarkdown(document, "completionConditions");
|
|
1969
|
+
return markdownListItems(completion).flatMap(({ line }) => {
|
|
1970
|
+
const match = line.trim().match(/^-\s+\[([ /xX-])\]\s+(Resolve reopened concern from `(\.rsp\/archives\/[^`]+)`: (\S.*))$/);
|
|
1971
|
+
const reason = match?.[4];
|
|
1972
|
+
return match && reason && markdownText(parseMarkdown(reason).root).trim() ? [{ active: match[1] !== "-", key: match[2], sourcePath: match[3] }] : [];
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
function hasExplicitGroupReopenEvidence(content, retainedEvidence, retainedGroupBriefPaths) {
|
|
1976
|
+
return getGroupReopenEvidence(content).some((item) => item.active && retainedGroupBriefPaths.has(item.sourcePath) && !retainedEvidence.has(item.key));
|
|
1977
|
+
}
|
|
1978
|
+
async function requireDeclaredGroupSlice(ref) {
|
|
1979
|
+
const briefPath = join6(dirname4(ref.path), GROUP_BRIEF_FILENAME);
|
|
1980
|
+
let content;
|
|
1981
|
+
try {
|
|
1982
|
+
content = await readFile3(briefPath, "utf-8");
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1985
|
+
throw new WorkRefError("work_tree_read_failed", `unable to read Group Brief ${briefPath}: ${message}`, ref.name);
|
|
1986
|
+
}
|
|
1987
|
+
const parsed = parseGroupBrief(ref.group, parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA));
|
|
1988
|
+
const errors = parsed.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
1989
|
+
if (errors.length > 0) {
|
|
1990
|
+
throw new WorkRefError(
|
|
1991
|
+
"invalid_group_brief",
|
|
1992
|
+
`Change Group "${ref.group}" brief is invalid: ${errors.map((error) => error.message).join("; ")}`,
|
|
1993
|
+
ref.name
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
if (!parsed.slices.some((slice) => slice.name === ref.name)) {
|
|
1997
|
+
throw new WorkRefError(
|
|
1998
|
+
"group_child_undeclared",
|
|
1999
|
+
`grouped Change is not declared by ${ref.group}/brief: ${ref.name}`,
|
|
2000
|
+
ref.name
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
async function hasArchivedGroupBrief(group) {
|
|
2005
|
+
const archiveTree = await inspectArchiveTree();
|
|
2006
|
+
if (archiveTree.diagnostics.length > 0) {
|
|
2007
|
+
const diagnostic = archiveTree.diagnostics[0];
|
|
2008
|
+
throw new WorkRefError(diagnostic.code, diagnostic.message, diagnostic.input);
|
|
2009
|
+
}
|
|
2010
|
+
const groupPrefix = `${group}/`;
|
|
2011
|
+
for (const path of archiveTree.files) {
|
|
2012
|
+
if (!normalizeLogicalPath(relative4(ARCHIVES_DIR, path)).startsWith(groupPrefix))
|
|
2013
|
+
continue;
|
|
2014
|
+
let content;
|
|
2015
|
+
try {
|
|
2016
|
+
content = await readFile3(path, "utf-8");
|
|
2017
|
+
} catch (error) {
|
|
2018
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2019
|
+
throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
|
|
2020
|
+
}
|
|
2021
|
+
if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === group)
|
|
2022
|
+
return true;
|
|
2023
|
+
}
|
|
2024
|
+
return false;
|
|
2025
|
+
}
|
|
2026
|
+
function parseGroupBrief(group, document) {
|
|
2027
|
+
const diagnostics = [];
|
|
2028
|
+
let frontmatter2;
|
|
2029
|
+
try {
|
|
2030
|
+
frontmatter2 = parseFrontmatter(document.markdown);
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
diagnostics.push({ severity: "error", code: "group_frontmatter_invalid", change: group, message: error instanceof Error ? error.message : String(error) });
|
|
2033
|
+
}
|
|
2034
|
+
if (frontmatter2?.kind !== "group")
|
|
2035
|
+
diagnostics.push({ severity: "error", code: "group_kind_invalid", change: group, message: "Group Brief frontmatter must declare kind: group" });
|
|
2036
|
+
if (getDocumentTitle(document, "identity") !== group)
|
|
2037
|
+
diagnostics.push({ severity: "error", code: "group_heading_mismatch", change: group, message: `Group Brief heading must be: # Change Group: ${group}` });
|
|
2038
|
+
for (const definition of GROUP_BRIEF_DOCUMENT_SCHEMA.sections) {
|
|
2039
|
+
if (getVisibleBodyLines(getDocumentSectionMarkdown(document, definition.id)).length === 0)
|
|
2040
|
+
diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${definition.heading}` });
|
|
2041
|
+
}
|
|
2042
|
+
const slices = [];
|
|
2043
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2044
|
+
for (const { line } of markdownListItems(getDocumentSectionMarkdown(document, "slices"))) {
|
|
2045
|
+
const trimmed = line.trim();
|
|
2046
|
+
const identity = trimmed.match(/^-\s+`(\S+)`/);
|
|
2047
|
+
if (!identity || identity[1].includes("<"))
|
|
2048
|
+
continue;
|
|
2049
|
+
const suffix = trimmed.slice(identity[0].length).trim();
|
|
2050
|
+
if (![":", "\u2014", "-"].includes(suffix[0])) {
|
|
2051
|
+
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires an identity and boundary: ${trimmed}` });
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
const name = identity[1];
|
|
2055
|
+
const boundary = suffix.slice(1).trim();
|
|
2056
|
+
if (!boundary) {
|
|
2057
|
+
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires a boundary: ${name}` });
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
if (!isCanonicalExecutableWorkRef(name) || !name.startsWith(`${group}/`)) {
|
|
2061
|
+
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `invalid direct child identity in Group Brief: ${name}` });
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
if (seen.has(name)) {
|
|
2065
|
+
diagnostics.push({ severity: "error", code: "group_slice_duplicate", change: group, message: `duplicate group slice: ${name}` });
|
|
2066
|
+
continue;
|
|
2067
|
+
}
|
|
2068
|
+
seen.add(name);
|
|
2069
|
+
slices.push({ name, boundary: boundary.trim() });
|
|
2070
|
+
}
|
|
2071
|
+
if (slices.length < 2)
|
|
2072
|
+
diagnostics.push({ severity: "warning", code: "group_slices_incomplete", change: group, message: "Change Group must declare at least two direct child slices" });
|
|
2073
|
+
const completion = countCheckboxes(getDocumentSectionMarkdown(document, "completionConditions"));
|
|
2074
|
+
if (completion.total === 0)
|
|
2075
|
+
diagnostics.push({ severity: "warning", code: "group_completion_missing", change: group, message: "Change Group must declare at least one completion condition" });
|
|
2076
|
+
return {
|
|
2077
|
+
slices,
|
|
2078
|
+
completion: { done: completion.done + completion.dropped, total: completion.total },
|
|
2079
|
+
blockers: hasMeaningfulBlockerBody(getDocumentSectionMarkdown(document, "blockers")),
|
|
2080
|
+
diagnostics
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
function groupSectionHeading(sectionId) {
|
|
2084
|
+
return renderDocumentSectionHeading(GROUP_BRIEF_DOCUMENT_SCHEMA, sectionId);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// src/core/dependency-plan.ts
|
|
2088
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
2089
|
+
|
|
2090
|
+
// src/history/current-file.ts
|
|
2091
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
2092
|
+
import { constants } from "fs";
|
|
2093
|
+
import { lstat, open, realpath as realpath2 } from "fs/promises";
|
|
2094
|
+
import { isAbsolute as isAbsolute3, relative as relative5, resolve as resolve3, sep as sep3 } from "path";
|
|
2095
|
+
|
|
2096
|
+
// src/history/model.ts
|
|
2097
|
+
var HISTORY_DEFAULT_LIMIT = 20;
|
|
2098
|
+
var HISTORY_MAX_LIMIT = 100;
|
|
2099
|
+
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
2100
|
+
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
2101
|
+
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
2102
|
+
var HISTORY_MAX_CANDIDATES = 20;
|
|
2103
|
+
var HISTORY_MAX_FILE_BYTES = 512 * 1024;
|
|
2104
|
+
var ArchiveHistoryError = class extends Error {
|
|
2105
|
+
constructor(code, message, candidates = []) {
|
|
2106
|
+
super(message);
|
|
2107
|
+
this.code = code;
|
|
2108
|
+
this.name = "ArchiveHistoryError";
|
|
2109
|
+
const ordered = [...candidates].sort();
|
|
2110
|
+
this.candidateTotal = ordered.length;
|
|
2111
|
+
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
2112
|
+
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
2113
|
+
}
|
|
2114
|
+
code;
|
|
2115
|
+
candidates;
|
|
2116
|
+
candidateTotal;
|
|
2117
|
+
candidatesTruncated;
|
|
2118
|
+
};
|
|
2119
|
+
|
|
2120
|
+
// src/history/current-file.ts
|
|
2121
|
+
var READ_NO_FOLLOW_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
|
|
2122
|
+
async function readCurrentArchiveFile(options) {
|
|
2123
|
+
const sourcePath = resolve3(options.sourcePath);
|
|
2124
|
+
const archivesDir = resolve3(options.archivesDir);
|
|
2125
|
+
const relativePath = relative5(archivesDir, sourcePath);
|
|
2126
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep3}`) || isAbsolute3(relativePath)) {
|
|
2127
|
+
throw changed(options.projectPath);
|
|
2128
|
+
}
|
|
2129
|
+
let inspectedRoot;
|
|
2130
|
+
let inspected;
|
|
2131
|
+
let realArchivesDir;
|
|
2132
|
+
try {
|
|
2133
|
+
inspectedRoot = await lstat(archivesDir, { bigint: true });
|
|
2134
|
+
if (!inspectedRoot.isDirectory())
|
|
2135
|
+
throw changed(options.projectPath);
|
|
2136
|
+
inspected = await lstat(sourcePath, { bigint: true });
|
|
2137
|
+
realArchivesDir = await realpath2(archivesDir);
|
|
2138
|
+
const resolvedSource = await realpath2(sourcePath);
|
|
2139
|
+
const expectedSource = resolve3(realArchivesDir, relativePath);
|
|
2140
|
+
if (!inspected.isFile() || resolvedSource !== expectedSource || !isContained(realArchivesDir, resolvedSource)) {
|
|
2141
|
+
throw changed(options.projectPath);
|
|
2142
|
+
}
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
if (error instanceof ArchiveHistoryError)
|
|
2145
|
+
throw error;
|
|
2146
|
+
if (isPermissionError(error))
|
|
2147
|
+
throw readFailed(options.projectPath);
|
|
2148
|
+
throw changed(options.projectPath);
|
|
2149
|
+
}
|
|
2150
|
+
let handle;
|
|
2151
|
+
try {
|
|
2152
|
+
handle = await open(sourcePath, READ_NO_FOLLOW_FLAGS);
|
|
2153
|
+
} catch (error) {
|
|
2154
|
+
if (isPermissionError(error))
|
|
2155
|
+
throw readFailed(options.projectPath);
|
|
2156
|
+
throw changed(options.projectPath);
|
|
2157
|
+
}
|
|
2158
|
+
try {
|
|
2159
|
+
const opened = await handle.stat({ bigint: true });
|
|
2160
|
+
const currentRoot = await realpath2(archivesDir);
|
|
2161
|
+
const currentSource = await realpath2(sourcePath);
|
|
2162
|
+
if (!opened.isFile() || currentRoot !== realArchivesDir || currentSource !== resolve3(currentRoot, relativePath) || !isContained(currentRoot, currentSource) || !sameIdentity(inspected, opened) || !sameSnapshot(inspected, opened)) {
|
|
2163
|
+
throw changed(options.projectPath);
|
|
2164
|
+
}
|
|
2165
|
+
if (opened.size > BigInt(options.maxFileBytes))
|
|
2166
|
+
throw tooLarge(options.projectPath, options.maxFileBytes);
|
|
2167
|
+
if (options.expectedSnapshot !== void 0 && !sameFileSnapshot(options.expectedSnapshot, opened)) {
|
|
2168
|
+
throw changed(options.projectPath);
|
|
2169
|
+
}
|
|
2170
|
+
const content = await readBoundedArchive(handle, opened, options.projectPath, options.maxFileBytes);
|
|
2171
|
+
await validateCurrentArchivePath({
|
|
2172
|
+
sourcePath,
|
|
2173
|
+
archivesDir,
|
|
2174
|
+
relativePath,
|
|
2175
|
+
realArchivesDir,
|
|
2176
|
+
inspectedRoot,
|
|
2177
|
+
finalHandle: content.final,
|
|
2178
|
+
projectPath: options.projectPath
|
|
2179
|
+
});
|
|
2180
|
+
return {
|
|
2181
|
+
sourcePath,
|
|
2182
|
+
archivesDir,
|
|
2183
|
+
content: content.content,
|
|
2184
|
+
bytes: content.bytes,
|
|
2185
|
+
snapshot: {
|
|
2186
|
+
device: content.final.dev,
|
|
2187
|
+
inode: content.final.ino,
|
|
2188
|
+
size: content.final.size,
|
|
2189
|
+
mtimeNs: content.final.mtimeNs,
|
|
2190
|
+
ctimeNs: content.final.ctimeNs
|
|
2191
|
+
}
|
|
2192
|
+
};
|
|
2193
|
+
} catch (error) {
|
|
2194
|
+
if (error instanceof ArchiveHistoryError)
|
|
2195
|
+
throw error;
|
|
2196
|
+
throw changed(options.projectPath);
|
|
2197
|
+
} finally {
|
|
2198
|
+
await handle.close();
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
async function readBoundedArchive(handle, opened, projectPath, maxFileBytes) {
|
|
2202
|
+
const buffer = Buffer2.allocUnsafe(maxFileBytes + 1);
|
|
2203
|
+
let bytes = 0;
|
|
2204
|
+
while (bytes < buffer.length) {
|
|
2205
|
+
const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
|
|
2206
|
+
if (result.bytesRead === 0)
|
|
2207
|
+
break;
|
|
2208
|
+
bytes += result.bytesRead;
|
|
2209
|
+
}
|
|
2210
|
+
const final = await handle.stat({ bigint: true });
|
|
2211
|
+
if (bytes > maxFileBytes || final.size > BigInt(maxFileBytes))
|
|
2212
|
+
throw tooLarge(projectPath, maxFileBytes);
|
|
2213
|
+
if (!sameIdentity(opened, final) || !sameSnapshot(opened, final) || final.size !== BigInt(bytes)) {
|
|
2214
|
+
throw changed(projectPath);
|
|
2215
|
+
}
|
|
2216
|
+
return {
|
|
2217
|
+
content: buffer.subarray(0, bytes).toString("utf-8"),
|
|
2218
|
+
bytes,
|
|
2219
|
+
final
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
async function validateCurrentArchivePath(options) {
|
|
2223
|
+
const currentRoot = await lstat(options.archivesDir, { bigint: true });
|
|
2224
|
+
const currentSource = await lstat(options.sourcePath, { bigint: true });
|
|
2225
|
+
const realCurrentRoot = await realpath2(options.archivesDir);
|
|
2226
|
+
const realCurrentSource = await realpath2(options.sourcePath);
|
|
2227
|
+
if (!currentRoot.isDirectory() || !currentSource.isFile() || !sameIdentity(options.inspectedRoot, currentRoot) || realCurrentRoot !== options.realArchivesDir || realCurrentSource !== resolve3(realCurrentRoot, options.relativePath) || !isContained(realCurrentRoot, realCurrentSource) || !sameIdentity(currentSource, options.finalHandle) || !sameSnapshot(currentSource, options.finalHandle)) {
|
|
2228
|
+
throw changed(options.projectPath);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
function sameIdentity(left, right) {
|
|
2232
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
2233
|
+
}
|
|
2234
|
+
function sameSnapshot(left, right) {
|
|
2235
|
+
return left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
2236
|
+
}
|
|
2237
|
+
function sameFileSnapshot(left, right) {
|
|
2238
|
+
return left.device === right.dev && left.inode === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
2239
|
+
}
|
|
2240
|
+
function isContained(root, path) {
|
|
2241
|
+
const candidate = relative5(root, path);
|
|
2242
|
+
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${sep3}`) && !isAbsolute3(candidate);
|
|
2243
|
+
}
|
|
2244
|
+
function changed(projectPath) {
|
|
2245
|
+
return new ArchiveHistoryError(
|
|
2246
|
+
"archive_file_changed",
|
|
2247
|
+
`archived Change is no longer the inspected readable regular file: ${projectPath}`
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
function tooLarge(projectPath, maxFileBytes) {
|
|
2251
|
+
return new ArchiveHistoryError(
|
|
2252
|
+
"archive_file_too_large",
|
|
2253
|
+
`archived Change exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
function readFailed(projectPath) {
|
|
2257
|
+
return new ArchiveHistoryError(
|
|
2258
|
+
"archive_read_failed",
|
|
2259
|
+
`unable to read archived Change ${projectPath}`
|
|
2260
|
+
);
|
|
2261
|
+
}
|
|
2262
|
+
function isPermissionError(error) {
|
|
2263
|
+
const code = error.code;
|
|
2264
|
+
return code === "EACCES" || code === "EPERM";
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// src/history/inspect.ts
|
|
2268
|
+
import { basename as basename2, dirname as dirname5, relative as relative6, resolve as resolve4 } from "path";
|
|
2269
|
+
|
|
2270
|
+
// src/core/issue-relationship.ts
|
|
2271
|
+
var ISSUE_RELATIONS = /* @__PURE__ */ new Set(["relates", "closes"]);
|
|
2272
|
+
var ISSUE_FIELDS = /* @__PURE__ */ new Set(["url", "relation"]);
|
|
2273
|
+
var IssueRelationshipError = class extends Error {
|
|
2274
|
+
constructor(code, message) {
|
|
2275
|
+
super(message);
|
|
2276
|
+
this.code = code;
|
|
2277
|
+
this.name = "IssueRelationshipError";
|
|
2278
|
+
}
|
|
2279
|
+
code;
|
|
2280
|
+
};
|
|
2281
|
+
function parseIssueRelationships(frontmatter2) {
|
|
2282
|
+
if (!frontmatter2 || !("issues" in frontmatter2))
|
|
2283
|
+
return [];
|
|
2284
|
+
if (!Array.isArray(frontmatter2.issues))
|
|
2285
|
+
throw new IssueRelationshipError("invalid_issue_shape", 'frontmatter field "issues" must be a list');
|
|
2286
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2287
|
+
return frontmatter2.issues.map((value, index) => {
|
|
2288
|
+
if (!isPlainObject(value))
|
|
2289
|
+
throw new IssueRelationshipError("invalid_issue_shape", `issues[${index}] must be a mapping with url and relation`);
|
|
2290
|
+
const fields = Object.keys(value);
|
|
2291
|
+
const unsupported = fields.filter((field) => !ISSUE_FIELDS.has(field));
|
|
2292
|
+
if (unsupported.length > 0)
|
|
2293
|
+
throw new IssueRelationshipError("unsupported_issue_field", `issues[${index}] contains unsupported field(s): ${unsupported.sort().join(", ")}`);
|
|
2294
|
+
if (fields.length !== 2 || !("url" in value) || !("relation" in value))
|
|
2295
|
+
throw new IssueRelationshipError("invalid_issue_shape", `issues[${index}] must contain exactly url and relation`);
|
|
2296
|
+
if (typeof value.url !== "string")
|
|
2297
|
+
throw new IssueRelationshipError("invalid_issue_url", `issues[${index}].url must be an absolute http or https URL`);
|
|
2298
|
+
if (typeof value.relation !== "string" || !ISSUE_RELATIONS.has(value.relation))
|
|
2299
|
+
throw new IssueRelationshipError("unsupported_issue_relation", `issues[${index}].relation must be relates or closes`);
|
|
2300
|
+
const url = normalizeIssueUrl(value.url, `issues[${index}].url`);
|
|
2301
|
+
if (seen.has(url))
|
|
2302
|
+
throw new IssueRelationshipError("duplicate_issue_url", `issues contains duplicate normalized URL: ${url}`);
|
|
2303
|
+
seen.add(url);
|
|
2304
|
+
return { url, relation: value.relation };
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
function createIssueRelationship(url, relation = "relates") {
|
|
2308
|
+
return parseIssueRelationships({ issues: [{ url, relation }] })[0];
|
|
2309
|
+
}
|
|
2310
|
+
function normalizeIssueUrl(raw, label) {
|
|
2311
|
+
if (raw.trim() !== raw || raw === "")
|
|
2312
|
+
throw new IssueRelationshipError("invalid_issue_url", `${label} must be an absolute http or https URL without surrounding whitespace`);
|
|
2313
|
+
let parsed;
|
|
2314
|
+
try {
|
|
2315
|
+
parsed = new URL(raw);
|
|
2316
|
+
} catch {
|
|
2317
|
+
throw new IssueRelationshipError("invalid_issue_url", `${label} must be an absolute http or https URL`);
|
|
2318
|
+
}
|
|
2319
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
2320
|
+
throw new IssueRelationshipError("invalid_issue_url", `${label} must use http or https`);
|
|
2321
|
+
if (!parsed.hostname)
|
|
2322
|
+
throw new IssueRelationshipError("invalid_issue_url", `${label} must include a host`);
|
|
2323
|
+
if (parsed.username || parsed.password)
|
|
2324
|
+
throw new IssueRelationshipError("unsafe_issue_url", `${label} must not contain credentials`);
|
|
2325
|
+
if (parsed.hash)
|
|
2326
|
+
throw new IssueRelationshipError("unsafe_issue_url", `${label} must not contain a fragment`);
|
|
2327
|
+
return parsed.toString();
|
|
2328
|
+
}
|
|
2329
|
+
function isPlainObject(value) {
|
|
2330
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// src/history/inspect.ts
|
|
2334
|
+
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
2335
|
+
async function inspectArchiveHistory(options = {}) {
|
|
2336
|
+
const archivesDir = resolve4(options.archivesDir ?? ARCHIVES_DIR);
|
|
2337
|
+
const maxFileBytes = options.maxFileBytes ?? HISTORY_MAX_FILE_BYTES;
|
|
2338
|
+
const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
|
|
2339
|
+
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
2340
|
+
severity: "error",
|
|
2341
|
+
code: diagnostic.code,
|
|
2342
|
+
path: normalizeOutputPath(diagnostic.path, archivesDir),
|
|
2343
|
+
message: diagnostic.message
|
|
2344
|
+
}));
|
|
2345
|
+
const runtime = [];
|
|
2346
|
+
const records = [];
|
|
2347
|
+
const groupBriefs = [];
|
|
2348
|
+
if (!tree.rootExists && tree.diagnostics.length === 0) {
|
|
2349
|
+
diagnostics.push({
|
|
2350
|
+
severity: "error",
|
|
2351
|
+
code: "archive_root_missing",
|
|
2352
|
+
path: ".rsp/archives",
|
|
2353
|
+
message: "archive root does not exist; run rsp init or rsp update before querying history"
|
|
2354
|
+
});
|
|
2355
|
+
}
|
|
2356
|
+
for (const sourcePath of tree.files) {
|
|
2357
|
+
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
2358
|
+
let current;
|
|
2359
|
+
try {
|
|
2360
|
+
current = await readCurrentArchiveFile({
|
|
2361
|
+
sourcePath,
|
|
2362
|
+
archivesDir,
|
|
2363
|
+
projectPath: path,
|
|
2364
|
+
maxFileBytes
|
|
2365
|
+
});
|
|
2366
|
+
} catch (error) {
|
|
2367
|
+
const code = error instanceof ArchiveHistoryError ? error.code : "archive_read_failed";
|
|
2368
|
+
const message = error instanceof ArchiveHistoryError ? error.message : `unable to read archived work: ${toErrorMessage(error)}`;
|
|
2369
|
+
diagnostics.push({ severity: "error", code, path, message });
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
const parsed = parseArchiveEntry(current, path, maxFileBytes);
|
|
2373
|
+
if (parsed.diagnostic)
|
|
2374
|
+
diagnostics.push(parsed.diagnostic);
|
|
2375
|
+
if (parsed.record)
|
|
2376
|
+
records.push(parsed.record);
|
|
2377
|
+
if (parsed.groupBrief)
|
|
2378
|
+
groupBriefs.push(parsed.groupBrief);
|
|
2379
|
+
}
|
|
2380
|
+
records.sort(compareHistoryRecords);
|
|
2381
|
+
groupBriefs.sort(compareArchivedGroupBriefs);
|
|
2382
|
+
diagnostics.sort(compareDiagnostics);
|
|
2383
|
+
const diagnosticTotal = diagnostics.length;
|
|
2384
|
+
const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
|
|
2385
|
+
return {
|
|
2386
|
+
rootExists: tree.rootExists,
|
|
2387
|
+
records,
|
|
2388
|
+
groupBriefs,
|
|
2389
|
+
diagnostics: boundedDiagnostics,
|
|
2390
|
+
diagnosticSummary: {
|
|
2391
|
+
total: diagnosticTotal,
|
|
2392
|
+
returned: boundedDiagnostics.length,
|
|
2393
|
+
hasMore: diagnosticTotal > boundedDiagnostics.length
|
|
2394
|
+
},
|
|
2395
|
+
runtime
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
function parseArchiveEntry(current, path, maxFileBytes) {
|
|
2399
|
+
const { sourcePath, archivesDir, content } = current;
|
|
2400
|
+
const nameMatch = basename2(sourcePath).match(ARCHIVE_NAME_RE);
|
|
2401
|
+
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
2402
|
+
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
2403
|
+
}
|
|
2404
|
+
const date = nameMatch[1];
|
|
2405
|
+
const archiveStem = nameMatch[2];
|
|
2406
|
+
const relativeDirectory = normalizeLogicalPath(relative6(archivesDir, dirname5(sourcePath)));
|
|
2407
|
+
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
2408
|
+
if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
|
|
2409
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
2410
|
+
const changeDocument2 = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
2411
|
+
const groupDocument = parseRspDocument(changeDocument2.markdown, GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
2412
|
+
const changeHeadings = getDocumentTitles(changeDocument2, "identity");
|
|
2413
|
+
const groupHeadings = getDocumentTitles(groupDocument, "identity");
|
|
2414
|
+
if (changeHeadings.length + groupHeadings.length !== 1) {
|
|
2415
|
+
return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
|
|
2416
|
+
}
|
|
2417
|
+
let frontmatter2;
|
|
2418
|
+
try {
|
|
2419
|
+
frontmatter2 = parseFrontmatter(changeDocument2.markdown);
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
|
|
2422
|
+
}
|
|
2423
|
+
if (typeof frontmatter2?.kind !== "string" || frontmatter2.kind.trim() === "")
|
|
2424
|
+
return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
|
|
2425
|
+
if (groupHeadings.length === 1) {
|
|
2426
|
+
const group = groupHeadings[0];
|
|
2427
|
+
if (!archiveGroup || group !== archiveGroup || frontmatter2.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
|
|
2428
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
|
|
2429
|
+
}
|
|
2430
|
+
return { groupBrief: { date, group, path, sourcePath } };
|
|
2431
|
+
}
|
|
2432
|
+
let issues;
|
|
2433
|
+
if ("issues" in frontmatter2) {
|
|
2434
|
+
try {
|
|
2435
|
+
issues = parseIssueRelationships(frontmatter2);
|
|
2436
|
+
} catch {
|
|
2437
|
+
issues = void 0;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
const workRef = changeHeadings[0];
|
|
2441
|
+
const segments = workRef.split("/");
|
|
2442
|
+
if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
|
|
2443
|
+
return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
|
|
2444
|
+
}
|
|
2445
|
+
const expectedGroup = segments.length === 2 ? segments[0] : null;
|
|
2446
|
+
const expectedBase = segments.at(-1);
|
|
2447
|
+
if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
|
|
2448
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
|
|
2449
|
+
}
|
|
2450
|
+
const rawSummary = extractChangeSummary(content, changeDocument2, { preservePlaceholder: true });
|
|
2451
|
+
if (!rawSummary)
|
|
2452
|
+
return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
|
|
2453
|
+
const summary = boundText(rawSummary);
|
|
2454
|
+
return {
|
|
2455
|
+
record: {
|
|
2456
|
+
date,
|
|
2457
|
+
workRef,
|
|
2458
|
+
group: expectedGroup,
|
|
2459
|
+
kind: frontmatter2.kind.trim(),
|
|
2460
|
+
summary: summary.value,
|
|
2461
|
+
summaryTruncated: summary.truncated,
|
|
2462
|
+
path,
|
|
2463
|
+
...issues === void 0 ? {} : { issues },
|
|
2464
|
+
sourcePath,
|
|
2465
|
+
archivesDir,
|
|
2466
|
+
sourceSnapshot: current.snapshot,
|
|
2467
|
+
maxFileBytes,
|
|
2468
|
+
searchSummary: rawSummary
|
|
2469
|
+
}
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
function boundText(value) {
|
|
2473
|
+
const codePoints = [...value];
|
|
2474
|
+
return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
|
|
2475
|
+
}
|
|
2476
|
+
function matchesArchiveStem(stem, identityBase) {
|
|
2477
|
+
return stem === identityBase || new RegExp(`^${escapeRegExp(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
|
|
2478
|
+
}
|
|
2479
|
+
function isCalendarDate(value) {
|
|
2480
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
2481
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
2482
|
+
}
|
|
2483
|
+
function archiveOutputPath(sourcePath, archivesDir) {
|
|
2484
|
+
return `.rsp/archives/${normalizeLogicalPath(relative6(resolve4(archivesDir), resolve4(sourcePath)))}`;
|
|
2485
|
+
}
|
|
2486
|
+
function normalizeOutputPath(path, archivesDir) {
|
|
2487
|
+
const relativePath = normalizeLogicalPath(relative6(resolve4(archivesDir), resolve4(path)));
|
|
2488
|
+
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
2489
|
+
}
|
|
2490
|
+
function errorDiagnostic(code, path, message) {
|
|
2491
|
+
return { severity: "error", code, path, message };
|
|
2492
|
+
}
|
|
2493
|
+
function escapeRegExp(value) {
|
|
2494
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2495
|
+
}
|
|
2496
|
+
function compareHistoryRecords(left, right) {
|
|
2497
|
+
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
2498
|
+
}
|
|
2499
|
+
function compareArchivedGroupBriefs(left, right) {
|
|
2500
|
+
return right.date.localeCompare(left.date) || left.group.localeCompare(right.group) || left.path.localeCompare(right.path);
|
|
2501
|
+
}
|
|
2502
|
+
function compareDiagnostics(left, right) {
|
|
2503
|
+
return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// src/history/query.ts
|
|
2507
|
+
function queryArchiveHistory(records, query = {}) {
|
|
2508
|
+
validateArchiveHistoryQuery(query);
|
|
2509
|
+
const group = query.group === void 0 ? void 0 : normalizeWorkRefSegment(query.group);
|
|
2510
|
+
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
2511
|
+
const matched = records.filter((record) => (!query.since || record.date >= query.since) && (!query.until || record.date <= query.until) && (!query.kind || record.kind === query.kind) && (!group || record.group === group) && (!query.search || `${record.workRef}
|
|
2512
|
+
${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
|
|
2513
|
+
const selected = matched.slice(0, limit);
|
|
2514
|
+
return {
|
|
2515
|
+
records: selected.map(toOutputRecord),
|
|
2516
|
+
summary: {
|
|
2517
|
+
matched: matched.length,
|
|
2518
|
+
returned: selected.length,
|
|
2519
|
+
hasMore: matched.length > selected.length
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
function validateArchiveHistoryQuery(query) {
|
|
2524
|
+
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
2525
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
|
|
2526
|
+
throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
|
|
2527
|
+
if (query.since && !isCalendarDate2(query.since))
|
|
2528
|
+
throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
|
|
2529
|
+
if (query.until && !isCalendarDate2(query.until))
|
|
2530
|
+
throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
|
|
2531
|
+
if (query.since && query.until && query.since > query.until)
|
|
2532
|
+
throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
|
|
2533
|
+
if (query.kind !== void 0 && query.kind.trim() === "")
|
|
2534
|
+
throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
|
|
2535
|
+
if (query.group !== void 0) {
|
|
2536
|
+
try {
|
|
2537
|
+
normalizeWorkRefSegment(query.group);
|
|
2538
|
+
} catch {
|
|
2539
|
+
throw new ArchiveHistoryError("invalid_history_group", "history group must be one safe Unicode segment");
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
if (query.search !== void 0 && query.search.trim() === "")
|
|
2543
|
+
throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
|
|
2544
|
+
}
|
|
2545
|
+
function selectArchiveHistoryRecord(records, selector) {
|
|
2546
|
+
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
|
|
2547
|
+
const identity = "path" in selector ? selector.path : selector.workRef;
|
|
2548
|
+
if (matches.length === 0)
|
|
2549
|
+
throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
|
|
2550
|
+
if (matches.length > 1) {
|
|
2551
|
+
const candidates = matches.map((record) => record.path).sort();
|
|
2552
|
+
throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
|
|
2553
|
+
}
|
|
2554
|
+
return matches[0];
|
|
2555
|
+
}
|
|
2556
|
+
function selectArchivedGroupBrief(records, selector) {
|
|
2557
|
+
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.group === selector.group);
|
|
2558
|
+
const identity = "path" in selector ? selector.path : selector.group;
|
|
2559
|
+
if (matches.length === 0)
|
|
2560
|
+
throw new ArchiveHistoryError("archive_not_found", `archived Change Group not found: ${identity}`);
|
|
2561
|
+
if (matches.length > 1) {
|
|
2562
|
+
const candidates = matches.map((record) => record.path).sort();
|
|
2563
|
+
throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match Change Group ${identity}`, candidates);
|
|
2564
|
+
}
|
|
2565
|
+
return matches[0];
|
|
2566
|
+
}
|
|
2567
|
+
async function readArchiveHistoryDetail(record) {
|
|
2568
|
+
return (await readArchiveHistoryDocument(record)).detail;
|
|
2569
|
+
}
|
|
2570
|
+
async function readArchiveHistoryDocument(record) {
|
|
2571
|
+
let content;
|
|
2572
|
+
try {
|
|
2573
|
+
content = (await readCurrentArchiveFile({
|
|
2574
|
+
sourcePath: record.sourcePath,
|
|
2575
|
+
archivesDir: record.archivesDir,
|
|
2576
|
+
projectPath: record.path,
|
|
2577
|
+
maxFileBytes: record.maxFileBytes,
|
|
2578
|
+
expectedSnapshot: record.sourceSnapshot
|
|
2579
|
+
})).content;
|
|
2580
|
+
} catch (error) {
|
|
2581
|
+
if (error instanceof ArchiveHistoryError)
|
|
2582
|
+
throw error;
|
|
2583
|
+
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2584
|
+
}
|
|
2585
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
2586
|
+
const tasks = getDocumentSectionMarkdown(document, "tasks");
|
|
2587
|
+
const verify = getDocumentSectionMarkdown(document, "verify");
|
|
2588
|
+
const blockers = getDocumentSectionMarkdown(document, "blockers");
|
|
2589
|
+
const detail = {
|
|
2590
|
+
...toOutputRecord(record),
|
|
2591
|
+
scenarioCount: parseScenarios(document.markdown).length,
|
|
2592
|
+
checkboxes: {
|
|
2593
|
+
tasks: countCheckboxes(tasks),
|
|
2594
|
+
verify: countCheckboxes(verify)
|
|
2595
|
+
},
|
|
2596
|
+
evidence: {
|
|
2597
|
+
tasks: boundEvidence(getVisibleBodyLines(tasks)),
|
|
2598
|
+
verify: boundEvidence(getVisibleBodyLines(verify)),
|
|
2599
|
+
blockers: boundEvidence(readBlockerEntries(blockers).map((entry) => entry.line))
|
|
2600
|
+
}
|
|
2601
|
+
};
|
|
2602
|
+
return { content, detail };
|
|
2603
|
+
}
|
|
2604
|
+
function historyInspectionComplete(inspection) {
|
|
2605
|
+
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
2606
|
+
}
|
|
2607
|
+
function toOutputRecord(record) {
|
|
2608
|
+
const {
|
|
2609
|
+
searchSummary: _searchSummary,
|
|
2610
|
+
sourcePath: _sourcePath,
|
|
2611
|
+
archivesDir: _archivesDir,
|
|
2612
|
+
sourceSnapshot: _sourceSnapshot,
|
|
2613
|
+
maxFileBytes: _maxFileBytes,
|
|
2614
|
+
...output
|
|
2615
|
+
} = record;
|
|
2616
|
+
return output;
|
|
2617
|
+
}
|
|
2618
|
+
function boundEvidence(rawItems) {
|
|
2619
|
+
const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
|
|
2620
|
+
return {
|
|
2621
|
+
items: boundedItems.map((item) => item.value),
|
|
2622
|
+
truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
function isCalendarDate2(value) {
|
|
2626
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
2627
|
+
return false;
|
|
2628
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
2629
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
2630
|
+
}
|
|
2631
|
+
function compareHistoryRecords2(left, right) {
|
|
2632
|
+
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// src/core/dependency-plan.ts
|
|
2636
|
+
async function inspectChangeDependencies(options = {}) {
|
|
2637
|
+
const workTree = options.workTree ?? await inspectWorkTree();
|
|
2638
|
+
const archiveTree = options.archiveTree ?? await inspectArchiveTree();
|
|
2639
|
+
const archiveHistory = archiveTree.rootExists ? options.archiveHistory ?? await inspectArchiveHistory({ archiveTree }) : null;
|
|
2640
|
+
const openNames = new Set(workTree.changes.map((ref) => ref.name));
|
|
2641
|
+
const archivedNames = new Set(
|
|
2642
|
+
archiveHistory && historyInspectionComplete(archiveHistory) ? archiveHistory.records.map((record) => record.workRef) : []
|
|
2643
|
+
);
|
|
2644
|
+
const parsedByName = /* @__PURE__ */ new Map();
|
|
2645
|
+
const blockedGroups = /* @__PURE__ */ new Set();
|
|
2646
|
+
const diagnostics = archiveTree.diagnostics.map((diagnostic) => ({
|
|
2647
|
+
severity: "error",
|
|
2648
|
+
code: diagnostic.code,
|
|
2649
|
+
path: diagnostic.path,
|
|
2650
|
+
message: diagnostic.message
|
|
2651
|
+
}));
|
|
2652
|
+
if (archiveHistory)
|
|
2653
|
+
diagnostics.push(...archiveHistory.diagnostics);
|
|
2654
|
+
for (const brief of workTree.briefs) {
|
|
2655
|
+
try {
|
|
2656
|
+
if (hasMeaningfulBlockers(await readFile4(brief.path, "utf-8")))
|
|
2657
|
+
blockedGroups.add(brief.group);
|
|
2658
|
+
} catch (error) {
|
|
2659
|
+
diagnostics.push({
|
|
2660
|
+
severity: "error",
|
|
2661
|
+
code: "group_brief_read_failed",
|
|
2662
|
+
change: brief.group,
|
|
2663
|
+
path: brief.path,
|
|
2664
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
for (const ref of workTree.changes) {
|
|
2669
|
+
try {
|
|
2670
|
+
const parsed = parseDependencyBlockers(await readFile4(ref.path, "utf-8"));
|
|
2671
|
+
if (ref.group && blockedGroups.has(ref.group))
|
|
2672
|
+
parsed.external = true;
|
|
2673
|
+
parsedByName.set(ref.name, parsed);
|
|
2674
|
+
for (const line of parsed.malformed) {
|
|
2675
|
+
diagnostics.push({
|
|
2676
|
+
severity: "error",
|
|
2677
|
+
code: "dependency_syntax_invalid",
|
|
2678
|
+
change: ref.name,
|
|
2679
|
+
path: ref.path,
|
|
2680
|
+
message: `invalid dependency blocker syntax: ${line}`,
|
|
2681
|
+
hint: "Use: - requires `<change-work-ref>`: <reason>"
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
diagnostics.push({
|
|
2686
|
+
severity: "error",
|
|
2687
|
+
code: "change_read_failed",
|
|
2688
|
+
change: ref.name,
|
|
2689
|
+
path: ref.path,
|
|
2690
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
const edges = [];
|
|
2695
|
+
for (const [change, blockers] of parsedByName) {
|
|
2696
|
+
for (const requirement of blockers.requires) {
|
|
2697
|
+
const required = requirement.name;
|
|
2698
|
+
const edge = {
|
|
2699
|
+
change,
|
|
2700
|
+
requires: required,
|
|
2701
|
+
reason: requirement.reason,
|
|
2702
|
+
state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
|
|
2703
|
+
};
|
|
2704
|
+
edges.push(edge);
|
|
2705
|
+
if (!isCanonicalExecutableWorkRef(required)) {
|
|
2706
|
+
diagnostics.push({
|
|
2707
|
+
severity: "error",
|
|
2708
|
+
code: "dependency_target_invalid",
|
|
2709
|
+
change,
|
|
2710
|
+
message: `dependency target must be an executable Change WorkRef: ${required}`
|
|
2711
|
+
});
|
|
2712
|
+
} else if (required === change) {
|
|
2713
|
+
diagnostics.push({
|
|
2714
|
+
severity: "error",
|
|
2715
|
+
code: "dependency_self_reference",
|
|
2716
|
+
change,
|
|
2717
|
+
message: "Change cannot require itself"
|
|
2718
|
+
});
|
|
2719
|
+
} else if (edge.state === "missing") {
|
|
2720
|
+
diagnostics.push({
|
|
2721
|
+
severity: "error",
|
|
2722
|
+
code: "dependency_target_missing",
|
|
2723
|
+
change,
|
|
2724
|
+
message: `dependency target is neither open nor archived: ${required}`
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
edges.sort(compareEdges);
|
|
2730
|
+
diagnostics.push(...findCycleDiagnostics(edges));
|
|
2731
|
+
const activeBlockers = /* @__PURE__ */ new Map();
|
|
2732
|
+
const blocked = [...parsedByName].map(([change, blockers]) => {
|
|
2733
|
+
const requires = edges.filter((edge) => edge.change === change && edge.state !== "archived").map((edge) => edge.requires).sort();
|
|
2734
|
+
activeBlockers.set(change, blockers.external || requires.length > 0);
|
|
2735
|
+
return { change, requires, external: blockers.external };
|
|
2736
|
+
}).filter((blocker) => blocker.external || blocker.requires.length > 0).sort((left, right) => left.change.localeCompare(right.change));
|
|
2737
|
+
const waves = buildWaves([...openNames], edges, parsedByName, options.preferredOrder ?? []);
|
|
2738
|
+
const graphValid = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
2739
|
+
if (!graphValid) {
|
|
2740
|
+
for (const name of openNames)
|
|
2741
|
+
activeBlockers.set(name, true);
|
|
2742
|
+
}
|
|
2743
|
+
const ready = graphValid ? waves[0] ?? [] : [];
|
|
2744
|
+
const executable = new Set(graphValid ? waves.flat() : []);
|
|
2745
|
+
const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
|
|
2746
|
+
let state;
|
|
2747
|
+
if (!openNames.has(name) && archivedNames.has(name))
|
|
2748
|
+
state = "archived";
|
|
2749
|
+
else if (!openNames.has(name))
|
|
2750
|
+
state = "missing";
|
|
2751
|
+
else if (ready.includes(name))
|
|
2752
|
+
state = "ready";
|
|
2753
|
+
else if (executable.has(name))
|
|
2754
|
+
state = "waiting";
|
|
2755
|
+
else
|
|
2756
|
+
state = "blocked";
|
|
2757
|
+
return { name, selection: openNames.has(name) ? "selected" : "prerequisite", state };
|
|
2758
|
+
});
|
|
2759
|
+
return {
|
|
2760
|
+
plan: {
|
|
2761
|
+
nodes,
|
|
2762
|
+
ready,
|
|
2763
|
+
edges,
|
|
2764
|
+
blocked,
|
|
2765
|
+
waves: graphValid ? waves : []
|
|
2766
|
+
},
|
|
2767
|
+
activeBlockers,
|
|
2768
|
+
diagnostics
|
|
2769
|
+
};
|
|
2770
|
+
}
|
|
2771
|
+
function parseDependencyBlockers(content) {
|
|
2772
|
+
const requires = /* @__PURE__ */ new Map();
|
|
2773
|
+
let external = false;
|
|
2774
|
+
const malformed = [];
|
|
2775
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
2776
|
+
const blockers = getDocumentSectionMarkdown(document, "blockers");
|
|
2777
|
+
for (const entry of readBlockerEntries(blockers)) {
|
|
2778
|
+
if (entry.kind === "dependency") {
|
|
2779
|
+
if (!requires.has(entry.name))
|
|
2780
|
+
requires.set(entry.name, entry.reason);
|
|
2781
|
+
} else {
|
|
2782
|
+
external = true;
|
|
2783
|
+
if (entry.kind === "malformed-dependency")
|
|
2784
|
+
malformed.push(entry.line);
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
return {
|
|
2788
|
+
requires: [...requires].map(([name, reason]) => ({ name, reason })).sort((left, right) => left.name.localeCompare(right.name)),
|
|
2789
|
+
external,
|
|
2790
|
+
malformed
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
function buildWaves(names, edges, blockersByName, preferredOrder) {
|
|
2794
|
+
const orderIndex = new Map(preferredOrder.map((name, index) => [name, index]));
|
|
2795
|
+
const excluded = new Set(
|
|
2796
|
+
[...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
|
|
2797
|
+
);
|
|
2798
|
+
let changed2 = true;
|
|
2799
|
+
while (changed2) {
|
|
2800
|
+
changed2 = false;
|
|
2801
|
+
for (const edge of edges) {
|
|
2802
|
+
if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
|
|
2803
|
+
excluded.add(edge.change);
|
|
2804
|
+
changed2 = true;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
const remaining = new Set(names.filter((name) => !excluded.has(name)));
|
|
2809
|
+
const waves = [];
|
|
2810
|
+
while (remaining.size > 0) {
|
|
2811
|
+
const wave = [...remaining].filter((name) => !edges.some((edge) => edge.change === name && edge.state === "open" && remaining.has(edge.requires))).sort((left, right) => comparePlanNames(left, right, orderIndex));
|
|
2812
|
+
if (wave.length === 0)
|
|
2813
|
+
break;
|
|
2814
|
+
waves.push(wave);
|
|
2815
|
+
for (const name of wave)
|
|
2816
|
+
remaining.delete(name);
|
|
2817
|
+
}
|
|
2818
|
+
return waves;
|
|
2819
|
+
}
|
|
2820
|
+
function comparePlanNames(left, right, orderIndex) {
|
|
2821
|
+
const leftGroup = left.includes("/") ? left.split("/")[0] : null;
|
|
2822
|
+
const rightGroup = right.includes("/") ? right.split("/")[0] : null;
|
|
2823
|
+
if (leftGroup && leftGroup === rightGroup) {
|
|
2824
|
+
const leftIndex = orderIndex.get(left);
|
|
2825
|
+
const rightIndex = orderIndex.get(right);
|
|
2826
|
+
if (leftIndex !== void 0 && rightIndex !== void 0)
|
|
2827
|
+
return leftIndex - rightIndex;
|
|
2828
|
+
}
|
|
2829
|
+
return left.localeCompare(right);
|
|
2830
|
+
}
|
|
2831
|
+
function compareEdges(left, right) {
|
|
2832
|
+
return left.change.localeCompare(right.change) || left.requires.localeCompare(right.requires);
|
|
2833
|
+
}
|
|
2834
|
+
function findCycleDiagnostics(edges) {
|
|
2835
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
2836
|
+
for (const edge of edges) {
|
|
2837
|
+
if (edge.state !== "open" || edge.change === edge.requires)
|
|
2838
|
+
continue;
|
|
2839
|
+
const targets = adjacency.get(edge.change) ?? [];
|
|
2840
|
+
targets.push(edge.requires);
|
|
2841
|
+
adjacency.set(edge.change, targets.sort());
|
|
2842
|
+
}
|
|
2843
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2844
|
+
const active = /* @__PURE__ */ new Set();
|
|
2845
|
+
const stack = [];
|
|
2846
|
+
const reported = /* @__PURE__ */ new Set();
|
|
2847
|
+
const diagnostics = [];
|
|
2848
|
+
const visit = (name) => {
|
|
2849
|
+
if (active.has(name)) {
|
|
2850
|
+
const start = stack.indexOf(name);
|
|
2851
|
+
const cycle = [...stack.slice(start), name];
|
|
2852
|
+
const key = [...new Set(cycle)].sort().join("|");
|
|
2853
|
+
if (!reported.has(key)) {
|
|
2854
|
+
reported.add(key);
|
|
2855
|
+
const message = `dependency cycle detected: ${cycle.join(" -> ")}`;
|
|
2856
|
+
for (const participant of [...new Set(cycle)].sort()) {
|
|
2857
|
+
diagnostics.push({
|
|
2858
|
+
severity: "error",
|
|
2859
|
+
code: "dependency_cycle",
|
|
2860
|
+
change: participant,
|
|
2861
|
+
message
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (visited.has(name))
|
|
2868
|
+
return;
|
|
2869
|
+
visited.add(name);
|
|
2870
|
+
active.add(name);
|
|
2871
|
+
stack.push(name);
|
|
2872
|
+
for (const target of adjacency.get(name) ?? [])
|
|
2873
|
+
visit(target);
|
|
2874
|
+
stack.pop();
|
|
2875
|
+
active.delete(name);
|
|
2876
|
+
};
|
|
2877
|
+
for (const name of [...adjacency.keys()].sort())
|
|
2878
|
+
visit(name);
|
|
2879
|
+
return diagnostics;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// src/core/readiness.ts
|
|
2883
|
+
function collectArchiveReadiness(content, options = {}) {
|
|
2884
|
+
const warnings = [];
|
|
2885
|
+
const documentDiagnostics = options.documentDiagnostics ?? [];
|
|
2886
|
+
warnings.push(...documentDiagnostics.map((diagnostic) => diagnostic.message));
|
|
2887
|
+
const document = typeof content === "string" ? parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA) : content;
|
|
2888
|
+
const missingSummary = !extractChangeSummary(document.source, document, { preservePlaceholder: true });
|
|
2889
|
+
if (missingSummary)
|
|
2890
|
+
warnings.push("archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary");
|
|
2891
|
+
const tasksSection = getDocumentSectionMarkdown(document, "tasks");
|
|
2892
|
+
const verifySection = getDocumentSectionMarkdown(document, "verify");
|
|
2893
|
+
const taskTodos = getOpenCheckboxes(tasksSection);
|
|
2894
|
+
if (taskTodos.length > 0)
|
|
2895
|
+
warnings.push(`${taskTodos.length} task item(s) still incomplete`);
|
|
2896
|
+
const verifyItems = collectVerifyCheckboxes(verifySection);
|
|
2897
|
+
const verifyCriticality = summarizeVerifyCheckboxes(verifyItems);
|
|
2898
|
+
const requiredVerifyTodos = openCheckboxLines([...verifyItems.required, ...verifyItems.unclassified]);
|
|
2899
|
+
const optionalVerifyTodos = openCheckboxLines(verifyItems.optional);
|
|
2900
|
+
const verifyTodos = [...requiredVerifyTodos, ...optionalVerifyTodos];
|
|
2901
|
+
if (requiredVerifyTodos.length > 0)
|
|
2902
|
+
warnings.push(`${requiredVerifyTodos.length} required Verify item(s) are still incomplete`);
|
|
2903
|
+
if (optionalVerifyTodos.length > 0)
|
|
2904
|
+
warnings.push(`${optionalVerifyTodos.length} optional Verify item(s) are still incomplete`);
|
|
2905
|
+
const activeBlockers = options.activeBlockers ?? hasMeaningfulBlockers(document);
|
|
2906
|
+
if (activeBlockers)
|
|
2907
|
+
warnings.push("active blockers are present in the change file");
|
|
2908
|
+
const scenarios = parseScenarios(document.markdown);
|
|
2909
|
+
const missingScenarios = scenarios.length === 0;
|
|
2910
|
+
if (missingScenarios)
|
|
2911
|
+
warnings.push("no Scenario blocks found (some changes do not need them)");
|
|
2912
|
+
const archiveReady = documentDiagnostics.some((diagnostic) => diagnostic.severity === "error") || missingSummary || activeBlockers || taskTodos.length > 0 || requiredVerifyTodos.length > 0 ? "no" : "yes";
|
|
2913
|
+
return {
|
|
2914
|
+
taskTodos,
|
|
2915
|
+
verifyTodos,
|
|
2916
|
+
requiredVerifyTodos,
|
|
2917
|
+
optionalVerifyTodos,
|
|
2918
|
+
verifyCriticality,
|
|
2919
|
+
activeBlockers,
|
|
2920
|
+
scenarioCount: scenarios.length,
|
|
2921
|
+
missingScenarios,
|
|
2922
|
+
deterministic: warnings.length === 0 ? "pass" : "warnings",
|
|
2923
|
+
semantic: "needs-review",
|
|
2924
|
+
archiveReady,
|
|
2925
|
+
warnings,
|
|
2926
|
+
documentDiagnostics
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
function toArchiveReadinessOutput(readiness) {
|
|
2930
|
+
return {
|
|
2931
|
+
incompleteTasks: readiness.taskTodos.length,
|
|
2932
|
+
incompleteVerify: readiness.verifyTodos.length,
|
|
2933
|
+
incompleteRequiredVerify: readiness.requiredVerifyTodos.length,
|
|
2934
|
+
incompleteOptionalVerify: readiness.optionalVerifyTodos.length,
|
|
2935
|
+
requiredVerify: readiness.verifyCriticality.required,
|
|
2936
|
+
optionalVerify: readiness.verifyCriticality.optional,
|
|
2937
|
+
legacyVerify: readiness.verifyCriticality.legacy,
|
|
2938
|
+
completionGate: readiness.archiveReady === "no" ? "blocked" : "pass",
|
|
2939
|
+
coverageWarnings: readiness.optionalVerifyTodos.length,
|
|
2940
|
+
activeBlockers: readiness.activeBlockers,
|
|
2941
|
+
missingScenarios: readiness.missingScenarios,
|
|
2942
|
+
deterministic: readiness.deterministic,
|
|
2943
|
+
semantic: readiness.semantic,
|
|
2944
|
+
archiveReady: readiness.archiveReady
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
function buildDurableReviewGuidance(factCandidateTargets, decisionRecordsPath) {
|
|
2948
|
+
return {
|
|
2949
|
+
required: true,
|
|
2950
|
+
factDecisions: ["No current-fact update needed", "Update existing spec or scoped instruction", "Create a new durable spec"],
|
|
2951
|
+
rationaleDecisions: ["No Decision Record needed", "Create or update a Decision Record"],
|
|
2952
|
+
factCandidateTargets,
|
|
2953
|
+
decisionRecordsPath,
|
|
2954
|
+
note: "Semantic review decides current-fact and lasting-rationale updates independently. The CLI never infers scoped instruction or Decision Record filenames and never promotes Change content automatically."
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
function getDurableReviewCandidateTargets() {
|
|
2958
|
+
return [".rsp/specs/design.md"];
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// src/status/inspect.ts
|
|
2962
|
+
async function inspectProjectStatus(options = {}) {
|
|
2963
|
+
const runtime = [];
|
|
2964
|
+
const diagnostics = [];
|
|
2965
|
+
let workflow = resolveWorkflowPolicy({});
|
|
2966
|
+
let language = resolveLanguagePolicy({});
|
|
2967
|
+
try {
|
|
2968
|
+
const configInspection = await inspectRspConfig();
|
|
2969
|
+
const configValid = configInspection.issues.length === 0;
|
|
2970
|
+
workflow = resolveWorkflowPolicy(configInspection.config, { configValid });
|
|
2971
|
+
language = resolveLanguagePolicy(configInspection.config, { configValid });
|
|
2972
|
+
if (!configValid) {
|
|
2973
|
+
diagnostics.push({
|
|
2974
|
+
severity: "error",
|
|
2975
|
+
code: "invalid_config",
|
|
2976
|
+
path: CONFIG_PATH,
|
|
2977
|
+
message: configInspection.issues.join("; ")
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
} catch (error) {
|
|
2981
|
+
workflow = resolveWorkflowPolicy({}, { configValid: false });
|
|
2982
|
+
language = resolveLanguagePolicy({}, { configValid: false });
|
|
2983
|
+
diagnostics.push({
|
|
2984
|
+
severity: "error",
|
|
2985
|
+
code: "invalid_config",
|
|
2986
|
+
path: CONFIG_PATH,
|
|
2987
|
+
message: toErrorMessage(error)
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
2990
|
+
const focusTree = await inspectFocusTree();
|
|
2991
|
+
for (const diagnostic of focusTree.diagnostics) {
|
|
2992
|
+
diagnostics.push({
|
|
2993
|
+
severity: "error",
|
|
2994
|
+
code: diagnostic.code,
|
|
2995
|
+
change: diagnostic.input,
|
|
2996
|
+
path: diagnostic.path,
|
|
2997
|
+
message: diagnostic.message
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
const rawFocusedSet = new Set(focusTree.markers.map((marker) => marker.name));
|
|
3001
|
+
const focusedSet = /* @__PURE__ */ new Set();
|
|
3002
|
+
const focusedPaths = /* @__PURE__ */ new Map();
|
|
3003
|
+
for (const name of rawFocusedSet) {
|
|
3004
|
+
try {
|
|
3005
|
+
const ref = resolveWorkRef(name, { executable: true });
|
|
3006
|
+
focusedSet.add(ref.name);
|
|
3007
|
+
focusedPaths.set(ref.name, ref.path);
|
|
3008
|
+
} catch (error) {
|
|
3009
|
+
if (!(error instanceof WorkRefError))
|
|
3010
|
+
throw error;
|
|
3011
|
+
diagnostics.push({
|
|
3012
|
+
severity: "error",
|
|
3013
|
+
code: error.code,
|
|
3014
|
+
path: `.rsp/focus.d/${name}`,
|
|
3015
|
+
change: name,
|
|
3016
|
+
message: error.message
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
const workTree = await inspectWorkTree();
|
|
3021
|
+
const archiveTree = await inspectArchiveTree();
|
|
3022
|
+
const historyInspection = await inspectArchiveHistory({ archiveTree });
|
|
3023
|
+
diagnostics.push(...workTree.diagnostics.map((diagnostic) => ({
|
|
3024
|
+
severity: "error",
|
|
3025
|
+
code: diagnostic.code,
|
|
3026
|
+
change: diagnostic.input,
|
|
3027
|
+
path: diagnostic.path,
|
|
3028
|
+
message: diagnostic.message
|
|
3029
|
+
})));
|
|
3030
|
+
const groupInspection = await inspectChangeGroups({ workTree, focusTree, archiveTree });
|
|
3031
|
+
diagnostics.push(...groupInspection.diagnostics);
|
|
3032
|
+
const dependencyInspection = await inspectChangeDependencies({
|
|
3033
|
+
workTree,
|
|
3034
|
+
archiveTree,
|
|
3035
|
+
archiveHistory: historyInspection,
|
|
3036
|
+
preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
|
|
3037
|
+
});
|
|
3038
|
+
diagnostics.push(...dependencyInspection.diagnostics);
|
|
3039
|
+
const changeMap = new Map(workTree.changes.map((ref) => [ref.name, ref.path]));
|
|
3040
|
+
const allNames = [.../* @__PURE__ */ new Set([...changeMap.keys(), ...focusedSet])].sort();
|
|
3041
|
+
const records = [];
|
|
3042
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
3043
|
+
for (const name of allNames) {
|
|
3044
|
+
const path = changeMap.get(name);
|
|
3045
|
+
let kind = "\u2014";
|
|
3046
|
+
let summary = null;
|
|
3047
|
+
let title = name;
|
|
3048
|
+
let externalBlockers = [];
|
|
3049
|
+
let readiness = emptyReadiness(false);
|
|
3050
|
+
let ageDays = null;
|
|
3051
|
+
const isFocused = focusedSet.has(name);
|
|
3052
|
+
let isBlocked = false;
|
|
3053
|
+
let done = 0;
|
|
3054
|
+
let total = 0;
|
|
3055
|
+
let progressKnown = false;
|
|
3056
|
+
let issues = [];
|
|
3057
|
+
if (path) {
|
|
3058
|
+
try {
|
|
3059
|
+
const content = await readFile5(path, "utf-8");
|
|
3060
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
3061
|
+
title = getDocumentTitle(document, "spaced") ?? name;
|
|
3062
|
+
summary = extractChangeSummary(content, document);
|
|
3063
|
+
externalBlockers = readBlockerEntries(getDocumentSectionMarkdown(document, "blockers")).filter((entry) => entry.kind !== "dependency").map((entry) => entry.text);
|
|
3064
|
+
try {
|
|
3065
|
+
const frontmatter2 = parseFrontmatter(document.markdown);
|
|
3066
|
+
kind = frontmatter2?.kind ? String(frontmatter2.kind) : "\u2014";
|
|
3067
|
+
issues = parseIssueRelationships(frontmatter2);
|
|
3068
|
+
} catch (error) {
|
|
3069
|
+
kind = "(invalid)";
|
|
3070
|
+
const issueError = error instanceof IssueRelationshipError;
|
|
3071
|
+
if (issueError) {
|
|
3072
|
+
diagnostics.push({
|
|
3073
|
+
severity: "error",
|
|
3074
|
+
code: error.code,
|
|
3075
|
+
change: name,
|
|
3076
|
+
path,
|
|
3077
|
+
message: error.message
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
3080
|
+
runtime.push({
|
|
3081
|
+
code: issueError ? error.code : "frontmatter_parse_failed",
|
|
3082
|
+
operation: "parseFrontmatter",
|
|
3083
|
+
path,
|
|
3084
|
+
message: toErrorMessage(error)
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
isBlocked = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(document);
|
|
3088
|
+
const details = collectArchiveReadiness(document, { activeBlockers: isBlocked });
|
|
3089
|
+
const taskCheckboxes = countCheckboxes(getDocumentSectionMarkdown(document, "tasks"));
|
|
3090
|
+
done = taskCheckboxes.done + details.verifyCriticality.required.done;
|
|
3091
|
+
total = taskCheckboxes.total + details.verifyCriticality.required.total;
|
|
3092
|
+
progressKnown = true;
|
|
3093
|
+
readiness = toArchiveReadinessOutput(details);
|
|
3094
|
+
} catch (error) {
|
|
3095
|
+
diagnostics.push({
|
|
3096
|
+
severity: "error",
|
|
3097
|
+
code: "change_read_failed",
|
|
3098
|
+
change: name,
|
|
3099
|
+
path,
|
|
3100
|
+
message: "unable to read open Change",
|
|
3101
|
+
hint: toErrorMessage(error)
|
|
3102
|
+
});
|
|
3103
|
+
runtime.push({
|
|
3104
|
+
code: "change_read_failed",
|
|
3105
|
+
operation: "readFile",
|
|
3106
|
+
path,
|
|
3107
|
+
message: toErrorMessage(error)
|
|
3108
|
+
});
|
|
3109
|
+
}
|
|
3110
|
+
try {
|
|
3111
|
+
const metadata = await stat2(path);
|
|
3112
|
+
ageDays = Math.floor((nowMs - metadata.mtime.getTime()) / (1e3 * 60 * 60 * 24));
|
|
3113
|
+
} catch (error) {
|
|
3114
|
+
diagnostics.push({
|
|
3115
|
+
severity: "error",
|
|
3116
|
+
code: "change_stat_failed",
|
|
3117
|
+
change: name,
|
|
3118
|
+
path,
|
|
3119
|
+
message: "unable to inspect open Change metadata",
|
|
3120
|
+
hint: toErrorMessage(error)
|
|
3121
|
+
});
|
|
3122
|
+
runtime.push({
|
|
3123
|
+
code: "change_stat_failed",
|
|
3124
|
+
operation: "stat",
|
|
3125
|
+
path,
|
|
3126
|
+
message: toErrorMessage(error)
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
} else {
|
|
3130
|
+
kind = "(missing)";
|
|
3131
|
+
const path2 = focusedPaths.get(name);
|
|
3132
|
+
diagnostics.push({
|
|
3133
|
+
severity: "error",
|
|
3134
|
+
code: "focused_change_missing",
|
|
3135
|
+
change: name,
|
|
3136
|
+
path: path2,
|
|
3137
|
+
message: "focus marker points to a missing Change file"
|
|
3138
|
+
});
|
|
3139
|
+
runtime.push({
|
|
3140
|
+
code: "focused_change_missing",
|
|
3141
|
+
operation: "resolveChange",
|
|
3142
|
+
path: path2,
|
|
3143
|
+
message: "focus marker points to a missing change file"
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
records.push({
|
|
3147
|
+
output: {
|
|
3148
|
+
name,
|
|
3149
|
+
summary,
|
|
3150
|
+
kind,
|
|
3151
|
+
progress: { done, total },
|
|
3152
|
+
ageDays,
|
|
3153
|
+
isFocused,
|
|
3154
|
+
isBlocked,
|
|
3155
|
+
path: path ? normalizeLogicalPath(path) : null,
|
|
3156
|
+
issues
|
|
3157
|
+
},
|
|
3158
|
+
progressKnown,
|
|
3159
|
+
title,
|
|
3160
|
+
externalBlockers,
|
|
3161
|
+
readiness
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
return {
|
|
3165
|
+
workflow,
|
|
3166
|
+
language,
|
|
3167
|
+
focused: [...focusedSet].sort(),
|
|
3168
|
+
records,
|
|
3169
|
+
groups: groupInspection.groups,
|
|
3170
|
+
plan: dependencyInspection.plan,
|
|
3171
|
+
archiveTrend: historyInspectionComplete(historyInspection) ? deriveArchiveTrend(historyInspection.records) : [],
|
|
3172
|
+
diagnostics: [...diagnostics, ...historyInspection.rootExists ? historyInspection.diagnostics : []],
|
|
3173
|
+
runtime: [...runtime, ...historyInspection.runtime]
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
function emptyReadiness(activeBlockers) {
|
|
3177
|
+
return {
|
|
3178
|
+
incompleteTasks: 0,
|
|
3179
|
+
incompleteVerify: 0,
|
|
3180
|
+
incompleteRequiredVerify: 0,
|
|
3181
|
+
incompleteOptionalVerify: 0,
|
|
3182
|
+
requiredVerify: { todo: 0, progress: 0, done: 0, dropped: 0, total: 0 },
|
|
3183
|
+
optionalVerify: { todo: 0, progress: 0, done: 0, dropped: 0, total: 0 },
|
|
3184
|
+
legacyVerify: false,
|
|
3185
|
+
completionGate: activeBlockers ? "blocked" : "pass",
|
|
3186
|
+
coverageWarnings: 0,
|
|
3187
|
+
activeBlockers,
|
|
3188
|
+
missingScenarios: false,
|
|
3189
|
+
deterministic: "pass",
|
|
3190
|
+
semantic: "needs-review",
|
|
3191
|
+
archiveReady: activeBlockers ? "no" : "judgment"
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
function deriveArchiveTrend(records) {
|
|
3195
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3196
|
+
for (const record of records) {
|
|
3197
|
+
const month = record.date.slice(0, 7);
|
|
3198
|
+
counts.set(month, (counts.get(month) ?? 0) + 1);
|
|
3199
|
+
}
|
|
3200
|
+
return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([month, count]) => ({ month, count }));
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// src/specs/model.ts
|
|
3204
|
+
var SPECS_DEFAULT_RESULT_LIMIT = 20;
|
|
3205
|
+
var SPECS_MAX_RESULT_LIMIT = 100;
|
|
3206
|
+
var SPECS_MAX_CANDIDATES = 1e3;
|
|
3207
|
+
var SPECS_MAX_FILE_BYTES = 512 * 1024;
|
|
3208
|
+
var SPECS_SEARCH_EXCERPT_CODE_POINTS = 240;
|
|
3209
|
+
var SPECS_DETAIL_CONTENT_CODE_POINTS = 12e3;
|
|
3210
|
+
var SPECS_MAX_DIAGNOSTICS = 20;
|
|
3211
|
+
|
|
3212
|
+
// src/specs/query.ts
|
|
3213
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
3214
|
+
import { spawnSync } from "child_process";
|
|
3215
|
+
import { constants as constants2 } from "fs";
|
|
3216
|
+
import { lstat as lstat2, open as open2, realpath as realpath3 } from "fs/promises";
|
|
3217
|
+
import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute4, join as join7, relative as relative7, resolve as resolve5, sep as sep4 } from "path";
|
|
3218
|
+
var SPECS_ROOT = join7(RSP_DIR, "specs");
|
|
3219
|
+
var GENERATED_INDEX_FILENAME = "00-index.md";
|
|
3220
|
+
var LEGACY_GENERATED_INDEX_FILENAME = "INDEX.md";
|
|
3221
|
+
var READ_NO_FOLLOW_FLAGS2 = constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0);
|
|
3222
|
+
var SpecsQueryError = class extends Error {
|
|
3223
|
+
constructor(code, message) {
|
|
3224
|
+
super(message);
|
|
3225
|
+
this.code = code;
|
|
3226
|
+
this.name = "SpecsQueryError";
|
|
3227
|
+
}
|
|
3228
|
+
code;
|
|
3229
|
+
};
|
|
3230
|
+
async function inspectSpecs(options = {}) {
|
|
3231
|
+
const projectRoot = await realpath3(resolve5(options.cwd ?? process.cwd()));
|
|
3232
|
+
const specsRoot = resolve5(projectRoot, SPECS_ROOT);
|
|
3233
|
+
const maxCandidates = options.maxCandidates ?? SPECS_MAX_CANDIDATES;
|
|
3234
|
+
const maxFileBytes = options.maxFileBytes ?? SPECS_MAX_FILE_BYTES;
|
|
3235
|
+
validatePositiveBound(maxCandidates, "maxCandidates");
|
|
3236
|
+
validatePositiveBound(maxFileBytes, "maxFileBytes");
|
|
3237
|
+
const configInspection = await inspectRspConfig(projectRoot);
|
|
3238
|
+
if (configInspection.issues.length > 0)
|
|
3239
|
+
throw new SpecsQueryError("invalid_config", configInspection.issues.join("; "));
|
|
3240
|
+
const decisionRecordsPath = resolveDecisionRecordsPath(configInspection.config);
|
|
3241
|
+
const decisionPathIssue = await validateDecisionRecordsFilesystemPath(decisionRecordsPath, projectRoot);
|
|
3242
|
+
if (decisionPathIssue)
|
|
3243
|
+
throw new SpecsQueryError("invalid_decision_records_path", decisionPathIssue);
|
|
3244
|
+
const decisionRecordsRoot = resolve5(projectRoot, decisionRecordsPath);
|
|
3245
|
+
const accumulator = {
|
|
3246
|
+
documents: [],
|
|
3247
|
+
generatedIndexes: [],
|
|
3248
|
+
diagnostics: [],
|
|
3249
|
+
candidateCount: 0,
|
|
3250
|
+
candidateLimitReported: false
|
|
3251
|
+
};
|
|
3252
|
+
const excludedSpecDirectories = [DEFAULT_DECISION_RECORDS_PATH, decisionRecordsPath].map((path) => resolve5(projectRoot, path)).filter((path) => isInside(specsRoot, path));
|
|
3253
|
+
await inspectDocumentRoot({
|
|
3254
|
+
projectRoot,
|
|
3255
|
+
root: specsRoot,
|
|
3256
|
+
label: "Specs",
|
|
3257
|
+
kind: "spec",
|
|
3258
|
+
excludedDirectories: excludedSpecDirectories,
|
|
3259
|
+
accumulator,
|
|
3260
|
+
maxCandidates,
|
|
3261
|
+
maxFileBytes,
|
|
3262
|
+
allowMissing: false
|
|
3263
|
+
});
|
|
3264
|
+
await inspectDocumentRoot({
|
|
3265
|
+
projectRoot,
|
|
3266
|
+
root: decisionRecordsRoot,
|
|
3267
|
+
label: "Decision Records",
|
|
3268
|
+
kind: "decision-record",
|
|
3269
|
+
excludedDirectories: [],
|
|
3270
|
+
accumulator,
|
|
3271
|
+
maxCandidates,
|
|
3272
|
+
maxFileBytes,
|
|
3273
|
+
allowMissing: options.allowMissingDecisionRecords === true
|
|
3274
|
+
});
|
|
3275
|
+
detectPathCollisions(accumulator);
|
|
3276
|
+
accumulator.documents.sort(compareDocument);
|
|
3277
|
+
accumulator.generatedIndexes.sort((left, right) => left.path.localeCompare(right.path));
|
|
3278
|
+
const boundedDiagnostics = accumulator.diagnostics.slice(0, SPECS_MAX_DIAGNOSTICS);
|
|
3279
|
+
return {
|
|
3280
|
+
source: await inspectSourceIdentity(projectRoot),
|
|
3281
|
+
roots: {
|
|
3282
|
+
specs: toProjectPath(projectRoot, specsRoot),
|
|
3283
|
+
decisions: toProjectPath(projectRoot, decisionRecordsRoot)
|
|
3284
|
+
},
|
|
3285
|
+
tree: buildDirectoryTree(projectRoot, specsRoot, "spec", accumulator.documents),
|
|
3286
|
+
decisionRecords: buildDirectoryTree(projectRoot, decisionRecordsRoot, "decision-record", accumulator.documents),
|
|
3287
|
+
documents: accumulator.documents.map(stripSource),
|
|
3288
|
+
generatedIndexes: accumulator.generatedIndexes,
|
|
3289
|
+
diagnostics: boundedDiagnostics,
|
|
3290
|
+
diagnosticSummary: {
|
|
3291
|
+
total: accumulator.diagnostics.length,
|
|
3292
|
+
returned: boundedDiagnostics.length,
|
|
3293
|
+
hasMore: accumulator.diagnostics.length > boundedDiagnostics.length
|
|
3294
|
+
},
|
|
3295
|
+
runtime: [],
|
|
3296
|
+
limits: {
|
|
3297
|
+
candidates: maxCandidates,
|
|
3298
|
+
fileBytes: maxFileBytes,
|
|
3299
|
+
results: SPECS_MAX_RESULT_LIMIT,
|
|
3300
|
+
searchExcerptCodePoints: SPECS_SEARCH_EXCERPT_CODE_POINTS,
|
|
3301
|
+
detailContentCodePoints: SPECS_DETAIL_CONTENT_CODE_POINTS
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
}
|
|
3305
|
+
function specsInspectionComplete(inspection) {
|
|
3306
|
+
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
3307
|
+
}
|
|
3308
|
+
async function readSpecsDetail(inspection, projectPath) {
|
|
3309
|
+
const current = await readCurrentSpecsDocument(inspection, projectPath);
|
|
3310
|
+
const bounded = boundCodePoints(current.content, SPECS_DETAIL_CONTENT_CODE_POINTS);
|
|
3311
|
+
return {
|
|
3312
|
+
...current,
|
|
3313
|
+
content: bounded.value,
|
|
3314
|
+
contentTruncated: bounded.truncated
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
async function readCurrentSpecsDocument(inspection, projectPath) {
|
|
3318
|
+
const normalized = normalizeRequestedPath(projectPath);
|
|
3319
|
+
const document = inspection.documents.find((candidate) => candidate.path === normalized);
|
|
3320
|
+
if (!document)
|
|
3321
|
+
throw new SpecsQueryError("specs_document_not_found", `Specs document not found: ${projectPath}`);
|
|
3322
|
+
const sourcePath = resolve5(inspection.source.root, normalized);
|
|
3323
|
+
const current = await readCurrentDocument(
|
|
3324
|
+
sourcePath,
|
|
3325
|
+
inspection.source.root,
|
|
3326
|
+
document.path,
|
|
3327
|
+
inspection.limits.fileBytes
|
|
3328
|
+
);
|
|
3329
|
+
const currentDocument = parseDocumentRecord(parseMarkdown(current.content), current.bytes, document.path, document.kind);
|
|
3330
|
+
return {
|
|
3331
|
+
...currentDocument,
|
|
3332
|
+
content: current.content
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
async function searchSpecs(inspection, literal, options = {}) {
|
|
3336
|
+
return (await searchSpecsWithSources(inspection, literal, options)).result;
|
|
3337
|
+
}
|
|
3338
|
+
async function searchSpecsWithSources(inspection, literal, options = {}) {
|
|
3339
|
+
const query = literal.trim();
|
|
3340
|
+
if (query === "")
|
|
3341
|
+
throw new SpecsQueryError("invalid_specs_search", "Specs search literal must be non-empty");
|
|
3342
|
+
const limit = options.limit ?? 20;
|
|
3343
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > SPECS_MAX_RESULT_LIMIT)
|
|
3344
|
+
throw new SpecsQueryError("invalid_specs_limit", `Specs search limit must be an integer from 1 through ${SPECS_MAX_RESULT_LIMIT}`);
|
|
3345
|
+
const excerptCodePoints = options.excerptCodePoints ?? SPECS_SEARCH_EXCERPT_CODE_POINTS;
|
|
3346
|
+
if (!Number.isInteger(excerptCodePoints) || excerptCodePoints < 40 || excerptCodePoints > 1e3)
|
|
3347
|
+
throw new SpecsQueryError("invalid_specs_excerpt", "Specs search excerpt must be an integer from 40 through 1000 code points");
|
|
3348
|
+
const lowered = query.toLowerCase();
|
|
3349
|
+
const matches = [];
|
|
3350
|
+
const documents = /* @__PURE__ */ new Map();
|
|
3351
|
+
let matched = 0;
|
|
3352
|
+
for (const document of inspection.documents) {
|
|
3353
|
+
const current = await readCurrentDocument(
|
|
3354
|
+
resolve5(inspection.source.root, document.path),
|
|
3355
|
+
inspection.source.root,
|
|
3356
|
+
document.path,
|
|
3357
|
+
inspection.limits.fileBytes
|
|
3358
|
+
);
|
|
3359
|
+
const currentDocument = parseDocumentRecord(parseMarkdown(current.content), current.bytes, document.path, document.kind);
|
|
3360
|
+
const lines = current.content.split(/\r?\n/);
|
|
3361
|
+
const headingsByLine = new Map(currentDocument.headings.map((item) => [item.line, item.title]));
|
|
3362
|
+
let heading = null;
|
|
3363
|
+
for (let index = 0; index < lines.length; index++) {
|
|
3364
|
+
heading = headingsByLine.get(index + 1) ?? heading;
|
|
3365
|
+
const position = lines[index].toLowerCase().indexOf(lowered);
|
|
3366
|
+
if (position < 0)
|
|
3367
|
+
continue;
|
|
3368
|
+
matched += 1;
|
|
3369
|
+
if (matches.length < limit) {
|
|
3370
|
+
if (!documents.has(document.path)) {
|
|
3371
|
+
documents.set(document.path, {
|
|
3372
|
+
...currentDocument,
|
|
3373
|
+
content: current.content
|
|
3374
|
+
});
|
|
3375
|
+
}
|
|
3376
|
+
matches.push({
|
|
3377
|
+
path: document.path,
|
|
3378
|
+
kind: document.kind,
|
|
3379
|
+
title: currentDocument.title,
|
|
3380
|
+
heading,
|
|
3381
|
+
line: index + 1,
|
|
3382
|
+
excerpt: excerptAround(
|
|
3383
|
+
lines[index],
|
|
3384
|
+
[...lines[index].slice(0, position)].length,
|
|
3385
|
+
[...query].length,
|
|
3386
|
+
excerptCodePoints
|
|
3387
|
+
)
|
|
3388
|
+
});
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
return {
|
|
3393
|
+
result: {
|
|
3394
|
+
matches,
|
|
3395
|
+
summary: {
|
|
3396
|
+
candidates: inspection.documents.length,
|
|
3397
|
+
searched: inspection.documents.length,
|
|
3398
|
+
matched,
|
|
3399
|
+
returned: matches.length,
|
|
3400
|
+
hasMore: matched > matches.length
|
|
3401
|
+
}
|
|
3402
|
+
},
|
|
3403
|
+
documents
|
|
3404
|
+
};
|
|
3405
|
+
}
|
|
3406
|
+
async function inspectDocumentRoot(options) {
|
|
3407
|
+
const inspection = await inspectManagedFileTree(options.root, options.label, { allowMissing: options.allowMissing });
|
|
3408
|
+
for (const issue of inspection.issues) {
|
|
3409
|
+
options.accumulator.diagnostics.push({
|
|
3410
|
+
severity: "error",
|
|
3411
|
+
code: "specs_tree_invalid",
|
|
3412
|
+
path: toProjectPath(options.projectRoot, issue.path),
|
|
3413
|
+
message: issue.message
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
if (!inspection.rootExists)
|
|
3417
|
+
return;
|
|
3418
|
+
for (const path of inspection.files) {
|
|
3419
|
+
if (options.excludedDirectories.some((directory) => isInside(directory, path)))
|
|
3420
|
+
continue;
|
|
3421
|
+
const projectPath = toProjectPath(options.projectRoot, path);
|
|
3422
|
+
const reserved = reservedIndexKind(options.root, path);
|
|
3423
|
+
if (reserved) {
|
|
3424
|
+
await classifyGeneratedIndex(path, options.projectRoot, projectPath, options.maxFileBytes, options.accumulator);
|
|
3425
|
+
continue;
|
|
3426
|
+
}
|
|
3427
|
+
if (!path.endsWith(".md"))
|
|
3428
|
+
continue;
|
|
3429
|
+
options.accumulator.candidateCount += 1;
|
|
3430
|
+
if (options.accumulator.candidateCount > options.maxCandidates) {
|
|
3431
|
+
if (!options.accumulator.candidateLimitReported) {
|
|
3432
|
+
options.accumulator.diagnostics.push({
|
|
3433
|
+
severity: "error",
|
|
3434
|
+
code: "specs_candidate_limit_exceeded",
|
|
3435
|
+
path: projectPath,
|
|
3436
|
+
message: `Specs candidate limit exceeded (${options.maxCandidates})`
|
|
3437
|
+
});
|
|
3438
|
+
options.accumulator.candidateLimitReported = true;
|
|
3439
|
+
}
|
|
3440
|
+
continue;
|
|
3441
|
+
}
|
|
3442
|
+
await inspectDocument(path, options.projectRoot, projectPath, options.kind, options.maxFileBytes, options.accumulator);
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
async function classifyGeneratedIndex(path, projectRoot, projectPath, maxFileBytes, accumulator) {
|
|
3446
|
+
try {
|
|
3447
|
+
const current = await readCurrentDocument(path, projectRoot, projectPath, maxFileBytes);
|
|
3448
|
+
const recognized = isRecognizedGeneratedSpecsIndex(current.content, projectPath);
|
|
3449
|
+
accumulator.generatedIndexes.push({
|
|
3450
|
+
path: projectPath,
|
|
3451
|
+
classification: recognized ? "safe-removal" : "owner-controlled",
|
|
3452
|
+
reason: recognized ? "metadata recognizes the reserved path as a generated Specs index" : "reserved path content is not recognized generated Specs-index output"
|
|
3453
|
+
});
|
|
3454
|
+
if (!recognized) {
|
|
3455
|
+
accumulator.diagnostics.push({
|
|
3456
|
+
severity: "error",
|
|
3457
|
+
code: "unrecognized_reserved_specs_index",
|
|
3458
|
+
path: projectPath,
|
|
3459
|
+
message: `reserved Specs index is owner-controlled and cannot be treated as generated content: ${projectPath}`
|
|
3460
|
+
});
|
|
3461
|
+
}
|
|
3462
|
+
} catch (error) {
|
|
3463
|
+
accumulator.generatedIndexes.push({
|
|
3464
|
+
path: projectPath,
|
|
3465
|
+
classification: "owner-controlled",
|
|
3466
|
+
reason: "reserved path could not be boundedly inspected as generated Specs-index output"
|
|
3467
|
+
});
|
|
3468
|
+
accumulator.diagnostics.push({
|
|
3469
|
+
severity: "error",
|
|
3470
|
+
code: error instanceof SpecsQueryError ? error.code : "specs_index_read_failed",
|
|
3471
|
+
path: projectPath,
|
|
3472
|
+
message: `unable to inspect reserved Specs index ${projectPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
3473
|
+
});
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
function isRecognizedGeneratedSpecsIndex(content, projectPath) {
|
|
3477
|
+
const metadata = parseFrontmatter(content);
|
|
3478
|
+
const expectedSourceDirectory = normalizeLogicalPath(dirname6(projectPath));
|
|
3479
|
+
return metadata?.kind === "generated-index" && metadata.index_type === "specs" && metadata.source_dir === expectedSourceDirectory;
|
|
3480
|
+
}
|
|
3481
|
+
async function inspectDocument(path, projectRoot, projectPath, kind, maxFileBytes, accumulator) {
|
|
3482
|
+
try {
|
|
3483
|
+
const current = await readCurrentDocument(path, projectRoot, projectPath, maxFileBytes);
|
|
3484
|
+
const document = parseMarkdown(current.content);
|
|
3485
|
+
const metadata = parseFrontmatter(document);
|
|
3486
|
+
if (metadata?.kind === "generated-index")
|
|
3487
|
+
return;
|
|
3488
|
+
accumulator.documents.push({
|
|
3489
|
+
sourcePath: path,
|
|
3490
|
+
...parseDocumentRecord(document, current.bytes, projectPath, kind, metadata)
|
|
3491
|
+
});
|
|
3492
|
+
} catch (error) {
|
|
3493
|
+
accumulator.diagnostics.push({
|
|
3494
|
+
severity: "error",
|
|
3495
|
+
code: error instanceof SpecsQueryError ? error.code : "specs_file_read_failed",
|
|
3496
|
+
path: projectPath,
|
|
3497
|
+
message: `unable to read Specs document ${projectPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
3498
|
+
});
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
function detectPathCollisions(accumulator) {
|
|
3502
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3503
|
+
for (const document of accumulator.documents) {
|
|
3504
|
+
const canonical = document.path.normalize("NFC");
|
|
3505
|
+
const existing = seen.get(canonical);
|
|
3506
|
+
if (existing && existing !== document.path) {
|
|
3507
|
+
accumulator.diagnostics.push({
|
|
3508
|
+
severity: "error",
|
|
3509
|
+
code: "specs_path_collision",
|
|
3510
|
+
path: document.path,
|
|
3511
|
+
message: `Specs path normalization collision: ${existing} and ${document.path}`
|
|
3512
|
+
});
|
|
3513
|
+
} else {
|
|
3514
|
+
seen.set(canonical, document.path);
|
|
3515
|
+
}
|
|
3516
|
+
if (canonical !== document.path) {
|
|
3517
|
+
accumulator.diagnostics.push({
|
|
3518
|
+
severity: "error",
|
|
3519
|
+
code: "specs_path_not_canonical",
|
|
3520
|
+
path: document.path,
|
|
3521
|
+
message: `Specs path must use canonical NFC form: ${document.path}`
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
function buildDirectoryTree(projectRoot, root, kind, documents) {
|
|
3527
|
+
const rootPath = toProjectPath(projectRoot, root);
|
|
3528
|
+
const tree = {
|
|
3529
|
+
name: basename3(root),
|
|
3530
|
+
path: rootPath,
|
|
3531
|
+
directories: [],
|
|
3532
|
+
documents: []
|
|
3533
|
+
};
|
|
3534
|
+
for (const document of documents.filter((candidate) => candidate.kind === kind)) {
|
|
3535
|
+
const rel = relative7(root, document.sourcePath);
|
|
3536
|
+
if (rel === ".." || rel.startsWith(`..${sep4}`) || isAbsolute4(rel))
|
|
3537
|
+
continue;
|
|
3538
|
+
const segments = normalizeLogicalPath(rel).split("/");
|
|
3539
|
+
let current = tree;
|
|
3540
|
+
for (const segment of segments.slice(0, -1)) {
|
|
3541
|
+
let child = current.directories.find((directory) => directory.name === segment);
|
|
3542
|
+
if (!child) {
|
|
3543
|
+
child = {
|
|
3544
|
+
name: segment,
|
|
3545
|
+
path: `${current.path}/${segment}`,
|
|
3546
|
+
directories: [],
|
|
3547
|
+
documents: []
|
|
3548
|
+
};
|
|
3549
|
+
current.directories.push(child);
|
|
3550
|
+
current.directories.sort((left, right) => left.name.localeCompare(right.name));
|
|
3551
|
+
}
|
|
3552
|
+
current = child;
|
|
3553
|
+
}
|
|
3554
|
+
current.documents.push(stripSource(document));
|
|
3555
|
+
current.documents.sort(compareDocument);
|
|
3556
|
+
}
|
|
3557
|
+
return tree;
|
|
3558
|
+
}
|
|
3559
|
+
function extractHeadings(document) {
|
|
3560
|
+
return document.root.children.flatMap((node) => node.type === "heading" ? [{ depth: node.depth, title: markdownText(node), line: node.position.start.line }] : []);
|
|
3561
|
+
}
|
|
3562
|
+
function extractSummary(document, frontmatterSummary) {
|
|
3563
|
+
if (typeof frontmatterSummary === "string" && frontmatterSummary.trim() !== "")
|
|
3564
|
+
return frontmatterSummary.trim();
|
|
3565
|
+
for (const node of document.root.children) {
|
|
3566
|
+
if (node.type !== "paragraph" && node.type !== "list")
|
|
3567
|
+
continue;
|
|
3568
|
+
const text = markdownText(node).split("\n", 1)[0].trim();
|
|
3569
|
+
if (text)
|
|
3570
|
+
return text;
|
|
3571
|
+
}
|
|
3572
|
+
return null;
|
|
3573
|
+
}
|
|
3574
|
+
async function inspectSourceIdentity(projectRoot) {
|
|
3575
|
+
const root = normalizeLogicalPath(projectRoot);
|
|
3576
|
+
const head = runGit(["rev-parse", "HEAD"], projectRoot);
|
|
3577
|
+
const branch = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], projectRoot);
|
|
3578
|
+
const status = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=normal"], {
|
|
3579
|
+
cwd: root,
|
|
3580
|
+
encoding: "utf-8",
|
|
3581
|
+
maxBuffer: 256 * 1024
|
|
3582
|
+
});
|
|
3583
|
+
return {
|
|
3584
|
+
root,
|
|
3585
|
+
gitHead: head,
|
|
3586
|
+
gitBranch: branch,
|
|
3587
|
+
dirty: status.status === 0 ? status.stdout.trim() !== "" : status.error && status.error.code === "ENOBUFS" ? true : null
|
|
3588
|
+
};
|
|
3589
|
+
}
|
|
3590
|
+
function runGit(args, cwd) {
|
|
3591
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf-8", maxBuffer: 64 * 1024 });
|
|
3592
|
+
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
3593
|
+
}
|
|
3594
|
+
function normalizeRequestedPath(pathValue) {
|
|
3595
|
+
const trimmed = pathValue.trim();
|
|
3596
|
+
if (trimmed === "")
|
|
3597
|
+
throw new SpecsQueryError("invalid_specs_path", "Specs detail path must be non-empty");
|
|
3598
|
+
const normalized = normalizeLogicalPath(trimmed.replace(/^\.\//, ""));
|
|
3599
|
+
if (isAbsolute4(trimmed) || normalized === ".." || normalized.startsWith("../"))
|
|
3600
|
+
throw new SpecsQueryError("invalid_specs_path", "Specs detail path must be one project-relative path returned by rsp specs");
|
|
3601
|
+
return normalized;
|
|
3602
|
+
}
|
|
3603
|
+
function reservedIndexKind(root, path) {
|
|
3604
|
+
if (basename3(path) === GENERATED_INDEX_FILENAME)
|
|
3605
|
+
return "current";
|
|
3606
|
+
if (path === join7(root, LEGACY_GENERATED_INDEX_FILENAME))
|
|
3607
|
+
return "legacy";
|
|
3608
|
+
return null;
|
|
3609
|
+
}
|
|
3610
|
+
function excerptAround(line, matchStart, matchLength, limit) {
|
|
3611
|
+
const codePoints = [...line];
|
|
3612
|
+
if (codePoints.length <= limit)
|
|
3613
|
+
return codePoints.join("").trim();
|
|
3614
|
+
const before = Math.max(0, matchStart - Math.floor((limit - matchLength) / 2));
|
|
3615
|
+
const value = codePoints.slice(before, before + limit).join("").trim();
|
|
3616
|
+
return `${before > 0 ? "\u2026" : ""}${value}${before + limit < codePoints.length ? "\u2026" : ""}`;
|
|
3617
|
+
}
|
|
3618
|
+
async function readCurrentDocument(path, root, projectPath, maxFileBytes) {
|
|
3619
|
+
const changed2 = () => new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${projectPath}`);
|
|
3620
|
+
const sourcePath = resolve5(path);
|
|
3621
|
+
const sourceRoot = resolve5(root);
|
|
3622
|
+
const relativePath = relative7(sourceRoot, sourcePath);
|
|
3623
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep4}`) || isAbsolute4(relativePath)) {
|
|
3624
|
+
throw changed2();
|
|
3625
|
+
}
|
|
3626
|
+
const inspection = inspectManagedFile(sourcePath, "Specs document");
|
|
3627
|
+
if (inspection.issue)
|
|
3628
|
+
throw changed2();
|
|
3629
|
+
let inspectedRoot;
|
|
3630
|
+
let inspected;
|
|
3631
|
+
let realRoot;
|
|
3632
|
+
try {
|
|
3633
|
+
inspectedRoot = await lstat2(sourceRoot, { bigint: true });
|
|
3634
|
+
inspected = await lstat2(sourcePath, { bigint: true });
|
|
3635
|
+
realRoot = await realpath3(sourceRoot);
|
|
3636
|
+
const resolvedPath = await realpath3(sourcePath);
|
|
3637
|
+
if (!inspectedRoot.isDirectory() || !inspected.isFile() || resolvedPath !== resolve5(realRoot, relativePath) || !isContained2(realRoot, resolvedPath)) {
|
|
3638
|
+
throw changed2();
|
|
3639
|
+
}
|
|
3640
|
+
} catch (error) {
|
|
3641
|
+
if (error instanceof SpecsQueryError)
|
|
3642
|
+
throw error;
|
|
3643
|
+
throw changed2();
|
|
3644
|
+
}
|
|
3645
|
+
let handle;
|
|
3646
|
+
try {
|
|
3647
|
+
handle = await open2(sourcePath, READ_NO_FOLLOW_FLAGS2);
|
|
3648
|
+
} catch {
|
|
3649
|
+
throw changed2();
|
|
3650
|
+
}
|
|
3651
|
+
try {
|
|
3652
|
+
const opened = await handle.stat({ bigint: true });
|
|
3653
|
+
const currentRoot = await realpath3(sourceRoot);
|
|
3654
|
+
const currentPath = await realpath3(sourcePath);
|
|
3655
|
+
if (!opened.isFile() || currentRoot !== realRoot || currentPath !== resolve5(currentRoot, relativePath) || !isContained2(currentRoot, currentPath) || !sameIdentity2(inspected, opened) || !sameSnapshot2(inspected, opened)) {
|
|
3656
|
+
throw changed2();
|
|
3657
|
+
}
|
|
3658
|
+
if (opened.size > BigInt(maxFileBytes))
|
|
3659
|
+
throw new SpecsQueryError("specs_file_too_large", `Specs document exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`);
|
|
3660
|
+
const current = await readBoundedDocument(handle, opened, projectPath, maxFileBytes);
|
|
3661
|
+
await validateCurrentSpecsPath({
|
|
3662
|
+
sourcePath,
|
|
3663
|
+
sourceRoot,
|
|
3664
|
+
relativePath,
|
|
3665
|
+
realRoot,
|
|
3666
|
+
inspectedRoot,
|
|
3667
|
+
finalHandle: current.final,
|
|
3668
|
+
projectPath
|
|
3669
|
+
});
|
|
3670
|
+
return {
|
|
3671
|
+
content: current.content,
|
|
3672
|
+
bytes: current.bytes
|
|
3673
|
+
};
|
|
3674
|
+
} catch (error) {
|
|
3675
|
+
if (error instanceof SpecsQueryError)
|
|
3676
|
+
throw error;
|
|
3677
|
+
throw changed2();
|
|
3678
|
+
} finally {
|
|
3679
|
+
await handle.close();
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
async function readBoundedDocument(handle, opened, projectPath, maxFileBytes) {
|
|
3683
|
+
const buffer = Buffer3.allocUnsafe(maxFileBytes + 1);
|
|
3684
|
+
let bytes = 0;
|
|
3685
|
+
while (bytes < buffer.length) {
|
|
3686
|
+
const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
|
|
3687
|
+
if (result.bytesRead === 0)
|
|
3688
|
+
break;
|
|
3689
|
+
bytes += result.bytesRead;
|
|
3690
|
+
}
|
|
3691
|
+
const final = await handle.stat({ bigint: true });
|
|
3692
|
+
if (bytes > maxFileBytes || final.size > BigInt(maxFileBytes))
|
|
3693
|
+
throw new SpecsQueryError("specs_file_too_large", `Specs document exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`);
|
|
3694
|
+
if (!sameIdentity2(opened, final) || !sameSnapshot2(opened, final) || final.size !== BigInt(bytes)) {
|
|
3695
|
+
throw new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${projectPath}`);
|
|
3696
|
+
}
|
|
3697
|
+
return {
|
|
3698
|
+
content: buffer.subarray(0, bytes).toString("utf-8"),
|
|
3699
|
+
bytes,
|
|
3700
|
+
final
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
async function validateCurrentSpecsPath(options) {
|
|
3704
|
+
const changed2 = () => new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${options.projectPath}`);
|
|
3705
|
+
try {
|
|
3706
|
+
const currentRoot = await lstat2(options.sourceRoot, { bigint: true });
|
|
3707
|
+
const currentPath = await lstat2(options.sourcePath, { bigint: true });
|
|
3708
|
+
const realCurrentRoot = await realpath3(options.sourceRoot);
|
|
3709
|
+
const realCurrentPath = await realpath3(options.sourcePath);
|
|
3710
|
+
if (!currentRoot.isDirectory() || !currentPath.isFile() || !sameIdentity2(options.inspectedRoot, currentRoot) || realCurrentRoot !== options.realRoot || realCurrentPath !== resolve5(realCurrentRoot, options.relativePath) || !isContained2(realCurrentRoot, realCurrentPath) || !sameIdentity2(currentPath, options.finalHandle) || !sameSnapshot2(currentPath, options.finalHandle)) {
|
|
3711
|
+
throw changed2();
|
|
3712
|
+
}
|
|
3713
|
+
} catch (error) {
|
|
3714
|
+
if (error instanceof SpecsQueryError)
|
|
3715
|
+
throw error;
|
|
3716
|
+
throw changed2();
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
function sameIdentity2(left, right) {
|
|
3720
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
3721
|
+
}
|
|
3722
|
+
function sameSnapshot2(left, right) {
|
|
3723
|
+
return left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
3724
|
+
}
|
|
3725
|
+
function isContained2(root, path) {
|
|
3726
|
+
const candidate = relative7(root, path);
|
|
3727
|
+
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${sep4}`) && !isAbsolute4(candidate);
|
|
3728
|
+
}
|
|
3729
|
+
function boundCodePoints(value, limit) {
|
|
3730
|
+
const codePoints = [...value];
|
|
3731
|
+
return {
|
|
3732
|
+
value: codePoints.slice(0, limit).join(""),
|
|
3733
|
+
truncated: codePoints.length > limit
|
|
3734
|
+
};
|
|
3735
|
+
}
|
|
3736
|
+
function stripSource(document) {
|
|
3737
|
+
const { sourcePath: _sourcePath, ...record } = document;
|
|
3738
|
+
return record;
|
|
3739
|
+
}
|
|
3740
|
+
function parseDocumentRecord(document, bytes, path, kind, metadata = parseFrontmatter(document)) {
|
|
3741
|
+
const headings = extractHeadings(document);
|
|
3742
|
+
return {
|
|
3743
|
+
path,
|
|
3744
|
+
kind,
|
|
3745
|
+
title: typeof metadata?.title === "string" && metadata.title.trim() !== "" ? metadata.title.trim() : headings.find((heading) => heading.depth === 1)?.title ?? basename3(path, ".md"),
|
|
3746
|
+
summary: extractSummary(document, metadata?.summary),
|
|
3747
|
+
bytes,
|
|
3748
|
+
headings
|
|
3749
|
+
};
|
|
3750
|
+
}
|
|
3751
|
+
function compareDocument(left, right) {
|
|
3752
|
+
return left.path.localeCompare(right.path);
|
|
3753
|
+
}
|
|
3754
|
+
function validatePositiveBound(value, label) {
|
|
3755
|
+
if (!Number.isInteger(value) || value < 1)
|
|
3756
|
+
throw new SpecsQueryError("invalid_specs_bound", `${label} must be a positive integer`);
|
|
3757
|
+
}
|
|
3758
|
+
function isInside(directory, path) {
|
|
3759
|
+
const rel = relative7(directory, path);
|
|
3760
|
+
return rel === "" || !isAbsolute4(rel) && rel !== ".." && !rel.startsWith(`..${sep4}`);
|
|
3761
|
+
}
|
|
3762
|
+
function toProjectPath(projectRoot, path) {
|
|
3763
|
+
return normalizeLogicalPath(relative7(projectRoot, path)) || ".";
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
// src/specs/projection.ts
|
|
3767
|
+
function projectSpecsTree(inspection) {
|
|
3768
|
+
assertCompleteInspection(inspection);
|
|
3769
|
+
return {
|
|
3770
|
+
...projectionBase(inspection),
|
|
3771
|
+
mode: "tree",
|
|
3772
|
+
tree: inspection.tree,
|
|
3773
|
+
decisionRecords: inspection.decisionRecords,
|
|
3774
|
+
documents: inspection.documents
|
|
3775
|
+
};
|
|
3776
|
+
}
|
|
3777
|
+
async function projectSpecsDetail(inspection, path) {
|
|
3778
|
+
assertCompleteInspection(inspection);
|
|
3779
|
+
return {
|
|
3780
|
+
...projectionBase(inspection),
|
|
3781
|
+
mode: "detail",
|
|
3782
|
+
document: await readSpecsDetail(inspection, path)
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
async function projectSpecsSearch(inspection, literal, options) {
|
|
3786
|
+
assertCompleteInspection(inspection);
|
|
3787
|
+
const result = await searchSpecs(inspection, literal, options);
|
|
3788
|
+
return {
|
|
3789
|
+
...projectionBase(inspection),
|
|
3790
|
+
mode: "search",
|
|
3791
|
+
query: {
|
|
3792
|
+
literal,
|
|
3793
|
+
limit: options.limit,
|
|
3794
|
+
excerptCodePoints: options.excerptCodePoints
|
|
3795
|
+
},
|
|
3796
|
+
matches: result.matches,
|
|
3797
|
+
summary: result.summary
|
|
3798
|
+
};
|
|
3799
|
+
}
|
|
3800
|
+
function assertCompleteInspection(inspection) {
|
|
3801
|
+
if (!specsInspectionComplete(inspection)) {
|
|
3802
|
+
throw new SpecsQueryError(
|
|
3803
|
+
"specs_inspection_incomplete",
|
|
3804
|
+
"Specs inspection is incomplete; resolve the reported diagnostics before using the projection"
|
|
3805
|
+
);
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
function projectionBase(inspection) {
|
|
3809
|
+
return {
|
|
3810
|
+
source: inspection.source,
|
|
3811
|
+
roots: inspection.roots,
|
|
3812
|
+
generatedIndexes: inspection.generatedIndexes,
|
|
3813
|
+
diagnostics: inspection.diagnostics,
|
|
3814
|
+
diagnosticSummary: inspection.diagnosticSummary,
|
|
3815
|
+
runtime: inspection.runtime,
|
|
3816
|
+
limits: inspection.limits
|
|
3817
|
+
};
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
// src/status/dependency-forest.ts
|
|
3821
|
+
function defaultRoots(plan) {
|
|
3822
|
+
const selected = new Set(plan.nodes.filter((node) => node.selection === "selected").map((node) => node.name));
|
|
3823
|
+
const nested = new Set(plan.edges.filter((edge) => selected.has(edge.change) && selected.has(edge.requires)).map((edge) => edge.requires));
|
|
3824
|
+
const roots = [...selected].filter((name) => !nested.has(name)).sort();
|
|
3825
|
+
return roots.length > 0 ? roots : [...selected].sort();
|
|
3826
|
+
}
|
|
3827
|
+
function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
|
|
3828
|
+
const nodes = new Map(plan.nodes.map((node) => [node.name, node]));
|
|
3829
|
+
const edges = /* @__PURE__ */ new Map();
|
|
3830
|
+
for (const edge of plan.edges) {
|
|
3831
|
+
const children = edges.get(edge.change) ?? [];
|
|
3832
|
+
children.push(edge);
|
|
3833
|
+
edges.set(edge.change, children);
|
|
3834
|
+
}
|
|
3835
|
+
for (const children of edges.values())
|
|
3836
|
+
children.sort((left, right) => left.requires.localeCompare(right.requires));
|
|
3837
|
+
const expanded = /* @__PURE__ */ new Set();
|
|
3838
|
+
const visit = (name, edge) => {
|
|
3839
|
+
const node = nodes.get(name);
|
|
3840
|
+
if (!node)
|
|
3841
|
+
return null;
|
|
3842
|
+
const shared = expanded.has(name);
|
|
3843
|
+
if (!shared)
|
|
3844
|
+
expanded.add(name);
|
|
3845
|
+
return {
|
|
3846
|
+
...node,
|
|
3847
|
+
...edge ? { reason: edge.reason, edgeState: edge.state } : {},
|
|
3848
|
+
shared,
|
|
3849
|
+
children: shared ? [] : (edges.get(name) ?? []).map((child) => visit(child.requires, child)).filter((child) => child !== null)
|
|
3850
|
+
};
|
|
3851
|
+
};
|
|
3852
|
+
return requestedRoots.map((name) => visit(name)).filter((node) => node !== null);
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
export {
|
|
3856
|
+
parseMarkdown,
|
|
3857
|
+
isMarkdownComment,
|
|
3858
|
+
markdownTopLevelComments,
|
|
3859
|
+
markdownText,
|
|
3860
|
+
markdownProse,
|
|
3861
|
+
CHANGE_DOCUMENT_SCHEMA,
|
|
3862
|
+
GROUP_BRIEF_DOCUMENT_SCHEMA,
|
|
3863
|
+
DocumentSectionCardinalityError,
|
|
3864
|
+
parseRspDocument,
|
|
3865
|
+
getDocumentTitles,
|
|
3866
|
+
getDocumentSections,
|
|
3867
|
+
getDocumentSectionMarkdown,
|
|
3868
|
+
getDocumentSectionDefinitionByHeading,
|
|
3869
|
+
renderDocumentSectionHeading,
|
|
3870
|
+
renderDocumentTitle,
|
|
3871
|
+
appendDocumentSectionItem,
|
|
3872
|
+
parseFrontmatter,
|
|
3873
|
+
countCheckboxes,
|
|
3874
|
+
getVisibleBodyLines,
|
|
3875
|
+
hasMeaningfulBlockers,
|
|
3876
|
+
detectDeltaSections,
|
|
3877
|
+
parseScenarios,
|
|
3878
|
+
IssueRelationshipError,
|
|
3879
|
+
parseIssueRelationships,
|
|
3880
|
+
createIssueRelationship,
|
|
3881
|
+
ManagedPathError,
|
|
3882
|
+
inspectManagedDirectory,
|
|
3883
|
+
requireManagedDirectory,
|
|
3884
|
+
inspectManagedFile,
|
|
3885
|
+
requireManagedFile,
|
|
3886
|
+
ensureManagedFile,
|
|
3887
|
+
writeManagedFile,
|
|
3888
|
+
resolveManagedDirectoryChain,
|
|
3889
|
+
inspectManagedFileTree,
|
|
3890
|
+
DEFAULT_DECISION_RECORDS_PATH,
|
|
3891
|
+
resolveDecisionRecordsPath,
|
|
3892
|
+
validateDecisionRecordsFilesystemPath,
|
|
3893
|
+
pc,
|
|
3894
|
+
PKG_ROOT,
|
|
3895
|
+
RSP_DIR,
|
|
3896
|
+
RSP_RULES_PATH,
|
|
3897
|
+
OBSOLETE_RSP_RULES_PATH,
|
|
3898
|
+
CHANGES_DIR,
|
|
3899
|
+
FOCUS_DIR,
|
|
3900
|
+
MAX_FOCUS_CAPSULE_BYTES,
|
|
3901
|
+
ARCHIVES_DIR,
|
|
3902
|
+
LOCK_PATH,
|
|
3903
|
+
CONFIG_PATH,
|
|
3904
|
+
generateConfigTemplate,
|
|
3905
|
+
loadRspConfig,
|
|
3906
|
+
inspectRspConfig,
|
|
3907
|
+
clearConfigCache,
|
|
3908
|
+
resolveKinds,
|
|
3909
|
+
resolveRequiredSections,
|
|
3910
|
+
resolveWorkflowPolicy,
|
|
3911
|
+
resolveLanguagePolicy,
|
|
3912
|
+
getVersion,
|
|
3913
|
+
toErrorMessage,
|
|
3914
|
+
emitJson,
|
|
3915
|
+
walkMarkdownFiles,
|
|
3916
|
+
inspectUnsupportedRules,
|
|
3917
|
+
normalizeLogicalPath,
|
|
3918
|
+
isValidSpecName,
|
|
3919
|
+
inspectRspInitialization,
|
|
3920
|
+
guardRspInitialized,
|
|
3921
|
+
cleanupEmptyParentDirs,
|
|
3922
|
+
detectProjectName,
|
|
3923
|
+
GROUP_BRIEF_FILENAME,
|
|
3924
|
+
WorkRefError,
|
|
3925
|
+
normalizeWorkRefSegment,
|
|
3926
|
+
normalizeExecutableWorkRef,
|
|
3927
|
+
resolveWorkRef,
|
|
3928
|
+
resolveWorkRefPath,
|
|
3929
|
+
resolveFocusMarkerPath,
|
|
3930
|
+
resolveArchiveDirectory,
|
|
3931
|
+
inspectFocusTree,
|
|
3932
|
+
inspectArchiveTree,
|
|
3933
|
+
inspectWorkTree,
|
|
3934
|
+
GROUP_REOPEN_COMPLETION_PREFIX,
|
|
3935
|
+
resolveExecutableChange,
|
|
3936
|
+
generateGroupBriefContent,
|
|
3937
|
+
inspectChangeGroups,
|
|
3938
|
+
getGroupReopenEvidence,
|
|
3939
|
+
hasArchivedGroupBrief,
|
|
3940
|
+
HISTORY_DEFAULT_LIMIT,
|
|
3941
|
+
HISTORY_MAX_LIMIT,
|
|
3942
|
+
ArchiveHistoryError,
|
|
3943
|
+
inspectArchiveHistory,
|
|
3944
|
+
queryArchiveHistory,
|
|
3945
|
+
selectArchiveHistoryRecord,
|
|
3946
|
+
selectArchivedGroupBrief,
|
|
3947
|
+
readArchiveHistoryDetail,
|
|
3948
|
+
readArchiveHistoryDocument,
|
|
3949
|
+
historyInspectionComplete,
|
|
3950
|
+
inspectChangeDependencies,
|
|
3951
|
+
SPECS_DEFAULT_RESULT_LIMIT,
|
|
3952
|
+
SPECS_MAX_RESULT_LIMIT,
|
|
3953
|
+
SPECS_SEARCH_EXCERPT_CODE_POINTS,
|
|
3954
|
+
SPECS_DETAIL_CONTENT_CODE_POINTS,
|
|
3955
|
+
SpecsQueryError,
|
|
3956
|
+
inspectSpecs,
|
|
3957
|
+
specsInspectionComplete,
|
|
3958
|
+
isRecognizedGeneratedSpecsIndex,
|
|
3959
|
+
collectArchiveReadiness,
|
|
3960
|
+
toArchiveReadinessOutput,
|
|
3961
|
+
buildDurableReviewGuidance,
|
|
3962
|
+
getDurableReviewCandidateTargets,
|
|
3963
|
+
projectSpecsTree,
|
|
3964
|
+
projectSpecsDetail,
|
|
3965
|
+
projectSpecsSearch,
|
|
3966
|
+
inspectProjectStatus,
|
|
3967
|
+
projectDependencyForest
|
|
3968
|
+
};
|