@oevery/rsp 3.1.0-beta.2 → 3.1.0-beta.4
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 +53 -35
- package/README.zh-CN.md +46 -33
- package/dist/{chunk-4K53353L.mjs → chunk-D7XDXBFO.mjs} +41 -5
- package/dist/{chunk-EALHA3ZS.mjs → chunk-FI3SYHVH.mjs} +552 -428
- package/dist/{chunk-OTJFL6XH.mjs → chunk-FJVH7ZYU.mjs} +344 -216
- package/dist/cli.mjs +524 -339
- package/dist/{entry-UWUX3HM7.mjs → entry-5OPKUZHF.mjs} +2 -2
- package/dist/{entry-N7WLWGTI.mjs → entry-FVUG6YW2.mjs} +2 -2
- package/package.json +1 -1
- package/rules/rsp-rules.md +11 -6
- package/skills/rsp/SKILL.md +12 -6
- package/skills/rsp/references/durable-review.md +1 -1
- package/skills/rsp/references/managed-routing.md +15 -5
- package/skills/rsp/references/setup-repair.md +2 -2
- package/skills/rsp-commit/SKILL.md +2 -2
- package/skills/rsp-design/SKILL.md +2 -2
- package/skills/rsp-manage/SKILL.md +31 -12
- package/skills/rsp-release-docs/references/convention-discovery.md +2 -2
- package/skills/{rsp-address-review → rsp-resolve-findings}/SKILL.md +4 -4
- package/skills/rsp-shape/SKILL.md +2 -2
- package/skills/{rsp-codebase-audit → rsp-structural-audit}/SKILL.md +5 -3
- package/skills/{rsp-codebase-audit → rsp-structural-audit}/references/structural-lenses.md +6 -0
|
@@ -2,39 +2,50 @@
|
|
|
2
2
|
import {
|
|
3
3
|
ARCHIVES_DIR,
|
|
4
4
|
CHANGES_DIR,
|
|
5
|
+
CHANGE_DOCUMENT_SCHEMA,
|
|
5
6
|
CONFIG_PATH,
|
|
6
7
|
FOCUS_DIR,
|
|
8
|
+
GROUP_BRIEF_DOCUMENT_SCHEMA,
|
|
7
9
|
ManagedPathError,
|
|
8
10
|
collectArchiveReadiness,
|
|
9
11
|
countCheckboxes,
|
|
10
12
|
extractBlockerLines,
|
|
11
|
-
|
|
13
|
+
getCanonicalSectionHeadings,
|
|
14
|
+
getDocumentSectionBody,
|
|
15
|
+
getDocumentTitle,
|
|
16
|
+
getDocumentTitles,
|
|
17
|
+
hasMeaningfulBlockerBody,
|
|
12
18
|
hasMeaningfulBlockers,
|
|
13
19
|
inspectManagedDirectory,
|
|
14
20
|
inspectRspConfig,
|
|
15
21
|
normalizeLogicalPath,
|
|
16
22
|
parseFrontmatter,
|
|
23
|
+
parseRspDocument,
|
|
17
24
|
parseScenarios,
|
|
25
|
+
renderDocumentSectionHeading,
|
|
26
|
+
renderDocumentTitle,
|
|
18
27
|
requireManagedDirectory,
|
|
19
28
|
requireManagedFile,
|
|
29
|
+
resolveLanguagePolicy,
|
|
20
30
|
resolveManagePolicy,
|
|
21
31
|
toErrorMessage
|
|
22
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-FJVH7ZYU.mjs";
|
|
23
33
|
|
|
24
34
|
// src/status/inspect.ts
|
|
25
|
-
import {
|
|
26
|
-
import { readFile as readFile3, stat } from "fs/promises";
|
|
27
|
-
import { join as join3 } from "path";
|
|
35
|
+
import { readFile as readFile5, stat } from "fs/promises";
|
|
28
36
|
|
|
29
37
|
// src/core/change-group.ts
|
|
30
38
|
import { readFile } from "fs/promises";
|
|
31
39
|
import { dirname, join as join2, relative as relative2 } from "path";
|
|
32
40
|
|
|
33
41
|
// src/core/work-ref.ts
|
|
34
|
-
import {
|
|
42
|
+
import { Buffer } from "buffer";
|
|
43
|
+
import { lstatSync, readdirSync } from "fs";
|
|
35
44
|
import { readdir } from "fs/promises";
|
|
36
45
|
import { isAbsolute, join, relative } from "path";
|
|
37
|
-
var WORK_SEGMENT_RE = /^[
|
|
46
|
+
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
|
+
var WORK_SEGMENT_MAX_CODE_POINTS = 100;
|
|
48
|
+
var WORK_SEGMENT_MAX_UTF8_BYTES = 200;
|
|
38
49
|
var GROUP_BRIEF_FILENAME = "00-brief.md";
|
|
39
50
|
var WorkRefError = class extends Error {
|
|
40
51
|
constructor(code, message, input) {
|
|
@@ -46,35 +57,51 @@ var WorkRefError = class extends Error {
|
|
|
46
57
|
code;
|
|
47
58
|
input;
|
|
48
59
|
};
|
|
49
|
-
function
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
const isGroupBrief = segments.length === 2 && segments[1] === "brief";
|
|
53
|
-
if (segments.length > 2) {
|
|
54
|
-
throw new WorkRefError(
|
|
55
|
-
"unsupported_work_depth",
|
|
56
|
-
`work identity "${name}" exceeds the supported one-level Change Group depth`,
|
|
57
|
-
name
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
if (!name || segments.some((segment) => !WORK_SEGMENT_RE.test(segment))) {
|
|
60
|
+
function normalizeWorkRefSegment(segment) {
|
|
61
|
+
const normalized = segment.normalize("NFC");
|
|
62
|
+
if (!WORK_SEGMENT_RE.test(normalized) || [...normalized].length > WORK_SEGMENT_MAX_CODE_POINTS || Buffer.byteLength(normalized, "utf8") > WORK_SEGMENT_MAX_UTF8_BYTES) {
|
|
61
63
|
throw new WorkRefError(
|
|
62
64
|
"invalid_work_ref",
|
|
63
|
-
`work identity "${
|
|
64
|
-
|
|
65
|
+
`work identity segment "${segment}" must contain at most ${WORK_SEGMENT_MAX_CODE_POINTS} lowercase or caseless Unicode letters, marks, or decimal numbers separated only by internal hyphens and fit within ${WORK_SEGMENT_MAX_UTF8_BYTES} UTF-8 bytes`,
|
|
66
|
+
segment
|
|
65
67
|
);
|
|
66
68
|
}
|
|
67
|
-
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
function normalizeExecutableWorkRef(name) {
|
|
72
|
+
return normalizeWorkRefName(name, false);
|
|
73
|
+
}
|
|
74
|
+
function isCanonicalExecutableWorkRef(name) {
|
|
75
|
+
try {
|
|
76
|
+
return normalizeExecutableWorkRef(name) === name;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isCanonicalWorkRefSegment(segment) {
|
|
82
|
+
try {
|
|
83
|
+
return normalizeWorkRefSegment(segment) === segment;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function resolveWorkRef(name, options = {}) {
|
|
89
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
90
|
+
const normalizedName = normalizeWorkRefName(name, true);
|
|
91
|
+
if (options.canonical && normalizedName !== name) {
|
|
68
92
|
throw new WorkRefError(
|
|
69
93
|
"invalid_work_ref",
|
|
70
|
-
`work identity "${name}"
|
|
94
|
+
`stored work identity "${name}" must use Unicode NFC normalization`,
|
|
71
95
|
name
|
|
72
96
|
);
|
|
73
97
|
}
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
const segments = normalizedName.split("/");
|
|
99
|
+
const isGroupBrief = segments.length === 2 && segments[1] === "brief";
|
|
100
|
+
assertValidWorkRoot(changesDir, normalizedName);
|
|
101
|
+
assertNoNormalizationCollision(changesDir, segments, normalizedName, isGroupBrief);
|
|
102
|
+
assertNoIdentityCollision(changesDir, segments, normalizedName, isGroupBrief ? GROUP_BRIEF_FILENAME : void 0);
|
|
76
103
|
const path = join(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
|
|
77
|
-
const ref = segments.length === 1 ? { kind: "change", name, path, group: null } : isGroupBrief ? { kind: "group-brief", name, path, group: segments[0] } : { kind: "group-change", name, path, group: segments[0] };
|
|
104
|
+
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] };
|
|
78
105
|
if (ref.kind === "group-change" && options.executable !== false)
|
|
79
106
|
assertGroupBrief(changesDir, ref);
|
|
80
107
|
if (options.executable && ref.kind === "group-brief") {
|
|
@@ -149,7 +176,7 @@ function resolveWorkRefPath(path, options = {}) {
|
|
|
149
176
|
);
|
|
150
177
|
}
|
|
151
178
|
const name = segments.length === 2 && segments[1] === GROUP_BRIEF_FILENAME ? `${segments[0]}/brief` : logicalPath.slice(0, -3);
|
|
152
|
-
return resolveWorkRef(name, options);
|
|
179
|
+
return resolveWorkRef(name, { ...options, canonical: true });
|
|
153
180
|
}
|
|
154
181
|
function isExecutableWorkRef(ref) {
|
|
155
182
|
return ref.kind !== "group-brief";
|
|
@@ -157,6 +184,7 @@ function isExecutableWorkRef(ref) {
|
|
|
157
184
|
function resolveFocusMarkerPath(ref, options = {}) {
|
|
158
185
|
const focusDir = options.focusDir ?? FOCUS_DIR;
|
|
159
186
|
assertManagedRoot(focusDir, ref.name, "invalid_focus_root", "focus root", true);
|
|
187
|
+
assertNoManagedNormalizationCollision(focusDir, ref.name.split("/"), ref.name, "invalid_focus_path");
|
|
160
188
|
if (ref.group) {
|
|
161
189
|
assertOptionalManagedDirectory(
|
|
162
190
|
join(focusDir, ref.group),
|
|
@@ -180,6 +208,7 @@ function resolveArchiveDirectory(ref, options = {}) {
|
|
|
180
208
|
assertManagedRoot(archivesDir, ref.name, "invalid_archive_root", "archive root", true);
|
|
181
209
|
if (!ref.group)
|
|
182
210
|
return archivesDir;
|
|
211
|
+
assertNoManagedNormalizationCollision(archivesDir, [ref.group], ref.name, "invalid_archive_path");
|
|
183
212
|
const groupPath = join(archivesDir, ref.group);
|
|
184
213
|
assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
|
|
185
214
|
return groupPath;
|
|
@@ -203,10 +232,10 @@ async function inspectFocusTree(options = {}) {
|
|
|
203
232
|
if (entry.name === ".gitkeep" && entry.isFile())
|
|
204
233
|
continue;
|
|
205
234
|
if (entry.isDirectory()) {
|
|
206
|
-
if (!
|
|
235
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
207
236
|
addDiagnostic(result, new WorkRefError(
|
|
208
237
|
"invalid_work_ref",
|
|
209
|
-
`work identity "${entry.name}" must
|
|
238
|
+
`stored work identity "${entry.name}" must be a safe Unicode NFC segment`,
|
|
210
239
|
entry.name
|
|
211
240
|
), path);
|
|
212
241
|
continue;
|
|
@@ -246,7 +275,7 @@ async function inspectArchiveTree(options = {}) {
|
|
|
246
275
|
if (entry.name === "INDEX.md" && entry.isFile())
|
|
247
276
|
continue;
|
|
248
277
|
if (entry.isDirectory()) {
|
|
249
|
-
if (!
|
|
278
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
250
279
|
addArchivePathDiagnostic(result, entry.name, path);
|
|
251
280
|
continue;
|
|
252
281
|
}
|
|
@@ -284,10 +313,10 @@ async function inspectWorkTree(options = {}) {
|
|
|
284
313
|
if (entry.name === ".gitkeep" && entry.isFile())
|
|
285
314
|
continue;
|
|
286
315
|
if (entry.isDirectory()) {
|
|
287
|
-
if (!
|
|
316
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
288
317
|
addDiagnostic(result, new WorkRefError(
|
|
289
318
|
"invalid_work_ref",
|
|
290
|
-
`work identity "${entry.name}" must
|
|
319
|
+
`stored work identity "${entry.name}" must be a safe Unicode NFC segment`,
|
|
291
320
|
entry.name
|
|
292
321
|
), path);
|
|
293
322
|
continue;
|
|
@@ -402,7 +431,7 @@ async function inspectArchiveGroup(group, groupPath, result) {
|
|
|
402
431
|
}
|
|
403
432
|
function collectFocusMarker(input, path, changesDir, result) {
|
|
404
433
|
try {
|
|
405
|
-
const ref = resolveWorkRef(input, { changesDir, executable: true });
|
|
434
|
+
const ref = resolveWorkRef(input, { changesDir, canonical: true, executable: true });
|
|
406
435
|
result.markers.push({ name: ref.name, path });
|
|
407
436
|
} catch (error) {
|
|
408
437
|
if (!(error instanceof WorkRefError))
|
|
@@ -450,6 +479,98 @@ function addDiagnostic(result, error, path) {
|
|
|
450
479
|
return;
|
|
451
480
|
result.diagnostics.push({ code: error.code, input: error.input, path, message: error.message });
|
|
452
481
|
}
|
|
482
|
+
function normalizeWorkRefName(name, allowGroupBrief) {
|
|
483
|
+
const segments = name.split("/");
|
|
484
|
+
if (segments.length > 2) {
|
|
485
|
+
throw new WorkRefError(
|
|
486
|
+
"unsupported_work_depth",
|
|
487
|
+
`work identity "${name}" exceeds the supported one-level Change Group depth`,
|
|
488
|
+
name
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if (!name || segments.some((segment) => !segment)) {
|
|
492
|
+
throw new WorkRefError(
|
|
493
|
+
"invalid_work_ref",
|
|
494
|
+
`work identity "${name}" must contain one or two safe Unicode segments`,
|
|
495
|
+
name
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const normalizedSegments = segments.map(normalizeWorkRefSegment);
|
|
499
|
+
const child = normalizedSegments[1];
|
|
500
|
+
if (segments.length === 2 && child === GROUP_BRIEF_FILENAME.slice(0, -3)) {
|
|
501
|
+
throw new WorkRefError(
|
|
502
|
+
"invalid_work_ref",
|
|
503
|
+
`work identity "${name}" uses the reserved Group Brief filename`,
|
|
504
|
+
name
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (segments.length === 2 && child === "brief" && !allowGroupBrief) {
|
|
508
|
+
throw new WorkRefError(
|
|
509
|
+
"non_executable_work_ref",
|
|
510
|
+
`work identity "${name}" is a Group Brief and cannot be used as an executable Change`,
|
|
511
|
+
name
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
return normalizedSegments.join("/");
|
|
515
|
+
}
|
|
516
|
+
function assertNoNormalizationCollision(changesDir, segments, input, isGroupBrief) {
|
|
517
|
+
for (let depth = 0; depth < segments.length; depth++) {
|
|
518
|
+
const parent = join(changesDir, ...segments.slice(0, depth));
|
|
519
|
+
const parentKind = getPathKind(parent, input);
|
|
520
|
+
if (parentKind === "missing")
|
|
521
|
+
return;
|
|
522
|
+
if (parentKind !== "directory")
|
|
523
|
+
continue;
|
|
524
|
+
let entries;
|
|
525
|
+
try {
|
|
526
|
+
entries = readdirSync(parent, { withFileTypes: true });
|
|
527
|
+
} catch (error) {
|
|
528
|
+
throw asReadFailure(parent, input, error);
|
|
529
|
+
}
|
|
530
|
+
const expected = segments[depth];
|
|
531
|
+
for (const entry of entries) {
|
|
532
|
+
let candidate = null;
|
|
533
|
+
if (entry.isDirectory())
|
|
534
|
+
candidate = entry.name;
|
|
535
|
+
else if (entry.isFile() && entry.name.endsWith(".md"))
|
|
536
|
+
candidate = entry.name.slice(0, -3);
|
|
537
|
+
if (depth === segments.length - 1 && isGroupBrief && entry.name === GROUP_BRIEF_FILENAME)
|
|
538
|
+
candidate = "brief";
|
|
539
|
+
if (candidate && candidate !== expected && candidate.normalize("NFC") === expected) {
|
|
540
|
+
throw new WorkRefError(
|
|
541
|
+
"work_ref_collision",
|
|
542
|
+
`work identity "${input}" collides with non-canonical stored identity "${candidate}" after Unicode NFC normalization`,
|
|
543
|
+
input
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function assertNoManagedNormalizationCollision(root, segments, input, code) {
|
|
550
|
+
for (let depth = 0; depth < segments.length; depth++) {
|
|
551
|
+
const parent = join(root, ...segments.slice(0, depth));
|
|
552
|
+
const parentKind = getPathKind(parent, input);
|
|
553
|
+
if (parentKind === "missing")
|
|
554
|
+
return;
|
|
555
|
+
if (parentKind !== "directory")
|
|
556
|
+
continue;
|
|
557
|
+
let entries;
|
|
558
|
+
try {
|
|
559
|
+
entries = readdirSync(parent, { withFileTypes: true });
|
|
560
|
+
} catch (error) {
|
|
561
|
+
throw asReadFailure(parent, input, error);
|
|
562
|
+
}
|
|
563
|
+
const expected = segments[depth];
|
|
564
|
+
const alias = entries.find((entry) => entry.name !== expected && entry.name.normalize("NFC") === expected);
|
|
565
|
+
if (alias) {
|
|
566
|
+
throw new WorkRefError(
|
|
567
|
+
code,
|
|
568
|
+
`managed identity "${input}" collides with non-canonical stored entry "${alias.name}" after Unicode NFC normalization`,
|
|
569
|
+
input
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
453
574
|
function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
|
|
454
575
|
for (let depth = 1; depth <= segments.length; depth++) {
|
|
455
576
|
const identityPath = join(changesDir, ...segments.slice(0, depth));
|
|
@@ -544,7 +665,7 @@ function normalizeLogicalPath2(path) {
|
|
|
544
665
|
}
|
|
545
666
|
|
|
546
667
|
// src/core/change-group.ts
|
|
547
|
-
var GROUP_BRIEF_SECTIONS =
|
|
668
|
+
var GROUP_BRIEF_SECTIONS = getCanonicalSectionHeadings(GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
548
669
|
async function resolveExecutableChange(name, options = {}) {
|
|
549
670
|
const ref = resolveWorkRef(name, { ...options, executable: true });
|
|
550
671
|
if (ref.kind === "group-change")
|
|
@@ -552,32 +673,33 @@ async function resolveExecutableChange(name, options = {}) {
|
|
|
552
673
|
return ref;
|
|
553
674
|
}
|
|
554
675
|
function generateGroupBriefContent(name, goal = "") {
|
|
676
|
+
const placeholder = "<\u2026>";
|
|
555
677
|
return `---
|
|
556
678
|
kind: group
|
|
557
679
|
---
|
|
558
680
|
|
|
559
|
-
|
|
681
|
+
${renderDocumentTitle(GROUP_BRIEF_DOCUMENT_SCHEMA, name)}
|
|
560
682
|
|
|
561
|
-
|
|
562
|
-
- ${goal ||
|
|
683
|
+
${groupSectionHeading("goal")}
|
|
684
|
+
- ${goal || placeholder}
|
|
563
685
|
|
|
564
|
-
|
|
565
|
-
-
|
|
686
|
+
${groupSectionHeading("scope")}
|
|
687
|
+
- ${placeholder}
|
|
566
688
|
|
|
567
|
-
|
|
568
|
-
-
|
|
689
|
+
${groupSectionHeading("sharedConstraints")}
|
|
690
|
+
- ${placeholder}
|
|
569
691
|
|
|
570
|
-
|
|
571
|
-
- \`${name}/<change>\`:
|
|
692
|
+
${groupSectionHeading("slices")}
|
|
693
|
+
- \`${name}/<change>\`: ${placeholder}
|
|
572
694
|
|
|
573
|
-
|
|
574
|
-
- [ ]
|
|
695
|
+
${groupSectionHeading("completionConditions")}
|
|
696
|
+
- [ ] ${placeholder}
|
|
575
697
|
|
|
576
|
-
|
|
577
|
-
- Current facts:
|
|
578
|
-
- Lasting rationale:
|
|
698
|
+
${groupSectionHeading("durableOutcomes")}
|
|
699
|
+
- Current facts: ${placeholder}
|
|
700
|
+
- Lasting rationale: ${placeholder}
|
|
579
701
|
|
|
580
|
-
|
|
702
|
+
${groupSectionHeading("blockers")}
|
|
581
703
|
- none
|
|
582
704
|
`;
|
|
583
705
|
}
|
|
@@ -597,10 +719,10 @@ async function inspectChangeGroups(options = {}) {
|
|
|
597
719
|
continue;
|
|
598
720
|
try {
|
|
599
721
|
const content = await readFile(path, "utf-8");
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (!
|
|
722
|
+
const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
723
|
+
const identity = getDocumentTitle(archivedChange, "identity");
|
|
724
|
+
if (identity?.includes("/")) {
|
|
725
|
+
if (!isCanonicalExecutableWorkRef(identity) || !identity.startsWith(`${archiveGroup}/`)) {
|
|
604
726
|
diagnostics.push({
|
|
605
727
|
severity: "error",
|
|
606
728
|
code: "group_archive_identity_mismatch",
|
|
@@ -614,7 +736,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
614
736
|
archivedSlicesByGroup.set(archiveGroup, groupSlices);
|
|
615
737
|
}
|
|
616
738
|
}
|
|
617
|
-
if (
|
|
739
|
+
if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === archiveGroup)
|
|
618
740
|
archivedGroups.add(archiveGroup);
|
|
619
741
|
} catch (error) {
|
|
620
742
|
diagnostics.push({
|
|
@@ -776,13 +898,14 @@ async function hasArchivedGroupBrief(group) {
|
|
|
776
898
|
const message = error instanceof Error ? error.message : String(error);
|
|
777
899
|
throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
|
|
778
900
|
}
|
|
779
|
-
if (
|
|
901
|
+
if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === group)
|
|
780
902
|
return true;
|
|
781
903
|
}
|
|
782
904
|
return false;
|
|
783
905
|
}
|
|
784
906
|
function parseGroupBrief(group, content) {
|
|
785
907
|
const diagnostics = [];
|
|
908
|
+
const document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
786
909
|
let frontmatter;
|
|
787
910
|
try {
|
|
788
911
|
frontmatter = parseFrontmatter(content);
|
|
@@ -791,15 +914,15 @@ function parseGroupBrief(group, content) {
|
|
|
791
914
|
}
|
|
792
915
|
if (frontmatter?.kind !== "group")
|
|
793
916
|
diagnostics.push({ severity: "error", code: "group_kind_invalid", change: group, message: "Group Brief frontmatter must declare kind: group" });
|
|
794
|
-
if (
|
|
917
|
+
if (getDocumentTitle(document, "identity") !== group)
|
|
795
918
|
diagnostics.push({ severity: "error", code: "group_heading_mismatch", change: group, message: `Group Brief heading must be: # Change Group: ${group}` });
|
|
796
|
-
for (const
|
|
797
|
-
if (!
|
|
798
|
-
diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${
|
|
919
|
+
for (const definition of GROUP_BRIEF_DOCUMENT_SCHEMA.sections) {
|
|
920
|
+
if (!getDocumentSectionBody(document, definition.id))
|
|
921
|
+
diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${definition.heading}` });
|
|
799
922
|
}
|
|
800
923
|
const slices = [];
|
|
801
924
|
const seen = /* @__PURE__ */ new Set();
|
|
802
|
-
for (const line of
|
|
925
|
+
for (const line of getDocumentSectionBody(document, "slices").split("\n")) {
|
|
803
926
|
const trimmed = line.trim();
|
|
804
927
|
const identity = trimmed.match(/^-\s+`(\S+)`/);
|
|
805
928
|
if (!identity || identity[1].includes("<"))
|
|
@@ -815,7 +938,7 @@ function parseGroupBrief(group, content) {
|
|
|
815
938
|
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires a boundary: ${name}` });
|
|
816
939
|
continue;
|
|
817
940
|
}
|
|
818
|
-
if (!
|
|
941
|
+
if (!isCanonicalExecutableWorkRef(name) || !name.startsWith(`${group}/`)) {
|
|
819
942
|
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `invalid direct child identity in Group Brief: ${name}` });
|
|
820
943
|
continue;
|
|
821
944
|
}
|
|
@@ -828,90 +951,369 @@ function parseGroupBrief(group, content) {
|
|
|
828
951
|
}
|
|
829
952
|
if (slices.length < 2)
|
|
830
953
|
diagnostics.push({ severity: "warning", code: "group_slices_incomplete", change: group, message: "Change Group must declare at least two direct child slices" });
|
|
831
|
-
const completion = countCheckboxes(
|
|
954
|
+
const completion = countCheckboxes(getDocumentSectionBody(document, "completionConditions"));
|
|
832
955
|
if (completion.total === 0)
|
|
833
956
|
diagnostics.push({ severity: "warning", code: "group_completion_missing", change: group, message: "Change Group must declare at least one completion condition" });
|
|
834
957
|
return {
|
|
835
958
|
slices,
|
|
836
959
|
completion: { done: completion.done + completion.dropped, total: completion.total },
|
|
837
|
-
blockers:
|
|
960
|
+
blockers: hasMeaningfulBlockerBody(getDocumentSectionBody(document, "blockers")),
|
|
838
961
|
diagnostics
|
|
839
962
|
};
|
|
840
963
|
}
|
|
841
|
-
function
|
|
842
|
-
return
|
|
964
|
+
function groupSectionHeading(sectionId) {
|
|
965
|
+
return renderDocumentSectionHeading(GROUP_BRIEF_DOCUMENT_SCHEMA, sectionId);
|
|
843
966
|
}
|
|
844
967
|
|
|
845
968
|
// src/core/dependency-plan.ts
|
|
969
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
970
|
+
|
|
971
|
+
// src/history/query.ts
|
|
972
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
973
|
+
|
|
974
|
+
// src/history/inspect.ts
|
|
846
975
|
import { readFile as readFile2 } from "fs/promises";
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
976
|
+
import { basename, dirname as dirname2, relative as relative3 } from "path";
|
|
977
|
+
|
|
978
|
+
// src/history/model.ts
|
|
979
|
+
var HISTORY_DEFAULT_LIMIT = 20;
|
|
980
|
+
var HISTORY_MAX_LIMIT = 100;
|
|
981
|
+
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
982
|
+
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
983
|
+
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
984
|
+
var HISTORY_MAX_CANDIDATES = 20;
|
|
985
|
+
var ArchiveHistoryError = class extends Error {
|
|
986
|
+
constructor(code, message, candidates = []) {
|
|
987
|
+
super(message);
|
|
988
|
+
this.code = code;
|
|
989
|
+
this.name = "ArchiveHistoryError";
|
|
990
|
+
const ordered = [...candidates].sort();
|
|
991
|
+
this.candidateTotal = ordered.length;
|
|
992
|
+
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
993
|
+
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
994
|
+
}
|
|
995
|
+
code;
|
|
996
|
+
candidates;
|
|
997
|
+
candidateTotal;
|
|
998
|
+
candidatesTruncated;
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// src/history/inspect.ts
|
|
1002
|
+
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
1003
|
+
async function inspectArchiveHistory(options = {}) {
|
|
1004
|
+
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1005
|
+
const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
|
|
1006
|
+
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
858
1007
|
severity: "error",
|
|
859
1008
|
code: diagnostic.code,
|
|
860
|
-
path: diagnostic.path,
|
|
1009
|
+
path: normalizeOutputPath(diagnostic.path, archivesDir),
|
|
861
1010
|
message: diagnostic.message
|
|
862
1011
|
}));
|
|
863
|
-
|
|
1012
|
+
const runtime = [];
|
|
1013
|
+
const records = [];
|
|
1014
|
+
if (!tree.rootExists && tree.diagnostics.length === 0) {
|
|
1015
|
+
diagnostics.push({
|
|
1016
|
+
severity: "error",
|
|
1017
|
+
code: "archive_root_missing",
|
|
1018
|
+
path: ".rsp/archives",
|
|
1019
|
+
message: "archive root does not exist; run rsp init or rsp update before querying history"
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
for (const sourcePath of tree.files) {
|
|
1023
|
+
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
1024
|
+
let content;
|
|
864
1025
|
try {
|
|
865
|
-
|
|
866
|
-
if (heading)
|
|
867
|
-
archivedNames.add(heading[1]);
|
|
1026
|
+
content = await readFile2(sourcePath, "utf-8");
|
|
868
1027
|
} catch (error) {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
path,
|
|
873
|
-
message: error instanceof Error ? error.message : String(error)
|
|
874
|
-
});
|
|
1028
|
+
const message = toErrorMessage(error);
|
|
1029
|
+
diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
|
|
1030
|
+
continue;
|
|
875
1031
|
}
|
|
1032
|
+
const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
|
|
1033
|
+
if (parsed.diagnostic)
|
|
1034
|
+
diagnostics.push(parsed.diagnostic);
|
|
1035
|
+
if (parsed.record)
|
|
1036
|
+
records.push(parsed.record);
|
|
876
1037
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1038
|
+
records.sort(compareHistoryRecords);
|
|
1039
|
+
diagnostics.sort(compareDiagnostics);
|
|
1040
|
+
const diagnosticTotal = diagnostics.length;
|
|
1041
|
+
const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
|
|
1042
|
+
return {
|
|
1043
|
+
rootExists: tree.rootExists,
|
|
1044
|
+
records,
|
|
1045
|
+
diagnostics: boundedDiagnostics,
|
|
1046
|
+
diagnosticSummary: {
|
|
1047
|
+
total: diagnosticTotal,
|
|
1048
|
+
returned: boundedDiagnostics.length,
|
|
1049
|
+
hasMore: diagnosticTotal > boundedDiagnostics.length
|
|
1050
|
+
},
|
|
1051
|
+
runtime
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
1055
|
+
const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
|
|
1056
|
+
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
1057
|
+
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
1058
|
+
}
|
|
1059
|
+
const date = nameMatch[1];
|
|
1060
|
+
const archiveStem = nameMatch[2];
|
|
1061
|
+
const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
|
|
1062
|
+
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
1063
|
+
if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
|
|
1064
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
1065
|
+
const changeDocument = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1066
|
+
const groupDocument = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
1067
|
+
const changeHeadings = getDocumentTitles(changeDocument, "identity");
|
|
1068
|
+
const groupHeadings = getDocumentTitles(groupDocument, "identity");
|
|
1069
|
+
if (changeHeadings.length + groupHeadings.length !== 1) {
|
|
1070
|
+
return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
|
|
1071
|
+
}
|
|
1072
|
+
let frontmatter;
|
|
1073
|
+
try {
|
|
1074
|
+
frontmatter = parseFrontmatter(content);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
|
|
1077
|
+
}
|
|
1078
|
+
if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
|
|
1079
|
+
return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
|
|
1080
|
+
if (groupHeadings.length === 1) {
|
|
1081
|
+
const group = groupHeadings[0];
|
|
1082
|
+
if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
|
|
1083
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
|
|
889
1084
|
}
|
|
1085
|
+
return {};
|
|
890
1086
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1087
|
+
const workRef = changeHeadings[0];
|
|
1088
|
+
const segments = workRef.split("/");
|
|
1089
|
+
if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
|
|
1090
|
+
return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
|
|
1091
|
+
}
|
|
1092
|
+
const expectedGroup = segments.length === 2 ? segments[0] : null;
|
|
1093
|
+
const expectedBase = segments.at(-1);
|
|
1094
|
+
if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
|
|
1095
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
|
|
1096
|
+
}
|
|
1097
|
+
const rawSummary = extractSummary(content, changeDocument);
|
|
1098
|
+
if (!rawSummary)
|
|
1099
|
+
return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
|
|
1100
|
+
const summary = boundText(rawSummary);
|
|
1101
|
+
return {
|
|
1102
|
+
record: {
|
|
1103
|
+
date,
|
|
1104
|
+
workRef,
|
|
1105
|
+
group: expectedGroup,
|
|
1106
|
+
kind: frontmatter.kind.trim(),
|
|
1107
|
+
summary: summary.value,
|
|
1108
|
+
summaryTruncated: summary.truncated,
|
|
1109
|
+
path,
|
|
1110
|
+
sourcePath,
|
|
1111
|
+
searchSummary: rawSummary
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
function extractSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA)) {
|
|
1116
|
+
const frontmatter = parseFrontmatter(content);
|
|
1117
|
+
if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
|
|
1118
|
+
return frontmatter.summary.trim();
|
|
1119
|
+
const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
|
|
1120
|
+
for (const prefix of ["- Outcome:", "- Summary:"]) {
|
|
1121
|
+
const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
|
|
1122
|
+
if (line && line.slice(prefix.length).trim())
|
|
1123
|
+
return line.slice(prefix.length).trim();
|
|
1124
|
+
}
|
|
1125
|
+
return "";
|
|
1126
|
+
}
|
|
1127
|
+
function boundText(value) {
|
|
1128
|
+
const codePoints = [...value];
|
|
1129
|
+
return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
|
|
1130
|
+
}
|
|
1131
|
+
function matchesArchiveStem(stem, identityBase) {
|
|
1132
|
+
return stem === identityBase || new RegExp(`^${escapeRegExp(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
|
|
1133
|
+
}
|
|
1134
|
+
function isCalendarDate(value) {
|
|
1135
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1136
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1137
|
+
}
|
|
1138
|
+
function archiveOutputPath(sourcePath, archivesDir) {
|
|
1139
|
+
return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
|
|
1140
|
+
}
|
|
1141
|
+
function normalizeOutputPath(path, archivesDir) {
|
|
1142
|
+
const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
|
|
1143
|
+
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
1144
|
+
}
|
|
1145
|
+
function errorDiagnostic(code, path, message) {
|
|
1146
|
+
return { severity: "error", code, path, message };
|
|
1147
|
+
}
|
|
1148
|
+
function escapeRegExp(value) {
|
|
1149
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1150
|
+
}
|
|
1151
|
+
function compareHistoryRecords(left, right) {
|
|
1152
|
+
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1153
|
+
}
|
|
1154
|
+
function compareDiagnostics(left, right) {
|
|
1155
|
+
return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/history/query.ts
|
|
1159
|
+
function queryArchiveHistory(records, query = {}) {
|
|
1160
|
+
validateArchiveHistoryQuery(query);
|
|
1161
|
+
const group = query.group === void 0 ? void 0 : normalizeWorkRefSegment(query.group);
|
|
1162
|
+
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1163
|
+
const matched = records.filter((record) => (!query.since || record.date >= query.since) && (!query.until || record.date <= query.until) && (!query.kind || record.kind === query.kind) && (!group || record.group === group) && (!query.search || `${record.workRef}
|
|
1164
|
+
${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
|
|
1165
|
+
const selected = matched.slice(0, limit);
|
|
1166
|
+
return {
|
|
1167
|
+
records: selected.map(toOutputRecord),
|
|
1168
|
+
summary: {
|
|
1169
|
+
matched: matched.length,
|
|
1170
|
+
returned: selected.length,
|
|
1171
|
+
hasMore: matched.length > selected.length
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
function validateArchiveHistoryQuery(query) {
|
|
1176
|
+
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1177
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
|
|
1178
|
+
throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
|
|
1179
|
+
if (query.since && !isCalendarDate2(query.since))
|
|
1180
|
+
throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
|
|
1181
|
+
if (query.until && !isCalendarDate2(query.until))
|
|
1182
|
+
throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
|
|
1183
|
+
if (query.since && query.until && query.since > query.until)
|
|
1184
|
+
throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
|
|
1185
|
+
if (query.kind !== void 0 && query.kind.trim() === "")
|
|
1186
|
+
throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
|
|
1187
|
+
if (query.group !== void 0) {
|
|
1188
|
+
try {
|
|
1189
|
+
normalizeWorkRefSegment(query.group);
|
|
1190
|
+
} catch {
|
|
1191
|
+
throw new ArchiveHistoryError("invalid_history_group", "history group must be one safe Unicode segment");
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (query.search !== void 0 && query.search.trim() === "")
|
|
1195
|
+
throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
|
|
1196
|
+
}
|
|
1197
|
+
function selectArchiveHistoryRecord(records, selector) {
|
|
1198
|
+
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
|
|
1199
|
+
const identity = "path" in selector ? selector.path : selector.workRef;
|
|
1200
|
+
if (matches.length === 0)
|
|
1201
|
+
throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
|
|
1202
|
+
if (matches.length > 1) {
|
|
1203
|
+
const candidates = matches.map((record) => record.path).sort();
|
|
1204
|
+
throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
|
|
1205
|
+
}
|
|
1206
|
+
return matches[0];
|
|
1207
|
+
}
|
|
1208
|
+
async function readArchiveHistoryDetail(record) {
|
|
1209
|
+
let content;
|
|
1210
|
+
try {
|
|
1211
|
+
content = await readFile3(record.sourcePath, "utf-8");
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1214
|
+
}
|
|
1215
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1216
|
+
const tasks = getDocumentSectionBody(document, "tasks");
|
|
1217
|
+
const verify = getDocumentSectionBody(document, "verify");
|
|
1218
|
+
const blockers = getDocumentSectionBody(document, "blockers");
|
|
1219
|
+
return {
|
|
1220
|
+
...toOutputRecord(record),
|
|
1221
|
+
scenarioCount: parseScenarios(content).length,
|
|
1222
|
+
checkboxes: {
|
|
1223
|
+
tasks: countCheckboxes(tasks),
|
|
1224
|
+
verify: countCheckboxes(verify)
|
|
1225
|
+
},
|
|
1226
|
+
evidence: {
|
|
1227
|
+
tasks: boundEvidence(tasks),
|
|
1228
|
+
verify: boundEvidence(verify),
|
|
1229
|
+
blockers: boundEvidence(blockers, true)
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function historyInspectionComplete(inspection) {
|
|
1234
|
+
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
1235
|
+
}
|
|
1236
|
+
function toOutputRecord(record) {
|
|
1237
|
+
const { searchSummary: _searchSummary, sourcePath: _sourcePath, ...output } = record;
|
|
1238
|
+
return output;
|
|
1239
|
+
}
|
|
1240
|
+
function boundEvidence(section, omitNone = false) {
|
|
1241
|
+
const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
|
|
1242
|
+
const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
|
|
1243
|
+
return {
|
|
1244
|
+
items: boundedItems.map((item) => item.value),
|
|
1245
|
+
truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
function isCalendarDate2(value) {
|
|
1249
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
1250
|
+
return false;
|
|
1251
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1252
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1253
|
+
}
|
|
1254
|
+
function compareHistoryRecords2(left, right) {
|
|
1255
|
+
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// src/core/dependency-plan.ts
|
|
1259
|
+
var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
|
|
1260
|
+
var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
|
|
1261
|
+
async function inspectChangeDependencies(options = {}) {
|
|
1262
|
+
const workTree = options.workTree ?? await inspectWorkTree();
|
|
1263
|
+
const archiveTree = options.archiveTree ?? await inspectArchiveTree();
|
|
1264
|
+
const archiveHistory = archiveTree.rootExists ? options.archiveHistory ?? await inspectArchiveHistory({ archiveTree }) : null;
|
|
1265
|
+
const openNames = new Set(workTree.changes.map((ref) => ref.name));
|
|
1266
|
+
const archivedNames = new Set(
|
|
1267
|
+
archiveHistory && historyInspectionComplete(archiveHistory) ? archiveHistory.records.map((record) => record.workRef) : []
|
|
1268
|
+
);
|
|
1269
|
+
const parsedByName = /* @__PURE__ */ new Map();
|
|
1270
|
+
const blockedGroups = /* @__PURE__ */ new Set();
|
|
1271
|
+
const diagnostics = archiveTree.diagnostics.map((diagnostic) => ({
|
|
1272
|
+
severity: "error",
|
|
1273
|
+
code: diagnostic.code,
|
|
1274
|
+
path: diagnostic.path,
|
|
1275
|
+
message: diagnostic.message
|
|
1276
|
+
}));
|
|
1277
|
+
if (archiveHistory)
|
|
1278
|
+
diagnostics.push(...archiveHistory.diagnostics);
|
|
1279
|
+
for (const brief of workTree.briefs) {
|
|
1280
|
+
try {
|
|
1281
|
+
if (hasMeaningfulBlockers(await readFile4(brief.path, "utf-8")))
|
|
1282
|
+
blockedGroups.add(brief.group);
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
diagnostics.push({
|
|
1285
|
+
severity: "error",
|
|
1286
|
+
code: "group_brief_read_failed",
|
|
1287
|
+
change: brief.group,
|
|
1288
|
+
path: brief.path,
|
|
1289
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
for (const ref of workTree.changes) {
|
|
1294
|
+
try {
|
|
1295
|
+
const parsed = parseDependencyBlockers(await readFile4(ref.path, "utf-8"));
|
|
1296
|
+
if (ref.group && blockedGroups.has(ref.group))
|
|
1297
|
+
parsed.external = true;
|
|
1298
|
+
parsedByName.set(ref.name, parsed);
|
|
1299
|
+
for (const line of parsed.malformed) {
|
|
1300
|
+
diagnostics.push({
|
|
1301
|
+
severity: "error",
|
|
1302
|
+
code: "dependency_syntax_invalid",
|
|
1303
|
+
change: ref.name,
|
|
1304
|
+
path: ref.path,
|
|
1305
|
+
message: `invalid dependency blocker syntax: ${line}`,
|
|
1306
|
+
hint: "Use: - requires `<change-work-ref>`: <reason>"
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
diagnostics.push({
|
|
1311
|
+
severity: "error",
|
|
1312
|
+
code: "change_read_failed",
|
|
1313
|
+
change: ref.name,
|
|
1314
|
+
path: ref.path,
|
|
1315
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1316
|
+
});
|
|
915
1317
|
}
|
|
916
1318
|
}
|
|
917
1319
|
const edges = [];
|
|
@@ -925,7 +1327,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
925
1327
|
state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
|
|
926
1328
|
};
|
|
927
1329
|
edges.push(edge);
|
|
928
|
-
if (!
|
|
1330
|
+
if (!isCanonicalExecutableWorkRef(required)) {
|
|
929
1331
|
diagnostics.push({
|
|
930
1332
|
severity: "error",
|
|
931
1333
|
code: "dependency_target_invalid",
|
|
@@ -967,7 +1369,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
967
1369
|
const executable = new Set(graphValid ? waves.flat() : []);
|
|
968
1370
|
const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
|
|
969
1371
|
let state;
|
|
970
|
-
if (archivedNames.has(name))
|
|
1372
|
+
if (!openNames.has(name) && archivedNames.has(name))
|
|
971
1373
|
state = "archived";
|
|
972
1374
|
else if (!openNames.has(name))
|
|
973
1375
|
state = "missing";
|
|
@@ -1109,10 +1511,12 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1109
1511
|
const runtime = [];
|
|
1110
1512
|
const diagnostics = [];
|
|
1111
1513
|
let manage = resolveManagePolicy({});
|
|
1514
|
+
let language = resolveLanguagePolicy({});
|
|
1112
1515
|
try {
|
|
1113
1516
|
const configInspection = await inspectRspConfig();
|
|
1114
1517
|
const configValid = configInspection.issues.length === 0;
|
|
1115
1518
|
manage = resolveManagePolicy(configInspection.config, { configValid });
|
|
1519
|
+
language = resolveLanguagePolicy(configInspection.config, { configValid });
|
|
1116
1520
|
if (!configValid) {
|
|
1117
1521
|
diagnostics.push({
|
|
1118
1522
|
severity: "error",
|
|
@@ -1123,6 +1527,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1123
1527
|
}
|
|
1124
1528
|
} catch (error) {
|
|
1125
1529
|
manage = resolveManagePolicy({}, { configValid: false });
|
|
1530
|
+
language = resolveLanguagePolicy({}, { configValid: false });
|
|
1126
1531
|
diagnostics.push({
|
|
1127
1532
|
severity: "error",
|
|
1128
1533
|
code: "invalid_config",
|
|
@@ -1161,6 +1566,8 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1161
1566
|
}
|
|
1162
1567
|
}
|
|
1163
1568
|
const workTree = await inspectWorkTree();
|
|
1569
|
+
const archiveTree = await inspectArchiveTree();
|
|
1570
|
+
const historyInspection = await inspectArchiveHistory({ archiveTree });
|
|
1164
1571
|
diagnostics.push(...workTree.diagnostics.map((diagnostic) => ({
|
|
1165
1572
|
severity: "error",
|
|
1166
1573
|
code: diagnostic.code,
|
|
@@ -1168,10 +1575,12 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1168
1575
|
path: diagnostic.path,
|
|
1169
1576
|
message: diagnostic.message
|
|
1170
1577
|
})));
|
|
1171
|
-
const groupInspection = await inspectChangeGroups({ workTree, focusTree });
|
|
1578
|
+
const groupInspection = await inspectChangeGroups({ workTree, focusTree, archiveTree });
|
|
1172
1579
|
diagnostics.push(...groupInspection.diagnostics);
|
|
1173
1580
|
const dependencyInspection = await inspectChangeDependencies({
|
|
1174
1581
|
workTree,
|
|
1582
|
+
archiveTree,
|
|
1583
|
+
archiveHistory: historyInspection,
|
|
1175
1584
|
preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
|
|
1176
1585
|
});
|
|
1177
1586
|
diagnostics.push(...dependencyInspection.diagnostics);
|
|
@@ -1193,7 +1602,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1193
1602
|
let progressKnown = false;
|
|
1194
1603
|
if (path) {
|
|
1195
1604
|
try {
|
|
1196
|
-
const content = await
|
|
1605
|
+
const content = await readFile5(path, "utf-8");
|
|
1197
1606
|
title = extractChangeTitle(content) ?? name;
|
|
1198
1607
|
blockerEntries = extractBlockerEntries(content);
|
|
1199
1608
|
try {
|
|
@@ -1293,13 +1702,14 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1293
1702
|
}
|
|
1294
1703
|
return {
|
|
1295
1704
|
manage,
|
|
1705
|
+
language,
|
|
1296
1706
|
focused: [...focusedSet].sort(),
|
|
1297
1707
|
records,
|
|
1298
1708
|
groups: groupInspection.groups,
|
|
1299
1709
|
plan: dependencyInspection.plan,
|
|
1300
|
-
archiveTrend:
|
|
1301
|
-
diagnostics,
|
|
1302
|
-
runtime
|
|
1710
|
+
archiveTrend: historyInspectionComplete(historyInspection) ? deriveArchiveTrend(historyInspection.records) : [],
|
|
1711
|
+
diagnostics: [...diagnostics, ...historyInspection.rootExists ? historyInspection.diagnostics : []],
|
|
1712
|
+
runtime: [...runtime, ...historyInspection.runtime]
|
|
1303
1713
|
};
|
|
1304
1714
|
}
|
|
1305
1715
|
function emptyReadiness(activeBlockers) {
|
|
@@ -1314,307 +1724,19 @@ function emptyReadiness(activeBlockers) {
|
|
|
1314
1724
|
};
|
|
1315
1725
|
}
|
|
1316
1726
|
function extractChangeTitle(content) {
|
|
1317
|
-
|
|
1318
|
-
return heading?.slice("# Change: ".length).trim() || null;
|
|
1727
|
+
return getDocumentTitle(parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), "spaced");
|
|
1319
1728
|
}
|
|
1320
1729
|
function extractBlockerEntries(content) {
|
|
1321
|
-
|
|
1730
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1731
|
+
return getDocumentSectionBody(document, "blockers").split("\n").map((line) => line.trim()).filter((line) => /^[-*]\s+/.test(line)).map((line) => line.replace(/^[-*]\s+/, "").trim()).filter((line) => line.toLowerCase() !== "none");
|
|
1322
1732
|
}
|
|
1323
|
-
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
const content = await readFile3(indexPath, "utf-8");
|
|
1329
|
-
const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
|
|
1330
|
-
const counts = {};
|
|
1331
|
-
let match;
|
|
1332
|
-
while ((match = dateRe.exec(content)) !== null) {
|
|
1333
|
-
const month = match[1];
|
|
1334
|
-
counts[month] = (counts[month] || 0) + 1;
|
|
1335
|
-
}
|
|
1336
|
-
return Object.keys(counts).sort().map((month) => ({ month, count: counts[month] }));
|
|
1337
|
-
} catch (error) {
|
|
1338
|
-
runtime.push({
|
|
1339
|
-
code: "archive_index_read_failed",
|
|
1340
|
-
operation: "readFile",
|
|
1341
|
-
path: indexPath,
|
|
1342
|
-
message: toErrorMessage(error)
|
|
1343
|
-
});
|
|
1344
|
-
return [];
|
|
1733
|
+
function deriveArchiveTrend(records) {
|
|
1734
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1735
|
+
for (const record of records) {
|
|
1736
|
+
const month = record.date.slice(0, 7);
|
|
1737
|
+
counts.set(month, (counts.get(month) ?? 0) + 1);
|
|
1345
1738
|
}
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
// src/history/model.ts
|
|
1349
|
-
var HISTORY_DEFAULT_LIMIT = 20;
|
|
1350
|
-
var HISTORY_MAX_LIMIT = 100;
|
|
1351
|
-
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
1352
|
-
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
1353
|
-
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
1354
|
-
var HISTORY_MAX_CANDIDATES = 20;
|
|
1355
|
-
var ArchiveHistoryError = class extends Error {
|
|
1356
|
-
constructor(code, message, candidates = []) {
|
|
1357
|
-
super(message);
|
|
1358
|
-
this.code = code;
|
|
1359
|
-
this.name = "ArchiveHistoryError";
|
|
1360
|
-
const ordered = [...candidates].sort();
|
|
1361
|
-
this.candidateTotal = ordered.length;
|
|
1362
|
-
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
1363
|
-
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
1364
|
-
}
|
|
1365
|
-
code;
|
|
1366
|
-
candidates;
|
|
1367
|
-
candidateTotal;
|
|
1368
|
-
candidatesTruncated;
|
|
1369
|
-
};
|
|
1370
|
-
|
|
1371
|
-
// src/history/inspect.ts
|
|
1372
|
-
import { readFile as readFile4 } from "fs/promises";
|
|
1373
|
-
import { basename, dirname as dirname2, relative as relative3 } from "path";
|
|
1374
|
-
var EXECUTABLE_WORK_REF_RE2 = /^[a-z0-9-]+(?:\/[a-z0-9-]+)?$/;
|
|
1375
|
-
var GROUP_NAME_RE = /^[a-z0-9-]+$/;
|
|
1376
|
-
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
1377
|
-
async function inspectArchiveHistory(options = {}) {
|
|
1378
|
-
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1379
|
-
const tree = await inspectArchiveTree({ archivesDir });
|
|
1380
|
-
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
1381
|
-
severity: "error",
|
|
1382
|
-
code: diagnostic.code,
|
|
1383
|
-
path: normalizeOutputPath(diagnostic.path, archivesDir),
|
|
1384
|
-
message: diagnostic.message
|
|
1385
|
-
}));
|
|
1386
|
-
const runtime = [];
|
|
1387
|
-
const records = [];
|
|
1388
|
-
if (!tree.rootExists && tree.diagnostics.length === 0) {
|
|
1389
|
-
diagnostics.push({
|
|
1390
|
-
severity: "error",
|
|
1391
|
-
code: "archive_root_missing",
|
|
1392
|
-
path: ".rsp/archives",
|
|
1393
|
-
message: "archive root does not exist; run rsp init or rsp update before querying history"
|
|
1394
|
-
});
|
|
1395
|
-
}
|
|
1396
|
-
for (const sourcePath of tree.files) {
|
|
1397
|
-
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
1398
|
-
let content;
|
|
1399
|
-
try {
|
|
1400
|
-
content = await readFile4(sourcePath, "utf-8");
|
|
1401
|
-
} catch (error) {
|
|
1402
|
-
const message = toErrorMessage(error);
|
|
1403
|
-
diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
|
|
1404
|
-
continue;
|
|
1405
|
-
}
|
|
1406
|
-
const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
|
|
1407
|
-
if (parsed.diagnostic)
|
|
1408
|
-
diagnostics.push(parsed.diagnostic);
|
|
1409
|
-
if (parsed.record)
|
|
1410
|
-
records.push(parsed.record);
|
|
1411
|
-
}
|
|
1412
|
-
records.sort(compareHistoryRecords);
|
|
1413
|
-
diagnostics.sort(compareDiagnostics);
|
|
1414
|
-
const diagnosticTotal = diagnostics.length;
|
|
1415
|
-
const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
|
|
1416
|
-
return {
|
|
1417
|
-
rootExists: tree.rootExists,
|
|
1418
|
-
records,
|
|
1419
|
-
diagnostics: boundedDiagnostics,
|
|
1420
|
-
diagnosticSummary: {
|
|
1421
|
-
total: diagnosticTotal,
|
|
1422
|
-
returned: boundedDiagnostics.length,
|
|
1423
|
-
hasMore: diagnosticTotal > boundedDiagnostics.length
|
|
1424
|
-
},
|
|
1425
|
-
runtime
|
|
1426
|
-
};
|
|
1427
|
-
}
|
|
1428
|
-
function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
1429
|
-
const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
|
|
1430
|
-
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
1431
|
-
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
1432
|
-
}
|
|
1433
|
-
const date = nameMatch[1];
|
|
1434
|
-
const archiveStem = nameMatch[2];
|
|
1435
|
-
const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
|
|
1436
|
-
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
1437
|
-
if (archiveGroup && !GROUP_NAME_RE.test(archiveGroup))
|
|
1438
|
-
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
1439
|
-
const changeHeadings = [...content.matchAll(/^# Change:\s+(\S+)\s*$/gm)].map((match) => match[1]);
|
|
1440
|
-
const groupHeadings = [...content.matchAll(/^# Change Group:\s+(\S+)\s*$/gm)].map((match) => match[1]);
|
|
1441
|
-
if (changeHeadings.length + groupHeadings.length !== 1) {
|
|
1442
|
-
return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
|
|
1443
|
-
}
|
|
1444
|
-
let frontmatter;
|
|
1445
|
-
try {
|
|
1446
|
-
frontmatter = parseFrontmatter(content);
|
|
1447
|
-
} catch (error) {
|
|
1448
|
-
return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
|
|
1449
|
-
}
|
|
1450
|
-
if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
|
|
1451
|
-
return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
|
|
1452
|
-
if (groupHeadings.length === 1) {
|
|
1453
|
-
const group = groupHeadings[0];
|
|
1454
|
-
if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
|
|
1455
|
-
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
|
|
1456
|
-
}
|
|
1457
|
-
return {};
|
|
1458
|
-
}
|
|
1459
|
-
const workRef = changeHeadings[0];
|
|
1460
|
-
const segments = workRef.split("/");
|
|
1461
|
-
if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
|
|
1462
|
-
return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
|
|
1463
|
-
}
|
|
1464
|
-
const expectedGroup = segments.length === 2 ? segments[0] : null;
|
|
1465
|
-
const expectedBase = segments.at(-1);
|
|
1466
|
-
if (!EXECUTABLE_WORK_REF_RE2.test(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
|
|
1467
|
-
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
|
|
1468
|
-
}
|
|
1469
|
-
const rawSummary = extractSummary(content);
|
|
1470
|
-
if (!rawSummary)
|
|
1471
|
-
return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
|
|
1472
|
-
const summary = boundText(rawSummary);
|
|
1473
|
-
return {
|
|
1474
|
-
record: {
|
|
1475
|
-
date,
|
|
1476
|
-
workRef,
|
|
1477
|
-
group: expectedGroup,
|
|
1478
|
-
kind: frontmatter.kind.trim(),
|
|
1479
|
-
summary: summary.value,
|
|
1480
|
-
summaryTruncated: summary.truncated,
|
|
1481
|
-
path,
|
|
1482
|
-
sourcePath
|
|
1483
|
-
}
|
|
1484
|
-
};
|
|
1485
|
-
}
|
|
1486
|
-
function extractSummary(content) {
|
|
1487
|
-
const frontmatter = parseFrontmatter(content);
|
|
1488
|
-
if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
|
|
1489
|
-
return frontmatter.summary.trim();
|
|
1490
|
-
const proposalLines = extractSection(content, "Proposal").split("\n").map((line) => line.trim());
|
|
1491
|
-
for (const prefix of ["- Outcome:", "- Summary:"]) {
|
|
1492
|
-
const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
|
|
1493
|
-
if (line && line.slice(prefix.length).trim())
|
|
1494
|
-
return line.slice(prefix.length).trim();
|
|
1495
|
-
}
|
|
1496
|
-
return "";
|
|
1497
|
-
}
|
|
1498
|
-
function boundText(value) {
|
|
1499
|
-
const codePoints = [...value];
|
|
1500
|
-
return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
|
|
1501
|
-
}
|
|
1502
|
-
function matchesArchiveStem(stem, identityBase) {
|
|
1503
|
-
return stem === identityBase || new RegExp(`^${escapeRegExp2(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
|
|
1504
|
-
}
|
|
1505
|
-
function isCalendarDate(value) {
|
|
1506
|
-
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1507
|
-
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1508
|
-
}
|
|
1509
|
-
function archiveOutputPath(sourcePath, archivesDir) {
|
|
1510
|
-
return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
|
|
1511
|
-
}
|
|
1512
|
-
function normalizeOutputPath(path, archivesDir) {
|
|
1513
|
-
const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
|
|
1514
|
-
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
1515
|
-
}
|
|
1516
|
-
function errorDiagnostic(code, path, message) {
|
|
1517
|
-
return { severity: "error", code, path, message };
|
|
1518
|
-
}
|
|
1519
|
-
function escapeRegExp2(value) {
|
|
1520
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1521
|
-
}
|
|
1522
|
-
function compareHistoryRecords(left, right) {
|
|
1523
|
-
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1524
|
-
}
|
|
1525
|
-
function compareDiagnostics(left, right) {
|
|
1526
|
-
return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
// src/history/query.ts
|
|
1530
|
-
import { readFile as readFile5 } from "fs/promises";
|
|
1531
|
-
function queryArchiveHistory(records, query = {}) {
|
|
1532
|
-
validateArchiveHistoryQuery(query);
|
|
1533
|
-
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1534
|
-
const matched = records.filter((record) => (!query.since || record.date >= query.since) && (!query.until || record.date <= query.until) && (!query.kind || record.kind === query.kind) && (!query.group || record.group === query.group)).sort(compareHistoryRecords2);
|
|
1535
|
-
const selected = matched.slice(0, limit);
|
|
1536
|
-
return {
|
|
1537
|
-
records: selected.map(toOutputRecord),
|
|
1538
|
-
summary: {
|
|
1539
|
-
matched: matched.length,
|
|
1540
|
-
returned: selected.length,
|
|
1541
|
-
hasMore: matched.length > selected.length
|
|
1542
|
-
}
|
|
1543
|
-
};
|
|
1544
|
-
}
|
|
1545
|
-
function validateArchiveHistoryQuery(query) {
|
|
1546
|
-
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1547
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
|
|
1548
|
-
throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
|
|
1549
|
-
if (query.since && !isCalendarDate2(query.since))
|
|
1550
|
-
throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
|
|
1551
|
-
if (query.until && !isCalendarDate2(query.until))
|
|
1552
|
-
throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
|
|
1553
|
-
if (query.since && query.until && query.since > query.until)
|
|
1554
|
-
throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
|
|
1555
|
-
if (query.kind !== void 0 && query.kind.trim() === "")
|
|
1556
|
-
throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
|
|
1557
|
-
if (query.group !== void 0 && !/^[a-z0-9-]+$/.test(query.group))
|
|
1558
|
-
throw new ArchiveHistoryError("invalid_history_group", "history group must be one lowercase kebab-case segment");
|
|
1559
|
-
}
|
|
1560
|
-
function selectArchiveHistoryRecord(records, selector) {
|
|
1561
|
-
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
|
|
1562
|
-
const identity = "path" in selector ? selector.path : selector.workRef;
|
|
1563
|
-
if (matches.length === 0)
|
|
1564
|
-
throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
|
|
1565
|
-
if (matches.length > 1) {
|
|
1566
|
-
const candidates = matches.map((record) => record.path).sort();
|
|
1567
|
-
throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
|
|
1568
|
-
}
|
|
1569
|
-
return matches[0];
|
|
1570
|
-
}
|
|
1571
|
-
async function readArchiveHistoryDetail(record) {
|
|
1572
|
-
let content;
|
|
1573
|
-
try {
|
|
1574
|
-
content = await readFile5(record.sourcePath, "utf-8");
|
|
1575
|
-
} catch (error) {
|
|
1576
|
-
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1577
|
-
}
|
|
1578
|
-
const tasks = extractSection(content, "Tasks");
|
|
1579
|
-
const verify = extractSection(content, "Verify");
|
|
1580
|
-
const blockers = extractSection(content, "Blockers");
|
|
1581
|
-
return {
|
|
1582
|
-
...toOutputRecord(record),
|
|
1583
|
-
scenarioCount: parseScenarios(content).length,
|
|
1584
|
-
checkboxes: {
|
|
1585
|
-
tasks: countCheckboxes(tasks),
|
|
1586
|
-
verify: countCheckboxes(verify)
|
|
1587
|
-
},
|
|
1588
|
-
evidence: {
|
|
1589
|
-
tasks: boundEvidence(tasks),
|
|
1590
|
-
verify: boundEvidence(verify),
|
|
1591
|
-
blockers: boundEvidence(blockers, true)
|
|
1592
|
-
}
|
|
1593
|
-
};
|
|
1594
|
-
}
|
|
1595
|
-
function historyInspectionComplete(inspection) {
|
|
1596
|
-
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
1597
|
-
}
|
|
1598
|
-
function toOutputRecord(record) {
|
|
1599
|
-
const { sourcePath: _sourcePath, ...output } = record;
|
|
1600
|
-
return output;
|
|
1601
|
-
}
|
|
1602
|
-
function boundEvidence(section, omitNone = false) {
|
|
1603
|
-
const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
|
|
1604
|
-
const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
|
|
1605
|
-
return {
|
|
1606
|
-
items: boundedItems.map((item) => item.value),
|
|
1607
|
-
truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
|
|
1608
|
-
};
|
|
1609
|
-
}
|
|
1610
|
-
function isCalendarDate2(value) {
|
|
1611
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
1612
|
-
return false;
|
|
1613
|
-
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1614
|
-
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1615
|
-
}
|
|
1616
|
-
function compareHistoryRecords2(left, right) {
|
|
1617
|
-
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1739
|
+
return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([month, count]) => ({ month, count }));
|
|
1618
1740
|
}
|
|
1619
1741
|
|
|
1620
1742
|
// src/status/dependency-forest.ts
|
|
@@ -1655,6 +1777,8 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
|
|
|
1655
1777
|
export {
|
|
1656
1778
|
GROUP_BRIEF_FILENAME,
|
|
1657
1779
|
WorkRefError,
|
|
1780
|
+
normalizeWorkRefSegment,
|
|
1781
|
+
normalizeExecutableWorkRef,
|
|
1658
1782
|
resolveWorkRef,
|
|
1659
1783
|
resolveFocusMarkerPath,
|
|
1660
1784
|
resolveArchiveDirectory,
|
|
@@ -1665,7 +1789,6 @@ export {
|
|
|
1665
1789
|
generateGroupBriefContent,
|
|
1666
1790
|
inspectChangeGroups,
|
|
1667
1791
|
hasArchivedGroupBrief,
|
|
1668
|
-
inspectChangeDependencies,
|
|
1669
1792
|
HISTORY_DEFAULT_LIMIT,
|
|
1670
1793
|
HISTORY_MAX_LIMIT,
|
|
1671
1794
|
ArchiveHistoryError,
|
|
@@ -1674,6 +1797,7 @@ export {
|
|
|
1674
1797
|
selectArchiveHistoryRecord,
|
|
1675
1798
|
readArchiveHistoryDetail,
|
|
1676
1799
|
historyInspectionComplete,
|
|
1800
|
+
inspectChangeDependencies,
|
|
1677
1801
|
inspectProjectStatus,
|
|
1678
1802
|
projectDependencyForest
|
|
1679
1803
|
};
|