@llman-sdd/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Change collection + rendering (peripheral-commands capability, r20).
3
+ * Port of v1 commands/list.rs shapes. Pure — IO injected.
4
+ */
5
+ import { readBinding } from '../change/frontmatter.ts';
6
+ import { CHANGES_DIR } from '../change/lifecycle.ts';
7
+
8
+ export interface ChangeFsIo {
9
+ exists(path: string): boolean;
10
+ readText(path: string): string;
11
+ listDir(path: string): string[];
12
+ isDirectory(path: string): boolean;
13
+ mtimeMs(path: string): number;
14
+ }
15
+
16
+ export interface ChangeSummary {
17
+ name: string;
18
+ path: string;
19
+ title: string;
20
+ stage: 'draft' | 'designed' | 'planned' | 'full';
21
+ hasBinding: boolean;
22
+ completedTasks: number;
23
+ totalTasks: number;
24
+ lastModified: Date;
25
+ idleDays: number;
26
+ }
27
+
28
+ export type ChangeStatus = 'no-tasks' | 'complete' | 'in-progress';
29
+
30
+ export function statusFor(total: number, completed: number): ChangeStatus {
31
+ if (total === 0) return 'no-tasks';
32
+ return completed >= total ? 'complete' : 'in-progress';
33
+ }
34
+
35
+ export function stageFor(
36
+ hasDesign: boolean,
37
+ hasTasks: boolean,
38
+ hasBinding: boolean,
39
+ ): ChangeSummary['stage'] {
40
+ if (hasBinding) return 'full';
41
+ if (hasTasks) return 'planned';
42
+ if (hasDesign) return 'designed';
43
+ return 'draft';
44
+ }
45
+
46
+ function countTasks(tasksMd: string): { completed: number; total: number } {
47
+ let completed = 0;
48
+ let total = 0;
49
+ for (const line of tasksMd.split('\n')) {
50
+ const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
51
+ if (m) {
52
+ total += 1;
53
+ if (m[1] !== ' ') completed += 1;
54
+ }
55
+ }
56
+ return { completed, total };
57
+ }
58
+
59
+ function firstH1(md: string): string {
60
+ for (const line of md.split('\n')) {
61
+ const m = line.match(/^#\s+(.*)$/u);
62
+ if (m?.[1]) return m[1].trim();
63
+ }
64
+ return '';
65
+ }
66
+
67
+ export function collectChanges(io: ChangeFsIo, root: string, now: Date): ChangeSummary[] {
68
+ const changesDir = `${root}/${CHANGES_DIR}`;
69
+ if (!io.exists(changesDir) || !io.isDirectory(changesDir)) return [];
70
+ const out: ChangeSummary[] = [];
71
+ for (const name of io.listDir(changesDir).toSorted()) {
72
+ if (name === 'archive') continue;
73
+ const dir = `${changesDir}/${name}`;
74
+ if (!io.isDirectory(dir)) continue;
75
+ const proposal = `${dir}/proposal.md`;
76
+ if (!io.exists(proposal)) continue;
77
+
78
+ const hasDesign = io.exists(`${dir}/design.md`);
79
+ const hasTasks = io.exists(`${dir}/tasks.md`);
80
+ const hasBinding = readBinding(io.readText(proposal)) !== null;
81
+ const { completed, total } = hasTasks
82
+ ? countTasks(io.readText(`${dir}/tasks.md`))
83
+ : { completed: 0, total: 0 };
84
+
85
+ // lastModified = newest mtime across the change dir
86
+ let latest = io.mtimeMs(proposal);
87
+ for (const f of [hasDesign ? `${dir}/design.md` : null, hasTasks ? `${dir}/tasks.md` : null]) {
88
+ if (f) latest = Math.max(latest, io.mtimeMs(f));
89
+ }
90
+ const lastModified = new Date(latest);
91
+ const idleDays = Math.floor((now.getTime() - lastModified.getTime()) / 86_400_000);
92
+
93
+ out.push({
94
+ name,
95
+ path: name,
96
+ title: firstH1(io.readText(proposal)),
97
+ stage: stageFor(hasDesign, hasTasks, hasBinding),
98
+ hasBinding,
99
+ completedTasks: completed,
100
+ totalTasks: total,
101
+ lastModified,
102
+ idleDays,
103
+ });
104
+ }
105
+ // v1 lists newest-first
106
+ return out.toSorted((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
107
+ }
108
+
109
+ export function statusHuman(c: ChangeSummary): string {
110
+ const s = statusFor(c.totalTasks, c.completedTasks);
111
+ if (s === 'no-tasks') return 'no tasks';
112
+ if (s === 'complete') return 'complete';
113
+ return `${c.completedTasks}/${c.totalTasks} tasks`;
114
+ }
115
+
116
+ export function relativeTime(from: Date, now: Date): string {
117
+ const ms = now.getTime() - from.getTime();
118
+ const minutes = Math.floor(ms / 60_000);
119
+ if (minutes < 1) return 'just now';
120
+ if (minutes < 60) return `${minutes}m ago`;
121
+ const hours = Math.floor(minutes / 60);
122
+ if (hours < 24) return `${hours}h ago`;
123
+ return `${Math.floor(hours / 24)}d ago`;
124
+ }
125
+
126
+ function pad(s: string, width: number): string {
127
+ return s.length >= width ? s : s + ' '.repeat(width - s.length);
128
+ }
129
+
130
+ export function renderChangesList(changes: readonly ChangeSummary[], now: Date): string[] {
131
+ const lines = ['Active changes:'];
132
+ if (changes.length === 0) return lines;
133
+ const nameWidth = Math.max(...changes.map((c) => c.name.length)) + 2;
134
+ const stageWidth = Math.max(...changes.map((c) => c.stage.length)) + 7;
135
+ const statusWidth = Math.max(...changes.map((c) => statusHuman(c).length)) + 5;
136
+ for (const c of changes) {
137
+ lines.push(
138
+ ` ${pad(c.name, nameWidth)}${pad(c.stage, stageWidth)}${pad(statusHuman(c), statusWidth)}${relativeTime(
139
+ c.lastModified,
140
+ now,
141
+ )}${statusFor(c.totalTasks, c.completedTasks) === 'no-tasks' ? ` (idle ${c.idleDays}d)` : ''}`,
142
+ );
143
+ }
144
+ return lines;
145
+ }
146
+
147
+ export function renderChangesJson(changes: readonly ChangeSummary[]): string {
148
+ return JSON.stringify(
149
+ {
150
+ changes: changes.map((c) => ({
151
+ name: c.name,
152
+ path: c.path,
153
+ stage: c.stage,
154
+ completedTasks: c.completedTasks,
155
+ totalTasks: c.totalTasks,
156
+ lastModified: c.lastModified.toISOString(),
157
+ idleDays: c.idleDays,
158
+ status: statusFor(c.totalTasks, c.completedTasks),
159
+ })),
160
+ },
161
+ null,
162
+ 2,
163
+ );
164
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Change dependency graph (peripheral-commands capability, r21):
3
+ * mermaid output with v1 conventions — node ids dash→underscore, archived
4
+ * changes annotated "✓ done" with the archived class, depends_on edges.
5
+ */
6
+ import { CHANGES_DIR } from '../change/lifecycle.ts';
7
+
8
+ export interface GraphFsIo {
9
+ exists(path: string): boolean;
10
+ readText(path: string): string;
11
+ listDir(path: string): string[];
12
+ isDirectory(path: string): boolean;
13
+ }
14
+
15
+ interface GraphNode {
16
+ id: string;
17
+ archived: boolean;
18
+ dependsOn: string[];
19
+ }
20
+
21
+ function collectNodes(io: GraphFsIo, root: string): GraphNode[] {
22
+ const nodes: GraphNode[] = [];
23
+ const scan = (dir: string, archived: boolean, stripDatePrefix: boolean): void => {
24
+ if (!io.exists(dir) || !io.isDirectory(dir)) return;
25
+ for (const name of io.listDir(dir).toSorted()) {
26
+ if (name === 'archive') continue;
27
+ const full = `${dir}/${name}`;
28
+ if (!io.isDirectory(full)) continue;
29
+ const proposal = `${full}/proposal.md`;
30
+ if (!io.exists(proposal)) continue;
31
+ const id = archived && stripDatePrefix ? name.replace(/^\d{4}-\d{2}-\d{2}-/u, '') : name;
32
+ const deps: string[] = [];
33
+ const fm = io.readText(proposal).match(/^---\n([\s\S]*?)\n---/u);
34
+ if (fm?.[1]) {
35
+ const lines = fm[1].split('\n');
36
+ let inDeps = false;
37
+ for (const line of lines) {
38
+ if (/^depends_on:\s*$/u.test(line)) {
39
+ inDeps = true;
40
+ continue;
41
+ }
42
+ if (inDeps) {
43
+ const m = line.match(/^\s*-\s+(\S+)/u);
44
+ if (m?.[1]) deps.push(m[1]);
45
+ else if (line.trim() !== '') inDeps = false;
46
+ }
47
+ }
48
+ }
49
+ nodes.push({ id, archived, dependsOn: deps });
50
+ }
51
+ };
52
+ scan(`${root}/${CHANGES_DIR}`, false, false);
53
+ // archived nodes appear only when referenced by an active depends_on
54
+ const referenced = new Set(nodes.flatMap((n) => n.dependsOn));
55
+ const archiveDir = `${root}/${CHANGES_DIR}/archive`;
56
+ if (io.exists(archiveDir) && io.isDirectory(archiveDir)) {
57
+ for (const name of io.listDir(archiveDir).toSorted()) {
58
+ if (!/\d{4}-\d{2}-\d{2}-/u.test(name)) continue;
59
+ const id = name.replace(/^\d{4}-\d{2}-\d{2}-/u, '');
60
+ if (!referenced.has(id)) continue;
61
+ // archived nodes keep their real depends_on — v1 emits their edges too
62
+ const deps: string[] = [];
63
+ const proposal = `${archiveDir}/${name}/proposal.md`;
64
+ if (io.exists(proposal)) {
65
+ const fm = io.readText(proposal).match(/^---\n([\s\S]*?)\n---/u);
66
+ let inDeps = false;
67
+ for (const line of fm?.[1]?.split('\n') ?? []) {
68
+ if (/^depends_on:\s*$/u.test(line)) {
69
+ inDeps = true;
70
+ continue;
71
+ }
72
+ if (inDeps) {
73
+ const m = line.match(/^\s*-\s+(\S+)/u);
74
+ if (m?.[1]) deps.push(m[1]);
75
+ else if (line.trim() !== '') inDeps = false;
76
+ }
77
+ }
78
+ }
79
+ nodes.push({ id, archived: true, dependsOn: deps });
80
+ }
81
+ }
82
+ return nodes;
83
+ }
84
+
85
+ export function graphMermaid(io: GraphFsIo, root: string): string[] {
86
+ const nodes = collectNodes(io, root);
87
+ const lines: string[] = ['flowchart TD'];
88
+ for (const node of nodes) {
89
+ const nid = node.id.replaceAll('-', '_');
90
+ const label = node.archived ? `${node.id} ✓ done` : node.id;
91
+ lines.push(` ${nid}["${label}"]${node.archived ? ':::archived' : ''}`);
92
+ }
93
+ const byId = new Set(nodes.map((n) => n.id));
94
+ for (const node of nodes) {
95
+ for (const dep of node.dependsOn) {
96
+ // edges render only when both endpoints are shown nodes
97
+ if (!byId.has(dep)) continue;
98
+ lines.push(` ${node.id.replaceAll('-', '_')} -->|depends on| ${dep.replaceAll('-', '_')}`);
99
+ }
100
+ }
101
+ lines.push(' classDef archived fill:#d4edda,stroke:#28a745,color:#333');
102
+ return lines;
103
+ }
@@ -0,0 +1,138 @@
1
+ import { readBinding } from '../change/frontmatter.ts';
2
+ import { CHANGES_DIR } from '../change/lifecycle.ts';
3
+ import { isCleanTree, currentBranch, makeSpawnGit } from '../git/spawnGit.ts';
4
+ import { discoverSpecs, type DiscoveryIo } from '../validation/discover.ts';
5
+ /**
6
+ * show change JSON (peripheral-commands capability, r21): v1 field set and
7
+ * gate structure (clean-tree / on-bound-branch / stage-complete /
8
+ * specs-landed / tasks-done / validate). Object keys are inserted in v1's
9
+ * order; consumers compare structurally.
10
+ */
11
+ import { validateAllSpecs } from '../validation/validate.ts';
12
+ import { stageFor, type ChangeFsIo } from './collect.ts';
13
+
14
+ export interface ShowFsIo extends ChangeFsIo {
15
+ readText(path: string): string;
16
+ }
17
+
18
+ export interface ShowDeps {
19
+ io: ShowFsIo;
20
+ discovery: DiscoveryIo;
21
+ root: string;
22
+ specsDir: string;
23
+ now: Date;
24
+ }
25
+
26
+ function parseTasks(md: string): { completed: number; total: number } {
27
+ let completed = 0;
28
+ let total = 0;
29
+ for (const line of md.split('\n')) {
30
+ const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
31
+ if (m) {
32
+ total += 1;
33
+ if (m[1] !== ' ') completed += 1;
34
+ }
35
+ }
36
+ return { completed, total };
37
+ }
38
+
39
+ function existsSyncLike(deps: ShowDeps, p: string): boolean {
40
+ return deps.io.exists(p) || deps.discovery.exists(p);
41
+ }
42
+
43
+ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unknown> {
44
+ const { io, discovery, root, now } = deps;
45
+ const dir = `${root}/${CHANGES_DIR}/${id}`;
46
+ const proposalPath = `${dir}/proposal.md`;
47
+ if (!io.exists(proposalPath)) throw new Error(`change not found: ${id}`);
48
+
49
+ const proposal = io.readText(proposalPath);
50
+ const titleMatch = proposal.match(/^#\s+(.*)$/mu);
51
+ const hasDesign = io.exists(`${dir}/design.md`);
52
+ const hasTasks = io.exists(`${dir}/tasks.md`);
53
+ const binding = readBinding(proposal);
54
+ const stage = stageFor(hasDesign, hasTasks, binding !== null);
55
+
56
+ const artifacts = ['proposal.md', 'design.md', 'tasks.md'].filter((f) =>
57
+ io.exists(`${dir}/${f}`),
58
+ );
59
+ const { completed, total } = hasTasks
60
+ ? parseTasks(io.readText(`${dir}/tasks.md`))
61
+ : { completed: 0, total: 0 };
62
+
63
+ const git = makeSpawnGit(root);
64
+ const cleanTree = isCleanTree(git);
65
+ const onBoundBranch = binding !== null && currentBranch(git) === binding.branch;
66
+
67
+ // specs landing: bound changes must have touched llmanspec/specs/ since base
68
+ let specsLanded = false;
69
+ if (binding !== null) {
70
+ const touched =
71
+ git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
72
+ specsLanded = touched.includes('llmanspec/specs/');
73
+ }
74
+ // needs_specs_change is an explicit frontmatter declaration (default true)
75
+ const fmMatch = proposal.match(/^---\n([\s\S]*?)\n---/u);
76
+ const declaredNeeds = fmMatch?.[1]?.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1];
77
+ const needsSpecsChange = declaredNeeds !== undefined ? declaredNeeds === 'true' : true;
78
+
79
+ const tasksDone = total > 0 && completed >= total;
80
+ const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, discovery), {
81
+ exists: (p: string) => existsSyncLike(deps, p),
82
+ });
83
+ const validateOk = !specReport.failed && (total === 0 || tasksDone);
84
+
85
+ const stageComplete = stage === 'full';
86
+
87
+ const gateChecks = [
88
+ {
89
+ name: 'clean-tree',
90
+ pass: cleanTree,
91
+ hint: cleanTree ? '' : 'commit/stash before change start',
92
+ },
93
+ {
94
+ name: 'on-bound-branch',
95
+ pass: onBoundBranch,
96
+ hint: onBoundBranch ? '' : 'change is not attached; run `llman sdd change start <id>`',
97
+ },
98
+ {
99
+ name: 'stage-complete',
100
+ pass: stageComplete,
101
+ hint: stageComplete ? '' : `add ${!hasDesign ? 'design.md' : 'tasks.md'} (current: ${stage})`,
102
+ },
103
+ {
104
+ name: 'specs-landed',
105
+ pass: specsLanded,
106
+ hint: specsLanded
107
+ ? ''
108
+ : 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
109
+ },
110
+ {
111
+ name: 'tasks-done',
112
+ pass: tasksDone,
113
+ hint: tasksDone ? '' : `${total - completed} unchecked tasks`,
114
+ },
115
+ {
116
+ name: 'validate',
117
+ pass: validateOk,
118
+ hint: validateOk ? '' : 'fix issues reported by `llman sdd validate <id> --strict`',
119
+ },
120
+ ];
121
+ void now;
122
+
123
+ return {
124
+ id,
125
+ path: id,
126
+ title: titleMatch?.[1]?.trim() ?? '',
127
+ stage,
128
+ artifacts,
129
+ readyToImplement: gateChecks.every((g) => g.pass),
130
+ specsLanded,
131
+ needsSpecsChange,
132
+ attached: binding !== null,
133
+ deltaCount: 0,
134
+ deltas: [] as string[],
135
+ gateChecks,
136
+ matchedViaPrefix: false,
137
+ };
138
+ }
@@ -0,0 +1,63 @@
1
+ import { parseCapability } from '../spec/parser.ts';
2
+ /**
3
+ * Spec authoring helpers (peripheral-commands capability, r22):
4
+ * skeleton generation + global next req id. Pure, IO injected.
5
+ */
6
+ import { localeFallbacks } from '../templates/locale.ts';
7
+
8
+ export interface SpecHelperIo {
9
+ exists(path: string): boolean;
10
+ readText(path: string): string;
11
+ writeText(path: string, content: string): void;
12
+ mkdirp(path: string): void;
13
+ isDirectory(path: string): boolean;
14
+ listDir(path: string): string[];
15
+ }
16
+
17
+ /** Scan all specs under `specsDir` for @req:rN TAGS (not step text) and return the next free id. */
18
+ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
19
+ let max = 0;
20
+ const walk = (dir: string): void => {
21
+ if (!io.exists(dir)) return;
22
+ for (const name of io.listDir(dir)) {
23
+ const full = `${dir}/${name}`;
24
+ if (name.endsWith('.feature')) {
25
+ const doc = parseCapability(io.readText(full), full);
26
+ for (const scenario of doc.scenarios) {
27
+ for (const reqId of scenario.reqIds) {
28
+ max = Math.max(max, Math.trunc(Number(reqId.replace(/^r/u, ''))));
29
+ }
30
+ }
31
+ } else if (io.isDirectory(full)) {
32
+ walk(full);
33
+ }
34
+ }
35
+ };
36
+ walk(specsDir);
37
+ return `r${max + 1}`;
38
+ }
39
+
40
+ export function skeletonContent(capability: string, reqId: string, locale: string): string {
41
+ const zh = localeFallbacks(locale)[0] === 'zh-Hans';
42
+ const header = zh
43
+ ? `# language: zh-CN\n# capability: ${capability}\n# purpose: TODO: 一句话描述该能力与其目的。\n# scope: src/`
44
+ : `# language: en\n# capability: ${capability}\n# purpose: TODO: Describe this capability and its purpose.\n# scope: src/`;
45
+ const feature = zh ? `功能: ${capability}` : `Feature: ${capability}`;
46
+ const scenario = zh ? '场景: TODO-rule' : 'Scenario: TODO-rule';
47
+ return `${header}\n\n${feature}\n\n @req:${reqId} @human\n ${scenario}\n System MUST ...\n`;
48
+ }
49
+
50
+ /** `spec skeleton <cap>`: write llmanspec/specs/<cap>.feature + scaffold scope dir. */
51
+ export function scaffoldSpec(
52
+ io: SpecHelperIo,
53
+ specsDir: string,
54
+ capability: string,
55
+ locale: string,
56
+ ): string {
57
+ const reqId = nextReqId(io, specsDir);
58
+ const path = `${specsDir}/${capability}.feature`;
59
+ if (io.exists(path)) throw new Error(`spec already exists: ${path}`);
60
+ io.mkdirp('src/');
61
+ io.writeText(path, skeletonContent(capability, reqId, locale));
62
+ return path;
63
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Specs listing (peripheral-commands capability, r20/r21): morphology counts
3
+ * aligned with v1 — rules = @human scenarios; enforced = rules carrying an
4
+ * @req link that has an executable acceptance scenario; pending = the rest.
5
+ */
6
+ import type { CapabilityDoc, ScenarioIR } from '../spec/ir.ts';
7
+ import type { SpecEntry } from '../validation/validate.ts';
8
+
9
+ export interface SpecMorphology {
10
+ ruleCount: number;
11
+ ruleEnforcedCount: number;
12
+ ruleManualCount: number;
13
+ rulePendingCount: number;
14
+ acceptanceCount: number;
15
+ orphanAcceptanceCount: number;
16
+ }
17
+
18
+ export interface SpecSummary {
19
+ id: string;
20
+ title: string;
21
+ purpose: string;
22
+ validScope: string[];
23
+ requirementCount: number;
24
+ health: null;
25
+ staleness: null;
26
+ morphology: SpecMorphology;
27
+ }
28
+
29
+ function morphologyOf(doc: CapabilityDoc): SpecMorphology {
30
+ const rules = doc.scenarios.filter((s) => s.classification === 'human');
31
+ const acceptance = doc.scenarios.filter((s) => s.classification === 'executable');
32
+ const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
33
+ const enforced = rules.filter((r) => r.reqIds.some((id) => acceptanceReqIds.has(id)));
34
+ const orphan = acceptance.filter((s) => s.reqIds.length === 0);
35
+ return {
36
+ ruleCount: rules.length,
37
+ ruleEnforcedCount: enforced.length,
38
+ ruleManualCount: rules.filter((r: ScenarioIR) => r.manual).length,
39
+ rulePendingCount: rules.length - enforced.length,
40
+ acceptanceCount: acceptance.length,
41
+ orphanAcceptanceCount: orphan.length,
42
+ };
43
+ }
44
+
45
+ export function collectSpecs(entries: readonly SpecEntry[]): SpecSummary[] {
46
+ return entries.map((e) => ({
47
+ id: e.doc.header.capability ?? e.fileName,
48
+ title: e.doc.header.capability ?? e.fileName,
49
+ purpose: e.doc.header.purpose ?? '',
50
+ validScope: (e.doc.header.scope ?? '')
51
+ .split(',')
52
+ .map((s) => s.trim())
53
+ .filter((s) => s !== ''),
54
+ requirementCount: morphologyOf(e.doc).ruleCount,
55
+ health: null,
56
+ staleness: null,
57
+ morphology: morphologyOf(e.doc),
58
+ }));
59
+ }
60
+
61
+ function pad(s: string, width: number): string {
62
+ return s.length >= width ? s : s + ' '.repeat(width - s.length);
63
+ }
64
+
65
+ export function renderSpecsList(summaries: readonly SpecSummary[]): string[] {
66
+ const lines = ['Available specs:'];
67
+ const idWidth = Math.max(...summaries.map((s) => s.id.length), 0) + 2;
68
+ for (const s of summaries) {
69
+ const m = s.morphology;
70
+ lines.push(
71
+ ` ${pad(s.id, idWidth)}rules ${m.ruleCount} enforced ${m.ruleEnforcedCount} manual ${m.ruleManualCount} pending ${m.rulePendingCount} acceptance ${m.acceptanceCount}`,
72
+ );
73
+ }
74
+ return lines;
75
+ }
76
+
77
+ export function renderSpecsJson(summaries: readonly SpecSummary[]): string {
78
+ return JSON.stringify(summaries, null, 2);
79
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Review aggregation (review-freeze capability, r23): five-signal review over
3
+ * spec IR + validate sweep. Port of v1 sdd/review.rs observable contract.
4
+ */
5
+ import { validateAllSpecs, type SpecEntry, type SpecIo } from '../validation/validate.ts';
6
+
7
+ export type ReviewKind = 'pending' | 'manual' | 'unbound' | 'stale' | 'locked' | 'validate';
8
+
9
+ export interface ReviewSignal {
10
+ kind: ReviewKind;
11
+ capability: string;
12
+ count: number;
13
+ detail: string;
14
+ }
15
+
16
+ export interface TagBinding {
17
+ kind: 'tags';
18
+ tags: string[];
19
+ }
20
+
21
+ export interface ReviewInput {
22
+ entries: readonly SpecEntry[];
23
+ /** config bdd.bindings (tags sources); null/empty = every acceptance unbound. */
24
+ bindings: readonly TagBinding[] | null;
25
+ /** Active changes currently carrying a branch binding. */
26
+ boundChangeCount: number;
27
+ /** Active change summaries for the strict sweep (pending tasks → FAIL). */
28
+ activeChanges?: readonly { name: string; completedTasks: number; totalTasks: number }[];
29
+ }
30
+
31
+ export interface ReviewResult {
32
+ signals: ReviewSignal[];
33
+ summary: { criticalCount: number; warningCount: number };
34
+ lines: string[];
35
+ exitCode: number;
36
+ }
37
+
38
+ function executableBound(
39
+ scenarioTags: readonly string[],
40
+ bindings: readonly TagBinding[] | null,
41
+ ): boolean {
42
+ if (!bindings || bindings.length === 0) return false;
43
+ const has = (t: string): boolean => scenarioTags.includes(t);
44
+ return bindings.some((b) => (b.kind === 'tags' ? b.tags.every((t) => has(t)) : false));
45
+ }
46
+
47
+ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
48
+ const { entries, bindings, boundChangeCount } = input;
49
+ const strictChangeFails = (input.activeChanges ?? []).filter(
50
+ (c) => c.totalTasks > 0 && c.completedTasks < c.totalTasks,
51
+ );
52
+ const signals: ReviewSignal[] = [];
53
+ let critical = 0;
54
+ let warning = 0;
55
+ const push = (kind: ReviewKind, capability: string, count: number, detail = ''): void => {
56
+ signals.push({ kind, capability, count, detail });
57
+ if (kind === 'validate') {
58
+ critical += count;
59
+ } else if (count > 0) {
60
+ warning += count;
61
+ }
62
+ };
63
+
64
+ const sweep = validateAllSpecs(entries, io);
65
+ const sorted = [...entries].toSorted((a, b) => a.fileName.localeCompare(b.fileName));
66
+
67
+ for (const entry of sorted) {
68
+ const cap = entry.doc.header.capability ?? entry.fileName;
69
+ const rules = entry.doc.scenarios.filter((s) => s.classification === 'human');
70
+ const acceptance = entry.doc.scenarios.filter((s) => s.classification === 'executable');
71
+ const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
72
+ const pending = rules.filter((r) => !r.reqIds.some((id) => acceptanceReqIds.has(id)));
73
+ const manual = rules.filter((r) => r.manual);
74
+ const unbound = acceptance.filter((s) => !executableBound(s.tags, bindings));
75
+
76
+ push('pending', cap, pending.length);
77
+ push('manual', cap, manual.length);
78
+ push('unbound', cap, unbound.length);
79
+ // staleness is deferred in v2 — placeholder signal (golden normalizes it)
80
+ push('stale', cap, 0, 'DEFERRED');
81
+ }
82
+
83
+ const failed = sweep.verdicts.filter((v) => !v.ok);
84
+ // v1 sweep = `validate --all --strict --no-check`: pending tasks escalate a
85
+ // change to FAIL, and that feeds the review critical count.
86
+ for (const c of strictChangeFails) {
87
+ failed.push({
88
+ fileName: c.name,
89
+ capability: c.name,
90
+ ok: false,
91
+ items: [
92
+ {
93
+ level: 'ERROR',
94
+ id: `${c.name}/tasks`,
95
+ message: `${c.totalTasks - c.completedTasks} unchecked tasks`,
96
+ },
97
+ ],
98
+ });
99
+ }
100
+ // v1 parity: `count` is the removed locked-rule-confirmation concept (always
101
+ // 0 now); the detail text carries the bound-change count separately.
102
+ push(
103
+ 'locked',
104
+ '-',
105
+ 0,
106
+ `${boundChangeCount} bound change(s); inspect with \`llman sdd change diff <id>\``,
107
+ );
108
+ push(
109
+ 'validate',
110
+ '-',
111
+ failed.length,
112
+ failed.length > 0 ? 'validate --all failed; run `llman sdd validate --all` for details' : 'ok',
113
+ );
114
+
115
+ const warningCount = signals
116
+ .filter((s) => s.kind !== 'validate' && s.kind !== 'locked')
117
+ .reduce((acc, s) => acc + s.count, 0);
118
+ const criticalCount = failed.length;
119
+
120
+ const lines: string[] = [`Review: critical=${criticalCount} warning=${warningCount}`];
121
+ for (const cap of sorted.map((e) => e.doc.header.capability ?? e.fileName)) {
122
+ for (const kind of ['pending', 'manual', 'unbound', 'stale'] as const) {
123
+ const s = signals.find((x) => x.kind === kind && x.capability === cap);
124
+ if (!s) continue;
125
+ lines.push(`${kind}: ${cap} (${s.count})`);
126
+ if (s.detail !== '') lines.push(` - ${s.detail}`);
127
+ }
128
+ }
129
+ for (const kind of ['locked', 'validate'] as const) {
130
+ const s = signals.find((x) => x.kind === kind);
131
+ if (!s) continue;
132
+ lines.push(`${kind}: ${s.capability} (${s.count})`);
133
+ if (s.detail !== '') lines.push(` - ${s.detail}`);
134
+ }
135
+
136
+ return {
137
+ signals,
138
+ summary: { criticalCount, warningCount },
139
+ lines,
140
+ exitCode: criticalCount > 0 ? 1 : 0,
141
+ };
142
+ }