@oevery/rsp 3.1.1 → 3.3.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 +4 -3
- package/README.zh-CN.md +3 -2
- package/dist/{chunk-WJXKLLIF.mjs → chunk-7HXQNBIG.mjs} +447 -734
- package/dist/{chunk-3Z4IO2TD.mjs → chunk-QM2UVPQK.mjs} +47 -30
- package/dist/{chunk-MUNLLVVH.mjs → chunk-TSUDEX5Y.mjs} +1205 -117
- package/dist/cli.mjs +5336 -3045
- package/dist/entry-GIDNR7CJ.mjs +1846 -0
- package/dist/{entry-KAWA2AS4.mjs → entry-YAW3PYFA.mjs} +4 -4
- package/package.json +12 -7
- package/rules/rsp-rules.md +30 -58
- package/skills/rsp/SKILL.md +28 -45
- package/skills/rsp/references/control-outcome.md +31 -0
- package/skills/rsp/references/durable-review.md +23 -4
- package/skills/rsp/references/focus-continuation.md +9 -0
- package/skills/rsp/references/implementation-evidence.md +12 -0
- package/skills/rsp/references/managed-routing.md +23 -18
- package/skills/rsp/references/response-language.md +11 -0
- package/skills/rsp/references/setup-repair.md +3 -3
- package/skills/rsp-commit/SKILL.md +26 -9
- package/skills/rsp-design/SKILL.md +1 -1
- package/skills/rsp-diagnose/SKILL.md +4 -2
- package/skills/rsp-implement/SKILL.md +18 -6
- package/skills/rsp-manage/SKILL.md +61 -37
- package/skills/rsp-manage/references/closeout.md +13 -0
- package/skills/rsp-manage/references/delegation.md +47 -0
- package/skills/rsp-manage/references/interruption-recovery.md +19 -0
- package/skills/rsp-manage/references/review-convergence.md +9 -0
- package/skills/rsp-release-docs/SKILL.md +15 -13
- package/skills/rsp-release-docs/references/publication-lifecycle.md +4 -0
- package/skills/rsp-resolve-findings/SKILL.md +3 -3
- package/skills/rsp-review/SKILL.md +10 -36
- package/skills/rsp-review/references/code-review.md +15 -0
- package/skills/rsp-review/references/document-review.md +15 -0
- package/skills/rsp-shape/SKILL.md +14 -4
- package/skills/rsp-shape/references/deep-clarification.md +2 -0
- package/skills/rsp-structural-audit/SKILL.md +1 -1
- package/skills/rsp-tdd/SKILL.md +11 -3
- package/skills/rsp-verify/SKILL.md +40 -0
- package/dist/entry-IPHJBI53.mjs +0 -882
- package/skills/rsp/references/release-operations.md +0 -9
|
@@ -4,45 +4,160 @@ import {
|
|
|
4
4
|
CHANGES_DIR,
|
|
5
5
|
CHANGE_DOCUMENT_SCHEMA,
|
|
6
6
|
CONFIG_PATH,
|
|
7
|
+
DEFAULT_DECISION_RECORDS_PATH,
|
|
7
8
|
FOCUS_DIR,
|
|
8
9
|
GROUP_BRIEF_DOCUMENT_SCHEMA,
|
|
9
10
|
ManagedPathError,
|
|
10
|
-
|
|
11
|
+
OBSOLETE_RSP_RULES_PATH,
|
|
12
|
+
RSP_DIR,
|
|
13
|
+
RSP_RULES_PATH,
|
|
14
|
+
classifyVerifyCheckboxes,
|
|
15
|
+
collectVerifyCheckboxLines,
|
|
11
16
|
countCheckboxes,
|
|
12
17
|
extractBlockerLines,
|
|
13
18
|
getCanonicalSectionHeadings,
|
|
14
19
|
getDocumentSectionBody,
|
|
15
20
|
getDocumentTitle,
|
|
16
21
|
getDocumentTitles,
|
|
22
|
+
getOpenCheckboxes,
|
|
17
23
|
hasMeaningfulBlockerBody,
|
|
18
24
|
hasMeaningfulBlockers,
|
|
19
25
|
inspectManagedDirectory,
|
|
26
|
+
inspectManagedFile,
|
|
27
|
+
inspectManagedFileTree,
|
|
20
28
|
inspectRspConfig,
|
|
21
|
-
|
|
29
|
+
isEmptyBlockerLine,
|
|
22
30
|
parseFrontmatter,
|
|
23
31
|
parseRspDocument,
|
|
24
32
|
parseScenarios,
|
|
33
|
+
pc,
|
|
25
34
|
renderDocumentSectionHeading,
|
|
26
35
|
renderDocumentTitle,
|
|
27
36
|
requireManagedDirectory,
|
|
28
37
|
requireManagedFile,
|
|
38
|
+
resolveDecisionRecordsPath,
|
|
29
39
|
resolveLanguagePolicy,
|
|
30
40
|
resolveManagePolicy,
|
|
31
|
-
toErrorMessage
|
|
32
|
-
|
|
41
|
+
toErrorMessage,
|
|
42
|
+
validateDecisionRecordsFilesystemPath
|
|
43
|
+
} from "./chunk-7HXQNBIG.mjs";
|
|
33
44
|
|
|
34
45
|
// src/status/inspect.ts
|
|
35
|
-
import { readFile as
|
|
46
|
+
import { readFile as readFile4, stat } from "fs/promises";
|
|
36
47
|
|
|
37
48
|
// src/core/change-group.ts
|
|
38
|
-
import { readFile } from "fs/promises";
|
|
39
|
-
import { dirname, join as
|
|
49
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
50
|
+
import { dirname as dirname2, join as join3, relative as relative3 } from "path";
|
|
51
|
+
|
|
52
|
+
// src/core/filesystem.ts
|
|
53
|
+
import { existsSync } from "fs";
|
|
54
|
+
import { readdir, readFile, rmdir } from "fs/promises";
|
|
55
|
+
import { basename, dirname, join, relative, sep } from "path";
|
|
56
|
+
async function walkMarkdownFiles(dir, options = {}) {
|
|
57
|
+
const all = await walkFiles(dir, options);
|
|
58
|
+
return all.filter((f) => f.endsWith(".md"));
|
|
59
|
+
}
|
|
60
|
+
async function inspectUnsupportedRules() {
|
|
61
|
+
const rulesDir = join(RSP_DIR, "rules");
|
|
62
|
+
const diagnostics = [];
|
|
63
|
+
const rawEntries = await walkAllEntries(rulesDir, diagnostics);
|
|
64
|
+
const entries = rawEntries.filter((entry) => entry.path !== OBSOLETE_RSP_RULES_PATH).map((entry) => ({ path: normalizeLogicalPath(relative(rulesDir, entry.path)), kind: entry.kind }));
|
|
65
|
+
const obsoleteFallbackPresent = existsSync(OBSOLETE_RSP_RULES_PATH);
|
|
66
|
+
return {
|
|
67
|
+
entries,
|
|
68
|
+
diagnostics,
|
|
69
|
+
directoryRemaining: existsSync(rulesDir) && (!obsoleteFallbackPresent || entries.length > 0)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function walkAllEntries(dir, diagnostics) {
|
|
73
|
+
const entries = [];
|
|
74
|
+
try {
|
|
75
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
76
|
+
for (const item of items) {
|
|
77
|
+
const path = join(dir, item.name);
|
|
78
|
+
const kind = item.isDirectory() ? "directory" : "file";
|
|
79
|
+
entries.push({ path, kind });
|
|
80
|
+
if (kind === "directory")
|
|
81
|
+
entries.push(...await walkAllEntries(path, diagnostics));
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code !== "ENOENT") {
|
|
85
|
+
diagnostics.push({ code: "walk_failed", operation: "readdir", path: dir, message: toErrorMessage(error) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
async function walkFiles(dir, options = {}) {
|
|
91
|
+
const files = [];
|
|
92
|
+
try {
|
|
93
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
94
|
+
for (const item of items) {
|
|
95
|
+
if (item.name.startsWith("."))
|
|
96
|
+
continue;
|
|
97
|
+
const filePath = join(dir, item.name);
|
|
98
|
+
if (item.isDirectory())
|
|
99
|
+
files.push(...await walkFiles(filePath, options));
|
|
100
|
+
else
|
|
101
|
+
files.push(filePath);
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error.code !== "ENOENT") {
|
|
105
|
+
options.onError?.({ code: "walk_failed", operation: "readdir", path: dir, message: toErrorMessage(error) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
110
|
+
function normalizeLogicalPath(pathValue) {
|
|
111
|
+
return pathValue.split(sep).join("/").replace(/\\/g, "/");
|
|
112
|
+
}
|
|
113
|
+
var SPEC_NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
|
|
114
|
+
function isValidSpecName(name) {
|
|
115
|
+
return SPEC_NAME_RE.test(name);
|
|
116
|
+
}
|
|
117
|
+
function guardRspInitialized() {
|
|
118
|
+
const designPath = join(RSP_DIR, "specs", "design.md");
|
|
119
|
+
const rules = inspectManagedFile(RSP_RULES_PATH, "fallback protocol", { allowMissing: true });
|
|
120
|
+
const design = inspectManagedFile(designPath, "design Spec", { allowMissing: true });
|
|
121
|
+
if (rules.issue || design.issue || !rules.exists || !design.exists) {
|
|
122
|
+
const initialized = existsSync(RSP_DIR);
|
|
123
|
+
console.error(` ${pc.red("Error:")} ${initialized ? "RSP project requires an update" : "RSP is not initialized in this project"}`);
|
|
124
|
+
console.error(` ${pc.dim(initialized ? "Run: rsp update" : "Run: rsp init")}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function cleanupEmptyParentDirs(path, stopDir) {
|
|
129
|
+
let parent = dirname(path);
|
|
130
|
+
while (parent !== stopDir) {
|
|
131
|
+
try {
|
|
132
|
+
const remaining = await readdir(parent);
|
|
133
|
+
if (remaining.length === 0)
|
|
134
|
+
await rmdir(parent);
|
|
135
|
+
else
|
|
136
|
+
break;
|
|
137
|
+
} catch {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
parent = dirname(parent);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function detectProjectName() {
|
|
144
|
+
try {
|
|
145
|
+
if (existsSync("package.json")) {
|
|
146
|
+
const raw = await readFile("package.json", "utf-8");
|
|
147
|
+
const pkg = JSON.parse(raw);
|
|
148
|
+
if (pkg.name && pkg.name !== "")
|
|
149
|
+
return pkg.name;
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
}
|
|
153
|
+
return basename(process.cwd());
|
|
154
|
+
}
|
|
40
155
|
|
|
41
156
|
// src/core/work-ref.ts
|
|
42
157
|
import { Buffer } from "buffer";
|
|
43
158
|
import { lstatSync, readdirSync } from "fs";
|
|
44
|
-
import { readdir } from "fs/promises";
|
|
45
|
-
import { isAbsolute, join, relative } from "path";
|
|
159
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
160
|
+
import { isAbsolute, join as join2, relative as relative2 } from "path";
|
|
46
161
|
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;
|
|
47
162
|
var WORK_SEGMENT_MAX_CODE_POINTS = 100;
|
|
48
163
|
var WORK_SEGMENT_MAX_UTF8_BYTES = 200;
|
|
@@ -100,7 +215,7 @@ function resolveWorkRef(name, options = {}) {
|
|
|
100
215
|
assertValidWorkRoot(changesDir, normalizedName);
|
|
101
216
|
assertNoNormalizationCollision(changesDir, segments, normalizedName, isGroupBrief);
|
|
102
217
|
assertNoIdentityCollision(changesDir, segments, normalizedName, isGroupBrief ? GROUP_BRIEF_FILENAME : void 0);
|
|
103
|
-
const path =
|
|
218
|
+
const path = join2(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
|
|
104
219
|
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] };
|
|
105
220
|
if (ref.kind === "group-change" && options.executable !== false)
|
|
106
221
|
assertGroupBrief(changesDir, ref);
|
|
@@ -115,7 +230,7 @@ function resolveWorkRef(name, options = {}) {
|
|
|
115
230
|
return ref;
|
|
116
231
|
}
|
|
117
232
|
function assertGroupBrief(changesDir, ref) {
|
|
118
|
-
const briefPath =
|
|
233
|
+
const briefPath = join2(changesDir, ref.group, GROUP_BRIEF_FILENAME);
|
|
119
234
|
const kind = getPathKind(briefPath, ref.name);
|
|
120
235
|
if (kind === "missing") {
|
|
121
236
|
throw new WorkRefError(
|
|
@@ -159,7 +274,7 @@ function assertRegularWorkFile(ref, mustExist) {
|
|
|
159
274
|
}
|
|
160
275
|
function resolveWorkRefPath(path, options = {}) {
|
|
161
276
|
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
162
|
-
const logicalPath = normalizeLogicalPath2(
|
|
277
|
+
const logicalPath = normalizeLogicalPath2(relative2(changesDir, path));
|
|
163
278
|
if (isAbsolute(logicalPath) || logicalPath === ".." || logicalPath.startsWith("../") || !logicalPath.endsWith(".md")) {
|
|
164
279
|
throw new WorkRefError(
|
|
165
280
|
"invalid_work_ref_path",
|
|
@@ -187,13 +302,13 @@ function resolveFocusMarkerPath(ref, options = {}) {
|
|
|
187
302
|
assertNoManagedNormalizationCollision(focusDir, ref.name.split("/"), ref.name, "invalid_focus_path");
|
|
188
303
|
if (ref.group) {
|
|
189
304
|
assertOptionalManagedDirectory(
|
|
190
|
-
|
|
305
|
+
join2(focusDir, ref.group),
|
|
191
306
|
ref.name,
|
|
192
307
|
"invalid_focus_path",
|
|
193
308
|
"focus path"
|
|
194
309
|
);
|
|
195
310
|
}
|
|
196
|
-
const markerPath =
|
|
311
|
+
const markerPath = join2(focusDir, ...ref.name.split("/"));
|
|
197
312
|
try {
|
|
198
313
|
requireManagedFile(markerPath, "focus marker", { allowMissing: true });
|
|
199
314
|
} catch (error) {
|
|
@@ -209,7 +324,7 @@ function resolveArchiveDirectory(ref, options = {}) {
|
|
|
209
324
|
if (!ref.group)
|
|
210
325
|
return archivesDir;
|
|
211
326
|
assertNoManagedNormalizationCollision(archivesDir, [ref.group], ref.name, "invalid_archive_path");
|
|
212
|
-
const groupPath =
|
|
327
|
+
const groupPath = join2(archivesDir, ref.group);
|
|
213
328
|
assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
|
|
214
329
|
return groupPath;
|
|
215
330
|
}
|
|
@@ -228,7 +343,7 @@ async function inspectFocusTree(options = {}) {
|
|
|
228
343
|
if (!rootEntries)
|
|
229
344
|
return result;
|
|
230
345
|
for (const entry of rootEntries) {
|
|
231
|
-
const path =
|
|
346
|
+
const path = join2(focusDir, entry.name);
|
|
232
347
|
if (entry.name === ".gitkeep" && entry.isFile())
|
|
233
348
|
continue;
|
|
234
349
|
if (entry.isDirectory()) {
|
|
@@ -271,7 +386,7 @@ async function inspectArchiveTree(options = {}) {
|
|
|
271
386
|
if (!rootEntries)
|
|
272
387
|
return result;
|
|
273
388
|
for (const entry of rootEntries) {
|
|
274
|
-
const path =
|
|
389
|
+
const path = join2(archivesDir, entry.name);
|
|
275
390
|
if (entry.name === "INDEX.md" && entry.isFile())
|
|
276
391
|
continue;
|
|
277
392
|
if (entry.isDirectory()) {
|
|
@@ -309,7 +424,7 @@ async function inspectWorkTree(options = {}) {
|
|
|
309
424
|
const rootMarkdownNames = new Set(rootEntries.filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name.slice(0, -3)));
|
|
310
425
|
const collidingGroups = new Set([...rootDirectories].filter((name) => rootMarkdownNames.has(name)));
|
|
311
426
|
for (const entry of rootEntries) {
|
|
312
|
-
const path =
|
|
427
|
+
const path = join2(changesDir, entry.name);
|
|
313
428
|
if (entry.name === ".gitkeep" && entry.isFile())
|
|
314
429
|
continue;
|
|
315
430
|
if (entry.isDirectory()) {
|
|
@@ -356,17 +471,17 @@ async function inspectGroupDirectory(group, groupPath, changesDir, result, colli
|
|
|
356
471
|
"group_brief_missing",
|
|
357
472
|
`Change Group "${group}" requires a Group Brief; run: rsp group create ${group}`,
|
|
358
473
|
group
|
|
359
|
-
),
|
|
474
|
+
), join2(groupPath, GROUP_BRIEF_FILENAME));
|
|
360
475
|
} else if (!briefEntry.isFile()) {
|
|
361
476
|
addDiagnostic(result, new WorkRefError(
|
|
362
477
|
"work_ref_not_file",
|
|
363
|
-
`Group Brief must be a regular file: ${
|
|
478
|
+
`Group Brief must be a regular file: ${join2(groupPath, GROUP_BRIEF_FILENAME)}`,
|
|
364
479
|
group
|
|
365
|
-
),
|
|
480
|
+
), join2(groupPath, GROUP_BRIEF_FILENAME));
|
|
366
481
|
}
|
|
367
482
|
for (const entry of entries) {
|
|
368
483
|
const input = `${group}/${entry.name}`;
|
|
369
|
-
const path =
|
|
484
|
+
const path = join2(groupPath, entry.name);
|
|
370
485
|
if (entry.name === GROUP_BRIEF_FILENAME && !entry.isFile())
|
|
371
486
|
continue;
|
|
372
487
|
if (entry.name === "brief.md") {
|
|
@@ -395,7 +510,7 @@ async function inspectFocusGroup(group, groupPath, changesDir, result) {
|
|
|
395
510
|
return;
|
|
396
511
|
for (const entry of entries) {
|
|
397
512
|
const input = `${group}/${entry.name}`;
|
|
398
|
-
const path =
|
|
513
|
+
const path = join2(groupPath, entry.name);
|
|
399
514
|
if (entry.isDirectory()) {
|
|
400
515
|
addDiagnostic(result, new WorkRefError(
|
|
401
516
|
"unsupported_work_depth",
|
|
@@ -421,7 +536,7 @@ async function inspectArchiveGroup(group, groupPath, result) {
|
|
|
421
536
|
return;
|
|
422
537
|
for (const entry of entries) {
|
|
423
538
|
const input = `${group}/${entry.name}`;
|
|
424
|
-
const path =
|
|
539
|
+
const path = join2(groupPath, entry.name);
|
|
425
540
|
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
426
541
|
addArchivePathDiagnostic(result, input, path);
|
|
427
542
|
continue;
|
|
@@ -454,7 +569,7 @@ function collectResolvedPath(path, changesDir, result) {
|
|
|
454
569
|
}
|
|
455
570
|
async function readDirectory(path, input, result) {
|
|
456
571
|
try {
|
|
457
|
-
return (await
|
|
572
|
+
return (await readdir2(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
458
573
|
} catch (error) {
|
|
459
574
|
addDiagnostic(result, asReadFailure(path, input, error), path);
|
|
460
575
|
return null;
|
|
@@ -515,7 +630,7 @@ function normalizeWorkRefName(name, allowGroupBrief) {
|
|
|
515
630
|
}
|
|
516
631
|
function assertNoNormalizationCollision(changesDir, segments, input, isGroupBrief) {
|
|
517
632
|
for (let depth = 0; depth < segments.length; depth++) {
|
|
518
|
-
const parent =
|
|
633
|
+
const parent = join2(changesDir, ...segments.slice(0, depth));
|
|
519
634
|
const parentKind = getPathKind(parent, input);
|
|
520
635
|
if (parentKind === "missing")
|
|
521
636
|
return;
|
|
@@ -548,7 +663,7 @@ function assertNoNormalizationCollision(changesDir, segments, input, isGroupBrie
|
|
|
548
663
|
}
|
|
549
664
|
function assertNoManagedNormalizationCollision(root, segments, input, code) {
|
|
550
665
|
for (let depth = 0; depth < segments.length; depth++) {
|
|
551
|
-
const parent =
|
|
666
|
+
const parent = join2(root, ...segments.slice(0, depth));
|
|
552
667
|
const parentKind = getPathKind(parent, input);
|
|
553
668
|
if (parentKind === "missing")
|
|
554
669
|
return;
|
|
@@ -573,8 +688,8 @@ function assertNoManagedNormalizationCollision(root, segments, input, code) {
|
|
|
573
688
|
}
|
|
574
689
|
function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
|
|
575
690
|
for (let depth = 1; depth <= segments.length; depth++) {
|
|
576
|
-
const identityPath =
|
|
577
|
-
const filePath = finalFilename && depth === segments.length ?
|
|
691
|
+
const identityPath = join2(changesDir, ...segments.slice(0, depth));
|
|
692
|
+
const filePath = finalFilename && depth === segments.length ? join2(changesDir, ...segments.slice(0, -1), finalFilename) : `${identityPath}.md`;
|
|
578
693
|
const fileKind = getPathKind(filePath, input);
|
|
579
694
|
const identityKind = getPathKind(identityPath, input);
|
|
580
695
|
const isPrefix = depth < segments.length;
|
|
@@ -664,6 +779,43 @@ function normalizeLogicalPath2(path) {
|
|
|
664
779
|
return path.split("\\").join("/");
|
|
665
780
|
}
|
|
666
781
|
|
|
782
|
+
// src/core/work-summary.ts
|
|
783
|
+
var PLACEHOLDER_SUMMARY = "<\u2026>";
|
|
784
|
+
function extractChangeSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), options = {}) {
|
|
785
|
+
try {
|
|
786
|
+
const frontmatter = parseFrontmatter(content);
|
|
787
|
+
const frontmatterSummary = meaningfulSummary(frontmatter?.summary, options);
|
|
788
|
+
if (frontmatterSummary)
|
|
789
|
+
return frontmatterSummary;
|
|
790
|
+
} catch {
|
|
791
|
+
}
|
|
792
|
+
const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
|
|
793
|
+
for (const field of ["Outcome", "Summary"]) {
|
|
794
|
+
const prefix = `- ${field}:`;
|
|
795
|
+
for (const line of proposalLines) {
|
|
796
|
+
const summary = line.startsWith(prefix) ? meaningfulSummary(line.slice(prefix.length), options) : null;
|
|
797
|
+
if (summary)
|
|
798
|
+
return summary;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
function extractGroupSummary(content, document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA)) {
|
|
804
|
+
for (const line of getDocumentSectionBody(document, "goal").split("\n")) {
|
|
805
|
+
const trimmed = line.trim();
|
|
806
|
+
const summary = trimmed.startsWith("- ") || trimmed.startsWith("* ") ? meaningfulSummary(trimmed.slice(2)) : null;
|
|
807
|
+
if (summary)
|
|
808
|
+
return summary;
|
|
809
|
+
}
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
function meaningfulSummary(value, options = {}) {
|
|
813
|
+
if (typeof value !== "string")
|
|
814
|
+
return null;
|
|
815
|
+
const summary = value.trim();
|
|
816
|
+
return summary && (options.preservePlaceholder || summary !== PLACEHOLDER_SUMMARY) ? summary : null;
|
|
817
|
+
}
|
|
818
|
+
|
|
667
819
|
// src/core/change-group.ts
|
|
668
820
|
var GROUP_BRIEF_SECTIONS = getCanonicalSectionHeadings(GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
669
821
|
var GROUP_REOPEN_COMPLETION_PREFIX = "Resolve reopened concern from";
|
|
@@ -716,12 +868,12 @@ async function inspectChangeGroups(options = {}) {
|
|
|
716
868
|
const archivedGroupBriefPaths = /* @__PURE__ */ new Map();
|
|
717
869
|
const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
|
|
718
870
|
for (const path of archiveTree.files) {
|
|
719
|
-
const archivePath = normalizeLogicalPath(
|
|
871
|
+
const archivePath = normalizeLogicalPath(relative3(ARCHIVES_DIR, path));
|
|
720
872
|
const archiveGroup = archivePath.includes("/") ? archivePath.split("/")[0] : null;
|
|
721
873
|
if (!archiveGroup || !openGroupNames.has(archiveGroup))
|
|
722
874
|
continue;
|
|
723
875
|
try {
|
|
724
|
-
const content = await
|
|
876
|
+
const content = await readFile2(path, "utf-8");
|
|
725
877
|
const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
726
878
|
const identity = getDocumentTitle(archivedChange, "identity");
|
|
727
879
|
if (identity?.includes("/")) {
|
|
@@ -762,7 +914,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
762
914
|
for (const brief of workTree.briefs) {
|
|
763
915
|
let content;
|
|
764
916
|
try {
|
|
765
|
-
content = await
|
|
917
|
+
content = await readFile2(brief.path, "utf-8");
|
|
766
918
|
} catch (error) {
|
|
767
919
|
diagnostics.push({
|
|
768
920
|
severity: "error",
|
|
@@ -791,7 +943,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
791
943
|
for (const diagnostic of archiveTree.diagnostics.filter((item) => {
|
|
792
944
|
if (item.code === "invalid_archive_root")
|
|
793
945
|
return true;
|
|
794
|
-
const archivePath = normalizeLogicalPath(
|
|
946
|
+
const archivePath = normalizeLogicalPath(relative3(ARCHIVES_DIR, item.path));
|
|
795
947
|
return archivePath === brief.group || archivePath.startsWith(`${brief.group}/`);
|
|
796
948
|
})) {
|
|
797
949
|
diagnostics.push({
|
|
@@ -859,6 +1011,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
859
1011
|
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;
|
|
860
1012
|
groups.push({
|
|
861
1013
|
name: brief.group,
|
|
1014
|
+
summary: extractGroupSummary(content),
|
|
862
1015
|
path: normalizeLogicalPath(brief.path),
|
|
863
1016
|
slices,
|
|
864
1017
|
completion: parsed.completion,
|
|
@@ -881,10 +1034,10 @@ function hasExplicitGroupReopenEvidence(content, retainedEvidence, retainedGroup
|
|
|
881
1034
|
return getGroupReopenEvidence(content).some((item) => item.active && retainedGroupBriefPaths.has(item.sourcePath) && !retainedEvidence.has(item.key));
|
|
882
1035
|
}
|
|
883
1036
|
async function requireDeclaredGroupSlice(ref) {
|
|
884
|
-
const briefPath =
|
|
1037
|
+
const briefPath = join3(dirname2(ref.path), GROUP_BRIEF_FILENAME);
|
|
885
1038
|
let content;
|
|
886
1039
|
try {
|
|
887
|
-
content = await
|
|
1040
|
+
content = await readFile2(briefPath, "utf-8");
|
|
888
1041
|
} catch (error) {
|
|
889
1042
|
const message = error instanceof Error ? error.message : String(error);
|
|
890
1043
|
throw new WorkRefError("work_tree_read_failed", `unable to read Group Brief ${briefPath}: ${message}`, ref.name);
|
|
@@ -914,11 +1067,11 @@ async function hasArchivedGroupBrief(group) {
|
|
|
914
1067
|
}
|
|
915
1068
|
const groupPrefix = `${group}/`;
|
|
916
1069
|
for (const path of archiveTree.files) {
|
|
917
|
-
if (!normalizeLogicalPath(
|
|
1070
|
+
if (!normalizeLogicalPath(relative3(ARCHIVES_DIR, path)).startsWith(groupPrefix))
|
|
918
1071
|
continue;
|
|
919
1072
|
let content;
|
|
920
1073
|
try {
|
|
921
|
-
content = await
|
|
1074
|
+
content = await readFile2(path, "utf-8");
|
|
922
1075
|
} catch (error) {
|
|
923
1076
|
const message = error instanceof Error ? error.message : String(error);
|
|
924
1077
|
throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
|
|
@@ -991,14 +1144,187 @@ function groupSectionHeading(sectionId) {
|
|
|
991
1144
|
}
|
|
992
1145
|
|
|
993
1146
|
// src/core/dependency-plan.ts
|
|
994
|
-
import { readFile as readFile4 } from "fs/promises";
|
|
995
|
-
|
|
996
|
-
// src/history/query.ts
|
|
997
1147
|
import { readFile as readFile3 } from "fs/promises";
|
|
998
1148
|
|
|
1149
|
+
// src/history/current-file.ts
|
|
1150
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
1151
|
+
import { constants } from "fs";
|
|
1152
|
+
import { lstat, open, realpath } from "fs/promises";
|
|
1153
|
+
import { isAbsolute as isAbsolute2, relative as relative4, resolve, sep as sep2 } from "path";
|
|
1154
|
+
|
|
1155
|
+
// src/history/model.ts
|
|
1156
|
+
var HISTORY_DEFAULT_LIMIT = 20;
|
|
1157
|
+
var HISTORY_MAX_LIMIT = 100;
|
|
1158
|
+
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
1159
|
+
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
1160
|
+
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
1161
|
+
var HISTORY_MAX_CANDIDATES = 20;
|
|
1162
|
+
var HISTORY_MAX_FILE_BYTES = 512 * 1024;
|
|
1163
|
+
var ArchiveHistoryError = class extends Error {
|
|
1164
|
+
constructor(code, message, candidates = []) {
|
|
1165
|
+
super(message);
|
|
1166
|
+
this.code = code;
|
|
1167
|
+
this.name = "ArchiveHistoryError";
|
|
1168
|
+
const ordered = [...candidates].sort();
|
|
1169
|
+
this.candidateTotal = ordered.length;
|
|
1170
|
+
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
1171
|
+
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
1172
|
+
}
|
|
1173
|
+
code;
|
|
1174
|
+
candidates;
|
|
1175
|
+
candidateTotal;
|
|
1176
|
+
candidatesTruncated;
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
// src/history/current-file.ts
|
|
1180
|
+
var READ_NO_FOLLOW_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
|
|
1181
|
+
async function readCurrentArchiveFile(options) {
|
|
1182
|
+
const sourcePath = resolve(options.sourcePath);
|
|
1183
|
+
const archivesDir = resolve(options.archivesDir);
|
|
1184
|
+
const relativePath = relative4(archivesDir, sourcePath);
|
|
1185
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep2}`) || isAbsolute2(relativePath)) {
|
|
1186
|
+
throw changed(options.projectPath);
|
|
1187
|
+
}
|
|
1188
|
+
let inspectedRoot;
|
|
1189
|
+
let inspected;
|
|
1190
|
+
let realArchivesDir;
|
|
1191
|
+
try {
|
|
1192
|
+
inspectedRoot = await lstat(archivesDir, { bigint: true });
|
|
1193
|
+
if (!inspectedRoot.isDirectory())
|
|
1194
|
+
throw changed(options.projectPath);
|
|
1195
|
+
inspected = await lstat(sourcePath, { bigint: true });
|
|
1196
|
+
realArchivesDir = await realpath(archivesDir);
|
|
1197
|
+
const resolvedSource = await realpath(sourcePath);
|
|
1198
|
+
const expectedSource = resolve(realArchivesDir, relativePath);
|
|
1199
|
+
if (!inspected.isFile() || resolvedSource !== expectedSource || !isContained(realArchivesDir, resolvedSource)) {
|
|
1200
|
+
throw changed(options.projectPath);
|
|
1201
|
+
}
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
if (error instanceof ArchiveHistoryError)
|
|
1204
|
+
throw error;
|
|
1205
|
+
if (isPermissionError(error))
|
|
1206
|
+
throw readFailed(options.projectPath);
|
|
1207
|
+
throw changed(options.projectPath);
|
|
1208
|
+
}
|
|
1209
|
+
let handle;
|
|
1210
|
+
try {
|
|
1211
|
+
handle = await open(sourcePath, READ_NO_FOLLOW_FLAGS);
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
if (isPermissionError(error))
|
|
1214
|
+
throw readFailed(options.projectPath);
|
|
1215
|
+
throw changed(options.projectPath);
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
const opened = await handle.stat({ bigint: true });
|
|
1219
|
+
const currentRoot = await realpath(archivesDir);
|
|
1220
|
+
const currentSource = await realpath(sourcePath);
|
|
1221
|
+
if (!opened.isFile() || currentRoot !== realArchivesDir || currentSource !== resolve(currentRoot, relativePath) || !isContained(currentRoot, currentSource) || !sameIdentity(inspected, opened) || !sameSnapshot(inspected, opened)) {
|
|
1222
|
+
throw changed(options.projectPath);
|
|
1223
|
+
}
|
|
1224
|
+
if (opened.size > BigInt(options.maxFileBytes))
|
|
1225
|
+
throw tooLarge(options.projectPath, options.maxFileBytes);
|
|
1226
|
+
if (options.expectedSnapshot !== void 0 && !sameFileSnapshot(options.expectedSnapshot, opened)) {
|
|
1227
|
+
throw changed(options.projectPath);
|
|
1228
|
+
}
|
|
1229
|
+
const content = await readBoundedArchive(handle, opened, options.projectPath, options.maxFileBytes);
|
|
1230
|
+
await validateCurrentArchivePath({
|
|
1231
|
+
sourcePath,
|
|
1232
|
+
archivesDir,
|
|
1233
|
+
relativePath,
|
|
1234
|
+
realArchivesDir,
|
|
1235
|
+
inspectedRoot,
|
|
1236
|
+
finalHandle: content.final,
|
|
1237
|
+
projectPath: options.projectPath
|
|
1238
|
+
});
|
|
1239
|
+
return {
|
|
1240
|
+
sourcePath,
|
|
1241
|
+
archivesDir,
|
|
1242
|
+
content: content.content,
|
|
1243
|
+
bytes: content.bytes,
|
|
1244
|
+
snapshot: {
|
|
1245
|
+
device: content.final.dev,
|
|
1246
|
+
inode: content.final.ino,
|
|
1247
|
+
size: content.final.size,
|
|
1248
|
+
mtimeNs: content.final.mtimeNs,
|
|
1249
|
+
ctimeNs: content.final.ctimeNs
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
if (error instanceof ArchiveHistoryError)
|
|
1254
|
+
throw error;
|
|
1255
|
+
throw changed(options.projectPath);
|
|
1256
|
+
} finally {
|
|
1257
|
+
await handle.close();
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
async function readBoundedArchive(handle, opened, projectPath, maxFileBytes) {
|
|
1261
|
+
const buffer = Buffer2.allocUnsafe(maxFileBytes + 1);
|
|
1262
|
+
let bytes = 0;
|
|
1263
|
+
while (bytes < buffer.length) {
|
|
1264
|
+
const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
|
|
1265
|
+
if (result.bytesRead === 0)
|
|
1266
|
+
break;
|
|
1267
|
+
bytes += result.bytesRead;
|
|
1268
|
+
}
|
|
1269
|
+
const final = await handle.stat({ bigint: true });
|
|
1270
|
+
if (bytes > maxFileBytes || final.size > BigInt(maxFileBytes))
|
|
1271
|
+
throw tooLarge(projectPath, maxFileBytes);
|
|
1272
|
+
if (!sameIdentity(opened, final) || !sameSnapshot(opened, final) || final.size !== BigInt(bytes)) {
|
|
1273
|
+
throw changed(projectPath);
|
|
1274
|
+
}
|
|
1275
|
+
return {
|
|
1276
|
+
content: buffer.subarray(0, bytes).toString("utf-8"),
|
|
1277
|
+
bytes,
|
|
1278
|
+
final
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
async function validateCurrentArchivePath(options) {
|
|
1282
|
+
const currentRoot = await lstat(options.archivesDir, { bigint: true });
|
|
1283
|
+
const currentSource = await lstat(options.sourcePath, { bigint: true });
|
|
1284
|
+
const realCurrentRoot = await realpath(options.archivesDir);
|
|
1285
|
+
const realCurrentSource = await realpath(options.sourcePath);
|
|
1286
|
+
if (!currentRoot.isDirectory() || !currentSource.isFile() || !sameIdentity(options.inspectedRoot, currentRoot) || realCurrentRoot !== options.realArchivesDir || realCurrentSource !== resolve(realCurrentRoot, options.relativePath) || !isContained(realCurrentRoot, realCurrentSource) || !sameIdentity(currentSource, options.finalHandle) || !sameSnapshot(currentSource, options.finalHandle)) {
|
|
1287
|
+
throw changed(options.projectPath);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function sameIdentity(left, right) {
|
|
1291
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
1292
|
+
}
|
|
1293
|
+
function sameSnapshot(left, right) {
|
|
1294
|
+
return left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
1295
|
+
}
|
|
1296
|
+
function sameFileSnapshot(left, right) {
|
|
1297
|
+
return left.device === right.dev && left.inode === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
1298
|
+
}
|
|
1299
|
+
function isContained(root, path) {
|
|
1300
|
+
const candidate = relative4(root, path);
|
|
1301
|
+
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${sep2}`) && !isAbsolute2(candidate);
|
|
1302
|
+
}
|
|
1303
|
+
function changed(projectPath) {
|
|
1304
|
+
return new ArchiveHistoryError(
|
|
1305
|
+
"archive_file_changed",
|
|
1306
|
+
`archived Change is no longer the inspected readable regular file: ${projectPath}`
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
function tooLarge(projectPath, maxFileBytes) {
|
|
1310
|
+
return new ArchiveHistoryError(
|
|
1311
|
+
"archive_file_too_large",
|
|
1312
|
+
`archived Change exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
function readFailed(projectPath) {
|
|
1316
|
+
return new ArchiveHistoryError(
|
|
1317
|
+
"archive_read_failed",
|
|
1318
|
+
`unable to read archived Change ${projectPath}`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
function isPermissionError(error) {
|
|
1322
|
+
const code = error.code;
|
|
1323
|
+
return code === "EACCES" || code === "EPERM";
|
|
1324
|
+
}
|
|
1325
|
+
|
|
999
1326
|
// src/history/inspect.ts
|
|
1000
|
-
import {
|
|
1001
|
-
import { basename, dirname as dirname2, relative as relative3 } from "path";
|
|
1327
|
+
import { basename as basename2, dirname as dirname3, relative as relative5, resolve as resolve2 } from "path";
|
|
1002
1328
|
|
|
1003
1329
|
// src/core/issue-relationship.ts
|
|
1004
1330
|
var ISSUE_RELATIONS = /* @__PURE__ */ new Set(["relates", "closes"]);
|
|
@@ -1063,33 +1389,11 @@ function isPlainObject(value) {
|
|
|
1063
1389
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1064
1390
|
}
|
|
1065
1391
|
|
|
1066
|
-
// src/history/model.ts
|
|
1067
|
-
var HISTORY_DEFAULT_LIMIT = 20;
|
|
1068
|
-
var HISTORY_MAX_LIMIT = 100;
|
|
1069
|
-
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
1070
|
-
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
1071
|
-
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
1072
|
-
var HISTORY_MAX_CANDIDATES = 20;
|
|
1073
|
-
var ArchiveHistoryError = class extends Error {
|
|
1074
|
-
constructor(code, message, candidates = []) {
|
|
1075
|
-
super(message);
|
|
1076
|
-
this.code = code;
|
|
1077
|
-
this.name = "ArchiveHistoryError";
|
|
1078
|
-
const ordered = [...candidates].sort();
|
|
1079
|
-
this.candidateTotal = ordered.length;
|
|
1080
|
-
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
1081
|
-
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
1082
|
-
}
|
|
1083
|
-
code;
|
|
1084
|
-
candidates;
|
|
1085
|
-
candidateTotal;
|
|
1086
|
-
candidatesTruncated;
|
|
1087
|
-
};
|
|
1088
|
-
|
|
1089
1392
|
// src/history/inspect.ts
|
|
1090
1393
|
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
1091
1394
|
async function inspectArchiveHistory(options = {}) {
|
|
1092
|
-
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1395
|
+
const archivesDir = resolve2(options.archivesDir ?? ARCHIVES_DIR);
|
|
1396
|
+
const maxFileBytes = options.maxFileBytes ?? HISTORY_MAX_FILE_BYTES;
|
|
1093
1397
|
const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
|
|
1094
1398
|
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
1095
1399
|
severity: "error",
|
|
@@ -1110,15 +1414,21 @@ async function inspectArchiveHistory(options = {}) {
|
|
|
1110
1414
|
}
|
|
1111
1415
|
for (const sourcePath of tree.files) {
|
|
1112
1416
|
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
1113
|
-
let
|
|
1417
|
+
let current;
|
|
1114
1418
|
try {
|
|
1115
|
-
|
|
1419
|
+
current = await readCurrentArchiveFile({
|
|
1420
|
+
sourcePath,
|
|
1421
|
+
archivesDir,
|
|
1422
|
+
projectPath: path,
|
|
1423
|
+
maxFileBytes
|
|
1424
|
+
});
|
|
1116
1425
|
} catch (error) {
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1426
|
+
const code = error instanceof ArchiveHistoryError ? error.code : "archive_read_failed";
|
|
1427
|
+
const message = error instanceof ArchiveHistoryError ? error.message : `unable to read archived work: ${toErrorMessage(error)}`;
|
|
1428
|
+
diagnostics.push({ severity: "error", code, path, message });
|
|
1119
1429
|
continue;
|
|
1120
1430
|
}
|
|
1121
|
-
const parsed = parseArchiveEntry(
|
|
1431
|
+
const parsed = parseArchiveEntry(current, path, maxFileBytes);
|
|
1122
1432
|
if (parsed.diagnostic)
|
|
1123
1433
|
diagnostics.push(parsed.diagnostic);
|
|
1124
1434
|
if (parsed.record)
|
|
@@ -1144,14 +1454,15 @@ async function inspectArchiveHistory(options = {}) {
|
|
|
1144
1454
|
runtime
|
|
1145
1455
|
};
|
|
1146
1456
|
}
|
|
1147
|
-
function parseArchiveEntry(
|
|
1148
|
-
const
|
|
1457
|
+
function parseArchiveEntry(current, path, maxFileBytes) {
|
|
1458
|
+
const { sourcePath, archivesDir, content } = current;
|
|
1459
|
+
const nameMatch = basename2(sourcePath).match(ARCHIVE_NAME_RE);
|
|
1149
1460
|
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
1150
1461
|
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
1151
1462
|
}
|
|
1152
1463
|
const date = nameMatch[1];
|
|
1153
1464
|
const archiveStem = nameMatch[2];
|
|
1154
|
-
const relativeDirectory = normalizeLogicalPath(
|
|
1465
|
+
const relativeDirectory = normalizeLogicalPath(relative5(archivesDir, dirname3(sourcePath)));
|
|
1155
1466
|
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
1156
1467
|
if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
|
|
1157
1468
|
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
@@ -1195,7 +1506,7 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
|
1195
1506
|
if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
|
|
1196
1507
|
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
|
|
1197
1508
|
}
|
|
1198
|
-
const rawSummary =
|
|
1509
|
+
const rawSummary = extractChangeSummary(content, changeDocument, { preservePlaceholder: true });
|
|
1199
1510
|
if (!rawSummary)
|
|
1200
1511
|
return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
|
|
1201
1512
|
const summary = boundText(rawSummary);
|
|
@@ -1210,22 +1521,13 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
|
1210
1521
|
path,
|
|
1211
1522
|
...issues === void 0 ? {} : { issues },
|
|
1212
1523
|
sourcePath,
|
|
1524
|
+
archivesDir,
|
|
1525
|
+
sourceSnapshot: current.snapshot,
|
|
1526
|
+
maxFileBytes,
|
|
1213
1527
|
searchSummary: rawSummary
|
|
1214
1528
|
}
|
|
1215
1529
|
};
|
|
1216
1530
|
}
|
|
1217
|
-
function extractSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA)) {
|
|
1218
|
-
const frontmatter = parseFrontmatter(content);
|
|
1219
|
-
if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
|
|
1220
|
-
return frontmatter.summary.trim();
|
|
1221
|
-
const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
|
|
1222
|
-
for (const prefix of ["- Outcome:", "- Summary:"]) {
|
|
1223
|
-
const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
|
|
1224
|
-
if (line && line.slice(prefix.length).trim())
|
|
1225
|
-
return line.slice(prefix.length).trim();
|
|
1226
|
-
}
|
|
1227
|
-
return "";
|
|
1228
|
-
}
|
|
1229
1531
|
function boundText(value) {
|
|
1230
1532
|
const codePoints = [...value];
|
|
1231
1533
|
return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
|
|
@@ -1238,10 +1540,10 @@ function isCalendarDate(value) {
|
|
|
1238
1540
|
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1239
1541
|
}
|
|
1240
1542
|
function archiveOutputPath(sourcePath, archivesDir) {
|
|
1241
|
-
return `.rsp/archives/${normalizeLogicalPath(
|
|
1543
|
+
return `.rsp/archives/${normalizeLogicalPath(relative5(resolve2(archivesDir), resolve2(sourcePath)))}`;
|
|
1242
1544
|
}
|
|
1243
1545
|
function normalizeOutputPath(path, archivesDir) {
|
|
1244
|
-
const relativePath = normalizeLogicalPath(
|
|
1546
|
+
const relativePath = normalizeLogicalPath(relative5(resolve2(archivesDir), resolve2(path)));
|
|
1245
1547
|
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
1246
1548
|
}
|
|
1247
1549
|
function errorDiagnostic(code, path, message) {
|
|
@@ -1322,17 +1624,28 @@ function selectArchivedGroupBrief(records, selector) {
|
|
|
1322
1624
|
return matches[0];
|
|
1323
1625
|
}
|
|
1324
1626
|
async function readArchiveHistoryDetail(record) {
|
|
1627
|
+
return (await readArchiveHistoryDocument(record)).detail;
|
|
1628
|
+
}
|
|
1629
|
+
async function readArchiveHistoryDocument(record) {
|
|
1325
1630
|
let content;
|
|
1326
1631
|
try {
|
|
1327
|
-
content = await
|
|
1632
|
+
content = (await readCurrentArchiveFile({
|
|
1633
|
+
sourcePath: record.sourcePath,
|
|
1634
|
+
archivesDir: record.archivesDir,
|
|
1635
|
+
projectPath: record.path,
|
|
1636
|
+
maxFileBytes: record.maxFileBytes,
|
|
1637
|
+
expectedSnapshot: record.sourceSnapshot
|
|
1638
|
+
})).content;
|
|
1328
1639
|
} catch (error) {
|
|
1640
|
+
if (error instanceof ArchiveHistoryError)
|
|
1641
|
+
throw error;
|
|
1329
1642
|
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1330
1643
|
}
|
|
1331
1644
|
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1332
1645
|
const tasks = getDocumentSectionBody(document, "tasks");
|
|
1333
1646
|
const verify = getDocumentSectionBody(document, "verify");
|
|
1334
1647
|
const blockers = getDocumentSectionBody(document, "blockers");
|
|
1335
|
-
|
|
1648
|
+
const detail = {
|
|
1336
1649
|
...toOutputRecord(record),
|
|
1337
1650
|
scenarioCount: parseScenarios(content).length,
|
|
1338
1651
|
checkboxes: {
|
|
@@ -1345,12 +1658,20 @@ async function readArchiveHistoryDetail(record) {
|
|
|
1345
1658
|
blockers: boundEvidence(blockers, true)
|
|
1346
1659
|
}
|
|
1347
1660
|
};
|
|
1661
|
+
return { content, detail };
|
|
1348
1662
|
}
|
|
1349
1663
|
function historyInspectionComplete(inspection) {
|
|
1350
1664
|
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
1351
1665
|
}
|
|
1352
1666
|
function toOutputRecord(record) {
|
|
1353
|
-
const {
|
|
1667
|
+
const {
|
|
1668
|
+
searchSummary: _searchSummary,
|
|
1669
|
+
sourcePath: _sourcePath,
|
|
1670
|
+
archivesDir: _archivesDir,
|
|
1671
|
+
sourceSnapshot: _sourceSnapshot,
|
|
1672
|
+
maxFileBytes: _maxFileBytes,
|
|
1673
|
+
...output
|
|
1674
|
+
} = record;
|
|
1354
1675
|
return output;
|
|
1355
1676
|
}
|
|
1356
1677
|
function boundEvidence(section, omitNone = false) {
|
|
@@ -1372,7 +1693,6 @@ function compareHistoryRecords2(left, right) {
|
|
|
1372
1693
|
}
|
|
1373
1694
|
|
|
1374
1695
|
// src/core/dependency-plan.ts
|
|
1375
|
-
var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
|
|
1376
1696
|
var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
|
|
1377
1697
|
async function inspectChangeDependencies(options = {}) {
|
|
1378
1698
|
const workTree = options.workTree ?? await inspectWorkTree();
|
|
@@ -1394,7 +1714,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
1394
1714
|
diagnostics.push(...archiveHistory.diagnostics);
|
|
1395
1715
|
for (const brief of workTree.briefs) {
|
|
1396
1716
|
try {
|
|
1397
|
-
if (hasMeaningfulBlockers(await
|
|
1717
|
+
if (hasMeaningfulBlockers(await readFile3(brief.path, "utf-8")))
|
|
1398
1718
|
blockedGroups.add(brief.group);
|
|
1399
1719
|
} catch (error) {
|
|
1400
1720
|
diagnostics.push({
|
|
@@ -1408,7 +1728,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
1408
1728
|
}
|
|
1409
1729
|
for (const ref of workTree.changes) {
|
|
1410
1730
|
try {
|
|
1411
|
-
const parsed = parseDependencyBlockers(await
|
|
1731
|
+
const parsed = parseDependencyBlockers(await readFile3(ref.path, "utf-8"));
|
|
1412
1732
|
if (ref.group && blockedGroups.has(ref.group))
|
|
1413
1733
|
parsed.external = true;
|
|
1414
1734
|
parsedByName.set(ref.name, parsed);
|
|
@@ -1514,7 +1834,7 @@ function parseDependencyBlockers(content) {
|
|
|
1514
1834
|
let external = false;
|
|
1515
1835
|
const malformed = [];
|
|
1516
1836
|
for (const line of extractBlockerLines(content)) {
|
|
1517
|
-
if (!line ||
|
|
1837
|
+
if (!line || isEmptyBlockerLine(line))
|
|
1518
1838
|
continue;
|
|
1519
1839
|
const dependency = line.match(REQUIRES_RE);
|
|
1520
1840
|
if (dependency) {
|
|
@@ -1538,13 +1858,13 @@ function buildWaves(names, edges, blockersByName, preferredOrder) {
|
|
|
1538
1858
|
const excluded = new Set(
|
|
1539
1859
|
[...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
|
|
1540
1860
|
);
|
|
1541
|
-
let
|
|
1542
|
-
while (
|
|
1543
|
-
|
|
1861
|
+
let changed2 = true;
|
|
1862
|
+
while (changed2) {
|
|
1863
|
+
changed2 = false;
|
|
1544
1864
|
for (const edge of edges) {
|
|
1545
1865
|
if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
|
|
1546
1866
|
excluded.add(edge.change);
|
|
1547
|
-
|
|
1867
|
+
changed2 = true;
|
|
1548
1868
|
}
|
|
1549
1869
|
}
|
|
1550
1870
|
}
|
|
@@ -1622,6 +1942,82 @@ function findCycleDiagnostics(edges) {
|
|
|
1622
1942
|
return diagnostics;
|
|
1623
1943
|
}
|
|
1624
1944
|
|
|
1945
|
+
// src/core/readiness.ts
|
|
1946
|
+
function collectArchiveReadiness(content, options = {}) {
|
|
1947
|
+
const warnings = [];
|
|
1948
|
+
const documentDiagnostics = options.documentDiagnostics ?? [];
|
|
1949
|
+
warnings.push(...documentDiagnostics.map((diagnostic) => diagnostic.message));
|
|
1950
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1951
|
+
const tasksSection = getDocumentSectionBody(document, "tasks");
|
|
1952
|
+
const verifySection = getDocumentSectionBody(document, "verify");
|
|
1953
|
+
const taskTodos = getOpenCheckboxes(tasksSection);
|
|
1954
|
+
if (taskTodos.length > 0)
|
|
1955
|
+
warnings.push(`${taskTodos.length} task item(s) still incomplete`);
|
|
1956
|
+
const verifyCriticality = classifyVerifyCheckboxes(verifySection);
|
|
1957
|
+
const verifyLines = collectVerifyCheckboxLines(verifySection);
|
|
1958
|
+
const requiredVerifyTodos = getOpenCheckboxes([...verifyLines.required, ...verifyLines.unclassified].join("\n"));
|
|
1959
|
+
const optionalVerifyTodos = getOpenCheckboxes(verifyLines.optional.join("\n"));
|
|
1960
|
+
const verifyTodos = [...requiredVerifyTodos, ...optionalVerifyTodos];
|
|
1961
|
+
if (requiredVerifyTodos.length > 0)
|
|
1962
|
+
warnings.push(`${requiredVerifyTodos.length} required Verify item(s) are still incomplete`);
|
|
1963
|
+
if (optionalVerifyTodos.length > 0)
|
|
1964
|
+
warnings.push(`${optionalVerifyTodos.length} optional Verify item(s) are still incomplete`);
|
|
1965
|
+
const activeBlockers = options.activeBlockers ?? hasMeaningfulBlockers(content);
|
|
1966
|
+
if (activeBlockers)
|
|
1967
|
+
warnings.push("active blockers are present in the change file");
|
|
1968
|
+
const scenarios = parseScenarios(content);
|
|
1969
|
+
const missingScenarios = scenarios.length === 0;
|
|
1970
|
+
if (missingScenarios)
|
|
1971
|
+
warnings.push("no Scenario blocks found (some changes do not need them)");
|
|
1972
|
+
const archiveReady = documentDiagnostics.some((diagnostic) => diagnostic.severity === "error") || activeBlockers || taskTodos.length > 0 || requiredVerifyTodos.length > 0 ? "no" : "yes";
|
|
1973
|
+
return {
|
|
1974
|
+
taskTodos,
|
|
1975
|
+
verifyTodos,
|
|
1976
|
+
requiredVerifyTodos,
|
|
1977
|
+
optionalVerifyTodos,
|
|
1978
|
+
verifyCriticality,
|
|
1979
|
+
activeBlockers,
|
|
1980
|
+
scenarioCount: scenarios.length,
|
|
1981
|
+
missingScenarios,
|
|
1982
|
+
deterministic: warnings.length === 0 ? "pass" : "warnings",
|
|
1983
|
+
semantic: "needs-review",
|
|
1984
|
+
archiveReady,
|
|
1985
|
+
warnings,
|
|
1986
|
+
documentDiagnostics
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
function toArchiveReadinessOutput(readiness) {
|
|
1990
|
+
return {
|
|
1991
|
+
incompleteTasks: readiness.taskTodos.length,
|
|
1992
|
+
incompleteVerify: readiness.verifyTodos.length,
|
|
1993
|
+
incompleteRequiredVerify: readiness.requiredVerifyTodos.length,
|
|
1994
|
+
incompleteOptionalVerify: readiness.optionalVerifyTodos.length,
|
|
1995
|
+
requiredVerify: readiness.verifyCriticality.required,
|
|
1996
|
+
optionalVerify: readiness.verifyCriticality.optional,
|
|
1997
|
+
legacyVerify: readiness.verifyCriticality.legacy,
|
|
1998
|
+
completionGate: readiness.archiveReady === "no" ? "blocked" : "pass",
|
|
1999
|
+
coverageWarnings: readiness.optionalVerifyTodos.length,
|
|
2000
|
+
activeBlockers: readiness.activeBlockers,
|
|
2001
|
+
missingScenarios: readiness.missingScenarios,
|
|
2002
|
+
deterministic: readiness.deterministic,
|
|
2003
|
+
semantic: readiness.semantic,
|
|
2004
|
+
archiveReady: readiness.archiveReady
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
function buildDurableReviewGuidance(factCandidateTargets, decisionRecordsPath) {
|
|
2008
|
+
return {
|
|
2009
|
+
required: true,
|
|
2010
|
+
factDecisions: ["No current-fact update needed", "Update existing spec or scoped instruction", "Create a new durable spec"],
|
|
2011
|
+
rationaleDecisions: ["No Decision Record needed", "Create or update a Decision Record"],
|
|
2012
|
+
factCandidateTargets,
|
|
2013
|
+
decisionRecordsPath,
|
|
2014
|
+
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."
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
function getDurableReviewCandidateTargets() {
|
|
2018
|
+
return [".rsp/specs/design.md"];
|
|
2019
|
+
}
|
|
2020
|
+
|
|
1625
2021
|
// src/status/inspect.ts
|
|
1626
2022
|
async function inspectProjectStatus(options = {}) {
|
|
1627
2023
|
const runtime = [];
|
|
@@ -1707,6 +2103,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1707
2103
|
for (const name of allNames) {
|
|
1708
2104
|
const path = changeMap.get(name);
|
|
1709
2105
|
let kind = "\u2014";
|
|
2106
|
+
let summary = null;
|
|
1710
2107
|
let title = name;
|
|
1711
2108
|
let blockerEntries = [];
|
|
1712
2109
|
let readiness = emptyReadiness(false);
|
|
@@ -1719,8 +2116,9 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1719
2116
|
let issues = [];
|
|
1720
2117
|
if (path) {
|
|
1721
2118
|
try {
|
|
1722
|
-
const content = await
|
|
2119
|
+
const content = await readFile4(path, "utf-8");
|
|
1723
2120
|
title = extractChangeTitle(content) ?? name;
|
|
2121
|
+
summary = extractChangeSummary(content);
|
|
1724
2122
|
blockerEntries = extractBlockerEntries(content);
|
|
1725
2123
|
try {
|
|
1726
2124
|
const frontmatter = parseFrontmatter(content);
|
|
@@ -1746,20 +2144,13 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1746
2144
|
});
|
|
1747
2145
|
}
|
|
1748
2146
|
isBlocked = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(content);
|
|
1749
|
-
const checkboxes = countCheckboxes(content);
|
|
1750
|
-
done = checkboxes.done;
|
|
1751
|
-
total = checkboxes.total;
|
|
1752
|
-
progressKnown = true;
|
|
1753
2147
|
const details = collectArchiveReadiness(content, { activeBlockers: isBlocked });
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
semantic: details.semantic,
|
|
1761
|
-
archiveReady: details.archiveReady
|
|
1762
|
-
};
|
|
2148
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
2149
|
+
const taskCheckboxes = countCheckboxes(getDocumentSectionBody(document, "tasks"));
|
|
2150
|
+
done = taskCheckboxes.done + details.verifyCriticality.required.done;
|
|
2151
|
+
total = taskCheckboxes.total + details.verifyCriticality.required.total;
|
|
2152
|
+
progressKnown = true;
|
|
2153
|
+
readiness = toArchiveReadinessOutput(details);
|
|
1763
2154
|
} catch (error) {
|
|
1764
2155
|
diagnostics.push({
|
|
1765
2156
|
severity: "error",
|
|
@@ -1815,6 +2206,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1815
2206
|
records.push({
|
|
1816
2207
|
output: {
|
|
1817
2208
|
name,
|
|
2209
|
+
summary,
|
|
1818
2210
|
kind,
|
|
1819
2211
|
progress: { done, total },
|
|
1820
2212
|
ageDays,
|
|
@@ -1845,6 +2237,13 @@ function emptyReadiness(activeBlockers) {
|
|
|
1845
2237
|
return {
|
|
1846
2238
|
incompleteTasks: 0,
|
|
1847
2239
|
incompleteVerify: 0,
|
|
2240
|
+
incompleteRequiredVerify: 0,
|
|
2241
|
+
incompleteOptionalVerify: 0,
|
|
2242
|
+
requiredVerify: { todo: 0, progress: 0, done: 0, dropped: 0, total: 0 },
|
|
2243
|
+
optionalVerify: { todo: 0, progress: 0, done: 0, dropped: 0, total: 0 },
|
|
2244
|
+
legacyVerify: false,
|
|
2245
|
+
completionGate: activeBlockers ? "blocked" : "pass",
|
|
2246
|
+
coverageWarnings: 0,
|
|
1848
2247
|
activeBlockers,
|
|
1849
2248
|
missingScenarios: false,
|
|
1850
2249
|
deterministic: "pass",
|
|
@@ -1868,6 +2267,670 @@ function deriveArchiveTrend(records) {
|
|
|
1868
2267
|
return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([month, count]) => ({ month, count }));
|
|
1869
2268
|
}
|
|
1870
2269
|
|
|
2270
|
+
// src/specs/model.ts
|
|
2271
|
+
var SPECS_DEFAULT_RESULT_LIMIT = 20;
|
|
2272
|
+
var SPECS_MAX_RESULT_LIMIT = 100;
|
|
2273
|
+
var SPECS_MAX_CANDIDATES = 1e3;
|
|
2274
|
+
var SPECS_MAX_FILE_BYTES = 512 * 1024;
|
|
2275
|
+
var SPECS_SEARCH_EXCERPT_CODE_POINTS = 240;
|
|
2276
|
+
var SPECS_DETAIL_CONTENT_CODE_POINTS = 12e3;
|
|
2277
|
+
var SPECS_MAX_DIAGNOSTICS = 20;
|
|
2278
|
+
|
|
2279
|
+
// src/specs/query.ts
|
|
2280
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
2281
|
+
import { spawnSync } from "child_process";
|
|
2282
|
+
import { constants as constants2 } from "fs";
|
|
2283
|
+
import { lstat as lstat2, open as open2, realpath as realpath2 } from "fs/promises";
|
|
2284
|
+
import { basename as basename3, dirname as dirname4, isAbsolute as isAbsolute3, join as join4, relative as relative6, resolve as resolve3, sep as sep3 } from "path";
|
|
2285
|
+
var SPECS_ROOT = join4(RSP_DIR, "specs");
|
|
2286
|
+
var GENERATED_INDEX_FILENAME = "00-index.md";
|
|
2287
|
+
var LEGACY_GENERATED_INDEX_FILENAME = "INDEX.md";
|
|
2288
|
+
var READ_NO_FOLLOW_FLAGS2 = constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0);
|
|
2289
|
+
var SpecsQueryError = class extends Error {
|
|
2290
|
+
constructor(code, message) {
|
|
2291
|
+
super(message);
|
|
2292
|
+
this.code = code;
|
|
2293
|
+
this.name = "SpecsQueryError";
|
|
2294
|
+
}
|
|
2295
|
+
code;
|
|
2296
|
+
};
|
|
2297
|
+
async function inspectSpecs(options = {}) {
|
|
2298
|
+
const projectRoot = await realpath2(resolve3(options.cwd ?? process.cwd()));
|
|
2299
|
+
const specsRoot = resolve3(projectRoot, SPECS_ROOT);
|
|
2300
|
+
const maxCandidates = options.maxCandidates ?? SPECS_MAX_CANDIDATES;
|
|
2301
|
+
const maxFileBytes = options.maxFileBytes ?? SPECS_MAX_FILE_BYTES;
|
|
2302
|
+
validatePositiveBound(maxCandidates, "maxCandidates");
|
|
2303
|
+
validatePositiveBound(maxFileBytes, "maxFileBytes");
|
|
2304
|
+
const configInspection = await inspectRspConfig(projectRoot);
|
|
2305
|
+
if (configInspection.issues.length > 0)
|
|
2306
|
+
throw new SpecsQueryError("invalid_config", configInspection.issues.join("; "));
|
|
2307
|
+
const decisionRecordsPath = resolveDecisionRecordsPath(configInspection.config);
|
|
2308
|
+
const decisionPathIssue = await validateDecisionRecordsFilesystemPath(decisionRecordsPath, projectRoot);
|
|
2309
|
+
if (decisionPathIssue)
|
|
2310
|
+
throw new SpecsQueryError("invalid_decision_records_path", decisionPathIssue);
|
|
2311
|
+
const decisionRecordsRoot = resolve3(projectRoot, decisionRecordsPath);
|
|
2312
|
+
const accumulator = {
|
|
2313
|
+
documents: [],
|
|
2314
|
+
generatedIndexes: [],
|
|
2315
|
+
diagnostics: [],
|
|
2316
|
+
candidateCount: 0,
|
|
2317
|
+
candidateLimitReported: false
|
|
2318
|
+
};
|
|
2319
|
+
const excludedSpecDirectories = [DEFAULT_DECISION_RECORDS_PATH, decisionRecordsPath].map((path) => resolve3(projectRoot, path)).filter((path) => isInside(specsRoot, path));
|
|
2320
|
+
await inspectDocumentRoot({
|
|
2321
|
+
projectRoot,
|
|
2322
|
+
root: specsRoot,
|
|
2323
|
+
label: "Specs",
|
|
2324
|
+
kind: "spec",
|
|
2325
|
+
excludedDirectories: excludedSpecDirectories,
|
|
2326
|
+
accumulator,
|
|
2327
|
+
maxCandidates,
|
|
2328
|
+
maxFileBytes,
|
|
2329
|
+
allowMissing: false
|
|
2330
|
+
});
|
|
2331
|
+
await inspectDocumentRoot({
|
|
2332
|
+
projectRoot,
|
|
2333
|
+
root: decisionRecordsRoot,
|
|
2334
|
+
label: "Decision Records",
|
|
2335
|
+
kind: "decision-record",
|
|
2336
|
+
excludedDirectories: [],
|
|
2337
|
+
accumulator,
|
|
2338
|
+
maxCandidates,
|
|
2339
|
+
maxFileBytes,
|
|
2340
|
+
allowMissing: options.allowMissingDecisionRecords === true
|
|
2341
|
+
});
|
|
2342
|
+
detectPathCollisions(accumulator);
|
|
2343
|
+
accumulator.documents.sort(compareDocument);
|
|
2344
|
+
accumulator.generatedIndexes.sort((left, right) => left.path.localeCompare(right.path));
|
|
2345
|
+
const boundedDiagnostics = accumulator.diagnostics.slice(0, SPECS_MAX_DIAGNOSTICS);
|
|
2346
|
+
return {
|
|
2347
|
+
source: await inspectSourceIdentity(projectRoot),
|
|
2348
|
+
roots: {
|
|
2349
|
+
specs: toProjectPath(projectRoot, specsRoot),
|
|
2350
|
+
decisions: toProjectPath(projectRoot, decisionRecordsRoot)
|
|
2351
|
+
},
|
|
2352
|
+
tree: buildDirectoryTree(projectRoot, specsRoot, "spec", accumulator.documents),
|
|
2353
|
+
decisionRecords: buildDirectoryTree(projectRoot, decisionRecordsRoot, "decision-record", accumulator.documents),
|
|
2354
|
+
documents: accumulator.documents.map(stripSource),
|
|
2355
|
+
generatedIndexes: accumulator.generatedIndexes,
|
|
2356
|
+
diagnostics: boundedDiagnostics,
|
|
2357
|
+
diagnosticSummary: {
|
|
2358
|
+
total: accumulator.diagnostics.length,
|
|
2359
|
+
returned: boundedDiagnostics.length,
|
|
2360
|
+
hasMore: accumulator.diagnostics.length > boundedDiagnostics.length
|
|
2361
|
+
},
|
|
2362
|
+
runtime: [],
|
|
2363
|
+
limits: {
|
|
2364
|
+
candidates: maxCandidates,
|
|
2365
|
+
fileBytes: maxFileBytes,
|
|
2366
|
+
results: SPECS_MAX_RESULT_LIMIT,
|
|
2367
|
+
searchExcerptCodePoints: SPECS_SEARCH_EXCERPT_CODE_POINTS,
|
|
2368
|
+
detailContentCodePoints: SPECS_DETAIL_CONTENT_CODE_POINTS
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
function specsInspectionComplete(inspection) {
|
|
2373
|
+
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
2374
|
+
}
|
|
2375
|
+
async function readSpecsDetail(inspection, projectPath) {
|
|
2376
|
+
const current = await readCurrentSpecsDocument(inspection, projectPath);
|
|
2377
|
+
const bounded = boundCodePoints(current.content, SPECS_DETAIL_CONTENT_CODE_POINTS);
|
|
2378
|
+
return {
|
|
2379
|
+
...current,
|
|
2380
|
+
content: bounded.value,
|
|
2381
|
+
contentTruncated: bounded.truncated
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
async function readCurrentSpecsDocument(inspection, projectPath) {
|
|
2385
|
+
const normalized = normalizeRequestedPath(projectPath);
|
|
2386
|
+
const document = inspection.documents.find((candidate) => candidate.path === normalized);
|
|
2387
|
+
if (!document)
|
|
2388
|
+
throw new SpecsQueryError("specs_document_not_found", `Specs document not found: ${projectPath}`);
|
|
2389
|
+
const sourcePath = resolve3(inspection.source.root, normalized);
|
|
2390
|
+
const current = await readCurrentDocument(
|
|
2391
|
+
sourcePath,
|
|
2392
|
+
inspection.source.root,
|
|
2393
|
+
document.path,
|
|
2394
|
+
inspection.limits.fileBytes
|
|
2395
|
+
);
|
|
2396
|
+
const currentDocument = parseDocumentRecord(current.content, current.bytes, document.path, document.kind);
|
|
2397
|
+
return {
|
|
2398
|
+
...currentDocument,
|
|
2399
|
+
content: current.content
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
async function searchSpecs(inspection, literal, options = {}) {
|
|
2403
|
+
return (await searchSpecsWithSources(inspection, literal, options)).result;
|
|
2404
|
+
}
|
|
2405
|
+
async function searchSpecsWithSources(inspection, literal, options = {}) {
|
|
2406
|
+
const query = literal.trim();
|
|
2407
|
+
if (query === "")
|
|
2408
|
+
throw new SpecsQueryError("invalid_specs_search", "Specs search literal must be non-empty");
|
|
2409
|
+
const limit = options.limit ?? 20;
|
|
2410
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > SPECS_MAX_RESULT_LIMIT)
|
|
2411
|
+
throw new SpecsQueryError("invalid_specs_limit", `Specs search limit must be an integer from 1 through ${SPECS_MAX_RESULT_LIMIT}`);
|
|
2412
|
+
const excerptCodePoints = options.excerptCodePoints ?? SPECS_SEARCH_EXCERPT_CODE_POINTS;
|
|
2413
|
+
if (!Number.isInteger(excerptCodePoints) || excerptCodePoints < 40 || excerptCodePoints > 1e3)
|
|
2414
|
+
throw new SpecsQueryError("invalid_specs_excerpt", "Specs search excerpt must be an integer from 40 through 1000 code points");
|
|
2415
|
+
const lowered = query.toLowerCase();
|
|
2416
|
+
const matches = [];
|
|
2417
|
+
const documents = /* @__PURE__ */ new Map();
|
|
2418
|
+
let matched = 0;
|
|
2419
|
+
for (const document of inspection.documents) {
|
|
2420
|
+
const current = await readCurrentDocument(
|
|
2421
|
+
resolve3(inspection.source.root, document.path),
|
|
2422
|
+
inspection.source.root,
|
|
2423
|
+
document.path,
|
|
2424
|
+
inspection.limits.fileBytes
|
|
2425
|
+
);
|
|
2426
|
+
const currentDocument = parseDocumentRecord(current.content, current.bytes, document.path, document.kind);
|
|
2427
|
+
const lines = current.content.split(/\r?\n/);
|
|
2428
|
+
let heading = null;
|
|
2429
|
+
for (let index = 0; index < lines.length; index++) {
|
|
2430
|
+
const parsedHeading = parseMarkdownHeading(lines[index]);
|
|
2431
|
+
if (parsedHeading)
|
|
2432
|
+
heading = parsedHeading.title;
|
|
2433
|
+
const position = lines[index].toLowerCase().indexOf(lowered);
|
|
2434
|
+
if (position < 0)
|
|
2435
|
+
continue;
|
|
2436
|
+
matched += 1;
|
|
2437
|
+
if (matches.length < limit) {
|
|
2438
|
+
if (!documents.has(document.path)) {
|
|
2439
|
+
documents.set(document.path, {
|
|
2440
|
+
...currentDocument,
|
|
2441
|
+
content: current.content
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
matches.push({
|
|
2445
|
+
path: document.path,
|
|
2446
|
+
kind: document.kind,
|
|
2447
|
+
title: currentDocument.title,
|
|
2448
|
+
heading,
|
|
2449
|
+
line: index + 1,
|
|
2450
|
+
excerpt: excerptAround(
|
|
2451
|
+
lines[index],
|
|
2452
|
+
[...lines[index].slice(0, position)].length,
|
|
2453
|
+
[...query].length,
|
|
2454
|
+
excerptCodePoints
|
|
2455
|
+
)
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return {
|
|
2461
|
+
result: {
|
|
2462
|
+
matches,
|
|
2463
|
+
summary: {
|
|
2464
|
+
candidates: inspection.documents.length,
|
|
2465
|
+
searched: inspection.documents.length,
|
|
2466
|
+
matched,
|
|
2467
|
+
returned: matches.length,
|
|
2468
|
+
hasMore: matched > matches.length
|
|
2469
|
+
}
|
|
2470
|
+
},
|
|
2471
|
+
documents
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
async function inspectDocumentRoot(options) {
|
|
2475
|
+
const inspection = await inspectManagedFileTree(options.root, options.label, { allowMissing: options.allowMissing });
|
|
2476
|
+
for (const issue of inspection.issues) {
|
|
2477
|
+
options.accumulator.diagnostics.push({
|
|
2478
|
+
severity: "error",
|
|
2479
|
+
code: "specs_tree_invalid",
|
|
2480
|
+
path: toProjectPath(options.projectRoot, issue.path),
|
|
2481
|
+
message: issue.message
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
if (!inspection.rootExists)
|
|
2485
|
+
return;
|
|
2486
|
+
for (const path of inspection.files) {
|
|
2487
|
+
if (options.excludedDirectories.some((directory) => isInside(directory, path)))
|
|
2488
|
+
continue;
|
|
2489
|
+
const projectPath = toProjectPath(options.projectRoot, path);
|
|
2490
|
+
const reserved = reservedIndexKind(options.root, path);
|
|
2491
|
+
if (reserved) {
|
|
2492
|
+
await classifyGeneratedIndex(path, options.projectRoot, projectPath, options.maxFileBytes, options.accumulator);
|
|
2493
|
+
continue;
|
|
2494
|
+
}
|
|
2495
|
+
if (!path.endsWith(".md"))
|
|
2496
|
+
continue;
|
|
2497
|
+
options.accumulator.candidateCount += 1;
|
|
2498
|
+
if (options.accumulator.candidateCount > options.maxCandidates) {
|
|
2499
|
+
if (!options.accumulator.candidateLimitReported) {
|
|
2500
|
+
options.accumulator.diagnostics.push({
|
|
2501
|
+
severity: "error",
|
|
2502
|
+
code: "specs_candidate_limit_exceeded",
|
|
2503
|
+
path: projectPath,
|
|
2504
|
+
message: `Specs candidate limit exceeded (${options.maxCandidates})`
|
|
2505
|
+
});
|
|
2506
|
+
options.accumulator.candidateLimitReported = true;
|
|
2507
|
+
}
|
|
2508
|
+
continue;
|
|
2509
|
+
}
|
|
2510
|
+
await inspectDocument(path, options.projectRoot, projectPath, options.kind, options.maxFileBytes, options.accumulator);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
async function classifyGeneratedIndex(path, projectRoot, projectPath, maxFileBytes, accumulator) {
|
|
2514
|
+
try {
|
|
2515
|
+
const current = await readCurrentDocument(path, projectRoot, projectPath, maxFileBytes);
|
|
2516
|
+
const recognized = isRecognizedGeneratedSpecsIndex(current.content, projectPath);
|
|
2517
|
+
accumulator.generatedIndexes.push({
|
|
2518
|
+
path: projectPath,
|
|
2519
|
+
classification: recognized ? "safe-removal" : "owner-controlled",
|
|
2520
|
+
reason: recognized ? "metadata recognizes the reserved path as a generated Specs index" : "reserved path content is not recognized generated Specs-index output"
|
|
2521
|
+
});
|
|
2522
|
+
if (!recognized) {
|
|
2523
|
+
accumulator.diagnostics.push({
|
|
2524
|
+
severity: "error",
|
|
2525
|
+
code: "unrecognized_reserved_specs_index",
|
|
2526
|
+
path: projectPath,
|
|
2527
|
+
message: `reserved Specs index is owner-controlled and cannot be treated as generated content: ${projectPath}`
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
} catch (error) {
|
|
2531
|
+
accumulator.generatedIndexes.push({
|
|
2532
|
+
path: projectPath,
|
|
2533
|
+
classification: "owner-controlled",
|
|
2534
|
+
reason: "reserved path could not be boundedly inspected as generated Specs-index output"
|
|
2535
|
+
});
|
|
2536
|
+
accumulator.diagnostics.push({
|
|
2537
|
+
severity: "error",
|
|
2538
|
+
code: error instanceof SpecsQueryError ? error.code : "specs_index_read_failed",
|
|
2539
|
+
path: projectPath,
|
|
2540
|
+
message: `unable to inspect reserved Specs index ${projectPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
function isRecognizedGeneratedSpecsIndex(content, projectPath) {
|
|
2545
|
+
const metadata = parseFrontmatter(content);
|
|
2546
|
+
const expectedSourceDirectory = normalizeLogicalPath(dirname4(projectPath));
|
|
2547
|
+
return metadata?.kind === "generated-index" && metadata.index_type === "specs" && metadata.source_dir === expectedSourceDirectory;
|
|
2548
|
+
}
|
|
2549
|
+
async function inspectDocument(path, projectRoot, projectPath, kind, maxFileBytes, accumulator) {
|
|
2550
|
+
try {
|
|
2551
|
+
const current = await readCurrentDocument(path, projectRoot, projectPath, maxFileBytes);
|
|
2552
|
+
const metadata = parseFrontmatter(current.content);
|
|
2553
|
+
if (metadata?.kind === "generated-index")
|
|
2554
|
+
return;
|
|
2555
|
+
accumulator.documents.push({
|
|
2556
|
+
sourcePath: path,
|
|
2557
|
+
...parseDocumentRecord(current.content, current.bytes, projectPath, kind)
|
|
2558
|
+
});
|
|
2559
|
+
} catch (error) {
|
|
2560
|
+
accumulator.diagnostics.push({
|
|
2561
|
+
severity: "error",
|
|
2562
|
+
code: error instanceof SpecsQueryError ? error.code : "specs_file_read_failed",
|
|
2563
|
+
path: projectPath,
|
|
2564
|
+
message: `unable to read Specs document ${projectPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
function detectPathCollisions(accumulator) {
|
|
2569
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2570
|
+
for (const document of accumulator.documents) {
|
|
2571
|
+
const canonical = document.path.normalize("NFC");
|
|
2572
|
+
const existing = seen.get(canonical);
|
|
2573
|
+
if (existing && existing !== document.path) {
|
|
2574
|
+
accumulator.diagnostics.push({
|
|
2575
|
+
severity: "error",
|
|
2576
|
+
code: "specs_path_collision",
|
|
2577
|
+
path: document.path,
|
|
2578
|
+
message: `Specs path normalization collision: ${existing} and ${document.path}`
|
|
2579
|
+
});
|
|
2580
|
+
} else {
|
|
2581
|
+
seen.set(canonical, document.path);
|
|
2582
|
+
}
|
|
2583
|
+
if (canonical !== document.path) {
|
|
2584
|
+
accumulator.diagnostics.push({
|
|
2585
|
+
severity: "error",
|
|
2586
|
+
code: "specs_path_not_canonical",
|
|
2587
|
+
path: document.path,
|
|
2588
|
+
message: `Specs path must use canonical NFC form: ${document.path}`
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
function buildDirectoryTree(projectRoot, root, kind, documents) {
|
|
2594
|
+
const rootPath = toProjectPath(projectRoot, root);
|
|
2595
|
+
const tree = {
|
|
2596
|
+
name: basename3(root),
|
|
2597
|
+
path: rootPath,
|
|
2598
|
+
directories: [],
|
|
2599
|
+
documents: []
|
|
2600
|
+
};
|
|
2601
|
+
for (const document of documents.filter((candidate) => candidate.kind === kind)) {
|
|
2602
|
+
const rel = relative6(root, document.sourcePath);
|
|
2603
|
+
if (rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute3(rel))
|
|
2604
|
+
continue;
|
|
2605
|
+
const segments = normalizeLogicalPath(rel).split("/");
|
|
2606
|
+
let current = tree;
|
|
2607
|
+
for (const segment of segments.slice(0, -1)) {
|
|
2608
|
+
let child = current.directories.find((directory) => directory.name === segment);
|
|
2609
|
+
if (!child) {
|
|
2610
|
+
child = {
|
|
2611
|
+
name: segment,
|
|
2612
|
+
path: `${current.path}/${segment}`,
|
|
2613
|
+
directories: [],
|
|
2614
|
+
documents: []
|
|
2615
|
+
};
|
|
2616
|
+
current.directories.push(child);
|
|
2617
|
+
current.directories.sort((left, right) => left.name.localeCompare(right.name));
|
|
2618
|
+
}
|
|
2619
|
+
current = child;
|
|
2620
|
+
}
|
|
2621
|
+
current.documents.push(stripSource(document));
|
|
2622
|
+
current.documents.sort(compareDocument);
|
|
2623
|
+
}
|
|
2624
|
+
return tree;
|
|
2625
|
+
}
|
|
2626
|
+
function extractHeadings(content) {
|
|
2627
|
+
const headings = [];
|
|
2628
|
+
const lines = content.split(/\r?\n/);
|
|
2629
|
+
for (let index = 0; index < lines.length; index++) {
|
|
2630
|
+
const heading = parseMarkdownHeading(lines[index]);
|
|
2631
|
+
if (!heading)
|
|
2632
|
+
continue;
|
|
2633
|
+
headings.push({
|
|
2634
|
+
depth: heading.depth,
|
|
2635
|
+
title: heading.title,
|
|
2636
|
+
line: index + 1
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
return headings;
|
|
2640
|
+
}
|
|
2641
|
+
function parseMarkdownHeading(line) {
|
|
2642
|
+
let depth = 0;
|
|
2643
|
+
while (depth < 6 && line[depth] === "#")
|
|
2644
|
+
depth += 1;
|
|
2645
|
+
if (depth === 0 || line[depth] === "#" || !isWhitespace(line[depth]))
|
|
2646
|
+
return null;
|
|
2647
|
+
let contentStart = depth;
|
|
2648
|
+
while (contentStart < line.length && isWhitespace(line[contentStart]))
|
|
2649
|
+
contentStart += 1;
|
|
2650
|
+
if (contentStart === line.length)
|
|
2651
|
+
return null;
|
|
2652
|
+
const rawTitle = line.slice(contentStart);
|
|
2653
|
+
let titleEnd = rawTitle.length;
|
|
2654
|
+
while (titleEnd > 0 && isSpaceOrTab(rawTitle[titleEnd - 1]))
|
|
2655
|
+
titleEnd -= 1;
|
|
2656
|
+
let closingStart = titleEnd;
|
|
2657
|
+
while (closingStart > 0 && rawTitle[closingStart - 1] === "#")
|
|
2658
|
+
closingStart -= 1;
|
|
2659
|
+
const closingPredecessor = closingStart === 0 ? line[contentStart - 1] : rawTitle[closingStart - 1];
|
|
2660
|
+
if (closingStart < titleEnd && isSpaceOrTab(closingPredecessor)) {
|
|
2661
|
+
titleEnd = closingStart;
|
|
2662
|
+
while (titleEnd > 0 && isSpaceOrTab(rawTitle[titleEnd - 1]))
|
|
2663
|
+
titleEnd -= 1;
|
|
2664
|
+
}
|
|
2665
|
+
const title = rawTitle.slice(0, titleEnd).trim();
|
|
2666
|
+
return { depth, title };
|
|
2667
|
+
}
|
|
2668
|
+
function isWhitespace(character) {
|
|
2669
|
+
return character !== void 0 && character.trim() === "";
|
|
2670
|
+
}
|
|
2671
|
+
function isSpaceOrTab(character) {
|
|
2672
|
+
return character === " " || character === " ";
|
|
2673
|
+
}
|
|
2674
|
+
function extractSummary(content, frontmatterSummary) {
|
|
2675
|
+
if (typeof frontmatterSummary === "string" && frontmatterSummary.trim() !== "")
|
|
2676
|
+
return frontmatterSummary.trim();
|
|
2677
|
+
const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)\r?\n/);
|
|
2678
|
+
const body = frontmatter ? content.slice(frontmatter[0].length) : content;
|
|
2679
|
+
for (const rawLine of body.split(/\r?\n/)) {
|
|
2680
|
+
const line = rawLine.trim();
|
|
2681
|
+
if (line === "" || line.startsWith("#") || line.startsWith("<!--"))
|
|
2682
|
+
continue;
|
|
2683
|
+
return line.replace(/^[-*]\s*/, "").trim() || null;
|
|
2684
|
+
}
|
|
2685
|
+
return null;
|
|
2686
|
+
}
|
|
2687
|
+
async function inspectSourceIdentity(projectRoot) {
|
|
2688
|
+
const root = normalizeLogicalPath(projectRoot);
|
|
2689
|
+
const head = runGit(["rev-parse", "HEAD"], projectRoot);
|
|
2690
|
+
const branch = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], projectRoot);
|
|
2691
|
+
const status = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=normal"], {
|
|
2692
|
+
cwd: root,
|
|
2693
|
+
encoding: "utf-8",
|
|
2694
|
+
maxBuffer: 256 * 1024
|
|
2695
|
+
});
|
|
2696
|
+
return {
|
|
2697
|
+
root,
|
|
2698
|
+
gitHead: head,
|
|
2699
|
+
gitBranch: branch,
|
|
2700
|
+
dirty: status.status === 0 ? status.stdout.trim() !== "" : status.error && status.error.code === "ENOBUFS" ? true : null
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
function runGit(args, cwd) {
|
|
2704
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf-8", maxBuffer: 64 * 1024 });
|
|
2705
|
+
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
2706
|
+
}
|
|
2707
|
+
function normalizeRequestedPath(pathValue) {
|
|
2708
|
+
const trimmed = pathValue.trim();
|
|
2709
|
+
if (trimmed === "")
|
|
2710
|
+
throw new SpecsQueryError("invalid_specs_path", "Specs detail path must be non-empty");
|
|
2711
|
+
const normalized = normalizeLogicalPath(trimmed.replace(/^\.\//, ""));
|
|
2712
|
+
if (isAbsolute3(trimmed) || normalized === ".." || normalized.startsWith("../"))
|
|
2713
|
+
throw new SpecsQueryError("invalid_specs_path", "Specs detail path must be one project-relative path returned by rsp specs");
|
|
2714
|
+
return normalized;
|
|
2715
|
+
}
|
|
2716
|
+
function reservedIndexKind(root, path) {
|
|
2717
|
+
if (basename3(path) === GENERATED_INDEX_FILENAME)
|
|
2718
|
+
return "current";
|
|
2719
|
+
if (path === join4(root, LEGACY_GENERATED_INDEX_FILENAME))
|
|
2720
|
+
return "legacy";
|
|
2721
|
+
return null;
|
|
2722
|
+
}
|
|
2723
|
+
function excerptAround(line, matchStart, matchLength, limit) {
|
|
2724
|
+
const codePoints = [...line];
|
|
2725
|
+
if (codePoints.length <= limit)
|
|
2726
|
+
return codePoints.join("").trim();
|
|
2727
|
+
const before = Math.max(0, matchStart - Math.floor((limit - matchLength) / 2));
|
|
2728
|
+
const value = codePoints.slice(before, before + limit).join("").trim();
|
|
2729
|
+
return `${before > 0 ? "\u2026" : ""}${value}${before + limit < codePoints.length ? "\u2026" : ""}`;
|
|
2730
|
+
}
|
|
2731
|
+
async function readCurrentDocument(path, root, projectPath, maxFileBytes) {
|
|
2732
|
+
const changed2 = () => new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${projectPath}`);
|
|
2733
|
+
const sourcePath = resolve3(path);
|
|
2734
|
+
const sourceRoot = resolve3(root);
|
|
2735
|
+
const relativePath = relative6(sourceRoot, sourcePath);
|
|
2736
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep3}`) || isAbsolute3(relativePath)) {
|
|
2737
|
+
throw changed2();
|
|
2738
|
+
}
|
|
2739
|
+
const inspection = inspectManagedFile(sourcePath, "Specs document");
|
|
2740
|
+
if (inspection.issue)
|
|
2741
|
+
throw changed2();
|
|
2742
|
+
let inspectedRoot;
|
|
2743
|
+
let inspected;
|
|
2744
|
+
let realRoot;
|
|
2745
|
+
try {
|
|
2746
|
+
inspectedRoot = await lstat2(sourceRoot, { bigint: true });
|
|
2747
|
+
inspected = await lstat2(sourcePath, { bigint: true });
|
|
2748
|
+
realRoot = await realpath2(sourceRoot);
|
|
2749
|
+
const resolvedPath = await realpath2(sourcePath);
|
|
2750
|
+
if (!inspectedRoot.isDirectory() || !inspected.isFile() || resolvedPath !== resolve3(realRoot, relativePath) || !isContained2(realRoot, resolvedPath)) {
|
|
2751
|
+
throw changed2();
|
|
2752
|
+
}
|
|
2753
|
+
} catch (error) {
|
|
2754
|
+
if (error instanceof SpecsQueryError)
|
|
2755
|
+
throw error;
|
|
2756
|
+
throw changed2();
|
|
2757
|
+
}
|
|
2758
|
+
let handle;
|
|
2759
|
+
try {
|
|
2760
|
+
handle = await open2(sourcePath, READ_NO_FOLLOW_FLAGS2);
|
|
2761
|
+
} catch {
|
|
2762
|
+
throw changed2();
|
|
2763
|
+
}
|
|
2764
|
+
try {
|
|
2765
|
+
const opened = await handle.stat({ bigint: true });
|
|
2766
|
+
const currentRoot = await realpath2(sourceRoot);
|
|
2767
|
+
const currentPath = await realpath2(sourcePath);
|
|
2768
|
+
if (!opened.isFile() || currentRoot !== realRoot || currentPath !== resolve3(currentRoot, relativePath) || !isContained2(currentRoot, currentPath) || !sameIdentity2(inspected, opened) || !sameSnapshot2(inspected, opened)) {
|
|
2769
|
+
throw changed2();
|
|
2770
|
+
}
|
|
2771
|
+
if (opened.size > BigInt(maxFileBytes))
|
|
2772
|
+
throw new SpecsQueryError("specs_file_too_large", `Specs document exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`);
|
|
2773
|
+
const current = await readBoundedDocument(handle, opened, projectPath, maxFileBytes);
|
|
2774
|
+
await validateCurrentSpecsPath({
|
|
2775
|
+
sourcePath,
|
|
2776
|
+
sourceRoot,
|
|
2777
|
+
relativePath,
|
|
2778
|
+
realRoot,
|
|
2779
|
+
inspectedRoot,
|
|
2780
|
+
finalHandle: current.final,
|
|
2781
|
+
projectPath
|
|
2782
|
+
});
|
|
2783
|
+
return {
|
|
2784
|
+
content: current.content,
|
|
2785
|
+
bytes: current.bytes
|
|
2786
|
+
};
|
|
2787
|
+
} catch (error) {
|
|
2788
|
+
if (error instanceof SpecsQueryError)
|
|
2789
|
+
throw error;
|
|
2790
|
+
throw changed2();
|
|
2791
|
+
} finally {
|
|
2792
|
+
await handle.close();
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
async function readBoundedDocument(handle, opened, projectPath, maxFileBytes) {
|
|
2796
|
+
const buffer = Buffer3.allocUnsafe(maxFileBytes + 1);
|
|
2797
|
+
let bytes = 0;
|
|
2798
|
+
while (bytes < buffer.length) {
|
|
2799
|
+
const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
|
|
2800
|
+
if (result.bytesRead === 0)
|
|
2801
|
+
break;
|
|
2802
|
+
bytes += result.bytesRead;
|
|
2803
|
+
}
|
|
2804
|
+
const final = await handle.stat({ bigint: true });
|
|
2805
|
+
if (bytes > maxFileBytes || final.size > BigInt(maxFileBytes))
|
|
2806
|
+
throw new SpecsQueryError("specs_file_too_large", `Specs document exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`);
|
|
2807
|
+
if (!sameIdentity2(opened, final) || !sameSnapshot2(opened, final) || final.size !== BigInt(bytes)) {
|
|
2808
|
+
throw new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${projectPath}`);
|
|
2809
|
+
}
|
|
2810
|
+
return {
|
|
2811
|
+
content: buffer.subarray(0, bytes).toString("utf-8"),
|
|
2812
|
+
bytes,
|
|
2813
|
+
final
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
async function validateCurrentSpecsPath(options) {
|
|
2817
|
+
const changed2 = () => new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${options.projectPath}`);
|
|
2818
|
+
try {
|
|
2819
|
+
const currentRoot = await lstat2(options.sourceRoot, { bigint: true });
|
|
2820
|
+
const currentPath = await lstat2(options.sourcePath, { bigint: true });
|
|
2821
|
+
const realCurrentRoot = await realpath2(options.sourceRoot);
|
|
2822
|
+
const realCurrentPath = await realpath2(options.sourcePath);
|
|
2823
|
+
if (!currentRoot.isDirectory() || !currentPath.isFile() || !sameIdentity2(options.inspectedRoot, currentRoot) || realCurrentRoot !== options.realRoot || realCurrentPath !== resolve3(realCurrentRoot, options.relativePath) || !isContained2(realCurrentRoot, realCurrentPath) || !sameIdentity2(currentPath, options.finalHandle) || !sameSnapshot2(currentPath, options.finalHandle)) {
|
|
2824
|
+
throw changed2();
|
|
2825
|
+
}
|
|
2826
|
+
} catch (error) {
|
|
2827
|
+
if (error instanceof SpecsQueryError)
|
|
2828
|
+
throw error;
|
|
2829
|
+
throw changed2();
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
function sameIdentity2(left, right) {
|
|
2833
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
2834
|
+
}
|
|
2835
|
+
function sameSnapshot2(left, right) {
|
|
2836
|
+
return left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
2837
|
+
}
|
|
2838
|
+
function isContained2(root, path) {
|
|
2839
|
+
const candidate = relative6(root, path);
|
|
2840
|
+
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${sep3}`) && !isAbsolute3(candidate);
|
|
2841
|
+
}
|
|
2842
|
+
function boundCodePoints(value, limit) {
|
|
2843
|
+
const codePoints = [...value];
|
|
2844
|
+
return {
|
|
2845
|
+
value: codePoints.slice(0, limit).join(""),
|
|
2846
|
+
truncated: codePoints.length > limit
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
function stripSource(document) {
|
|
2850
|
+
const { sourcePath: _sourcePath, ...record } = document;
|
|
2851
|
+
return record;
|
|
2852
|
+
}
|
|
2853
|
+
function parseDocumentRecord(content, bytes, path, kind) {
|
|
2854
|
+
const metadata = parseFrontmatter(content);
|
|
2855
|
+
const headings = extractHeadings(content);
|
|
2856
|
+
return {
|
|
2857
|
+
path,
|
|
2858
|
+
kind,
|
|
2859
|
+
title: typeof metadata?.title === "string" && metadata.title.trim() !== "" ? metadata.title.trim() : headings.find((heading) => heading.depth === 1)?.title ?? basename3(path, ".md"),
|
|
2860
|
+
summary: extractSummary(content, metadata?.summary),
|
|
2861
|
+
bytes,
|
|
2862
|
+
headings
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
function compareDocument(left, right) {
|
|
2866
|
+
return left.path.localeCompare(right.path);
|
|
2867
|
+
}
|
|
2868
|
+
function validatePositiveBound(value, label) {
|
|
2869
|
+
if (!Number.isInteger(value) || value < 1)
|
|
2870
|
+
throw new SpecsQueryError("invalid_specs_bound", `${label} must be a positive integer`);
|
|
2871
|
+
}
|
|
2872
|
+
function isInside(directory, path) {
|
|
2873
|
+
const rel = relative6(directory, path);
|
|
2874
|
+
return rel === "" || !isAbsolute3(rel) && rel !== ".." && !rel.startsWith(`..${sep3}`);
|
|
2875
|
+
}
|
|
2876
|
+
function toProjectPath(projectRoot, path) {
|
|
2877
|
+
return normalizeLogicalPath(relative6(projectRoot, path)) || ".";
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
// src/specs/projection.ts
|
|
2881
|
+
function projectSpecsTree(inspection) {
|
|
2882
|
+
assertCompleteInspection(inspection);
|
|
2883
|
+
return {
|
|
2884
|
+
...projectionBase(inspection),
|
|
2885
|
+
mode: "tree",
|
|
2886
|
+
tree: inspection.tree,
|
|
2887
|
+
decisionRecords: inspection.decisionRecords,
|
|
2888
|
+
documents: inspection.documents
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
async function projectSpecsDetail(inspection, path) {
|
|
2892
|
+
assertCompleteInspection(inspection);
|
|
2893
|
+
return {
|
|
2894
|
+
...projectionBase(inspection),
|
|
2895
|
+
mode: "detail",
|
|
2896
|
+
document: await readSpecsDetail(inspection, path)
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
async function projectSpecsSearch(inspection, literal, options) {
|
|
2900
|
+
assertCompleteInspection(inspection);
|
|
2901
|
+
const result = await searchSpecs(inspection, literal, options);
|
|
2902
|
+
return {
|
|
2903
|
+
...projectionBase(inspection),
|
|
2904
|
+
mode: "search",
|
|
2905
|
+
query: {
|
|
2906
|
+
literal,
|
|
2907
|
+
limit: options.limit,
|
|
2908
|
+
excerptCodePoints: options.excerptCodePoints
|
|
2909
|
+
},
|
|
2910
|
+
matches: result.matches,
|
|
2911
|
+
summary: result.summary
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
function assertCompleteInspection(inspection) {
|
|
2915
|
+
if (!specsInspectionComplete(inspection)) {
|
|
2916
|
+
throw new SpecsQueryError(
|
|
2917
|
+
"specs_inspection_incomplete",
|
|
2918
|
+
"Specs inspection is incomplete; resolve the reported diagnostics before using the projection"
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
function projectionBase(inspection) {
|
|
2923
|
+
return {
|
|
2924
|
+
source: inspection.source,
|
|
2925
|
+
roots: inspection.roots,
|
|
2926
|
+
generatedIndexes: inspection.generatedIndexes,
|
|
2927
|
+
diagnostics: inspection.diagnostics,
|
|
2928
|
+
diagnosticSummary: inspection.diagnosticSummary,
|
|
2929
|
+
runtime: inspection.runtime,
|
|
2930
|
+
limits: inspection.limits
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
|
|
1871
2934
|
// src/status/dependency-forest.ts
|
|
1872
2935
|
function defaultRoots(plan) {
|
|
1873
2936
|
const selected = new Set(plan.nodes.filter((node) => node.selection === "selected").map((node) => node.name));
|
|
@@ -1904,11 +2967,22 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
|
|
|
1904
2967
|
}
|
|
1905
2968
|
|
|
1906
2969
|
export {
|
|
2970
|
+
IssueRelationshipError,
|
|
2971
|
+
parseIssueRelationships,
|
|
2972
|
+
createIssueRelationship,
|
|
2973
|
+
walkMarkdownFiles,
|
|
2974
|
+
inspectUnsupportedRules,
|
|
2975
|
+
normalizeLogicalPath,
|
|
2976
|
+
isValidSpecName,
|
|
2977
|
+
guardRspInitialized,
|
|
2978
|
+
cleanupEmptyParentDirs,
|
|
2979
|
+
detectProjectName,
|
|
1907
2980
|
GROUP_BRIEF_FILENAME,
|
|
1908
2981
|
WorkRefError,
|
|
1909
2982
|
normalizeWorkRefSegment,
|
|
1910
2983
|
normalizeExecutableWorkRef,
|
|
1911
2984
|
resolveWorkRef,
|
|
2985
|
+
resolveWorkRefPath,
|
|
1912
2986
|
resolveFocusMarkerPath,
|
|
1913
2987
|
resolveArchiveDirectory,
|
|
1914
2988
|
inspectFocusTree,
|
|
@@ -1920,9 +2994,6 @@ export {
|
|
|
1920
2994
|
inspectChangeGroups,
|
|
1921
2995
|
getGroupReopenEvidence,
|
|
1922
2996
|
hasArchivedGroupBrief,
|
|
1923
|
-
IssueRelationshipError,
|
|
1924
|
-
parseIssueRelationships,
|
|
1925
|
-
createIssueRelationship,
|
|
1926
2997
|
HISTORY_DEFAULT_LIMIT,
|
|
1927
2998
|
HISTORY_MAX_LIMIT,
|
|
1928
2999
|
ArchiveHistoryError,
|
|
@@ -1931,8 +3002,25 @@ export {
|
|
|
1931
3002
|
selectArchiveHistoryRecord,
|
|
1932
3003
|
selectArchivedGroupBrief,
|
|
1933
3004
|
readArchiveHistoryDetail,
|
|
3005
|
+
readArchiveHistoryDocument,
|
|
1934
3006
|
historyInspectionComplete,
|
|
1935
3007
|
inspectChangeDependencies,
|
|
3008
|
+
SPECS_DEFAULT_RESULT_LIMIT,
|
|
3009
|
+
SPECS_MAX_RESULT_LIMIT,
|
|
3010
|
+
SPECS_MAX_FILE_BYTES,
|
|
3011
|
+
SPECS_SEARCH_EXCERPT_CODE_POINTS,
|
|
3012
|
+
SPECS_DETAIL_CONTENT_CODE_POINTS,
|
|
3013
|
+
SpecsQueryError,
|
|
3014
|
+
inspectSpecs,
|
|
3015
|
+
specsInspectionComplete,
|
|
3016
|
+
isRecognizedGeneratedSpecsIndex,
|
|
3017
|
+
collectArchiveReadiness,
|
|
3018
|
+
toArchiveReadinessOutput,
|
|
3019
|
+
buildDurableReviewGuidance,
|
|
3020
|
+
getDurableReviewCandidateTargets,
|
|
3021
|
+
projectSpecsTree,
|
|
3022
|
+
projectSpecsDetail,
|
|
3023
|
+
projectSpecsSearch,
|
|
1936
3024
|
inspectProjectStatus,
|
|
1937
3025
|
projectDependencyForest
|
|
1938
3026
|
};
|