@oevery/rsp 3.1.0-beta.3 → 3.1.0-beta.5
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 +28 -16
- package/README.zh-CN.md +20 -13
- package/dist/{chunk-NX55NIC3.mjs → chunk-3X2V6IEW.mjs} +1 -1
- package/dist/{chunk-OTJFL6XH.mjs → chunk-UVYEXKYS.mjs} +358 -221
- package/dist/{chunk-7KKAUBKN.mjs → chunk-ZKZVWJQ7.mjs} +687 -421
- package/dist/cli.mjs +343 -88
- package/dist/{entry-WZDVKDUM.mjs → entry-BKEJDNOQ.mjs} +2 -2
- package/dist/{entry-X23GPCVS.mjs → entry-ETZGIFKP.mjs} +2 -2
- package/package.json +1 -1
- package/rules/rsp-rules.md +8 -5
- package/skills/rsp/SKILL.md +8 -18
- package/skills/rsp/references/groups-dependencies.md +1 -0
- package/skills/rsp/references/managed-routing.md +12 -23
- package/skills/rsp/references/release-operations.md +9 -0
- package/skills/rsp/references/reopen-recovery.md +9 -0
- package/skills/rsp-commit/SKILL.md +4 -2
- package/skills/rsp-implement/SKILL.md +5 -5
- package/skills/rsp-manage/SKILL.md +17 -7
- package/skills/rsp-release-docs/references/convention-discovery.md +2 -2
- package/skills/rsp-shape/SKILL.md +2 -0
- package/skills/rsp-shape/references/external-issue-input.md +9 -0
|
@@ -2,24 +2,34 @@
|
|
|
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-UVYEXKYS.mjs";
|
|
23
33
|
|
|
24
34
|
// src/status/inspect.ts
|
|
25
35
|
import { readFile as readFile5, stat } from "fs/promises";
|
|
@@ -29,10 +39,13 @@ import { readFile } from "fs/promises";
|
|
|
29
39
|
import { dirname, join as join2, relative as relative2 } from "path";
|
|
30
40
|
|
|
31
41
|
// src/core/work-ref.ts
|
|
32
|
-
import {
|
|
42
|
+
import { Buffer } from "buffer";
|
|
43
|
+
import { lstatSync, readdirSync } from "fs";
|
|
33
44
|
import { readdir } from "fs/promises";
|
|
34
45
|
import { isAbsolute, join, relative } from "path";
|
|
35
|
-
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;
|
|
36
49
|
var GROUP_BRIEF_FILENAME = "00-brief.md";
|
|
37
50
|
var WorkRefError = class extends Error {
|
|
38
51
|
constructor(code, message, input) {
|
|
@@ -44,35 +57,51 @@ var WorkRefError = class extends Error {
|
|
|
44
57
|
code;
|
|
45
58
|
input;
|
|
46
59
|
};
|
|
47
|
-
function
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const isGroupBrief = segments.length === 2 && segments[1] === "brief";
|
|
51
|
-
if (segments.length > 2) {
|
|
52
|
-
throw new WorkRefError(
|
|
53
|
-
"unsupported_work_depth",
|
|
54
|
-
`work identity "${name}" exceeds the supported one-level Change Group depth`,
|
|
55
|
-
name
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
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) {
|
|
59
63
|
throw new WorkRefError(
|
|
60
64
|
"invalid_work_ref",
|
|
61
|
-
`work identity "${
|
|
62
|
-
|
|
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
|
|
63
67
|
);
|
|
64
68
|
}
|
|
65
|
-
|
|
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) {
|
|
66
92
|
throw new WorkRefError(
|
|
67
93
|
"invalid_work_ref",
|
|
68
|
-
`work identity "${name}"
|
|
94
|
+
`stored work identity "${name}" must use Unicode NFC normalization`,
|
|
69
95
|
name
|
|
70
96
|
);
|
|
71
97
|
}
|
|
72
|
-
|
|
73
|
-
|
|
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);
|
|
74
103
|
const path = join(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
|
|
75
|
-
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] };
|
|
76
105
|
if (ref.kind === "group-change" && options.executable !== false)
|
|
77
106
|
assertGroupBrief(changesDir, ref);
|
|
78
107
|
if (options.executable && ref.kind === "group-brief") {
|
|
@@ -147,7 +176,7 @@ function resolveWorkRefPath(path, options = {}) {
|
|
|
147
176
|
);
|
|
148
177
|
}
|
|
149
178
|
const name = segments.length === 2 && segments[1] === GROUP_BRIEF_FILENAME ? `${segments[0]}/brief` : logicalPath.slice(0, -3);
|
|
150
|
-
return resolveWorkRef(name, options);
|
|
179
|
+
return resolveWorkRef(name, { ...options, canonical: true });
|
|
151
180
|
}
|
|
152
181
|
function isExecutableWorkRef(ref) {
|
|
153
182
|
return ref.kind !== "group-brief";
|
|
@@ -155,6 +184,7 @@ function isExecutableWorkRef(ref) {
|
|
|
155
184
|
function resolveFocusMarkerPath(ref, options = {}) {
|
|
156
185
|
const focusDir = options.focusDir ?? FOCUS_DIR;
|
|
157
186
|
assertManagedRoot(focusDir, ref.name, "invalid_focus_root", "focus root", true);
|
|
187
|
+
assertNoManagedNormalizationCollision(focusDir, ref.name.split("/"), ref.name, "invalid_focus_path");
|
|
158
188
|
if (ref.group) {
|
|
159
189
|
assertOptionalManagedDirectory(
|
|
160
190
|
join(focusDir, ref.group),
|
|
@@ -178,6 +208,7 @@ function resolveArchiveDirectory(ref, options = {}) {
|
|
|
178
208
|
assertManagedRoot(archivesDir, ref.name, "invalid_archive_root", "archive root", true);
|
|
179
209
|
if (!ref.group)
|
|
180
210
|
return archivesDir;
|
|
211
|
+
assertNoManagedNormalizationCollision(archivesDir, [ref.group], ref.name, "invalid_archive_path");
|
|
181
212
|
const groupPath = join(archivesDir, ref.group);
|
|
182
213
|
assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
|
|
183
214
|
return groupPath;
|
|
@@ -201,10 +232,10 @@ async function inspectFocusTree(options = {}) {
|
|
|
201
232
|
if (entry.name === ".gitkeep" && entry.isFile())
|
|
202
233
|
continue;
|
|
203
234
|
if (entry.isDirectory()) {
|
|
204
|
-
if (!
|
|
235
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
205
236
|
addDiagnostic(result, new WorkRefError(
|
|
206
237
|
"invalid_work_ref",
|
|
207
|
-
`work identity "${entry.name}" must
|
|
238
|
+
`stored work identity "${entry.name}" must be a safe Unicode NFC segment`,
|
|
208
239
|
entry.name
|
|
209
240
|
), path);
|
|
210
241
|
continue;
|
|
@@ -244,7 +275,7 @@ async function inspectArchiveTree(options = {}) {
|
|
|
244
275
|
if (entry.name === "INDEX.md" && entry.isFile())
|
|
245
276
|
continue;
|
|
246
277
|
if (entry.isDirectory()) {
|
|
247
|
-
if (!
|
|
278
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
248
279
|
addArchivePathDiagnostic(result, entry.name, path);
|
|
249
280
|
continue;
|
|
250
281
|
}
|
|
@@ -282,10 +313,10 @@ async function inspectWorkTree(options = {}) {
|
|
|
282
313
|
if (entry.name === ".gitkeep" && entry.isFile())
|
|
283
314
|
continue;
|
|
284
315
|
if (entry.isDirectory()) {
|
|
285
|
-
if (!
|
|
316
|
+
if (!isCanonicalWorkRefSegment(entry.name)) {
|
|
286
317
|
addDiagnostic(result, new WorkRefError(
|
|
287
318
|
"invalid_work_ref",
|
|
288
|
-
`work identity "${entry.name}" must
|
|
319
|
+
`stored work identity "${entry.name}" must be a safe Unicode NFC segment`,
|
|
289
320
|
entry.name
|
|
290
321
|
), path);
|
|
291
322
|
continue;
|
|
@@ -400,7 +431,7 @@ async function inspectArchiveGroup(group, groupPath, result) {
|
|
|
400
431
|
}
|
|
401
432
|
function collectFocusMarker(input, path, changesDir, result) {
|
|
402
433
|
try {
|
|
403
|
-
const ref = resolveWorkRef(input, { changesDir, executable: true });
|
|
434
|
+
const ref = resolveWorkRef(input, { changesDir, canonical: true, executable: true });
|
|
404
435
|
result.markers.push({ name: ref.name, path });
|
|
405
436
|
} catch (error) {
|
|
406
437
|
if (!(error instanceof WorkRefError))
|
|
@@ -448,6 +479,98 @@ function addDiagnostic(result, error, path) {
|
|
|
448
479
|
return;
|
|
449
480
|
result.diagnostics.push({ code: error.code, input: error.input, path, message: error.message });
|
|
450
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
|
+
}
|
|
451
574
|
function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
|
|
452
575
|
for (let depth = 1; depth <= segments.length; depth++) {
|
|
453
576
|
const identityPath = join(changesDir, ...segments.slice(0, depth));
|
|
@@ -542,7 +665,8 @@ function normalizeLogicalPath2(path) {
|
|
|
542
665
|
}
|
|
543
666
|
|
|
544
667
|
// src/core/change-group.ts
|
|
545
|
-
var GROUP_BRIEF_SECTIONS =
|
|
668
|
+
var GROUP_BRIEF_SECTIONS = getCanonicalSectionHeadings(GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
669
|
+
var GROUP_REOPEN_COMPLETION_PREFIX = "Resolve reopened concern from";
|
|
546
670
|
async function resolveExecutableChange(name, options = {}) {
|
|
547
671
|
const ref = resolveWorkRef(name, { ...options, executable: true });
|
|
548
672
|
if (ref.kind === "group-change")
|
|
@@ -550,32 +674,33 @@ async function resolveExecutableChange(name, options = {}) {
|
|
|
550
674
|
return ref;
|
|
551
675
|
}
|
|
552
676
|
function generateGroupBriefContent(name, goal = "") {
|
|
677
|
+
const placeholder = "<\u2026>";
|
|
553
678
|
return `---
|
|
554
679
|
kind: group
|
|
555
680
|
---
|
|
556
681
|
|
|
557
|
-
|
|
682
|
+
${renderDocumentTitle(GROUP_BRIEF_DOCUMENT_SCHEMA, name)}
|
|
558
683
|
|
|
559
|
-
|
|
560
|
-
- ${goal ||
|
|
684
|
+
${groupSectionHeading("goal")}
|
|
685
|
+
- ${goal || placeholder}
|
|
561
686
|
|
|
562
|
-
|
|
563
|
-
-
|
|
687
|
+
${groupSectionHeading("scope")}
|
|
688
|
+
- ${placeholder}
|
|
564
689
|
|
|
565
|
-
|
|
566
|
-
-
|
|
690
|
+
${groupSectionHeading("sharedConstraints")}
|
|
691
|
+
- ${placeholder}
|
|
567
692
|
|
|
568
|
-
|
|
569
|
-
- \`${name}/<change>\`:
|
|
693
|
+
${groupSectionHeading("slices")}
|
|
694
|
+
- \`${name}/<change>\`: ${placeholder}
|
|
570
695
|
|
|
571
|
-
|
|
572
|
-
- [ ]
|
|
696
|
+
${groupSectionHeading("completionConditions")}
|
|
697
|
+
- [ ] ${placeholder}
|
|
573
698
|
|
|
574
|
-
|
|
575
|
-
- Current facts:
|
|
576
|
-
- Lasting rationale:
|
|
699
|
+
${groupSectionHeading("durableOutcomes")}
|
|
700
|
+
- Current facts: ${placeholder}
|
|
701
|
+
- Lasting rationale: ${placeholder}
|
|
577
702
|
|
|
578
|
-
|
|
703
|
+
${groupSectionHeading("blockers")}
|
|
579
704
|
- none
|
|
580
705
|
`;
|
|
581
706
|
}
|
|
@@ -587,6 +712,8 @@ async function inspectChangeGroups(options = {}) {
|
|
|
587
712
|
const groups = [];
|
|
588
713
|
const archivedSlicesByGroup = /* @__PURE__ */ new Map();
|
|
589
714
|
const archivedGroups = /* @__PURE__ */ new Set();
|
|
715
|
+
const archivedGroupReopenEvidence = /* @__PURE__ */ new Map();
|
|
716
|
+
const archivedGroupBriefPaths = /* @__PURE__ */ new Map();
|
|
590
717
|
const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
|
|
591
718
|
for (const path of archiveTree.files) {
|
|
592
719
|
const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, path));
|
|
@@ -595,10 +722,10 @@ async function inspectChangeGroups(options = {}) {
|
|
|
595
722
|
continue;
|
|
596
723
|
try {
|
|
597
724
|
const content = await readFile(path, "utf-8");
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (!
|
|
725
|
+
const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
726
|
+
const identity = getDocumentTitle(archivedChange, "identity");
|
|
727
|
+
if (identity?.includes("/")) {
|
|
728
|
+
if (!isCanonicalExecutableWorkRef(identity) || !identity.startsWith(`${archiveGroup}/`)) {
|
|
602
729
|
diagnostics.push({
|
|
603
730
|
severity: "error",
|
|
604
731
|
code: "group_archive_identity_mismatch",
|
|
@@ -612,8 +739,16 @@ async function inspectChangeGroups(options = {}) {
|
|
|
612
739
|
archivedSlicesByGroup.set(archiveGroup, groupSlices);
|
|
613
740
|
}
|
|
614
741
|
}
|
|
615
|
-
if (
|
|
742
|
+
if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === archiveGroup) {
|
|
616
743
|
archivedGroups.add(archiveGroup);
|
|
744
|
+
const evidence = archivedGroupReopenEvidence.get(archiveGroup) ?? /* @__PURE__ */ new Set();
|
|
745
|
+
for (const item of getGroupReopenEvidence(content))
|
|
746
|
+
evidence.add(item.key);
|
|
747
|
+
archivedGroupReopenEvidence.set(archiveGroup, evidence);
|
|
748
|
+
const retainedPaths = archivedGroupBriefPaths.get(archiveGroup) ?? /* @__PURE__ */ new Set();
|
|
749
|
+
retainedPaths.add(`.rsp/archives/${archivePath}`);
|
|
750
|
+
archivedGroupBriefPaths.set(archiveGroup, retainedPaths);
|
|
751
|
+
}
|
|
617
752
|
} catch (error) {
|
|
618
753
|
diagnostics.push({
|
|
619
754
|
severity: "error",
|
|
@@ -640,7 +775,11 @@ async function inspectChangeGroups(options = {}) {
|
|
|
640
775
|
}
|
|
641
776
|
const parsed = parseGroupBrief(brief.group, content);
|
|
642
777
|
diagnostics.push(...parsed.diagnostics.map((diagnostic) => ({ ...diagnostic, path: brief.path })));
|
|
643
|
-
if (archivedGroups.has(brief.group)
|
|
778
|
+
if (archivedGroups.has(brief.group) && !hasExplicitGroupReopenEvidence(
|
|
779
|
+
content,
|
|
780
|
+
archivedGroupReopenEvidence.get(brief.group) ?? /* @__PURE__ */ new Set(),
|
|
781
|
+
archivedGroupBriefPaths.get(brief.group) ?? /* @__PURE__ */ new Set()
|
|
782
|
+
)) {
|
|
644
783
|
diagnostics.push({
|
|
645
784
|
severity: "error",
|
|
646
785
|
code: "group_identity_reopened",
|
|
@@ -731,6 +870,16 @@ async function inspectChangeGroups(options = {}) {
|
|
|
731
870
|
groups.sort((left, right) => left.name.localeCompare(right.name));
|
|
732
871
|
return { groups, diagnostics };
|
|
733
872
|
}
|
|
873
|
+
function getGroupReopenEvidence(content) {
|
|
874
|
+
const completion = getDocumentSectionBody(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "completionConditions");
|
|
875
|
+
return completion.split("\n").flatMap((line) => {
|
|
876
|
+
const match = line.trim().match(/^-\s+\[([ /xX-])\]\s+(Resolve reopened concern from `(\.rsp\/archives\/[^`]+)`: \S.*)$/);
|
|
877
|
+
return match ? [{ active: match[1] !== "-", key: match[2], sourcePath: match[3] }] : [];
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
function hasExplicitGroupReopenEvidence(content, retainedEvidence, retainedGroupBriefPaths) {
|
|
881
|
+
return getGroupReopenEvidence(content).some((item) => item.active && retainedGroupBriefPaths.has(item.sourcePath) && !retainedEvidence.has(item.key));
|
|
882
|
+
}
|
|
734
883
|
async function requireDeclaredGroupSlice(ref) {
|
|
735
884
|
const briefPath = join2(dirname(ref.path), GROUP_BRIEF_FILENAME);
|
|
736
885
|
let content;
|
|
@@ -774,13 +923,14 @@ async function hasArchivedGroupBrief(group) {
|
|
|
774
923
|
const message = error instanceof Error ? error.message : String(error);
|
|
775
924
|
throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
|
|
776
925
|
}
|
|
777
|
-
if (
|
|
926
|
+
if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === group)
|
|
778
927
|
return true;
|
|
779
928
|
}
|
|
780
929
|
return false;
|
|
781
930
|
}
|
|
782
931
|
function parseGroupBrief(group, content) {
|
|
783
932
|
const diagnostics = [];
|
|
933
|
+
const document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
784
934
|
let frontmatter;
|
|
785
935
|
try {
|
|
786
936
|
frontmatter = parseFrontmatter(content);
|
|
@@ -789,15 +939,15 @@ function parseGroupBrief(group, content) {
|
|
|
789
939
|
}
|
|
790
940
|
if (frontmatter?.kind !== "group")
|
|
791
941
|
diagnostics.push({ severity: "error", code: "group_kind_invalid", change: group, message: "Group Brief frontmatter must declare kind: group" });
|
|
792
|
-
if (
|
|
942
|
+
if (getDocumentTitle(document, "identity") !== group)
|
|
793
943
|
diagnostics.push({ severity: "error", code: "group_heading_mismatch", change: group, message: `Group Brief heading must be: # Change Group: ${group}` });
|
|
794
|
-
for (const
|
|
795
|
-
if (!
|
|
796
|
-
diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${
|
|
944
|
+
for (const definition of GROUP_BRIEF_DOCUMENT_SCHEMA.sections) {
|
|
945
|
+
if (!getDocumentSectionBody(document, definition.id))
|
|
946
|
+
diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${definition.heading}` });
|
|
797
947
|
}
|
|
798
948
|
const slices = [];
|
|
799
949
|
const seen = /* @__PURE__ */ new Set();
|
|
800
|
-
for (const line of
|
|
950
|
+
for (const line of getDocumentSectionBody(document, "slices").split("\n")) {
|
|
801
951
|
const trimmed = line.trim();
|
|
802
952
|
const identity = trimmed.match(/^-\s+`(\S+)`/);
|
|
803
953
|
if (!identity || identity[1].includes("<"))
|
|
@@ -813,7 +963,7 @@ function parseGroupBrief(group, content) {
|
|
|
813
963
|
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires a boundary: ${name}` });
|
|
814
964
|
continue;
|
|
815
965
|
}
|
|
816
|
-
if (!
|
|
966
|
+
if (!isCanonicalExecutableWorkRef(name) || !name.startsWith(`${group}/`)) {
|
|
817
967
|
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `invalid direct child identity in Group Brief: ${name}` });
|
|
818
968
|
continue;
|
|
819
969
|
}
|
|
@@ -826,107 +976,477 @@ function parseGroupBrief(group, content) {
|
|
|
826
976
|
}
|
|
827
977
|
if (slices.length < 2)
|
|
828
978
|
diagnostics.push({ severity: "warning", code: "group_slices_incomplete", change: group, message: "Change Group must declare at least two direct child slices" });
|
|
829
|
-
const completion = countCheckboxes(
|
|
979
|
+
const completion = countCheckboxes(getDocumentSectionBody(document, "completionConditions"));
|
|
830
980
|
if (completion.total === 0)
|
|
831
981
|
diagnostics.push({ severity: "warning", code: "group_completion_missing", change: group, message: "Change Group must declare at least one completion condition" });
|
|
832
982
|
return {
|
|
833
983
|
slices,
|
|
834
984
|
completion: { done: completion.done + completion.dropped, total: completion.total },
|
|
835
|
-
blockers:
|
|
985
|
+
blockers: hasMeaningfulBlockerBody(getDocumentSectionBody(document, "blockers")),
|
|
836
986
|
diagnostics
|
|
837
987
|
};
|
|
838
988
|
}
|
|
839
|
-
function
|
|
840
|
-
return
|
|
989
|
+
function groupSectionHeading(sectionId) {
|
|
990
|
+
return renderDocumentSectionHeading(GROUP_BRIEF_DOCUMENT_SCHEMA, sectionId);
|
|
841
991
|
}
|
|
842
992
|
|
|
843
993
|
// src/core/dependency-plan.ts
|
|
994
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
995
|
+
|
|
996
|
+
// src/history/query.ts
|
|
997
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
998
|
+
|
|
999
|
+
// src/history/inspect.ts
|
|
844
1000
|
import { readFile as readFile2 } from "fs/promises";
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1001
|
+
import { basename, dirname as dirname2, relative as relative3 } from "path";
|
|
1002
|
+
|
|
1003
|
+
// src/core/issue-relationship.ts
|
|
1004
|
+
var ISSUE_RELATIONS = /* @__PURE__ */ new Set(["relates", "closes"]);
|
|
1005
|
+
var ISSUE_FIELDS = /* @__PURE__ */ new Set(["url", "relation"]);
|
|
1006
|
+
var IssueRelationshipError = class extends Error {
|
|
1007
|
+
constructor(code, message) {
|
|
1008
|
+
super(message);
|
|
1009
|
+
this.code = code;
|
|
1010
|
+
this.name = "IssueRelationshipError";
|
|
1011
|
+
}
|
|
1012
|
+
code;
|
|
1013
|
+
};
|
|
1014
|
+
function parseIssueRelationships(frontmatter) {
|
|
1015
|
+
if (!frontmatter || !("issues" in frontmatter))
|
|
1016
|
+
return [];
|
|
1017
|
+
if (!Array.isArray(frontmatter.issues))
|
|
1018
|
+
throw new IssueRelationshipError("invalid_issue_shape", 'frontmatter field "issues" must be a list');
|
|
1019
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1020
|
+
return frontmatter.issues.map((value, index) => {
|
|
1021
|
+
if (!isPlainObject(value))
|
|
1022
|
+
throw new IssueRelationshipError("invalid_issue_shape", `issues[${index}] must be a mapping with url and relation`);
|
|
1023
|
+
const fields = Object.keys(value);
|
|
1024
|
+
const unsupported = fields.filter((field) => !ISSUE_FIELDS.has(field));
|
|
1025
|
+
if (unsupported.length > 0)
|
|
1026
|
+
throw new IssueRelationshipError("unsupported_issue_field", `issues[${index}] contains unsupported field(s): ${unsupported.sort().join(", ")}`);
|
|
1027
|
+
if (fields.length !== 2 || !("url" in value) || !("relation" in value))
|
|
1028
|
+
throw new IssueRelationshipError("invalid_issue_shape", `issues[${index}] must contain exactly url and relation`);
|
|
1029
|
+
if (typeof value.url !== "string")
|
|
1030
|
+
throw new IssueRelationshipError("invalid_issue_url", `issues[${index}].url must be an absolute http or https URL`);
|
|
1031
|
+
if (typeof value.relation !== "string" || !ISSUE_RELATIONS.has(value.relation))
|
|
1032
|
+
throw new IssueRelationshipError("unsupported_issue_relation", `issues[${index}].relation must be relates or closes`);
|
|
1033
|
+
const url = normalizeIssueUrl(value.url, `issues[${index}].url`);
|
|
1034
|
+
if (seen.has(url))
|
|
1035
|
+
throw new IssueRelationshipError("duplicate_issue_url", `issues contains duplicate normalized URL: ${url}`);
|
|
1036
|
+
seen.add(url);
|
|
1037
|
+
return { url, relation: value.relation };
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
function createIssueRelationship(url, relation = "relates") {
|
|
1041
|
+
return parseIssueRelationships({ issues: [{ url, relation }] })[0];
|
|
1042
|
+
}
|
|
1043
|
+
function normalizeIssueUrl(raw, label) {
|
|
1044
|
+
if (raw.trim() !== raw || raw === "")
|
|
1045
|
+
throw new IssueRelationshipError("invalid_issue_url", `${label} must be an absolute http or https URL without surrounding whitespace`);
|
|
1046
|
+
let parsed;
|
|
1047
|
+
try {
|
|
1048
|
+
parsed = new URL(raw);
|
|
1049
|
+
} catch {
|
|
1050
|
+
throw new IssueRelationshipError("invalid_issue_url", `${label} must be an absolute http or https URL`);
|
|
1051
|
+
}
|
|
1052
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
1053
|
+
throw new IssueRelationshipError("invalid_issue_url", `${label} must use http or https`);
|
|
1054
|
+
if (!parsed.hostname)
|
|
1055
|
+
throw new IssueRelationshipError("invalid_issue_url", `${label} must include a host`);
|
|
1056
|
+
if (parsed.username || parsed.password)
|
|
1057
|
+
throw new IssueRelationshipError("unsafe_issue_url", `${label} must not contain credentials`);
|
|
1058
|
+
if (parsed.hash)
|
|
1059
|
+
throw new IssueRelationshipError("unsafe_issue_url", `${label} must not contain a fragment`);
|
|
1060
|
+
return parsed.toString();
|
|
1061
|
+
}
|
|
1062
|
+
function isPlainObject(value) {
|
|
1063
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// src/history/model.ts
|
|
1067
|
+
var HISTORY_DEFAULT_LIMIT = 20;
|
|
1068
|
+
var HISTORY_MAX_LIMIT = 100;
|
|
1069
|
+
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
1070
|
+
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
1071
|
+
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
1072
|
+
var HISTORY_MAX_CANDIDATES = 20;
|
|
1073
|
+
var ArchiveHistoryError = class extends Error {
|
|
1074
|
+
constructor(code, message, candidates = []) {
|
|
1075
|
+
super(message);
|
|
1076
|
+
this.code = code;
|
|
1077
|
+
this.name = "ArchiveHistoryError";
|
|
1078
|
+
const ordered = [...candidates].sort();
|
|
1079
|
+
this.candidateTotal = ordered.length;
|
|
1080
|
+
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
1081
|
+
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
1082
|
+
}
|
|
1083
|
+
code;
|
|
1084
|
+
candidates;
|
|
1085
|
+
candidateTotal;
|
|
1086
|
+
candidatesTruncated;
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// src/history/inspect.ts
|
|
1090
|
+
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
1091
|
+
async function inspectArchiveHistory(options = {}) {
|
|
1092
|
+
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1093
|
+
const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
|
|
1094
|
+
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
856
1095
|
severity: "error",
|
|
857
1096
|
code: diagnostic.code,
|
|
858
|
-
path: diagnostic.path,
|
|
1097
|
+
path: normalizeOutputPath(diagnostic.path, archivesDir),
|
|
859
1098
|
message: diagnostic.message
|
|
860
1099
|
}));
|
|
861
|
-
|
|
1100
|
+
const runtime = [];
|
|
1101
|
+
const records = [];
|
|
1102
|
+
const groupBriefs = [];
|
|
1103
|
+
if (!tree.rootExists && tree.diagnostics.length === 0) {
|
|
1104
|
+
diagnostics.push({
|
|
1105
|
+
severity: "error",
|
|
1106
|
+
code: "archive_root_missing",
|
|
1107
|
+
path: ".rsp/archives",
|
|
1108
|
+
message: "archive root does not exist; run rsp init or rsp update before querying history"
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
for (const sourcePath of tree.files) {
|
|
1112
|
+
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
1113
|
+
let content;
|
|
862
1114
|
try {
|
|
863
|
-
|
|
864
|
-
if (heading)
|
|
865
|
-
archivedNames.add(heading[1]);
|
|
1115
|
+
content = await readFile2(sourcePath, "utf-8");
|
|
866
1116
|
} catch (error) {
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
path,
|
|
871
|
-
message: error instanceof Error ? error.message : String(error)
|
|
872
|
-
});
|
|
1117
|
+
const message = toErrorMessage(error);
|
|
1118
|
+
diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
|
|
1119
|
+
continue;
|
|
873
1120
|
}
|
|
1121
|
+
const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
|
|
1122
|
+
if (parsed.diagnostic)
|
|
1123
|
+
diagnostics.push(parsed.diagnostic);
|
|
1124
|
+
if (parsed.record)
|
|
1125
|
+
records.push(parsed.record);
|
|
1126
|
+
if (parsed.groupBrief)
|
|
1127
|
+
groupBriefs.push(parsed.groupBrief);
|
|
874
1128
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1129
|
+
records.sort(compareHistoryRecords);
|
|
1130
|
+
groupBriefs.sort(compareArchivedGroupBriefs);
|
|
1131
|
+
diagnostics.sort(compareDiagnostics);
|
|
1132
|
+
const diagnosticTotal = diagnostics.length;
|
|
1133
|
+
const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
|
|
1134
|
+
return {
|
|
1135
|
+
rootExists: tree.rootExists,
|
|
1136
|
+
records,
|
|
1137
|
+
groupBriefs,
|
|
1138
|
+
diagnostics: boundedDiagnostics,
|
|
1139
|
+
diagnosticSummary: {
|
|
1140
|
+
total: diagnosticTotal,
|
|
1141
|
+
returned: boundedDiagnostics.length,
|
|
1142
|
+
hasMore: diagnosticTotal > boundedDiagnostics.length
|
|
1143
|
+
},
|
|
1144
|
+
runtime
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
1148
|
+
const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
|
|
1149
|
+
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
1150
|
+
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
1151
|
+
}
|
|
1152
|
+
const date = nameMatch[1];
|
|
1153
|
+
const archiveStem = nameMatch[2];
|
|
1154
|
+
const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
|
|
1155
|
+
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
1156
|
+
if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
|
|
1157
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
1158
|
+
const changeDocument = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1159
|
+
const groupDocument = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
1160
|
+
const changeHeadings = getDocumentTitles(changeDocument, "identity");
|
|
1161
|
+
const groupHeadings = getDocumentTitles(groupDocument, "identity");
|
|
1162
|
+
if (changeHeadings.length + groupHeadings.length !== 1) {
|
|
1163
|
+
return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
|
|
1164
|
+
}
|
|
1165
|
+
let frontmatter;
|
|
1166
|
+
try {
|
|
1167
|
+
frontmatter = parseFrontmatter(content);
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
|
|
1170
|
+
}
|
|
1171
|
+
if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
|
|
1172
|
+
return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
|
|
1173
|
+
if (groupHeadings.length === 1) {
|
|
1174
|
+
const group = groupHeadings[0];
|
|
1175
|
+
if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
|
|
1176
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
|
|
887
1177
|
}
|
|
1178
|
+
return { groupBrief: { date, group, path, sourcePath } };
|
|
888
1179
|
}
|
|
889
|
-
|
|
1180
|
+
let issues;
|
|
1181
|
+
if ("issues" in frontmatter) {
|
|
890
1182
|
try {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
parsedByName.set(ref.name, parsed);
|
|
895
|
-
for (const line of parsed.malformed) {
|
|
896
|
-
diagnostics.push({
|
|
897
|
-
severity: "error",
|
|
898
|
-
code: "dependency_syntax_invalid",
|
|
899
|
-
change: ref.name,
|
|
900
|
-
path: ref.path,
|
|
901
|
-
message: `invalid dependency blocker syntax: ${line}`,
|
|
902
|
-
hint: "Use: - requires `<change-work-ref>`: <reason>"
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
} catch (error) {
|
|
906
|
-
diagnostics.push({
|
|
907
|
-
severity: "error",
|
|
908
|
-
code: "change_read_failed",
|
|
909
|
-
change: ref.name,
|
|
910
|
-
path: ref.path,
|
|
911
|
-
message: error instanceof Error ? error.message : String(error)
|
|
912
|
-
});
|
|
1183
|
+
issues = parseIssueRelationships(frontmatter);
|
|
1184
|
+
} catch {
|
|
1185
|
+
issues = void 0;
|
|
913
1186
|
}
|
|
914
1187
|
}
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1188
|
+
const workRef = changeHeadings[0];
|
|
1189
|
+
const segments = workRef.split("/");
|
|
1190
|
+
if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
|
|
1191
|
+
return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
|
|
1192
|
+
}
|
|
1193
|
+
const expectedGroup = segments.length === 2 ? segments[0] : null;
|
|
1194
|
+
const expectedBase = segments.at(-1);
|
|
1195
|
+
if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
|
|
1196
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
|
|
1197
|
+
}
|
|
1198
|
+
const rawSummary = extractSummary(content, changeDocument);
|
|
1199
|
+
if (!rawSummary)
|
|
1200
|
+
return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
|
|
1201
|
+
const summary = boundText(rawSummary);
|
|
1202
|
+
return {
|
|
1203
|
+
record: {
|
|
1204
|
+
date,
|
|
1205
|
+
workRef,
|
|
1206
|
+
group: expectedGroup,
|
|
1207
|
+
kind: frontmatter.kind.trim(),
|
|
1208
|
+
summary: summary.value,
|
|
1209
|
+
summaryTruncated: summary.truncated,
|
|
1210
|
+
path,
|
|
1211
|
+
...issues === void 0 ? {} : { issues },
|
|
1212
|
+
sourcePath,
|
|
1213
|
+
searchSummary: rawSummary
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
function extractSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA)) {
|
|
1218
|
+
const frontmatter = parseFrontmatter(content);
|
|
1219
|
+
if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
|
|
1220
|
+
return frontmatter.summary.trim();
|
|
1221
|
+
const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
|
|
1222
|
+
for (const prefix of ["- Outcome:", "- Summary:"]) {
|
|
1223
|
+
const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
|
|
1224
|
+
if (line && line.slice(prefix.length).trim())
|
|
1225
|
+
return line.slice(prefix.length).trim();
|
|
1226
|
+
}
|
|
1227
|
+
return "";
|
|
1228
|
+
}
|
|
1229
|
+
function boundText(value) {
|
|
1230
|
+
const codePoints = [...value];
|
|
1231
|
+
return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
|
|
1232
|
+
}
|
|
1233
|
+
function matchesArchiveStem(stem, identityBase) {
|
|
1234
|
+
return stem === identityBase || new RegExp(`^${escapeRegExp(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
|
|
1235
|
+
}
|
|
1236
|
+
function isCalendarDate(value) {
|
|
1237
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1238
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1239
|
+
}
|
|
1240
|
+
function archiveOutputPath(sourcePath, archivesDir) {
|
|
1241
|
+
return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
|
|
1242
|
+
}
|
|
1243
|
+
function normalizeOutputPath(path, archivesDir) {
|
|
1244
|
+
const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
|
|
1245
|
+
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
1246
|
+
}
|
|
1247
|
+
function errorDiagnostic(code, path, message) {
|
|
1248
|
+
return { severity: "error", code, path, message };
|
|
1249
|
+
}
|
|
1250
|
+
function escapeRegExp(value) {
|
|
1251
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1252
|
+
}
|
|
1253
|
+
function compareHistoryRecords(left, right) {
|
|
1254
|
+
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1255
|
+
}
|
|
1256
|
+
function compareArchivedGroupBriefs(left, right) {
|
|
1257
|
+
return right.date.localeCompare(left.date) || left.group.localeCompare(right.group) || left.path.localeCompare(right.path);
|
|
1258
|
+
}
|
|
1259
|
+
function compareDiagnostics(left, right) {
|
|
1260
|
+
return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/history/query.ts
|
|
1264
|
+
function queryArchiveHistory(records, query = {}) {
|
|
1265
|
+
validateArchiveHistoryQuery(query);
|
|
1266
|
+
const group = query.group === void 0 ? void 0 : normalizeWorkRefSegment(query.group);
|
|
1267
|
+
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1268
|
+
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}
|
|
1269
|
+
${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
|
|
1270
|
+
const selected = matched.slice(0, limit);
|
|
1271
|
+
return {
|
|
1272
|
+
records: selected.map(toOutputRecord),
|
|
1273
|
+
summary: {
|
|
1274
|
+
matched: matched.length,
|
|
1275
|
+
returned: selected.length,
|
|
1276
|
+
hasMore: matched.length > selected.length
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
function validateArchiveHistoryQuery(query) {
|
|
1281
|
+
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1282
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
|
|
1283
|
+
throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
|
|
1284
|
+
if (query.since && !isCalendarDate2(query.since))
|
|
1285
|
+
throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
|
|
1286
|
+
if (query.until && !isCalendarDate2(query.until))
|
|
1287
|
+
throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
|
|
1288
|
+
if (query.since && query.until && query.since > query.until)
|
|
1289
|
+
throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
|
|
1290
|
+
if (query.kind !== void 0 && query.kind.trim() === "")
|
|
1291
|
+
throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
|
|
1292
|
+
if (query.group !== void 0) {
|
|
1293
|
+
try {
|
|
1294
|
+
normalizeWorkRefSegment(query.group);
|
|
1295
|
+
} catch {
|
|
1296
|
+
throw new ArchiveHistoryError("invalid_history_group", "history group must be one safe Unicode segment");
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (query.search !== void 0 && query.search.trim() === "")
|
|
1300
|
+
throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
|
|
1301
|
+
}
|
|
1302
|
+
function selectArchiveHistoryRecord(records, selector) {
|
|
1303
|
+
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
|
|
1304
|
+
const identity = "path" in selector ? selector.path : selector.workRef;
|
|
1305
|
+
if (matches.length === 0)
|
|
1306
|
+
throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
|
|
1307
|
+
if (matches.length > 1) {
|
|
1308
|
+
const candidates = matches.map((record) => record.path).sort();
|
|
1309
|
+
throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
|
|
1310
|
+
}
|
|
1311
|
+
return matches[0];
|
|
1312
|
+
}
|
|
1313
|
+
function selectArchivedGroupBrief(records, selector) {
|
|
1314
|
+
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.group === selector.group);
|
|
1315
|
+
const identity = "path" in selector ? selector.path : selector.group;
|
|
1316
|
+
if (matches.length === 0)
|
|
1317
|
+
throw new ArchiveHistoryError("archive_not_found", `archived Change Group not found: ${identity}`);
|
|
1318
|
+
if (matches.length > 1) {
|
|
1319
|
+
const candidates = matches.map((record) => record.path).sort();
|
|
1320
|
+
throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match Change Group ${identity}`, candidates);
|
|
1321
|
+
}
|
|
1322
|
+
return matches[0];
|
|
1323
|
+
}
|
|
1324
|
+
async function readArchiveHistoryDetail(record) {
|
|
1325
|
+
let content;
|
|
1326
|
+
try {
|
|
1327
|
+
content = await readFile3(record.sourcePath, "utf-8");
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1330
|
+
}
|
|
1331
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1332
|
+
const tasks = getDocumentSectionBody(document, "tasks");
|
|
1333
|
+
const verify = getDocumentSectionBody(document, "verify");
|
|
1334
|
+
const blockers = getDocumentSectionBody(document, "blockers");
|
|
1335
|
+
return {
|
|
1336
|
+
...toOutputRecord(record),
|
|
1337
|
+
scenarioCount: parseScenarios(content).length,
|
|
1338
|
+
checkboxes: {
|
|
1339
|
+
tasks: countCheckboxes(tasks),
|
|
1340
|
+
verify: countCheckboxes(verify)
|
|
1341
|
+
},
|
|
1342
|
+
evidence: {
|
|
1343
|
+
tasks: boundEvidence(tasks),
|
|
1344
|
+
verify: boundEvidence(verify),
|
|
1345
|
+
blockers: boundEvidence(blockers, true)
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
function historyInspectionComplete(inspection) {
|
|
1350
|
+
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
1351
|
+
}
|
|
1352
|
+
function toOutputRecord(record) {
|
|
1353
|
+
const { searchSummary: _searchSummary, sourcePath: _sourcePath, ...output } = record;
|
|
1354
|
+
return output;
|
|
1355
|
+
}
|
|
1356
|
+
function boundEvidence(section, omitNone = false) {
|
|
1357
|
+
const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
|
|
1358
|
+
const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
|
|
1359
|
+
return {
|
|
1360
|
+
items: boundedItems.map((item) => item.value),
|
|
1361
|
+
truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
function isCalendarDate2(value) {
|
|
1365
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
1366
|
+
return false;
|
|
1367
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1368
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1369
|
+
}
|
|
1370
|
+
function compareHistoryRecords2(left, right) {
|
|
1371
|
+
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// src/core/dependency-plan.ts
|
|
1375
|
+
var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
|
|
1376
|
+
var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
|
|
1377
|
+
async function inspectChangeDependencies(options = {}) {
|
|
1378
|
+
const workTree = options.workTree ?? await inspectWorkTree();
|
|
1379
|
+
const archiveTree = options.archiveTree ?? await inspectArchiveTree();
|
|
1380
|
+
const archiveHistory = archiveTree.rootExists ? options.archiveHistory ?? await inspectArchiveHistory({ archiveTree }) : null;
|
|
1381
|
+
const openNames = new Set(workTree.changes.map((ref) => ref.name));
|
|
1382
|
+
const archivedNames = new Set(
|
|
1383
|
+
archiveHistory && historyInspectionComplete(archiveHistory) ? archiveHistory.records.map((record) => record.workRef) : []
|
|
1384
|
+
);
|
|
1385
|
+
const parsedByName = /* @__PURE__ */ new Map();
|
|
1386
|
+
const blockedGroups = /* @__PURE__ */ new Set();
|
|
1387
|
+
const diagnostics = archiveTree.diagnostics.map((diagnostic) => ({
|
|
1388
|
+
severity: "error",
|
|
1389
|
+
code: diagnostic.code,
|
|
1390
|
+
path: diagnostic.path,
|
|
1391
|
+
message: diagnostic.message
|
|
1392
|
+
}));
|
|
1393
|
+
if (archiveHistory)
|
|
1394
|
+
diagnostics.push(...archiveHistory.diagnostics);
|
|
1395
|
+
for (const brief of workTree.briefs) {
|
|
1396
|
+
try {
|
|
1397
|
+
if (hasMeaningfulBlockers(await readFile4(brief.path, "utf-8")))
|
|
1398
|
+
blockedGroups.add(brief.group);
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
diagnostics.push({
|
|
1401
|
+
severity: "error",
|
|
1402
|
+
code: "group_brief_read_failed",
|
|
1403
|
+
change: brief.group,
|
|
1404
|
+
path: brief.path,
|
|
1405
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
for (const ref of workTree.changes) {
|
|
1410
|
+
try {
|
|
1411
|
+
const parsed = parseDependencyBlockers(await readFile4(ref.path, "utf-8"));
|
|
1412
|
+
if (ref.group && blockedGroups.has(ref.group))
|
|
1413
|
+
parsed.external = true;
|
|
1414
|
+
parsedByName.set(ref.name, parsed);
|
|
1415
|
+
for (const line of parsed.malformed) {
|
|
1416
|
+
diagnostics.push({
|
|
1417
|
+
severity: "error",
|
|
1418
|
+
code: "dependency_syntax_invalid",
|
|
1419
|
+
change: ref.name,
|
|
1420
|
+
path: ref.path,
|
|
1421
|
+
message: `invalid dependency blocker syntax: ${line}`,
|
|
1422
|
+
hint: "Use: - requires `<change-work-ref>`: <reason>"
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
diagnostics.push({
|
|
1427
|
+
severity: "error",
|
|
1428
|
+
code: "change_read_failed",
|
|
1429
|
+
change: ref.name,
|
|
1430
|
+
path: ref.path,
|
|
1431
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const edges = [];
|
|
1436
|
+
for (const [change, blockers] of parsedByName) {
|
|
1437
|
+
for (const requirement of blockers.requires) {
|
|
1438
|
+
const required = requirement.name;
|
|
1439
|
+
const edge = {
|
|
1440
|
+
change,
|
|
1441
|
+
requires: required,
|
|
1442
|
+
reason: requirement.reason,
|
|
1443
|
+
state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
|
|
1444
|
+
};
|
|
1445
|
+
edges.push(edge);
|
|
1446
|
+
if (!isCanonicalExecutableWorkRef(required)) {
|
|
1447
|
+
diagnostics.push({
|
|
1448
|
+
severity: "error",
|
|
1449
|
+
code: "dependency_target_invalid",
|
|
930
1450
|
change,
|
|
931
1451
|
message: `dependency target must be an executable Change WorkRef: ${required}`
|
|
932
1452
|
});
|
|
@@ -1102,295 +1622,17 @@ function findCycleDiagnostics(edges) {
|
|
|
1102
1622
|
return diagnostics;
|
|
1103
1623
|
}
|
|
1104
1624
|
|
|
1105
|
-
// src/history/query.ts
|
|
1106
|
-
import { readFile as readFile4 } from "fs/promises";
|
|
1107
|
-
|
|
1108
|
-
// src/history/inspect.ts
|
|
1109
|
-
import { readFile as readFile3 } from "fs/promises";
|
|
1110
|
-
import { basename, dirname as dirname2, relative as relative3 } from "path";
|
|
1111
|
-
|
|
1112
|
-
// src/history/model.ts
|
|
1113
|
-
var HISTORY_DEFAULT_LIMIT = 20;
|
|
1114
|
-
var HISTORY_MAX_LIMIT = 100;
|
|
1115
|
-
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
1116
|
-
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
1117
|
-
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
1118
|
-
var HISTORY_MAX_CANDIDATES = 20;
|
|
1119
|
-
var ArchiveHistoryError = class extends Error {
|
|
1120
|
-
constructor(code, message, candidates = []) {
|
|
1121
|
-
super(message);
|
|
1122
|
-
this.code = code;
|
|
1123
|
-
this.name = "ArchiveHistoryError";
|
|
1124
|
-
const ordered = [...candidates].sort();
|
|
1125
|
-
this.candidateTotal = ordered.length;
|
|
1126
|
-
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
1127
|
-
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
1128
|
-
}
|
|
1129
|
-
code;
|
|
1130
|
-
candidates;
|
|
1131
|
-
candidateTotal;
|
|
1132
|
-
candidatesTruncated;
|
|
1133
|
-
};
|
|
1134
|
-
|
|
1135
|
-
// src/history/inspect.ts
|
|
1136
|
-
var EXECUTABLE_WORK_REF_RE2 = /^[a-z0-9-]+(?:\/[a-z0-9-]+)?$/;
|
|
1137
|
-
var GROUP_NAME_RE = /^[a-z0-9-]+$/;
|
|
1138
|
-
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
1139
|
-
async function inspectArchiveHistory(options = {}) {
|
|
1140
|
-
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1141
|
-
const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
|
|
1142
|
-
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
1143
|
-
severity: "error",
|
|
1144
|
-
code: diagnostic.code,
|
|
1145
|
-
path: normalizeOutputPath(diagnostic.path, archivesDir),
|
|
1146
|
-
message: diagnostic.message
|
|
1147
|
-
}));
|
|
1148
|
-
const runtime = [];
|
|
1149
|
-
const records = [];
|
|
1150
|
-
if (!tree.rootExists && tree.diagnostics.length === 0) {
|
|
1151
|
-
diagnostics.push({
|
|
1152
|
-
severity: "error",
|
|
1153
|
-
code: "archive_root_missing",
|
|
1154
|
-
path: ".rsp/archives",
|
|
1155
|
-
message: "archive root does not exist; run rsp init or rsp update before querying history"
|
|
1156
|
-
});
|
|
1157
|
-
}
|
|
1158
|
-
for (const sourcePath of tree.files) {
|
|
1159
|
-
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
1160
|
-
let content;
|
|
1161
|
-
try {
|
|
1162
|
-
content = await readFile3(sourcePath, "utf-8");
|
|
1163
|
-
} catch (error) {
|
|
1164
|
-
const message = toErrorMessage(error);
|
|
1165
|
-
diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
|
|
1166
|
-
continue;
|
|
1167
|
-
}
|
|
1168
|
-
const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
|
|
1169
|
-
if (parsed.diagnostic)
|
|
1170
|
-
diagnostics.push(parsed.diagnostic);
|
|
1171
|
-
if (parsed.record)
|
|
1172
|
-
records.push(parsed.record);
|
|
1173
|
-
}
|
|
1174
|
-
records.sort(compareHistoryRecords);
|
|
1175
|
-
diagnostics.sort(compareDiagnostics);
|
|
1176
|
-
const diagnosticTotal = diagnostics.length;
|
|
1177
|
-
const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
|
|
1178
|
-
return {
|
|
1179
|
-
rootExists: tree.rootExists,
|
|
1180
|
-
records,
|
|
1181
|
-
diagnostics: boundedDiagnostics,
|
|
1182
|
-
diagnosticSummary: {
|
|
1183
|
-
total: diagnosticTotal,
|
|
1184
|
-
returned: boundedDiagnostics.length,
|
|
1185
|
-
hasMore: diagnosticTotal > boundedDiagnostics.length
|
|
1186
|
-
},
|
|
1187
|
-
runtime
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
1191
|
-
const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
|
|
1192
|
-
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
1193
|
-
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
1194
|
-
}
|
|
1195
|
-
const date = nameMatch[1];
|
|
1196
|
-
const archiveStem = nameMatch[2];
|
|
1197
|
-
const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
|
|
1198
|
-
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
1199
|
-
if (archiveGroup && !GROUP_NAME_RE.test(archiveGroup))
|
|
1200
|
-
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
1201
|
-
const changeHeadings = [...content.matchAll(/^# Change:\s+(\S+)\s*$/gm)].map((match) => match[1]);
|
|
1202
|
-
const groupHeadings = [...content.matchAll(/^# Change Group:\s+(\S+)\s*$/gm)].map((match) => match[1]);
|
|
1203
|
-
if (changeHeadings.length + groupHeadings.length !== 1) {
|
|
1204
|
-
return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
|
|
1205
|
-
}
|
|
1206
|
-
let frontmatter;
|
|
1207
|
-
try {
|
|
1208
|
-
frontmatter = parseFrontmatter(content);
|
|
1209
|
-
} catch (error) {
|
|
1210
|
-
return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
|
|
1211
|
-
}
|
|
1212
|
-
if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
|
|
1213
|
-
return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
|
|
1214
|
-
if (groupHeadings.length === 1) {
|
|
1215
|
-
const group = groupHeadings[0];
|
|
1216
|
-
if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
|
|
1217
|
-
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
|
|
1218
|
-
}
|
|
1219
|
-
return {};
|
|
1220
|
-
}
|
|
1221
|
-
const workRef = changeHeadings[0];
|
|
1222
|
-
const segments = workRef.split("/");
|
|
1223
|
-
if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
|
|
1224
|
-
return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
|
|
1225
|
-
}
|
|
1226
|
-
const expectedGroup = segments.length === 2 ? segments[0] : null;
|
|
1227
|
-
const expectedBase = segments.at(-1);
|
|
1228
|
-
if (!EXECUTABLE_WORK_REF_RE2.test(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
|
|
1229
|
-
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
|
|
1230
|
-
}
|
|
1231
|
-
const rawSummary = extractSummary(content);
|
|
1232
|
-
if (!rawSummary)
|
|
1233
|
-
return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
|
|
1234
|
-
const summary = boundText(rawSummary);
|
|
1235
|
-
return {
|
|
1236
|
-
record: {
|
|
1237
|
-
date,
|
|
1238
|
-
workRef,
|
|
1239
|
-
group: expectedGroup,
|
|
1240
|
-
kind: frontmatter.kind.trim(),
|
|
1241
|
-
summary: summary.value,
|
|
1242
|
-
summaryTruncated: summary.truncated,
|
|
1243
|
-
path,
|
|
1244
|
-
sourcePath,
|
|
1245
|
-
searchSummary: rawSummary
|
|
1246
|
-
}
|
|
1247
|
-
};
|
|
1248
|
-
}
|
|
1249
|
-
function extractSummary(content) {
|
|
1250
|
-
const frontmatter = parseFrontmatter(content);
|
|
1251
|
-
if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
|
|
1252
|
-
return frontmatter.summary.trim();
|
|
1253
|
-
const proposalLines = extractSection(content, "Proposal").split("\n").map((line) => line.trim());
|
|
1254
|
-
for (const prefix of ["- Outcome:", "- Summary:"]) {
|
|
1255
|
-
const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
|
|
1256
|
-
if (line && line.slice(prefix.length).trim())
|
|
1257
|
-
return line.slice(prefix.length).trim();
|
|
1258
|
-
}
|
|
1259
|
-
return "";
|
|
1260
|
-
}
|
|
1261
|
-
function boundText(value) {
|
|
1262
|
-
const codePoints = [...value];
|
|
1263
|
-
return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
|
|
1264
|
-
}
|
|
1265
|
-
function matchesArchiveStem(stem, identityBase) {
|
|
1266
|
-
return stem === identityBase || new RegExp(`^${escapeRegExp2(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
|
|
1267
|
-
}
|
|
1268
|
-
function isCalendarDate(value) {
|
|
1269
|
-
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1270
|
-
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1271
|
-
}
|
|
1272
|
-
function archiveOutputPath(sourcePath, archivesDir) {
|
|
1273
|
-
return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
|
|
1274
|
-
}
|
|
1275
|
-
function normalizeOutputPath(path, archivesDir) {
|
|
1276
|
-
const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
|
|
1277
|
-
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
1278
|
-
}
|
|
1279
|
-
function errorDiagnostic(code, path, message) {
|
|
1280
|
-
return { severity: "error", code, path, message };
|
|
1281
|
-
}
|
|
1282
|
-
function escapeRegExp2(value) {
|
|
1283
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1284
|
-
}
|
|
1285
|
-
function compareHistoryRecords(left, right) {
|
|
1286
|
-
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1287
|
-
}
|
|
1288
|
-
function compareDiagnostics(left, right) {
|
|
1289
|
-
return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// src/history/query.ts
|
|
1293
|
-
function queryArchiveHistory(records, query = {}) {
|
|
1294
|
-
validateArchiveHistoryQuery(query);
|
|
1295
|
-
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1296
|
-
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) && (!query.search || `${record.workRef}
|
|
1297
|
-
${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
|
|
1298
|
-
const selected = matched.slice(0, limit);
|
|
1299
|
-
return {
|
|
1300
|
-
records: selected.map(toOutputRecord),
|
|
1301
|
-
summary: {
|
|
1302
|
-
matched: matched.length,
|
|
1303
|
-
returned: selected.length,
|
|
1304
|
-
hasMore: matched.length > selected.length
|
|
1305
|
-
}
|
|
1306
|
-
};
|
|
1307
|
-
}
|
|
1308
|
-
function validateArchiveHistoryQuery(query) {
|
|
1309
|
-
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1310
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
|
|
1311
|
-
throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
|
|
1312
|
-
if (query.since && !isCalendarDate2(query.since))
|
|
1313
|
-
throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
|
|
1314
|
-
if (query.until && !isCalendarDate2(query.until))
|
|
1315
|
-
throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
|
|
1316
|
-
if (query.since && query.until && query.since > query.until)
|
|
1317
|
-
throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
|
|
1318
|
-
if (query.kind !== void 0 && query.kind.trim() === "")
|
|
1319
|
-
throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
|
|
1320
|
-
if (query.group !== void 0 && !/^[a-z0-9-]+$/.test(query.group))
|
|
1321
|
-
throw new ArchiveHistoryError("invalid_history_group", "history group must be one lowercase kebab-case segment");
|
|
1322
|
-
if (query.search !== void 0 && query.search.trim() === "")
|
|
1323
|
-
throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
|
|
1324
|
-
}
|
|
1325
|
-
function selectArchiveHistoryRecord(records, selector) {
|
|
1326
|
-
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
|
|
1327
|
-
const identity = "path" in selector ? selector.path : selector.workRef;
|
|
1328
|
-
if (matches.length === 0)
|
|
1329
|
-
throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
|
|
1330
|
-
if (matches.length > 1) {
|
|
1331
|
-
const candidates = matches.map((record) => record.path).sort();
|
|
1332
|
-
throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
|
|
1333
|
-
}
|
|
1334
|
-
return matches[0];
|
|
1335
|
-
}
|
|
1336
|
-
async function readArchiveHistoryDetail(record) {
|
|
1337
|
-
let content;
|
|
1338
|
-
try {
|
|
1339
|
-
content = await readFile4(record.sourcePath, "utf-8");
|
|
1340
|
-
} catch (error) {
|
|
1341
|
-
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1342
|
-
}
|
|
1343
|
-
const tasks = extractSection(content, "Tasks");
|
|
1344
|
-
const verify = extractSection(content, "Verify");
|
|
1345
|
-
const blockers = extractSection(content, "Blockers");
|
|
1346
|
-
return {
|
|
1347
|
-
...toOutputRecord(record),
|
|
1348
|
-
scenarioCount: parseScenarios(content).length,
|
|
1349
|
-
checkboxes: {
|
|
1350
|
-
tasks: countCheckboxes(tasks),
|
|
1351
|
-
verify: countCheckboxes(verify)
|
|
1352
|
-
},
|
|
1353
|
-
evidence: {
|
|
1354
|
-
tasks: boundEvidence(tasks),
|
|
1355
|
-
verify: boundEvidence(verify),
|
|
1356
|
-
blockers: boundEvidence(blockers, true)
|
|
1357
|
-
}
|
|
1358
|
-
};
|
|
1359
|
-
}
|
|
1360
|
-
function historyInspectionComplete(inspection) {
|
|
1361
|
-
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
1362
|
-
}
|
|
1363
|
-
function toOutputRecord(record) {
|
|
1364
|
-
const { searchSummary: _searchSummary, sourcePath: _sourcePath, ...output } = record;
|
|
1365
|
-
return output;
|
|
1366
|
-
}
|
|
1367
|
-
function boundEvidence(section, omitNone = false) {
|
|
1368
|
-
const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
|
|
1369
|
-
const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
|
|
1370
|
-
return {
|
|
1371
|
-
items: boundedItems.map((item) => item.value),
|
|
1372
|
-
truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
|
|
1373
|
-
};
|
|
1374
|
-
}
|
|
1375
|
-
function isCalendarDate2(value) {
|
|
1376
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
1377
|
-
return false;
|
|
1378
|
-
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
1379
|
-
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
1380
|
-
}
|
|
1381
|
-
function compareHistoryRecords2(left, right) {
|
|
1382
|
-
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
1625
|
// src/status/inspect.ts
|
|
1386
1626
|
async function inspectProjectStatus(options = {}) {
|
|
1387
1627
|
const runtime = [];
|
|
1388
1628
|
const diagnostics = [];
|
|
1389
1629
|
let manage = resolveManagePolicy({});
|
|
1630
|
+
let language = resolveLanguagePolicy({});
|
|
1390
1631
|
try {
|
|
1391
1632
|
const configInspection = await inspectRspConfig();
|
|
1392
1633
|
const configValid = configInspection.issues.length === 0;
|
|
1393
1634
|
manage = resolveManagePolicy(configInspection.config, { configValid });
|
|
1635
|
+
language = resolveLanguagePolicy(configInspection.config, { configValid });
|
|
1394
1636
|
if (!configValid) {
|
|
1395
1637
|
diagnostics.push({
|
|
1396
1638
|
severity: "error",
|
|
@@ -1401,6 +1643,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1401
1643
|
}
|
|
1402
1644
|
} catch (error) {
|
|
1403
1645
|
manage = resolveManagePolicy({}, { configValid: false });
|
|
1646
|
+
language = resolveLanguagePolicy({}, { configValid: false });
|
|
1404
1647
|
diagnostics.push({
|
|
1405
1648
|
severity: "error",
|
|
1406
1649
|
code: "invalid_config",
|
|
@@ -1453,6 +1696,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1453
1696
|
const dependencyInspection = await inspectChangeDependencies({
|
|
1454
1697
|
workTree,
|
|
1455
1698
|
archiveTree,
|
|
1699
|
+
archiveHistory: historyInspection,
|
|
1456
1700
|
preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
|
|
1457
1701
|
});
|
|
1458
1702
|
diagnostics.push(...dependencyInspection.diagnostics);
|
|
@@ -1472,6 +1716,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1472
1716
|
let done = 0;
|
|
1473
1717
|
let total = 0;
|
|
1474
1718
|
let progressKnown = false;
|
|
1719
|
+
let issues = [];
|
|
1475
1720
|
if (path) {
|
|
1476
1721
|
try {
|
|
1477
1722
|
const content = await readFile5(path, "utf-8");
|
|
@@ -1480,10 +1725,21 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1480
1725
|
try {
|
|
1481
1726
|
const frontmatter = parseFrontmatter(content);
|
|
1482
1727
|
kind = frontmatter?.kind ? String(frontmatter.kind) : "\u2014";
|
|
1728
|
+
issues = parseIssueRelationships(frontmatter);
|
|
1483
1729
|
} catch (error) {
|
|
1484
1730
|
kind = "(invalid)";
|
|
1731
|
+
const issueError = error instanceof IssueRelationshipError;
|
|
1732
|
+
if (issueError) {
|
|
1733
|
+
diagnostics.push({
|
|
1734
|
+
severity: "error",
|
|
1735
|
+
code: error.code,
|
|
1736
|
+
change: name,
|
|
1737
|
+
path,
|
|
1738
|
+
message: error.message
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1485
1741
|
runtime.push({
|
|
1486
|
-
code: "frontmatter_parse_failed",
|
|
1742
|
+
code: issueError ? error.code : "frontmatter_parse_failed",
|
|
1487
1743
|
operation: "parseFrontmatter",
|
|
1488
1744
|
path,
|
|
1489
1745
|
message: toErrorMessage(error)
|
|
@@ -1564,7 +1820,8 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1564
1820
|
ageDays,
|
|
1565
1821
|
isFocused,
|
|
1566
1822
|
isBlocked,
|
|
1567
|
-
path: path ? normalizeLogicalPath(path) : null
|
|
1823
|
+
path: path ? normalizeLogicalPath(path) : null,
|
|
1824
|
+
issues
|
|
1568
1825
|
},
|
|
1569
1826
|
progressKnown,
|
|
1570
1827
|
title,
|
|
@@ -1574,6 +1831,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1574
1831
|
}
|
|
1575
1832
|
return {
|
|
1576
1833
|
manage,
|
|
1834
|
+
language,
|
|
1577
1835
|
focused: [...focusedSet].sort(),
|
|
1578
1836
|
records,
|
|
1579
1837
|
groups: groupInspection.groups,
|
|
@@ -1595,11 +1853,11 @@ function emptyReadiness(activeBlockers) {
|
|
|
1595
1853
|
};
|
|
1596
1854
|
}
|
|
1597
1855
|
function extractChangeTitle(content) {
|
|
1598
|
-
|
|
1599
|
-
return heading?.slice("# Change: ".length).trim() || null;
|
|
1856
|
+
return getDocumentTitle(parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), "spaced");
|
|
1600
1857
|
}
|
|
1601
1858
|
function extractBlockerEntries(content) {
|
|
1602
|
-
|
|
1859
|
+
const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
|
|
1860
|
+
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");
|
|
1603
1861
|
}
|
|
1604
1862
|
function deriveArchiveTrend(records) {
|
|
1605
1863
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -1648,25 +1906,33 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
|
|
|
1648
1906
|
export {
|
|
1649
1907
|
GROUP_BRIEF_FILENAME,
|
|
1650
1908
|
WorkRefError,
|
|
1909
|
+
normalizeWorkRefSegment,
|
|
1910
|
+
normalizeExecutableWorkRef,
|
|
1651
1911
|
resolveWorkRef,
|
|
1652
1912
|
resolveFocusMarkerPath,
|
|
1653
1913
|
resolveArchiveDirectory,
|
|
1654
1914
|
inspectFocusTree,
|
|
1655
1915
|
inspectArchiveTree,
|
|
1656
1916
|
inspectWorkTree,
|
|
1917
|
+
GROUP_REOPEN_COMPLETION_PREFIX,
|
|
1657
1918
|
resolveExecutableChange,
|
|
1658
1919
|
generateGroupBriefContent,
|
|
1659
1920
|
inspectChangeGroups,
|
|
1921
|
+
getGroupReopenEvidence,
|
|
1660
1922
|
hasArchivedGroupBrief,
|
|
1661
|
-
|
|
1923
|
+
IssueRelationshipError,
|
|
1924
|
+
parseIssueRelationships,
|
|
1925
|
+
createIssueRelationship,
|
|
1662
1926
|
HISTORY_DEFAULT_LIMIT,
|
|
1663
1927
|
HISTORY_MAX_LIMIT,
|
|
1664
1928
|
ArchiveHistoryError,
|
|
1665
1929
|
inspectArchiveHistory,
|
|
1666
1930
|
queryArchiveHistory,
|
|
1667
1931
|
selectArchiveHistoryRecord,
|
|
1932
|
+
selectArchivedGroupBrief,
|
|
1668
1933
|
readArchiveHistoryDetail,
|
|
1669
1934
|
historyInspectionComplete,
|
|
1935
|
+
inspectChangeDependencies,
|
|
1670
1936
|
inspectProjectStatus,
|
|
1671
1937
|
projectDependencyForest
|
|
1672
1938
|
};
|