@oevery/rsp 3.2.0 → 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-SEGJLWRE.mjs → chunk-7HXQNBIG.mjs} +447 -734
- package/dist/{chunk-JYDEJEM4.mjs → chunk-QM2UVPQK.mjs} +47 -30
- package/dist/{chunk-ZORUJTVS.mjs → chunk-TSUDEX5Y.mjs} +1163 -104
- package/dist/cli.mjs +5332 -3047
- package/dist/entry-GIDNR7CJ.mjs +1846 -0
- package/dist/{entry-RG6HREUN.mjs → entry-YAW3PYFA.mjs} +2 -2
- package/package.json +12 -7
- package/rules/rsp-rules.md +6 -5
- package/skills/rsp/SKILL.md +26 -57
- package/skills/rsp/references/control-outcome.md +31 -0
- package/skills/rsp/references/durable-review.md +18 -1
- 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 +9 -8
- package/skills/rsp/references/response-language.md +11 -0
- package/skills/rsp/references/setup-repair.md +3 -3
- package/skills/rsp-commit/SKILL.md +24 -9
- package/skills/rsp-design/SKILL.md +1 -1
- package/skills/rsp-diagnose/SKILL.md +3 -1
- package/skills/rsp-implement/SKILL.md +15 -5
- package/skills/rsp-manage/SKILL.md +56 -51
- package/skills/rsp-manage/references/closeout.md +3 -1
- package/skills/rsp-manage/references/delegation.md +47 -0
- package/skills/rsp-manage/references/interruption-recovery.md +15 -3
- package/skills/rsp-manage/references/review-convergence.md +3 -1
- package/skills/rsp-release-docs/SKILL.md +13 -13
- package/skills/rsp-release-docs/references/publication-lifecycle.md +4 -0
- package/skills/rsp-resolve-findings/SKILL.md +2 -2
- package/skills/rsp-review/SKILL.md +9 -12
- package/skills/rsp-review/references/code-review.md +3 -2
- package/skills/rsp-shape/SKILL.md +1 -1
- package/skills/rsp-tdd/SKILL.md +10 -2
- package/skills/rsp-verify/SKILL.md +40 -0
- package/dist/entry-I22OUIW5.mjs +0 -888
- 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;
|
|
@@ -753,12 +868,12 @@ async function inspectChangeGroups(options = {}) {
|
|
|
753
868
|
const archivedGroupBriefPaths = /* @__PURE__ */ new Map();
|
|
754
869
|
const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
|
|
755
870
|
for (const path of archiveTree.files) {
|
|
756
|
-
const archivePath = normalizeLogicalPath(
|
|
871
|
+
const archivePath = normalizeLogicalPath(relative3(ARCHIVES_DIR, path));
|
|
757
872
|
const archiveGroup = archivePath.includes("/") ? archivePath.split("/")[0] : null;
|
|
758
873
|
if (!archiveGroup || !openGroupNames.has(archiveGroup))
|
|
759
874
|
continue;
|
|
760
875
|
try {
|
|
761
|
-
const content = await
|
|
876
|
+
const content = await readFile2(path, "utf-8");
|
|
762
877
|
const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
763
878
|
const identity = getDocumentTitle(archivedChange, "identity");
|
|
764
879
|
if (identity?.includes("/")) {
|
|
@@ -799,7 +914,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
799
914
|
for (const brief of workTree.briefs) {
|
|
800
915
|
let content;
|
|
801
916
|
try {
|
|
802
|
-
content = await
|
|
917
|
+
content = await readFile2(brief.path, "utf-8");
|
|
803
918
|
} catch (error) {
|
|
804
919
|
diagnostics.push({
|
|
805
920
|
severity: "error",
|
|
@@ -828,7 +943,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
828
943
|
for (const diagnostic of archiveTree.diagnostics.filter((item) => {
|
|
829
944
|
if (item.code === "invalid_archive_root")
|
|
830
945
|
return true;
|
|
831
|
-
const archivePath = normalizeLogicalPath(
|
|
946
|
+
const archivePath = normalizeLogicalPath(relative3(ARCHIVES_DIR, item.path));
|
|
832
947
|
return archivePath === brief.group || archivePath.startsWith(`${brief.group}/`);
|
|
833
948
|
})) {
|
|
834
949
|
diagnostics.push({
|
|
@@ -919,10 +1034,10 @@ function hasExplicitGroupReopenEvidence(content, retainedEvidence, retainedGroup
|
|
|
919
1034
|
return getGroupReopenEvidence(content).some((item) => item.active && retainedGroupBriefPaths.has(item.sourcePath) && !retainedEvidence.has(item.key));
|
|
920
1035
|
}
|
|
921
1036
|
async function requireDeclaredGroupSlice(ref) {
|
|
922
|
-
const briefPath =
|
|
1037
|
+
const briefPath = join3(dirname2(ref.path), GROUP_BRIEF_FILENAME);
|
|
923
1038
|
let content;
|
|
924
1039
|
try {
|
|
925
|
-
content = await
|
|
1040
|
+
content = await readFile2(briefPath, "utf-8");
|
|
926
1041
|
} catch (error) {
|
|
927
1042
|
const message = error instanceof Error ? error.message : String(error);
|
|
928
1043
|
throw new WorkRefError("work_tree_read_failed", `unable to read Group Brief ${briefPath}: ${message}`, ref.name);
|
|
@@ -952,11 +1067,11 @@ async function hasArchivedGroupBrief(group) {
|
|
|
952
1067
|
}
|
|
953
1068
|
const groupPrefix = `${group}/`;
|
|
954
1069
|
for (const path of archiveTree.files) {
|
|
955
|
-
if (!normalizeLogicalPath(
|
|
1070
|
+
if (!normalizeLogicalPath(relative3(ARCHIVES_DIR, path)).startsWith(groupPrefix))
|
|
956
1071
|
continue;
|
|
957
1072
|
let content;
|
|
958
1073
|
try {
|
|
959
|
-
content = await
|
|
1074
|
+
content = await readFile2(path, "utf-8");
|
|
960
1075
|
} catch (error) {
|
|
961
1076
|
const message = error instanceof Error ? error.message : String(error);
|
|
962
1077
|
throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
|
|
@@ -1029,14 +1144,187 @@ function groupSectionHeading(sectionId) {
|
|
|
1029
1144
|
}
|
|
1030
1145
|
|
|
1031
1146
|
// src/core/dependency-plan.ts
|
|
1032
|
-
import { readFile as readFile4 } from "fs/promises";
|
|
1033
|
-
|
|
1034
|
-
// src/history/query.ts
|
|
1035
1147
|
import { readFile as readFile3 } from "fs/promises";
|
|
1036
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
|
+
|
|
1037
1326
|
// src/history/inspect.ts
|
|
1038
|
-
import {
|
|
1039
|
-
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";
|
|
1040
1328
|
|
|
1041
1329
|
// src/core/issue-relationship.ts
|
|
1042
1330
|
var ISSUE_RELATIONS = /* @__PURE__ */ new Set(["relates", "closes"]);
|
|
@@ -1101,33 +1389,11 @@ function isPlainObject(value) {
|
|
|
1101
1389
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1102
1390
|
}
|
|
1103
1391
|
|
|
1104
|
-
// src/history/model.ts
|
|
1105
|
-
var HISTORY_DEFAULT_LIMIT = 20;
|
|
1106
|
-
var HISTORY_MAX_LIMIT = 100;
|
|
1107
|
-
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
1108
|
-
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
1109
|
-
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
1110
|
-
var HISTORY_MAX_CANDIDATES = 20;
|
|
1111
|
-
var ArchiveHistoryError = class extends Error {
|
|
1112
|
-
constructor(code, message, candidates = []) {
|
|
1113
|
-
super(message);
|
|
1114
|
-
this.code = code;
|
|
1115
|
-
this.name = "ArchiveHistoryError";
|
|
1116
|
-
const ordered = [...candidates].sort();
|
|
1117
|
-
this.candidateTotal = ordered.length;
|
|
1118
|
-
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
1119
|
-
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
1120
|
-
}
|
|
1121
|
-
code;
|
|
1122
|
-
candidates;
|
|
1123
|
-
candidateTotal;
|
|
1124
|
-
candidatesTruncated;
|
|
1125
|
-
};
|
|
1126
|
-
|
|
1127
1392
|
// src/history/inspect.ts
|
|
1128
1393
|
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
1129
1394
|
async function inspectArchiveHistory(options = {}) {
|
|
1130
|
-
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1395
|
+
const archivesDir = resolve2(options.archivesDir ?? ARCHIVES_DIR);
|
|
1396
|
+
const maxFileBytes = options.maxFileBytes ?? HISTORY_MAX_FILE_BYTES;
|
|
1131
1397
|
const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
|
|
1132
1398
|
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
1133
1399
|
severity: "error",
|
|
@@ -1148,15 +1414,21 @@ async function inspectArchiveHistory(options = {}) {
|
|
|
1148
1414
|
}
|
|
1149
1415
|
for (const sourcePath of tree.files) {
|
|
1150
1416
|
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
1151
|
-
let
|
|
1417
|
+
let current;
|
|
1152
1418
|
try {
|
|
1153
|
-
|
|
1419
|
+
current = await readCurrentArchiveFile({
|
|
1420
|
+
sourcePath,
|
|
1421
|
+
archivesDir,
|
|
1422
|
+
projectPath: path,
|
|
1423
|
+
maxFileBytes
|
|
1424
|
+
});
|
|
1154
1425
|
} catch (error) {
|
|
1155
|
-
const
|
|
1156
|
-
|
|
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 });
|
|
1157
1429
|
continue;
|
|
1158
1430
|
}
|
|
1159
|
-
const parsed = parseArchiveEntry(
|
|
1431
|
+
const parsed = parseArchiveEntry(current, path, maxFileBytes);
|
|
1160
1432
|
if (parsed.diagnostic)
|
|
1161
1433
|
diagnostics.push(parsed.diagnostic);
|
|
1162
1434
|
if (parsed.record)
|
|
@@ -1182,14 +1454,15 @@ async function inspectArchiveHistory(options = {}) {
|
|
|
1182
1454
|
runtime
|
|
1183
1455
|
};
|
|
1184
1456
|
}
|
|
1185
|
-
function parseArchiveEntry(
|
|
1186
|
-
const
|
|
1457
|
+
function parseArchiveEntry(current, path, maxFileBytes) {
|
|
1458
|
+
const { sourcePath, archivesDir, content } = current;
|
|
1459
|
+
const nameMatch = basename2(sourcePath).match(ARCHIVE_NAME_RE);
|
|
1187
1460
|
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
1188
1461
|
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
1189
1462
|
}
|
|
1190
1463
|
const date = nameMatch[1];
|
|
1191
1464
|
const archiveStem = nameMatch[2];
|
|
1192
|
-
const relativeDirectory = normalizeLogicalPath(
|
|
1465
|
+
const relativeDirectory = normalizeLogicalPath(relative5(archivesDir, dirname3(sourcePath)));
|
|
1193
1466
|
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
1194
1467
|
if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
|
|
1195
1468
|
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
@@ -1248,6 +1521,9 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
|
1248
1521
|
path,
|
|
1249
1522
|
...issues === void 0 ? {} : { issues },
|
|
1250
1523
|
sourcePath,
|
|
1524
|
+
archivesDir,
|
|
1525
|
+
sourceSnapshot: current.snapshot,
|
|
1526
|
+
maxFileBytes,
|
|
1251
1527
|
searchSummary: rawSummary
|
|
1252
1528
|
}
|
|
1253
1529
|
};
|
|
@@ -1264,10 +1540,10 @@ function isCalendarDate(value) {
|
|
|
1264
1540
|
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1265
1541
|
}
|
|
1266
1542
|
function archiveOutputPath(sourcePath, archivesDir) {
|
|
1267
|
-
return `.rsp/archives/${normalizeLogicalPath(
|
|
1543
|
+
return `.rsp/archives/${normalizeLogicalPath(relative5(resolve2(archivesDir), resolve2(sourcePath)))}`;
|
|
1268
1544
|
}
|
|
1269
1545
|
function normalizeOutputPath(path, archivesDir) {
|
|
1270
|
-
const relativePath = normalizeLogicalPath(
|
|
1546
|
+
const relativePath = normalizeLogicalPath(relative5(resolve2(archivesDir), resolve2(path)));
|
|
1271
1547
|
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
1272
1548
|
}
|
|
1273
1549
|
function errorDiagnostic(code, path, message) {
|
|
@@ -1348,17 +1624,28 @@ function selectArchivedGroupBrief(records, selector) {
|
|
|
1348
1624
|
return matches[0];
|
|
1349
1625
|
}
|
|
1350
1626
|
async function readArchiveHistoryDetail(record) {
|
|
1627
|
+
return (await readArchiveHistoryDocument(record)).detail;
|
|
1628
|
+
}
|
|
1629
|
+
async function readArchiveHistoryDocument(record) {
|
|
1351
1630
|
let content;
|
|
1352
1631
|
try {
|
|
1353
|
-
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;
|
|
1354
1639
|
} catch (error) {
|
|
1640
|
+
if (error instanceof ArchiveHistoryError)
|
|
1641
|
+
throw error;
|
|
1355
1642
|
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1356
1643
|
}
|
|
1357
1644
|
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1358
1645
|
const tasks = getDocumentSectionBody(document, "tasks");
|
|
1359
1646
|
const verify = getDocumentSectionBody(document, "verify");
|
|
1360
1647
|
const blockers = getDocumentSectionBody(document, "blockers");
|
|
1361
|
-
|
|
1648
|
+
const detail = {
|
|
1362
1649
|
...toOutputRecord(record),
|
|
1363
1650
|
scenarioCount: parseScenarios(content).length,
|
|
1364
1651
|
checkboxes: {
|
|
@@ -1371,12 +1658,20 @@ async function readArchiveHistoryDetail(record) {
|
|
|
1371
1658
|
blockers: boundEvidence(blockers, true)
|
|
1372
1659
|
}
|
|
1373
1660
|
};
|
|
1661
|
+
return { content, detail };
|
|
1374
1662
|
}
|
|
1375
1663
|
function historyInspectionComplete(inspection) {
|
|
1376
1664
|
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
1377
1665
|
}
|
|
1378
1666
|
function toOutputRecord(record) {
|
|
1379
|
-
const {
|
|
1667
|
+
const {
|
|
1668
|
+
searchSummary: _searchSummary,
|
|
1669
|
+
sourcePath: _sourcePath,
|
|
1670
|
+
archivesDir: _archivesDir,
|
|
1671
|
+
sourceSnapshot: _sourceSnapshot,
|
|
1672
|
+
maxFileBytes: _maxFileBytes,
|
|
1673
|
+
...output
|
|
1674
|
+
} = record;
|
|
1380
1675
|
return output;
|
|
1381
1676
|
}
|
|
1382
1677
|
function boundEvidence(section, omitNone = false) {
|
|
@@ -1398,7 +1693,6 @@ function compareHistoryRecords2(left, right) {
|
|
|
1398
1693
|
}
|
|
1399
1694
|
|
|
1400
1695
|
// src/core/dependency-plan.ts
|
|
1401
|
-
var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
|
|
1402
1696
|
var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
|
|
1403
1697
|
async function inspectChangeDependencies(options = {}) {
|
|
1404
1698
|
const workTree = options.workTree ?? await inspectWorkTree();
|
|
@@ -1420,7 +1714,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
1420
1714
|
diagnostics.push(...archiveHistory.diagnostics);
|
|
1421
1715
|
for (const brief of workTree.briefs) {
|
|
1422
1716
|
try {
|
|
1423
|
-
if (hasMeaningfulBlockers(await
|
|
1717
|
+
if (hasMeaningfulBlockers(await readFile3(brief.path, "utf-8")))
|
|
1424
1718
|
blockedGroups.add(brief.group);
|
|
1425
1719
|
} catch (error) {
|
|
1426
1720
|
diagnostics.push({
|
|
@@ -1434,7 +1728,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
1434
1728
|
}
|
|
1435
1729
|
for (const ref of workTree.changes) {
|
|
1436
1730
|
try {
|
|
1437
|
-
const parsed = parseDependencyBlockers(await
|
|
1731
|
+
const parsed = parseDependencyBlockers(await readFile3(ref.path, "utf-8"));
|
|
1438
1732
|
if (ref.group && blockedGroups.has(ref.group))
|
|
1439
1733
|
parsed.external = true;
|
|
1440
1734
|
parsedByName.set(ref.name, parsed);
|
|
@@ -1540,7 +1834,7 @@ function parseDependencyBlockers(content) {
|
|
|
1540
1834
|
let external = false;
|
|
1541
1835
|
const malformed = [];
|
|
1542
1836
|
for (const line of extractBlockerLines(content)) {
|
|
1543
|
-
if (!line ||
|
|
1837
|
+
if (!line || isEmptyBlockerLine(line))
|
|
1544
1838
|
continue;
|
|
1545
1839
|
const dependency = line.match(REQUIRES_RE);
|
|
1546
1840
|
if (dependency) {
|
|
@@ -1564,13 +1858,13 @@ function buildWaves(names, edges, blockersByName, preferredOrder) {
|
|
|
1564
1858
|
const excluded = new Set(
|
|
1565
1859
|
[...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
|
|
1566
1860
|
);
|
|
1567
|
-
let
|
|
1568
|
-
while (
|
|
1569
|
-
|
|
1861
|
+
let changed2 = true;
|
|
1862
|
+
while (changed2) {
|
|
1863
|
+
changed2 = false;
|
|
1570
1864
|
for (const edge of edges) {
|
|
1571
1865
|
if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
|
|
1572
1866
|
excluded.add(edge.change);
|
|
1573
|
-
|
|
1867
|
+
changed2 = true;
|
|
1574
1868
|
}
|
|
1575
1869
|
}
|
|
1576
1870
|
}
|
|
@@ -1648,6 +1942,82 @@ function findCycleDiagnostics(edges) {
|
|
|
1648
1942
|
return diagnostics;
|
|
1649
1943
|
}
|
|
1650
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
|
+
|
|
1651
2021
|
// src/status/inspect.ts
|
|
1652
2022
|
async function inspectProjectStatus(options = {}) {
|
|
1653
2023
|
const runtime = [];
|
|
@@ -1746,7 +2116,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1746
2116
|
let issues = [];
|
|
1747
2117
|
if (path) {
|
|
1748
2118
|
try {
|
|
1749
|
-
const content = await
|
|
2119
|
+
const content = await readFile4(path, "utf-8");
|
|
1750
2120
|
title = extractChangeTitle(content) ?? name;
|
|
1751
2121
|
summary = extractChangeSummary(content);
|
|
1752
2122
|
blockerEntries = extractBlockerEntries(content);
|
|
@@ -1774,20 +2144,13 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1774
2144
|
});
|
|
1775
2145
|
}
|
|
1776
2146
|
isBlocked = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(content);
|
|
1777
|
-
const checkboxes = countCheckboxes(content);
|
|
1778
|
-
done = checkboxes.done;
|
|
1779
|
-
total = checkboxes.total;
|
|
1780
|
-
progressKnown = true;
|
|
1781
2147
|
const details = collectArchiveReadiness(content, { activeBlockers: isBlocked });
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
semantic: details.semantic,
|
|
1789
|
-
archiveReady: details.archiveReady
|
|
1790
|
-
};
|
|
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);
|
|
1791
2154
|
} catch (error) {
|
|
1792
2155
|
diagnostics.push({
|
|
1793
2156
|
severity: "error",
|
|
@@ -1874,6 +2237,13 @@ function emptyReadiness(activeBlockers) {
|
|
|
1874
2237
|
return {
|
|
1875
2238
|
incompleteTasks: 0,
|
|
1876
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,
|
|
1877
2247
|
activeBlockers,
|
|
1878
2248
|
missingScenarios: false,
|
|
1879
2249
|
deterministic: "pass",
|
|
@@ -1897,6 +2267,670 @@ function deriveArchiveTrend(records) {
|
|
|
1897
2267
|
return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([month, count]) => ({ month, count }));
|
|
1898
2268
|
}
|
|
1899
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
|
+
|
|
1900
2934
|
// src/status/dependency-forest.ts
|
|
1901
2935
|
function defaultRoots(plan) {
|
|
1902
2936
|
const selected = new Set(plan.nodes.filter((node) => node.selection === "selected").map((node) => node.name));
|
|
@@ -1933,11 +2967,22 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
|
|
|
1933
2967
|
}
|
|
1934
2968
|
|
|
1935
2969
|
export {
|
|
2970
|
+
IssueRelationshipError,
|
|
2971
|
+
parseIssueRelationships,
|
|
2972
|
+
createIssueRelationship,
|
|
2973
|
+
walkMarkdownFiles,
|
|
2974
|
+
inspectUnsupportedRules,
|
|
2975
|
+
normalizeLogicalPath,
|
|
2976
|
+
isValidSpecName,
|
|
2977
|
+
guardRspInitialized,
|
|
2978
|
+
cleanupEmptyParentDirs,
|
|
2979
|
+
detectProjectName,
|
|
1936
2980
|
GROUP_BRIEF_FILENAME,
|
|
1937
2981
|
WorkRefError,
|
|
1938
2982
|
normalizeWorkRefSegment,
|
|
1939
2983
|
normalizeExecutableWorkRef,
|
|
1940
2984
|
resolveWorkRef,
|
|
2985
|
+
resolveWorkRefPath,
|
|
1941
2986
|
resolveFocusMarkerPath,
|
|
1942
2987
|
resolveArchiveDirectory,
|
|
1943
2988
|
inspectFocusTree,
|
|
@@ -1949,9 +2994,6 @@ export {
|
|
|
1949
2994
|
inspectChangeGroups,
|
|
1950
2995
|
getGroupReopenEvidence,
|
|
1951
2996
|
hasArchivedGroupBrief,
|
|
1952
|
-
IssueRelationshipError,
|
|
1953
|
-
parseIssueRelationships,
|
|
1954
|
-
createIssueRelationship,
|
|
1955
2997
|
HISTORY_DEFAULT_LIMIT,
|
|
1956
2998
|
HISTORY_MAX_LIMIT,
|
|
1957
2999
|
ArchiveHistoryError,
|
|
@@ -1960,8 +3002,25 @@ export {
|
|
|
1960
3002
|
selectArchiveHistoryRecord,
|
|
1961
3003
|
selectArchivedGroupBrief,
|
|
1962
3004
|
readArchiveHistoryDetail,
|
|
3005
|
+
readArchiveHistoryDocument,
|
|
1963
3006
|
historyInspectionComplete,
|
|
1964
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,
|
|
1965
3024
|
inspectProjectStatus,
|
|
1966
3025
|
projectDependencyForest
|
|
1967
3026
|
};
|