@oevery/rsp 3.1.0-beta.3 → 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 +20 -13
- package/README.zh-CN.md +20 -13
- package/dist/{chunk-NX55NIC3.mjs → chunk-D7XDXBFO.mjs} +1 -1
- package/dist/{chunk-7KKAUBKN.mjs → chunk-FI3SYHVH.mjs} +560 -429
- package/dist/{chunk-OTJFL6XH.mjs → chunk-FJVH7ZYU.mjs} +344 -216
- package/dist/cli.mjs +107 -63
- package/dist/{entry-WZDVKDUM.mjs → entry-5OPKUZHF.mjs} +2 -2
- package/dist/{entry-X23GPCVS.mjs → entry-FVUG6YW2.mjs} +2 -2
- package/package.json +1 -1
- package/rules/rsp-rules.md +4 -3
- package/skills/rsp/SKILL.md +4 -4
- package/skills/rsp/references/managed-routing.md +6 -2
- package/skills/rsp-commit/SKILL.md +2 -2
- package/skills/rsp-manage/SKILL.md +7 -3
- package/skills/rsp-release-docs/references/convention-discovery.md +2 -2
|
@@ -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-FJVH7ZYU.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,7 @@ 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);
|
|
546
669
|
async function resolveExecutableChange(name, options = {}) {
|
|
547
670
|
const ref = resolveWorkRef(name, { ...options, executable: true });
|
|
548
671
|
if (ref.kind === "group-change")
|
|
@@ -550,32 +673,33 @@ async function resolveExecutableChange(name, options = {}) {
|
|
|
550
673
|
return ref;
|
|
551
674
|
}
|
|
552
675
|
function generateGroupBriefContent(name, goal = "") {
|
|
676
|
+
const placeholder = "<\u2026>";
|
|
553
677
|
return `---
|
|
554
678
|
kind: group
|
|
555
679
|
---
|
|
556
680
|
|
|
557
|
-
|
|
681
|
+
${renderDocumentTitle(GROUP_BRIEF_DOCUMENT_SCHEMA, name)}
|
|
558
682
|
|
|
559
|
-
|
|
560
|
-
- ${goal ||
|
|
683
|
+
${groupSectionHeading("goal")}
|
|
684
|
+
- ${goal || placeholder}
|
|
561
685
|
|
|
562
|
-
|
|
563
|
-
-
|
|
686
|
+
${groupSectionHeading("scope")}
|
|
687
|
+
- ${placeholder}
|
|
564
688
|
|
|
565
|
-
|
|
566
|
-
-
|
|
689
|
+
${groupSectionHeading("sharedConstraints")}
|
|
690
|
+
- ${placeholder}
|
|
567
691
|
|
|
568
|
-
|
|
569
|
-
- \`${name}/<change>\`:
|
|
692
|
+
${groupSectionHeading("slices")}
|
|
693
|
+
- \`${name}/<change>\`: ${placeholder}
|
|
570
694
|
|
|
571
|
-
|
|
572
|
-
- [ ]
|
|
695
|
+
${groupSectionHeading("completionConditions")}
|
|
696
|
+
- [ ] ${placeholder}
|
|
573
697
|
|
|
574
|
-
|
|
575
|
-
- Current facts:
|
|
576
|
-
- Lasting rationale:
|
|
698
|
+
${groupSectionHeading("durableOutcomes")}
|
|
699
|
+
- Current facts: ${placeholder}
|
|
700
|
+
- Lasting rationale: ${placeholder}
|
|
577
701
|
|
|
578
|
-
|
|
702
|
+
${groupSectionHeading("blockers")}
|
|
579
703
|
- none
|
|
580
704
|
`;
|
|
581
705
|
}
|
|
@@ -595,10 +719,10 @@ async function inspectChangeGroups(options = {}) {
|
|
|
595
719
|
continue;
|
|
596
720
|
try {
|
|
597
721
|
const content = await readFile(path, "utf-8");
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
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}/`)) {
|
|
602
726
|
diagnostics.push({
|
|
603
727
|
severity: "error",
|
|
604
728
|
code: "group_archive_identity_mismatch",
|
|
@@ -612,7 +736,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
612
736
|
archivedSlicesByGroup.set(archiveGroup, groupSlices);
|
|
613
737
|
}
|
|
614
738
|
}
|
|
615
|
-
if (
|
|
739
|
+
if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === archiveGroup)
|
|
616
740
|
archivedGroups.add(archiveGroup);
|
|
617
741
|
} catch (error) {
|
|
618
742
|
diagnostics.push({
|
|
@@ -774,13 +898,14 @@ async function hasArchivedGroupBrief(group) {
|
|
|
774
898
|
const message = error instanceof Error ? error.message : String(error);
|
|
775
899
|
throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
|
|
776
900
|
}
|
|
777
|
-
if (
|
|
901
|
+
if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === group)
|
|
778
902
|
return true;
|
|
779
903
|
}
|
|
780
904
|
return false;
|
|
781
905
|
}
|
|
782
906
|
function parseGroupBrief(group, content) {
|
|
783
907
|
const diagnostics = [];
|
|
908
|
+
const document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
|
|
784
909
|
let frontmatter;
|
|
785
910
|
try {
|
|
786
911
|
frontmatter = parseFrontmatter(content);
|
|
@@ -789,15 +914,15 @@ function parseGroupBrief(group, content) {
|
|
|
789
914
|
}
|
|
790
915
|
if (frontmatter?.kind !== "group")
|
|
791
916
|
diagnostics.push({ severity: "error", code: "group_kind_invalid", change: group, message: "Group Brief frontmatter must declare kind: group" });
|
|
792
|
-
if (
|
|
917
|
+
if (getDocumentTitle(document, "identity") !== group)
|
|
793
918
|
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: ${
|
|
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}` });
|
|
797
922
|
}
|
|
798
923
|
const slices = [];
|
|
799
924
|
const seen = /* @__PURE__ */ new Set();
|
|
800
|
-
for (const line of
|
|
925
|
+
for (const line of getDocumentSectionBody(document, "slices").split("\n")) {
|
|
801
926
|
const trimmed = line.trim();
|
|
802
927
|
const identity = trimmed.match(/^-\s+`(\S+)`/);
|
|
803
928
|
if (!identity || identity[1].includes("<"))
|
|
@@ -813,7 +938,7 @@ function parseGroupBrief(group, content) {
|
|
|
813
938
|
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires a boundary: ${name}` });
|
|
814
939
|
continue;
|
|
815
940
|
}
|
|
816
|
-
if (!
|
|
941
|
+
if (!isCanonicalExecutableWorkRef(name) || !name.startsWith(`${group}/`)) {
|
|
817
942
|
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `invalid direct child identity in Group Brief: ${name}` });
|
|
818
943
|
continue;
|
|
819
944
|
}
|
|
@@ -826,382 +951,123 @@ function parseGroupBrief(group, content) {
|
|
|
826
951
|
}
|
|
827
952
|
if (slices.length < 2)
|
|
828
953
|
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(
|
|
954
|
+
const completion = countCheckboxes(getDocumentSectionBody(document, "completionConditions"));
|
|
830
955
|
if (completion.total === 0)
|
|
831
956
|
diagnostics.push({ severity: "warning", code: "group_completion_missing", change: group, message: "Change Group must declare at least one completion condition" });
|
|
832
957
|
return {
|
|
833
958
|
slices,
|
|
834
959
|
completion: { done: completion.done + completion.dropped, total: completion.total },
|
|
835
|
-
blockers:
|
|
960
|
+
blockers: hasMeaningfulBlockerBody(getDocumentSectionBody(document, "blockers")),
|
|
836
961
|
diagnostics
|
|
837
962
|
};
|
|
838
963
|
}
|
|
839
|
-
function
|
|
840
|
-
return
|
|
964
|
+
function groupSectionHeading(sectionId) {
|
|
965
|
+
return renderDocumentSectionHeading(GROUP_BRIEF_DOCUMENT_SCHEMA, sectionId);
|
|
841
966
|
}
|
|
842
967
|
|
|
843
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
|
|
844
975
|
import { readFile as readFile2 } from "fs/promises";
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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) => ({
|
|
856
1007
|
severity: "error",
|
|
857
1008
|
code: diagnostic.code,
|
|
858
|
-
path: diagnostic.path,
|
|
1009
|
+
path: normalizeOutputPath(diagnostic.path, archivesDir),
|
|
859
1010
|
message: diagnostic.message
|
|
860
1011
|
}));
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
path,
|
|
871
|
-
message: error instanceof Error ? error.message : String(error)
|
|
872
|
-
});
|
|
873
|
-
}
|
|
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
|
+
});
|
|
874
1021
|
}
|
|
875
|
-
for (const
|
|
1022
|
+
for (const sourcePath of tree.files) {
|
|
1023
|
+
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
1024
|
+
let content;
|
|
876
1025
|
try {
|
|
877
|
-
|
|
878
|
-
blockedGroups.add(brief.group);
|
|
1026
|
+
content = await readFile2(sourcePath, "utf-8");
|
|
879
1027
|
} catch (error) {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
change: brief.group,
|
|
884
|
-
path: brief.path,
|
|
885
|
-
message: error instanceof Error ? error.message : String(error)
|
|
886
|
-
});
|
|
1028
|
+
const message = toErrorMessage(error);
|
|
1029
|
+
diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
|
|
1030
|
+
continue;
|
|
887
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);
|
|
888
1037
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
change: ref.name,
|
|
910
|
-
path: ref.path,
|
|
911
|
-
message: error instanceof Error ? error.message : String(error)
|
|
912
|
-
});
|
|
913
|
-
}
|
|
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") };
|
|
914
1058
|
}
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
diagnostics.push({
|
|
928
|
-
severity: "error",
|
|
929
|
-
code: "dependency_target_invalid",
|
|
930
|
-
change,
|
|
931
|
-
message: `dependency target must be an executable Change WorkRef: ${required}`
|
|
932
|
-
});
|
|
933
|
-
} else if (required === change) {
|
|
934
|
-
diagnostics.push({
|
|
935
|
-
severity: "error",
|
|
936
|
-
code: "dependency_self_reference",
|
|
937
|
-
change,
|
|
938
|
-
message: "Change cannot require itself"
|
|
939
|
-
});
|
|
940
|
-
} else if (edge.state === "missing") {
|
|
941
|
-
diagnostics.push({
|
|
942
|
-
severity: "error",
|
|
943
|
-
code: "dependency_target_missing",
|
|
944
|
-
change,
|
|
945
|
-
message: `dependency target is neither open nor archived: ${required}`
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
edges.sort(compareEdges);
|
|
951
|
-
diagnostics.push(...findCycleDiagnostics(edges));
|
|
952
|
-
const activeBlockers = /* @__PURE__ */ new Map();
|
|
953
|
-
const blocked = [...parsedByName].map(([change, blockers]) => {
|
|
954
|
-
const requires = edges.filter((edge) => edge.change === change && edge.state !== "archived").map((edge) => edge.requires).sort();
|
|
955
|
-
activeBlockers.set(change, blockers.external || requires.length > 0);
|
|
956
|
-
return { change, requires, external: blockers.external };
|
|
957
|
-
}).filter((blocker) => blocker.external || blocker.requires.length > 0).sort((left, right) => left.change.localeCompare(right.change));
|
|
958
|
-
const waves = buildWaves([...openNames], edges, parsedByName, options.preferredOrder ?? []);
|
|
959
|
-
const graphValid = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
960
|
-
if (!graphValid) {
|
|
961
|
-
for (const name of openNames)
|
|
962
|
-
activeBlockers.set(name, true);
|
|
963
|
-
}
|
|
964
|
-
const ready = graphValid ? waves[0] ?? [] : [];
|
|
965
|
-
const executable = new Set(graphValid ? waves.flat() : []);
|
|
966
|
-
const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
|
|
967
|
-
let state;
|
|
968
|
-
if (!openNames.has(name) && archivedNames.has(name))
|
|
969
|
-
state = "archived";
|
|
970
|
-
else if (!openNames.has(name))
|
|
971
|
-
state = "missing";
|
|
972
|
-
else if (ready.includes(name))
|
|
973
|
-
state = "ready";
|
|
974
|
-
else if (executable.has(name))
|
|
975
|
-
state = "waiting";
|
|
976
|
-
else
|
|
977
|
-
state = "blocked";
|
|
978
|
-
return { name, selection: openNames.has(name) ? "selected" : "prerequisite", state };
|
|
979
|
-
});
|
|
980
|
-
return {
|
|
981
|
-
plan: {
|
|
982
|
-
nodes,
|
|
983
|
-
ready,
|
|
984
|
-
edges,
|
|
985
|
-
blocked,
|
|
986
|
-
waves: graphValid ? waves : []
|
|
987
|
-
},
|
|
988
|
-
activeBlockers,
|
|
989
|
-
diagnostics
|
|
990
|
-
};
|
|
991
|
-
}
|
|
992
|
-
function parseDependencyBlockers(content) {
|
|
993
|
-
const requires = /* @__PURE__ */ new Map();
|
|
994
|
-
let external = false;
|
|
995
|
-
const malformed = [];
|
|
996
|
-
for (const line of extractBlockerLines(content)) {
|
|
997
|
-
if (!line || NONE_BLOCKER_RE.test(line) || /^none$/i.test(line))
|
|
998
|
-
continue;
|
|
999
|
-
const dependency = line.match(REQUIRES_RE);
|
|
1000
|
-
if (dependency) {
|
|
1001
|
-
if (!requires.has(dependency[1]))
|
|
1002
|
-
requires.set(dependency[1], dependency[2]);
|
|
1003
|
-
} else if (/^[-*]\s+requires\b/i.test(line)) {
|
|
1004
|
-
malformed.push(line);
|
|
1005
|
-
external = true;
|
|
1006
|
-
} else {
|
|
1007
|
-
external = true;
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
return {
|
|
1011
|
-
requires: [...requires].map(([name, reason]) => ({ name, reason })).sort((left, right) => left.name.localeCompare(right.name)),
|
|
1012
|
-
external,
|
|
1013
|
-
malformed
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
function buildWaves(names, edges, blockersByName, preferredOrder) {
|
|
1017
|
-
const orderIndex = new Map(preferredOrder.map((name, index) => [name, index]));
|
|
1018
|
-
const excluded = new Set(
|
|
1019
|
-
[...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
|
|
1020
|
-
);
|
|
1021
|
-
let changed = true;
|
|
1022
|
-
while (changed) {
|
|
1023
|
-
changed = false;
|
|
1024
|
-
for (const edge of edges) {
|
|
1025
|
-
if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
|
|
1026
|
-
excluded.add(edge.change);
|
|
1027
|
-
changed = true;
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
const remaining = new Set(names.filter((name) => !excluded.has(name)));
|
|
1032
|
-
const waves = [];
|
|
1033
|
-
while (remaining.size > 0) {
|
|
1034
|
-
const wave = [...remaining].filter((name) => !edges.some((edge) => edge.change === name && edge.state === "open" && remaining.has(edge.requires))).sort((left, right) => comparePlanNames(left, right, orderIndex));
|
|
1035
|
-
if (wave.length === 0)
|
|
1036
|
-
break;
|
|
1037
|
-
waves.push(wave);
|
|
1038
|
-
for (const name of wave)
|
|
1039
|
-
remaining.delete(name);
|
|
1040
|
-
}
|
|
1041
|
-
return waves;
|
|
1042
|
-
}
|
|
1043
|
-
function comparePlanNames(left, right, orderIndex) {
|
|
1044
|
-
const leftGroup = left.includes("/") ? left.split("/")[0] : null;
|
|
1045
|
-
const rightGroup = right.includes("/") ? right.split("/")[0] : null;
|
|
1046
|
-
if (leftGroup && leftGroup === rightGroup) {
|
|
1047
|
-
const leftIndex = orderIndex.get(left);
|
|
1048
|
-
const rightIndex = orderIndex.get(right);
|
|
1049
|
-
if (leftIndex !== void 0 && rightIndex !== void 0)
|
|
1050
|
-
return leftIndex - rightIndex;
|
|
1051
|
-
}
|
|
1052
|
-
return left.localeCompare(right);
|
|
1053
|
-
}
|
|
1054
|
-
function compareEdges(left, right) {
|
|
1055
|
-
return left.change.localeCompare(right.change) || left.requires.localeCompare(right.requires);
|
|
1056
|
-
}
|
|
1057
|
-
function findCycleDiagnostics(edges) {
|
|
1058
|
-
const adjacency = /* @__PURE__ */ new Map();
|
|
1059
|
-
for (const edge of edges) {
|
|
1060
|
-
if (edge.state !== "open" || edge.change === edge.requires)
|
|
1061
|
-
continue;
|
|
1062
|
-
const targets = adjacency.get(edge.change) ?? [];
|
|
1063
|
-
targets.push(edge.requires);
|
|
1064
|
-
adjacency.set(edge.change, targets.sort());
|
|
1065
|
-
}
|
|
1066
|
-
const visited = /* @__PURE__ */ new Set();
|
|
1067
|
-
const active = /* @__PURE__ */ new Set();
|
|
1068
|
-
const stack = [];
|
|
1069
|
-
const reported = /* @__PURE__ */ new Set();
|
|
1070
|
-
const diagnostics = [];
|
|
1071
|
-
const visit = (name) => {
|
|
1072
|
-
if (active.has(name)) {
|
|
1073
|
-
const start = stack.indexOf(name);
|
|
1074
|
-
const cycle = [...stack.slice(start), name];
|
|
1075
|
-
const key = [...new Set(cycle)].sort().join("|");
|
|
1076
|
-
if (!reported.has(key)) {
|
|
1077
|
-
reported.add(key);
|
|
1078
|
-
const message = `dependency cycle detected: ${cycle.join(" -> ")}`;
|
|
1079
|
-
for (const participant of [...new Set(cycle)].sort()) {
|
|
1080
|
-
diagnostics.push({
|
|
1081
|
-
severity: "error",
|
|
1082
|
-
code: "dependency_cycle",
|
|
1083
|
-
change: participant,
|
|
1084
|
-
message
|
|
1085
|
-
});
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
return;
|
|
1089
|
-
}
|
|
1090
|
-
if (visited.has(name))
|
|
1091
|
-
return;
|
|
1092
|
-
visited.add(name);
|
|
1093
|
-
active.add(name);
|
|
1094
|
-
stack.push(name);
|
|
1095
|
-
for (const target of adjacency.get(name) ?? [])
|
|
1096
|
-
visit(target);
|
|
1097
|
-
stack.pop();
|
|
1098
|
-
active.delete(name);
|
|
1099
|
-
};
|
|
1100
|
-
for (const name of [...adjacency.keys()].sort())
|
|
1101
|
-
visit(name);
|
|
1102
|
-
return diagnostics;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
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") };
|
|
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") };
|
|
1205
1071
|
}
|
|
1206
1072
|
let frontmatter;
|
|
1207
1073
|
try {
|
|
@@ -1225,10 +1091,10 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
|
1225
1091
|
}
|
|
1226
1092
|
const expectedGroup = segments.length === 2 ? segments[0] : null;
|
|
1227
1093
|
const expectedBase = segments.at(-1);
|
|
1228
|
-
if (!
|
|
1094
|
+
if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
|
|
1229
1095
|
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
|
|
1230
1096
|
}
|
|
1231
|
-
const rawSummary = extractSummary(content);
|
|
1097
|
+
const rawSummary = extractSummary(content, changeDocument);
|
|
1232
1098
|
if (!rawSummary)
|
|
1233
1099
|
return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
|
|
1234
1100
|
const summary = boundText(rawSummary);
|
|
@@ -1246,11 +1112,11 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
|
1246
1112
|
}
|
|
1247
1113
|
};
|
|
1248
1114
|
}
|
|
1249
|
-
function extractSummary(content) {
|
|
1115
|
+
function extractSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA)) {
|
|
1250
1116
|
const frontmatter = parseFrontmatter(content);
|
|
1251
1117
|
if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
|
|
1252
1118
|
return frontmatter.summary.trim();
|
|
1253
|
-
const proposalLines =
|
|
1119
|
+
const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
|
|
1254
1120
|
for (const prefix of ["- Outcome:", "- Summary:"]) {
|
|
1255
1121
|
const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
|
|
1256
1122
|
if (line && line.slice(prefix.length).trim())
|
|
@@ -1263,7 +1129,7 @@ function boundText(value) {
|
|
|
1263
1129
|
return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
|
|
1264
1130
|
}
|
|
1265
1131
|
function matchesArchiveStem(stem, identityBase) {
|
|
1266
|
-
return stem === identityBase || new RegExp(`^${
|
|
1132
|
+
return stem === identityBase || new RegExp(`^${escapeRegExp(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
|
|
1267
1133
|
}
|
|
1268
1134
|
function isCalendarDate(value) {
|
|
1269
1135
|
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
@@ -1279,7 +1145,7 @@ function normalizeOutputPath(path, archivesDir) {
|
|
|
1279
1145
|
function errorDiagnostic(code, path, message) {
|
|
1280
1146
|
return { severity: "error", code, path, message };
|
|
1281
1147
|
}
|
|
1282
|
-
function
|
|
1148
|
+
function escapeRegExp(value) {
|
|
1283
1149
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1284
1150
|
}
|
|
1285
1151
|
function compareHistoryRecords(left, right) {
|
|
@@ -1292,8 +1158,9 @@ function compareDiagnostics(left, right) {
|
|
|
1292
1158
|
// src/history/query.ts
|
|
1293
1159
|
function queryArchiveHistory(records, query = {}) {
|
|
1294
1160
|
validateArchiveHistoryQuery(query);
|
|
1161
|
+
const group = query.group === void 0 ? void 0 : normalizeWorkRefSegment(query.group);
|
|
1295
1162
|
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) && (!
|
|
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}
|
|
1297
1164
|
${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
|
|
1298
1165
|
const selected = matched.slice(0, limit);
|
|
1299
1166
|
return {
|
|
@@ -1317,8 +1184,13 @@ function validateArchiveHistoryQuery(query) {
|
|
|
1317
1184
|
throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
|
|
1318
1185
|
if (query.kind !== void 0 && query.kind.trim() === "")
|
|
1319
1186
|
throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
|
|
1320
|
-
if (query.group !== void 0
|
|
1321
|
-
|
|
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
|
+
}
|
|
1322
1194
|
if (query.search !== void 0 && query.search.trim() === "")
|
|
1323
1195
|
throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
|
|
1324
1196
|
}
|
|
@@ -1336,13 +1208,14 @@ function selectArchiveHistoryRecord(records, selector) {
|
|
|
1336
1208
|
async function readArchiveHistoryDetail(record) {
|
|
1337
1209
|
let content;
|
|
1338
1210
|
try {
|
|
1339
|
-
content = await
|
|
1211
|
+
content = await readFile3(record.sourcePath, "utf-8");
|
|
1340
1212
|
} catch (error) {
|
|
1341
1213
|
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1342
1214
|
}
|
|
1343
|
-
const
|
|
1344
|
-
const
|
|
1345
|
-
const
|
|
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");
|
|
1346
1219
|
return {
|
|
1347
1220
|
...toOutputRecord(record),
|
|
1348
1221
|
scenarioCount: parseScenarios(content).length,
|
|
@@ -1382,15 +1255,268 @@ function compareHistoryRecords2(left, right) {
|
|
|
1382
1255
|
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
1383
1256
|
}
|
|
1384
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
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
const edges = [];
|
|
1320
|
+
for (const [change, blockers] of parsedByName) {
|
|
1321
|
+
for (const requirement of blockers.requires) {
|
|
1322
|
+
const required = requirement.name;
|
|
1323
|
+
const edge = {
|
|
1324
|
+
change,
|
|
1325
|
+
requires: required,
|
|
1326
|
+
reason: requirement.reason,
|
|
1327
|
+
state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
|
|
1328
|
+
};
|
|
1329
|
+
edges.push(edge);
|
|
1330
|
+
if (!isCanonicalExecutableWorkRef(required)) {
|
|
1331
|
+
diagnostics.push({
|
|
1332
|
+
severity: "error",
|
|
1333
|
+
code: "dependency_target_invalid",
|
|
1334
|
+
change,
|
|
1335
|
+
message: `dependency target must be an executable Change WorkRef: ${required}`
|
|
1336
|
+
});
|
|
1337
|
+
} else if (required === change) {
|
|
1338
|
+
diagnostics.push({
|
|
1339
|
+
severity: "error",
|
|
1340
|
+
code: "dependency_self_reference",
|
|
1341
|
+
change,
|
|
1342
|
+
message: "Change cannot require itself"
|
|
1343
|
+
});
|
|
1344
|
+
} else if (edge.state === "missing") {
|
|
1345
|
+
diagnostics.push({
|
|
1346
|
+
severity: "error",
|
|
1347
|
+
code: "dependency_target_missing",
|
|
1348
|
+
change,
|
|
1349
|
+
message: `dependency target is neither open nor archived: ${required}`
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
edges.sort(compareEdges);
|
|
1355
|
+
diagnostics.push(...findCycleDiagnostics(edges));
|
|
1356
|
+
const activeBlockers = /* @__PURE__ */ new Map();
|
|
1357
|
+
const blocked = [...parsedByName].map(([change, blockers]) => {
|
|
1358
|
+
const requires = edges.filter((edge) => edge.change === change && edge.state !== "archived").map((edge) => edge.requires).sort();
|
|
1359
|
+
activeBlockers.set(change, blockers.external || requires.length > 0);
|
|
1360
|
+
return { change, requires, external: blockers.external };
|
|
1361
|
+
}).filter((blocker) => blocker.external || blocker.requires.length > 0).sort((left, right) => left.change.localeCompare(right.change));
|
|
1362
|
+
const waves = buildWaves([...openNames], edges, parsedByName, options.preferredOrder ?? []);
|
|
1363
|
+
const graphValid = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
1364
|
+
if (!graphValid) {
|
|
1365
|
+
for (const name of openNames)
|
|
1366
|
+
activeBlockers.set(name, true);
|
|
1367
|
+
}
|
|
1368
|
+
const ready = graphValid ? waves[0] ?? [] : [];
|
|
1369
|
+
const executable = new Set(graphValid ? waves.flat() : []);
|
|
1370
|
+
const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
|
|
1371
|
+
let state;
|
|
1372
|
+
if (!openNames.has(name) && archivedNames.has(name))
|
|
1373
|
+
state = "archived";
|
|
1374
|
+
else if (!openNames.has(name))
|
|
1375
|
+
state = "missing";
|
|
1376
|
+
else if (ready.includes(name))
|
|
1377
|
+
state = "ready";
|
|
1378
|
+
else if (executable.has(name))
|
|
1379
|
+
state = "waiting";
|
|
1380
|
+
else
|
|
1381
|
+
state = "blocked";
|
|
1382
|
+
return { name, selection: openNames.has(name) ? "selected" : "prerequisite", state };
|
|
1383
|
+
});
|
|
1384
|
+
return {
|
|
1385
|
+
plan: {
|
|
1386
|
+
nodes,
|
|
1387
|
+
ready,
|
|
1388
|
+
edges,
|
|
1389
|
+
blocked,
|
|
1390
|
+
waves: graphValid ? waves : []
|
|
1391
|
+
},
|
|
1392
|
+
activeBlockers,
|
|
1393
|
+
diagnostics
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
function parseDependencyBlockers(content) {
|
|
1397
|
+
const requires = /* @__PURE__ */ new Map();
|
|
1398
|
+
let external = false;
|
|
1399
|
+
const malformed = [];
|
|
1400
|
+
for (const line of extractBlockerLines(content)) {
|
|
1401
|
+
if (!line || NONE_BLOCKER_RE.test(line) || /^none$/i.test(line))
|
|
1402
|
+
continue;
|
|
1403
|
+
const dependency = line.match(REQUIRES_RE);
|
|
1404
|
+
if (dependency) {
|
|
1405
|
+
if (!requires.has(dependency[1]))
|
|
1406
|
+
requires.set(dependency[1], dependency[2]);
|
|
1407
|
+
} else if (/^[-*]\s+requires\b/i.test(line)) {
|
|
1408
|
+
malformed.push(line);
|
|
1409
|
+
external = true;
|
|
1410
|
+
} else {
|
|
1411
|
+
external = true;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
return {
|
|
1415
|
+
requires: [...requires].map(([name, reason]) => ({ name, reason })).sort((left, right) => left.name.localeCompare(right.name)),
|
|
1416
|
+
external,
|
|
1417
|
+
malformed
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
function buildWaves(names, edges, blockersByName, preferredOrder) {
|
|
1421
|
+
const orderIndex = new Map(preferredOrder.map((name, index) => [name, index]));
|
|
1422
|
+
const excluded = new Set(
|
|
1423
|
+
[...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
|
|
1424
|
+
);
|
|
1425
|
+
let changed = true;
|
|
1426
|
+
while (changed) {
|
|
1427
|
+
changed = false;
|
|
1428
|
+
for (const edge of edges) {
|
|
1429
|
+
if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
|
|
1430
|
+
excluded.add(edge.change);
|
|
1431
|
+
changed = true;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const remaining = new Set(names.filter((name) => !excluded.has(name)));
|
|
1436
|
+
const waves = [];
|
|
1437
|
+
while (remaining.size > 0) {
|
|
1438
|
+
const wave = [...remaining].filter((name) => !edges.some((edge) => edge.change === name && edge.state === "open" && remaining.has(edge.requires))).sort((left, right) => comparePlanNames(left, right, orderIndex));
|
|
1439
|
+
if (wave.length === 0)
|
|
1440
|
+
break;
|
|
1441
|
+
waves.push(wave);
|
|
1442
|
+
for (const name of wave)
|
|
1443
|
+
remaining.delete(name);
|
|
1444
|
+
}
|
|
1445
|
+
return waves;
|
|
1446
|
+
}
|
|
1447
|
+
function comparePlanNames(left, right, orderIndex) {
|
|
1448
|
+
const leftGroup = left.includes("/") ? left.split("/")[0] : null;
|
|
1449
|
+
const rightGroup = right.includes("/") ? right.split("/")[0] : null;
|
|
1450
|
+
if (leftGroup && leftGroup === rightGroup) {
|
|
1451
|
+
const leftIndex = orderIndex.get(left);
|
|
1452
|
+
const rightIndex = orderIndex.get(right);
|
|
1453
|
+
if (leftIndex !== void 0 && rightIndex !== void 0)
|
|
1454
|
+
return leftIndex - rightIndex;
|
|
1455
|
+
}
|
|
1456
|
+
return left.localeCompare(right);
|
|
1457
|
+
}
|
|
1458
|
+
function compareEdges(left, right) {
|
|
1459
|
+
return left.change.localeCompare(right.change) || left.requires.localeCompare(right.requires);
|
|
1460
|
+
}
|
|
1461
|
+
function findCycleDiagnostics(edges) {
|
|
1462
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
1463
|
+
for (const edge of edges) {
|
|
1464
|
+
if (edge.state !== "open" || edge.change === edge.requires)
|
|
1465
|
+
continue;
|
|
1466
|
+
const targets = adjacency.get(edge.change) ?? [];
|
|
1467
|
+
targets.push(edge.requires);
|
|
1468
|
+
adjacency.set(edge.change, targets.sort());
|
|
1469
|
+
}
|
|
1470
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1471
|
+
const active = /* @__PURE__ */ new Set();
|
|
1472
|
+
const stack = [];
|
|
1473
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1474
|
+
const diagnostics = [];
|
|
1475
|
+
const visit = (name) => {
|
|
1476
|
+
if (active.has(name)) {
|
|
1477
|
+
const start = stack.indexOf(name);
|
|
1478
|
+
const cycle = [...stack.slice(start), name];
|
|
1479
|
+
const key = [...new Set(cycle)].sort().join("|");
|
|
1480
|
+
if (!reported.has(key)) {
|
|
1481
|
+
reported.add(key);
|
|
1482
|
+
const message = `dependency cycle detected: ${cycle.join(" -> ")}`;
|
|
1483
|
+
for (const participant of [...new Set(cycle)].sort()) {
|
|
1484
|
+
diagnostics.push({
|
|
1485
|
+
severity: "error",
|
|
1486
|
+
code: "dependency_cycle",
|
|
1487
|
+
change: participant,
|
|
1488
|
+
message
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (visited.has(name))
|
|
1495
|
+
return;
|
|
1496
|
+
visited.add(name);
|
|
1497
|
+
active.add(name);
|
|
1498
|
+
stack.push(name);
|
|
1499
|
+
for (const target of adjacency.get(name) ?? [])
|
|
1500
|
+
visit(target);
|
|
1501
|
+
stack.pop();
|
|
1502
|
+
active.delete(name);
|
|
1503
|
+
};
|
|
1504
|
+
for (const name of [...adjacency.keys()].sort())
|
|
1505
|
+
visit(name);
|
|
1506
|
+
return diagnostics;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1385
1509
|
// src/status/inspect.ts
|
|
1386
1510
|
async function inspectProjectStatus(options = {}) {
|
|
1387
1511
|
const runtime = [];
|
|
1388
1512
|
const diagnostics = [];
|
|
1389
1513
|
let manage = resolveManagePolicy({});
|
|
1514
|
+
let language = resolveLanguagePolicy({});
|
|
1390
1515
|
try {
|
|
1391
1516
|
const configInspection = await inspectRspConfig();
|
|
1392
1517
|
const configValid = configInspection.issues.length === 0;
|
|
1393
1518
|
manage = resolveManagePolicy(configInspection.config, { configValid });
|
|
1519
|
+
language = resolveLanguagePolicy(configInspection.config, { configValid });
|
|
1394
1520
|
if (!configValid) {
|
|
1395
1521
|
diagnostics.push({
|
|
1396
1522
|
severity: "error",
|
|
@@ -1401,6 +1527,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1401
1527
|
}
|
|
1402
1528
|
} catch (error) {
|
|
1403
1529
|
manage = resolveManagePolicy({}, { configValid: false });
|
|
1530
|
+
language = resolveLanguagePolicy({}, { configValid: false });
|
|
1404
1531
|
diagnostics.push({
|
|
1405
1532
|
severity: "error",
|
|
1406
1533
|
code: "invalid_config",
|
|
@@ -1453,6 +1580,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1453
1580
|
const dependencyInspection = await inspectChangeDependencies({
|
|
1454
1581
|
workTree,
|
|
1455
1582
|
archiveTree,
|
|
1583
|
+
archiveHistory: historyInspection,
|
|
1456
1584
|
preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
|
|
1457
1585
|
});
|
|
1458
1586
|
diagnostics.push(...dependencyInspection.diagnostics);
|
|
@@ -1574,6 +1702,7 @@ async function inspectProjectStatus(options = {}) {
|
|
|
1574
1702
|
}
|
|
1575
1703
|
return {
|
|
1576
1704
|
manage,
|
|
1705
|
+
language,
|
|
1577
1706
|
focused: [...focusedSet].sort(),
|
|
1578
1707
|
records,
|
|
1579
1708
|
groups: groupInspection.groups,
|
|
@@ -1595,11 +1724,11 @@ function emptyReadiness(activeBlockers) {
|
|
|
1595
1724
|
};
|
|
1596
1725
|
}
|
|
1597
1726
|
function extractChangeTitle(content) {
|
|
1598
|
-
|
|
1599
|
-
return heading?.slice("# Change: ".length).trim() || null;
|
|
1727
|
+
return getDocumentTitle(parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), "spaced");
|
|
1600
1728
|
}
|
|
1601
1729
|
function extractBlockerEntries(content) {
|
|
1602
|
-
|
|
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");
|
|
1603
1732
|
}
|
|
1604
1733
|
function deriveArchiveTrend(records) {
|
|
1605
1734
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -1648,6 +1777,8 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
|
|
|
1648
1777
|
export {
|
|
1649
1778
|
GROUP_BRIEF_FILENAME,
|
|
1650
1779
|
WorkRefError,
|
|
1780
|
+
normalizeWorkRefSegment,
|
|
1781
|
+
normalizeExecutableWorkRef,
|
|
1651
1782
|
resolveWorkRef,
|
|
1652
1783
|
resolveFocusMarkerPath,
|
|
1653
1784
|
resolveArchiveDirectory,
|
|
@@ -1658,7 +1789,6 @@ export {
|
|
|
1658
1789
|
generateGroupBriefContent,
|
|
1659
1790
|
inspectChangeGroups,
|
|
1660
1791
|
hasArchivedGroupBrief,
|
|
1661
|
-
inspectChangeDependencies,
|
|
1662
1792
|
HISTORY_DEFAULT_LIMIT,
|
|
1663
1793
|
HISTORY_MAX_LIMIT,
|
|
1664
1794
|
ArchiveHistoryError,
|
|
@@ -1667,6 +1797,7 @@ export {
|
|
|
1667
1797
|
selectArchiveHistoryRecord,
|
|
1668
1798
|
readArchiveHistoryDetail,
|
|
1669
1799
|
historyInspectionComplete,
|
|
1800
|
+
inspectChangeDependencies,
|
|
1670
1801
|
inspectProjectStatus,
|
|
1671
1802
|
projectDependencyForest
|
|
1672
1803
|
};
|