@llman-sdd/core 0.3.1 → 0.5.1
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/package.json +2 -1
- package/src/archive/freeze.ts +86 -18
- package/src/archive/frozenCard.ts +105 -0
- package/src/archive/sevenzip.ts +15 -13
- package/src/change/closeOutHarness.ts +29 -0
- package/src/change/collect.ts +140 -0
- package/src/change/frontmatter.ts +48 -6
- package/src/change/id.ts +2 -6
- package/src/change/lifecycle.ts +285 -86
- package/src/change/nextId.ts +63 -2
- package/src/change/resolve.ts +2 -2
- package/src/change/tasks.ts +59 -0
- package/src/config/changeId.ts +14 -12
- package/src/config/load.ts +14 -0
- package/src/config/schema.ts +4 -41
- package/src/config/surface.ts +6 -36
- package/src/context/indexStore.ts +7 -3
- package/src/context/retrieve.ts +8 -10
- package/src/context/tree.ts +28 -24
- package/src/git/spawnGit.ts +90 -2
- package/src/index.ts +81 -54
- package/src/init/defaultConfig.ts +1 -5
- package/src/init/init.ts +19 -4
- package/src/ports.ts +1 -7
- package/src/project/migrateNotes.ts +104 -0
- package/src/render/machine.ts +30 -0
- package/src/report/collect.ts +11 -127
- package/src/report/graph/analysis.ts +152 -0
- package/src/report/graph/deps.ts +30 -0
- package/src/report/graph/graphData.ts +53 -0
- package/src/report/graph/nodes.ts +130 -0
- package/src/report/graph/render.ts +83 -0
- package/src/report/graph/types.ts +47 -0
- package/src/report/graph.ts +9 -381
- package/src/report/show.ts +20 -22
- package/src/report/specHelpers.ts +45 -22
- package/src/report/specs.ts +23 -25
- package/src/review/review.ts +45 -30
- package/src/spec/authoring.ts +147 -71
- package/src/spec/ir.ts +43 -15
- package/src/spec/keywords.ts +147 -0
- package/src/spec/migrateNative.ts +201 -0
- package/src/spec/parser.ts +95 -83
- package/src/spec/reqRegistry.ts +31 -15
- package/src/templates/embedded.ts +10 -16
- package/src/templates/engine.ts +10 -5
- package/src/templates/locale.ts +1 -1
- package/src/templates/skills.ts +4 -5
- package/src/validation/changeCheck.ts +128 -105
- package/src/validation/harness.ts +161 -0
- package/src/validation/staleness.ts +9 -5
- package/src/validation/validate.ts +60 -88
- package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
- package/templates/en/skills/llman-sdd-apply.md +58 -76
- package/templates/en/skills/llman-sdd-arch-review.md +12 -19
- package/templates/en/skills/llman-sdd-archive.md +27 -42
- package/templates/en/skills/llman-sdd-continue.md +17 -24
- package/templates/en/skills/llman-sdd-draft.md +17 -28
- package/templates/en/skills/llman-sdd-explore.md +29 -43
- package/templates/en/skills/llman-sdd-ff.md +12 -17
- package/templates/en/skills/llman-sdd-graph.md +14 -32
- package/templates/en/skills/llman-sdd-propose.md +48 -63
- package/templates/en/skills/llman-sdd-quick.md +12 -27
- package/templates/en/skills/llman-sdd-research.md +13 -24
- package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
- package/templates/en/skills/llman-sdd-validate.md +11 -15
- package/templates/en/skills/llman-sdd-verify.md +23 -44
- package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
- package/templates/en/units/skills/cli-footer.md +2 -0
- package/templates/en/units/skills/git-native-flow-brief.md +7 -6
- package/templates/en/units/skills/git-native-flow.md +21 -11
- package/templates/en/units/skills/human-readable-summary.md +2 -3
- package/templates/en/units/skills/stage-guard.md +7 -7
- package/templates/en/units/skills/structured-protocol.md +5 -8
- package/templates/en/units/skills/validation-hints.md +10 -14
- package/templates/en/units/spec/feature-contract.md +27 -16
- package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
- package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
- package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
- package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
- package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
- package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
- package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
- package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
- package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
- package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
- package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
- package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
- package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
- package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
- package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
- package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
- package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
- package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
- package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
- package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
- package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
- package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
- package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
- package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
- package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
- package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
- package/templates/en/skills/llman-sdd-onboard.md +0 -34
- package/templates/en/skills/llman-sdd-show.md +0 -24
- package/templates/en/units/migrate-prompt.md +0 -28
- package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
- package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
- package/templates/zh-Hans/units/migrate-prompt.md +0 -28
package/src/report/graph.ts
CHANGED
|
@@ -1,387 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Change dependency graph (peripheral-commands capability, r21/r30/r54):
|
|
3
|
-
*
|
|
3
|
+
* predecessor `commands/graph.rs` observable contract — scope parsing, connected
|
|
4
4
|
* component subgraphs, conditional classDef, empty placeholder, bidirectional
|
|
5
5
|
* seed BFS, error paths. Pure: IO injected.
|
|
6
|
+
*
|
|
7
|
+
* Implementation split under `graph/` (pure mechanical move, no behavior
|
|
8
|
+
* change); this file stays the public barrel.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
listDir(path: string): string[];
|
|
14
|
-
isDirectory(path: string): boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface GraphNode {
|
|
18
|
-
id: string;
|
|
19
|
-
archived: boolean;
|
|
20
|
-
/** true = directory on disk; false = phantom (frozen/removed dependency). */
|
|
21
|
-
present: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface DepEdge {
|
|
25
|
-
from: string;
|
|
26
|
-
to: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class GraphError extends Error {}
|
|
30
|
-
|
|
31
|
-
function extractArchivedId(name: string): string | null {
|
|
32
|
-
const m = name.match(/^\d{4}-\d{2}-\d{2}-(.+)$/u);
|
|
33
|
-
return m ? (m[1] ?? null) : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Extract depends_on entries (flow + block styles, r30). */
|
|
37
|
-
export function parseDeps(proposal: string): string[] {
|
|
38
|
-
const fm = proposal.match(/^---\n([\s\S]*?)\n---/u);
|
|
39
|
-
const deps: string[] = [];
|
|
40
|
-
if (!fm?.[1]) return deps;
|
|
41
|
-
let inDeps = false;
|
|
42
|
-
for (const line of fm[1].split('\n')) {
|
|
43
|
-
const flow = line.match(/^depends_on:\s*\[([^\]]*)\]\s*$/u);
|
|
44
|
-
if (flow) {
|
|
45
|
-
for (const item of flow[1]?.split(',') ?? []) {
|
|
46
|
-
const dep = item.trim().replaceAll(/^['"]|['"]$/gu, '');
|
|
47
|
-
if (dep !== '' && !/^[0-9]+$/u.test(dep)) deps.push(dep);
|
|
48
|
-
}
|
|
49
|
-
inDeps = false;
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (/^depends_on:\s*$/u.test(line)) {
|
|
53
|
-
inDeps = true;
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
if (inDeps) {
|
|
57
|
-
const m = line.match(/^\s*-\s+(\S+)/u);
|
|
58
|
-
if (m?.[1] && !/^[0-9]+$/u.test(m[1])) deps.push(m[1]);
|
|
59
|
-
else if (line.trim() !== '') inDeps = false;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return deps;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
type ScopeKind = 'active' | 'archived';
|
|
66
|
-
|
|
67
|
-
function parseScope(scope: string): ScopeKind[] {
|
|
68
|
-
if (scope === 'all') return ['active', 'archived'];
|
|
69
|
-
const kinds: ScopeKind[] = [];
|
|
70
|
-
for (const part of scope.split(',')) {
|
|
71
|
-
const t = part.trim();
|
|
72
|
-
if (t === 'active') kinds.push('active');
|
|
73
|
-
else if (t === 'archived') kinds.push('archived');
|
|
74
|
-
else {
|
|
75
|
-
throw new GraphError(
|
|
76
|
-
`Unknown scope: '${flagClean(t)}'. Supported: active, archived, all (or comma-separated like active,archived)`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (kinds.length === 0) throw new GraphError('Scope cannot be empty');
|
|
81
|
-
return kinds;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function flagClean(v: string): string {
|
|
85
|
-
return v;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Active nodes: dirs (recursively) with a proposal.md (v1 discovery). */
|
|
89
|
-
function collectActiveNodes(io: GraphFsIo, root: string): GraphNode[] {
|
|
90
|
-
const out: GraphNode[] = [];
|
|
91
|
-
const changesDir = `${root}/${CHANGES_DIR}`;
|
|
92
|
-
if (!io.exists(changesDir) || !io.isDirectory(changesDir)) return out;
|
|
93
|
-
const visit = (dir: string): void => {
|
|
94
|
-
for (const name of io.listDir(dir).toSorted()) {
|
|
95
|
-
if (name === 'archive' || name.startsWith('.')) continue;
|
|
96
|
-
const child = `${dir}/${name}`;
|
|
97
|
-
if (!io.isDirectory(child)) continue;
|
|
98
|
-
if (io.exists(`${child}/proposal.md`)) {
|
|
99
|
-
out.push({ id: name, archived: false, present: true });
|
|
100
|
-
} else {
|
|
101
|
-
visit(child);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
visit(changesDir);
|
|
106
|
-
return out;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function collectArchivedNodes(io: GraphFsIo, root: string): GraphNode[] {
|
|
110
|
-
const archiveDir = `${root}/${CHANGES_DIR}/archive`;
|
|
111
|
-
if (!io.exists(archiveDir) || !io.isDirectory(archiveDir)) return [];
|
|
112
|
-
const out: GraphNode[] = [];
|
|
113
|
-
const seen = new Set<string>();
|
|
114
|
-
for (const name of io.listDir(archiveDir)) {
|
|
115
|
-
const child = `${archiveDir}/${name}`;
|
|
116
|
-
if (!io.isDirectory(child)) continue;
|
|
117
|
-
const id = extractArchivedId(name);
|
|
118
|
-
if (id !== null && !seen.has(id)) {
|
|
119
|
-
seen.add(id);
|
|
120
|
-
out.push({ id, archived: true, present: true });
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return out;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function collectNodes(io: GraphFsIo, root: string, kinds: ScopeKind[]): GraphNode[] {
|
|
127
|
-
const combined: GraphNode[] = [];
|
|
128
|
-
const seen = new Set<string>();
|
|
129
|
-
for (const kind of kinds) {
|
|
130
|
-
const nodes = kind === 'active' ? collectActiveNodes(io, root) : collectArchivedNodes(io, root);
|
|
131
|
-
for (const node of nodes) {
|
|
132
|
-
if (!seen.has(node.id)) {
|
|
133
|
-
seen.add(node.id);
|
|
134
|
-
combined.push(node);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return combined;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function proposalFor(io: GraphFsIo, root: string, node: GraphNode): string {
|
|
142
|
-
if (node.archived) {
|
|
143
|
-
const archiveDir = `${root}/${CHANGES_DIR}/archive`;
|
|
144
|
-
// latest matching dirtategy: pick the newest date-prefixed dir
|
|
145
|
-
let best = '';
|
|
146
|
-
let bestDate = '';
|
|
147
|
-
for (const name of io.listDir(archiveDir)) {
|
|
148
|
-
const id = extractArchivedId(name);
|
|
149
|
-
if (id !== node.id) continue;
|
|
150
|
-
const date = name.slice(0, 10);
|
|
151
|
-
if (best === '' || date > bestDate) {
|
|
152
|
-
best = name;
|
|
153
|
-
bestDate = date;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
const p = `${archiveDir}/${best}/proposal.md`;
|
|
157
|
-
return io.exists(p) ? io.readText(p) : '';
|
|
158
|
-
}
|
|
159
|
-
const changesDir = `${root}/${CHANGES_DIR}`;
|
|
160
|
-
const found: string[] = [];
|
|
161
|
-
const visit = (dir: string): void => {
|
|
162
|
-
for (const name of io.listDir(dir).toSorted()) {
|
|
163
|
-
if (name === 'archive' || name.startsWith('.')) continue;
|
|
164
|
-
const child = `${dir}/${name}`;
|
|
165
|
-
if (!io.isDirectory(child)) continue;
|
|
166
|
-
if (io.exists(`${child}/proposal.md`)) {
|
|
167
|
-
if (name === node.id) found.push(`${child}/proposal.md`);
|
|
168
|
-
continue;
|
|
169
|
-
// ^ leaf change dir — do not descend further
|
|
170
|
-
}
|
|
171
|
-
visit(child);
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
visit(changesDir);
|
|
175
|
-
if (found.length === 0) return '';
|
|
176
|
-
return io.readText(found[0] ?? '');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
interface RelationMaps {
|
|
180
|
-
depends: Map<string, string[]>;
|
|
181
|
-
reverse: Map<string, string[]>;
|
|
182
|
-
nodeSet: Set<string>;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function buildMaps(io: GraphFsIo, root: string, nodes: GraphNode[]): RelationMaps {
|
|
186
|
-
const depends = new Map<string, string[]>();
|
|
187
|
-
const reverse = new Map<string, string[]>();
|
|
188
|
-
const nodeSet = new Set<string>();
|
|
189
|
-
for (const node of nodes) {
|
|
190
|
-
nodeSet.add(node.id);
|
|
191
|
-
if (!node.present) continue;
|
|
192
|
-
for (const dep of parseDeps(proposalFor(io, root, node))) {
|
|
193
|
-
depends.set(node.id, [...(depends.get(node.id) ?? []), dep]);
|
|
194
|
-
reverse.set(dep, [...(reverse.get(dep) ?? []), node.id]);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return { depends, reverse, nodeSet };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function buildDefaultNodes(io: GraphFsIo, root: string, kinds: ScopeKind[]): GraphNode[] {
|
|
201
|
-
const nodes = collectNodes(io, root, kinds);
|
|
202
|
-
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
203
|
-
const all = collectNodes(io, root, ['active', 'archived']);
|
|
204
|
-
const allMap = new Map(all.map((n) => [n.id, n]));
|
|
205
|
-
|
|
206
|
-
const missing: string[] = [];
|
|
207
|
-
for (const node of all) {
|
|
208
|
-
if (!nodeIds.has(node.id) || !node.present) continue;
|
|
209
|
-
for (const dep of parseDeps(proposalFor(io, root, node))) {
|
|
210
|
-
if (!nodeIds.has(dep)) missing.push(dep);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
const seenMissing = new Set<string>();
|
|
214
|
-
for (const dep of missing) {
|
|
215
|
-
if (seenMissing.has(dep)) continue;
|
|
216
|
-
seenMissing.add(dep);
|
|
217
|
-
const existing = allMap.get(dep);
|
|
218
|
-
if (existing !== undefined) nodes.push(existing);
|
|
219
|
-
else nodes.push({ id: dep, archived: false, present: false });
|
|
220
|
-
}
|
|
221
|
-
return nodes;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function collectEdges(io: GraphFsIo, root: string, nodes: GraphNode[]): DepEdge[] {
|
|
225
|
-
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
226
|
-
const edges: DepEdge[] = [];
|
|
227
|
-
for (const node of nodes) {
|
|
228
|
-
if (!node.present) continue;
|
|
229
|
-
for (const dep of parseDeps(proposalFor(io, root, node))) {
|
|
230
|
-
if (nodeIds.has(dep)) edges.push({ from: node.id, to: dep });
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return edges;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function connectedComponents(nodeIds: string[], edges: DepEdge[]): string[][] {
|
|
237
|
-
if (nodeIds.length === 0) return [];
|
|
238
|
-
const parent = new Map<string, string>(nodeIds.map((id) => [id, id]));
|
|
239
|
-
const find = (x: string): string => {
|
|
240
|
-
let root = parent.get(x) ?? x;
|
|
241
|
-
while (parent.get(root) !== root) root = parent.get(root) ?? root;
|
|
242
|
-
return root;
|
|
243
|
-
};
|
|
244
|
-
const union = (a: string, b: string): void => {
|
|
245
|
-
const ra = find(a);
|
|
246
|
-
const rb = find(b);
|
|
247
|
-
if (ra !== rb) parent.set(ra, rb);
|
|
248
|
-
};
|
|
249
|
-
for (const edge of edges) union(edge.from, edge.to);
|
|
250
|
-
const groups = new Map<string, string[]>();
|
|
251
|
-
for (const id of nodeIds) {
|
|
252
|
-
const root = find(id);
|
|
253
|
-
groups.set(root, [...(groups.get(root) ?? []), id]);
|
|
254
|
-
}
|
|
255
|
-
return [...groups.values()].toSorted((a, b) => b.length - a.length);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function componentLabel(nodes: GraphNode[], ids: Set<string>): string {
|
|
259
|
-
const comp = nodes.filter((n) => ids.has(n.id));
|
|
260
|
-
const allActive = comp.every((n) => !n.archived && n.present);
|
|
261
|
-
const allArchived = comp.every((n) => n.archived);
|
|
262
|
-
if (allActive) return 'Active';
|
|
263
|
-
if (allArchived) return 'Done';
|
|
264
|
-
return 'Mixed';
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const sanitize = (id: string): string => id.replaceAll('-', '_');
|
|
268
|
-
|
|
269
|
-
export interface GraphOptions {
|
|
270
|
-
scope?: string;
|
|
271
|
-
depth?: number;
|
|
272
|
-
seed?: string;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export function graphMermaid(io: GraphFsIo, root: string, opts: GraphOptions = {}): string[] {
|
|
276
|
-
const scope = opts.scope ?? 'active';
|
|
277
|
-
const kinds = parseScope(scope);
|
|
278
|
-
|
|
279
|
-
let nodes: GraphNode[];
|
|
280
|
-
if (opts.seed !== undefined) {
|
|
281
|
-
nodes = buildSeedNeighborhood(io, root, opts.seed, opts.depth ?? 1, kinds);
|
|
282
|
-
} else {
|
|
283
|
-
nodes = buildDefaultNodes(io, root, kinds);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (nodes.length === 0) {
|
|
287
|
-
return [
|
|
288
|
-
'flowchart TD',
|
|
289
|
-
opts.seed !== undefined
|
|
290
|
-
? ` empty["Change '${opts.seed}' not found or has no relationships"]`
|
|
291
|
-
: ` empty["No changes in scope '${scope}'"]`,
|
|
292
|
-
];
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const edges = collectEdges(io, root, nodes);
|
|
296
|
-
const nodeIds = nodes.map((n) => n.id);
|
|
297
|
-
const components = connectedComponents(nodeIds, edges);
|
|
298
|
-
const hasArchived = nodes.some((n) => n.archived);
|
|
299
|
-
const hasMissing = nodes.some((n) => !n.present);
|
|
300
|
-
|
|
301
|
-
const lines = ['flowchart TD'];
|
|
302
|
-
if (components.length > 1) {
|
|
303
|
-
components.forEach((comp, idx) => {
|
|
304
|
-
const ids = new Set(comp);
|
|
305
|
-
const label = componentLabel(nodes, ids);
|
|
306
|
-
lines.push(` subgraph sg${idx + 1}["${label}"]`);
|
|
307
|
-
renderNodesAndEdges(nodes, edges, ids, lines, true);
|
|
308
|
-
lines.push(' end');
|
|
309
|
-
});
|
|
310
|
-
} else {
|
|
311
|
-
renderNodesAndEdges(nodes, edges, new Set(nodeIds), lines, false);
|
|
312
|
-
}
|
|
313
|
-
if (hasArchived) lines.push(' classDef archived fill:#d4edda,stroke:#28a745,color:#333');
|
|
314
|
-
if (hasMissing) lines.push(' classDef missing fill:#f8d7da,stroke:#dc3545,color:#333');
|
|
315
|
-
return lines;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function renderNodesAndEdges(
|
|
319
|
-
nodes: GraphNode[],
|
|
320
|
-
edges: DepEdge[],
|
|
321
|
-
ids: Set<string>,
|
|
322
|
-
lines: string[],
|
|
323
|
-
indented: boolean,
|
|
324
|
-
): void {
|
|
325
|
-
const pad = indented ? ' ' : ' ';
|
|
326
|
-
for (const node of nodes) {
|
|
327
|
-
if (!ids.has(node.id)) continue;
|
|
328
|
-
if (!node.present) lines.push(`${pad}${sanitize(node.id)}["${node.id} ⚠ missing"]:::missing`);
|
|
329
|
-
else if (node.archived)
|
|
330
|
-
lines.push(`${pad}${sanitize(node.id)}["${node.id} ✓ done"]:::archived`);
|
|
331
|
-
else lines.push(`${pad}${sanitize(node.id)}["${node.id}"]`);
|
|
332
|
-
}
|
|
333
|
-
for (const edge of edges) {
|
|
334
|
-
if (!ids.has(edge.from)) continue;
|
|
335
|
-
lines.push(`${pad}${sanitize(edge.from)} -->|depends on| ${sanitize(edge.to)}`);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/** Seed neighborhood: bidirectional BFS (depends_on + reverse) over both sets. */
|
|
340
|
-
function buildSeedNeighborhood(
|
|
341
|
-
io: GraphFsIo,
|
|
342
|
-
root: string,
|
|
343
|
-
seedId: string,
|
|
344
|
-
maxDepth: number,
|
|
345
|
-
_kinds: ScopeKind[],
|
|
346
|
-
): GraphNode[] {
|
|
347
|
-
const resolved = resolveSeedId(io, root, seedId);
|
|
348
|
-
const allNodes = collectNodes(io, root, ['active', 'archived']);
|
|
349
|
-
const nodeMap = new Map(allNodes.map((n) => [n.id, n]));
|
|
350
|
-
if (!nodeMap.has(resolved)) {
|
|
351
|
-
const prefix = resolved.split('-')[0] ?? '';
|
|
352
|
-
const suggestions = [...nodeMap.keys()].filter((k) => k.startsWith(prefix));
|
|
353
|
-
const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
|
354
|
-
throw new GraphError(`change '${seedId}' not found.${hint}`);
|
|
355
|
-
}
|
|
356
|
-
const maps = buildMaps(io, root, allNodes);
|
|
357
|
-
const visited = new Set<string>([resolved]);
|
|
358
|
-
const queue: [string, number][] = [[resolved, 0]];
|
|
359
|
-
while (queue.length > 0) {
|
|
360
|
-
const [current, depth] = queue.shift() as [string, number];
|
|
361
|
-
if (depth >= maxDepth) continue;
|
|
362
|
-
const neighbors = [...(maps.depends.get(current) ?? []), ...(maps.reverse.get(current) ?? [])];
|
|
363
|
-
for (const neighbor of neighbors) {
|
|
364
|
-
if (maps.nodeSet.has(neighbor) && !visited.has(neighbor)) {
|
|
365
|
-
visited.add(neighbor);
|
|
366
|
-
queue.push([neighbor, depth + 1]);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
const result = [...visited]
|
|
371
|
-
.filter((id) => nodeMap.has(id))
|
|
372
|
-
.map((id) => nodeMap.get(id) as GraphNode);
|
|
373
|
-
result.sort((a, b) => a.id.localeCompare(b.id));
|
|
374
|
-
return result;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function resolveSeedId(io: GraphFsIo, root: string, seedId: string): string {
|
|
378
|
-
const active = collectActiveNodes(io, root);
|
|
379
|
-
const archived = collectArchivedNodes(io, root);
|
|
380
|
-
const all = [...active.map((n) => n.id), ...archived.map((n) => n.id)];
|
|
381
|
-
if (all.includes(seedId)) return seedId;
|
|
382
|
-
const viaPrefix = all.filter((id) => id.startsWith(seedId));
|
|
383
|
-
if (viaPrefix.length === 1) return viaPrefix[0] ?? seedId;
|
|
384
|
-
const viaArchivedPrefix = archived.map((n) => n.id).filter((id) => id.startsWith(seedId));
|
|
385
|
-
if (viaArchivedPrefix.length === 1) return viaArchivedPrefix[0] ?? seedId;
|
|
386
|
-
return seedId;
|
|
387
|
-
}
|
|
11
|
+
export { GraphError, type GraphFsIo } from './graph/types.ts';
|
|
12
|
+
export type { GraphOptions, GraphNodeIr, GraphEdgeIr, GraphDataIr } from './graph/types.ts';
|
|
13
|
+
export { parseDeps } from './graph/deps.ts';
|
|
14
|
+
export { graphData } from './graph/graphData.ts';
|
|
15
|
+
export { graphMermaid } from './graph/render.ts';
|
package/src/report/show.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { countTasks, firstH1, stageFor, type ChangeFsIo } from '../change/collect.ts';
|
|
2
|
+
import {
|
|
3
|
+
extractFrontmatter,
|
|
4
|
+
readBinding,
|
|
5
|
+
readNeedsSpecsChange,
|
|
6
|
+
specsLanded as specsLandedOf,
|
|
7
|
+
} from '../change/frontmatter.ts';
|
|
2
8
|
import { CHANGES_DIR } from '../change/lifecycle.ts';
|
|
3
9
|
import { currentBranch, isCleanTree, type GitLike } from '../git/spawnGit.ts';
|
|
4
10
|
import { discoverSpecs } from '../validation/discover.ts';
|
|
5
11
|
/**
|
|
6
|
-
* show change JSON (peripheral-commands capability, r21):
|
|
12
|
+
* show change JSON (peripheral-commands capability, r21): predecessor field set and
|
|
7
13
|
* gate structure (clean-tree / on-bound-branch / stage-complete /
|
|
8
|
-
* specs-landed / tasks-done / validate). Object keys are inserted in
|
|
14
|
+
* specs-landed / tasks-done / validate). Object keys are inserted in predecessor's
|
|
9
15
|
* order; consumers compare structurally. Effects flow through the injected
|
|
10
16
|
* FsIo and GitLike ports — this module stays pure.
|
|
11
17
|
*/
|
|
12
18
|
import { validateAllSpecs } from '../validation/validate.ts';
|
|
13
|
-
import { countTasks, firstH1, stageFor, type ChangeFsIo } from './collect.ts';
|
|
14
19
|
|
|
15
20
|
export type ShowFsIo = ChangeFsIo;
|
|
16
21
|
|
|
@@ -45,7 +50,7 @@ export function showChangeJson(
|
|
|
45
50
|
? countTasks(io.readText(`${dir}/tasks.md`))
|
|
46
51
|
: { completed: 0, total: 0 };
|
|
47
52
|
|
|
48
|
-
//
|
|
53
|
+
// predecessor parity: show works (gracefully degraded gates) outside a git repo.
|
|
49
54
|
let cleanTree = false;
|
|
50
55
|
let current = null;
|
|
51
56
|
try {
|
|
@@ -57,16 +62,11 @@ export function showChangeJson(
|
|
|
57
62
|
const onBoundBranch = binding !== null && current === binding.branch;
|
|
58
63
|
|
|
59
64
|
// specs landing: bound changes must have touched llmanspec/specs/ since base
|
|
60
|
-
let
|
|
65
|
+
let landedOnBranch = false;
|
|
61
66
|
if (binding !== null) {
|
|
62
|
-
|
|
63
|
-
git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
|
|
64
|
-
specsLanded = touched.includes('llmanspec/specs/');
|
|
67
|
+
landedOnBranch = specsLandedOf(git, binding);
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
const fmMatch = proposal.match(/^---\n([\s\S]*?)\n---/u);
|
|
68
|
-
const declaredNeeds = fmMatch?.[1]?.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1];
|
|
69
|
-
const needsSpecsChange = declaredNeeds !== undefined ? declaredNeeds === 'true' : true;
|
|
69
|
+
const needsSpecsChange = readNeedsSpecsChange(extractFrontmatter(proposal));
|
|
70
70
|
|
|
71
71
|
const tasksDone = total > 0 && completed >= total;
|
|
72
72
|
const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, io), io);
|
|
@@ -77,7 +77,7 @@ export function showChangeJson(
|
|
|
77
77
|
: !hasTasks
|
|
78
78
|
? 'add tasks.md (current: designed → planned)'
|
|
79
79
|
: stage === 'planned'
|
|
80
|
-
? 'bind via `llman
|
|
80
|
+
? 'bind via `llman-sdd change start <id>` (planned → full)'
|
|
81
81
|
: '';
|
|
82
82
|
const tasksHint =
|
|
83
83
|
total > 0 && completed < total
|
|
@@ -93,7 +93,7 @@ export function showChangeJson(
|
|
|
93
93
|
{
|
|
94
94
|
name: 'on-bound-branch',
|
|
95
95
|
pass: onBoundBranch,
|
|
96
|
-
hint: onBoundBranch ? '' : 'change is not attached; run `llman
|
|
96
|
+
hint: onBoundBranch ? '' : 'change is not attached; run `llman-sdd change start <id>`',
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
name: 'stage-complete',
|
|
@@ -102,11 +102,11 @@ export function showChangeJson(
|
|
|
102
102
|
},
|
|
103
103
|
{
|
|
104
104
|
name: 'specs-landed',
|
|
105
|
-
pass:
|
|
105
|
+
pass: landedOnBranch || !needsSpecsChange,
|
|
106
106
|
hint:
|
|
107
|
-
|
|
107
|
+
landedOnBranch || !needsSpecsChange
|
|
108
108
|
? ''
|
|
109
|
-
: 'edit live specs on the bound branch and commit (or needs_specs_change
|
|
109
|
+
: 'edit live specs on the bound branch and commit (or set needs_specs_change to false)',
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
name: 'tasks-done',
|
|
@@ -116,7 +116,7 @@ export function showChangeJson(
|
|
|
116
116
|
{
|
|
117
117
|
name: 'validate',
|
|
118
118
|
pass: validateOk,
|
|
119
|
-
hint: validateOk ? '' : 'fix issues reported by `llman
|
|
119
|
+
hint: validateOk ? '' : 'fix issues reported by `llman-sdd validate <id> --strict`',
|
|
120
120
|
},
|
|
121
121
|
];
|
|
122
122
|
|
|
@@ -127,11 +127,9 @@ export function showChangeJson(
|
|
|
127
127
|
stage,
|
|
128
128
|
artifacts,
|
|
129
129
|
readyToImplement: gateChecks.every((g) => g.pass),
|
|
130
|
-
specsLanded,
|
|
130
|
+
specsLanded: landedOnBranch,
|
|
131
131
|
needsSpecsChange,
|
|
132
132
|
attached: binding !== null,
|
|
133
|
-
deltaCount: 0,
|
|
134
|
-
deltas: [] as string[],
|
|
135
133
|
gateChecks,
|
|
136
134
|
matchedViaPrefix: opts.matchedViaPrefix === true,
|
|
137
135
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { officialKeywords, officialKeywordsOrEn } from '../spec/keywords.ts';
|
|
2
|
+
import { localeToGherkinLang, parseCapability } from '../spec/parser.ts';
|
|
3
|
+
import { buildReqRegistry } from '../spec/reqRegistry.ts';
|
|
2
4
|
/**
|
|
3
5
|
* Spec authoring helpers (peripheral-commands capability, r22):
|
|
4
6
|
* skeleton generation + global next req id. Pure, IO injected.
|
|
@@ -14,31 +16,40 @@ export interface SpecHelperIo {
|
|
|
14
16
|
listDir(path: string): string[];
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
interface ParsedEntry {
|
|
20
|
+
fileName: string;
|
|
21
|
+
doc: ReturnType<typeof parseCapability>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function collectSpecEntries(io: SpecHelperIo, specsDir: string): ParsedEntry[] {
|
|
25
|
+
const entries: ParsedEntry[] = [];
|
|
23
26
|
const walk = (dir: string): void => {
|
|
24
27
|
if (!io.exists(dir)) return;
|
|
25
28
|
for (const name of io.listDir(dir)) {
|
|
26
29
|
const full = `${dir}/${name}`;
|
|
27
30
|
if (name.endsWith('.feature')) {
|
|
28
|
-
|
|
29
|
-
for (const scenario of doc.scenarios) {
|
|
30
|
-
if (scenario.classification !== 'human') continue;
|
|
31
|
-
for (const reqId of scenario.reqIds) {
|
|
32
|
-
const n = Math.trunc(Number(reqId.replace(/^r/u, '')));
|
|
33
|
-
if (Number.isFinite(n)) used.add(n);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
31
|
+
entries.push({ fileName: full, doc: parseCapability(io.readText(full), full) });
|
|
36
32
|
} else if (io.isDirectory(full)) {
|
|
37
33
|
walk(full);
|
|
38
34
|
}
|
|
39
35
|
}
|
|
40
36
|
};
|
|
41
37
|
walk(specsDir);
|
|
38
|
+
return entries;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* predecessor parity (`req_registry.rs::next_req_id_from_index`): smallest free
|
|
43
|
+
* rN over the requirement handles (`@req` on `规则:` headers) only. The id set
|
|
44
|
+
* comes from the global req registry fed with the parsed specs.
|
|
45
|
+
*/
|
|
46
|
+
export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
47
|
+
const entries = collectSpecEntries(io, specsDir);
|
|
48
|
+
const used = new Set(
|
|
49
|
+
[...buildReqRegistry(entries).byId.keys()].map((reqId) =>
|
|
50
|
+
Math.trunc(Number(reqId.replace(/^r/u, ''))),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
42
53
|
let n = 1;
|
|
43
54
|
while (used.has(n)) n += 1;
|
|
44
55
|
return `r${n}`;
|
|
@@ -46,15 +57,28 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
|
46
57
|
|
|
47
58
|
export function skeletonContent(capability: string, reqId: string, locale: string): string {
|
|
48
59
|
const zh = localeFallbacks(locale)[0] === 'zh-Hans';
|
|
60
|
+
// r7: the `# language:` header derives from the locale mapping — never a
|
|
61
|
+
// second hardcoded caliber. Table-less locales fall back to en wholesale
|
|
62
|
+
// (header + keywords same-source) so the skeleton stays parseable.
|
|
63
|
+
const mapped = localeToGherkinLang(locale);
|
|
64
|
+
const language = officialKeywords(mapped) !== null ? mapped : 'en';
|
|
65
|
+
const kw = officialKeywordsOrEn(language);
|
|
49
66
|
const header = zh
|
|
50
|
-
? `# language:
|
|
51
|
-
: `# language:
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
67
|
+
? `# language: ${language}\n# capability: ${capability}\n# purpose: TODO: 一句话描述该能力与其目的。\n# scope: llmanspec/`
|
|
68
|
+
: `# language: ${language}\n# capability: ${capability}\n# purpose: TODO: Describe this capability and its purpose.\n# scope: llmanspec/`;
|
|
69
|
+
const ruleDesc = zh ? 'TODO: 需求描述(自由文本)。' : 'TODO: requirement statement (free text).';
|
|
70
|
+
// Skeleton placeholder prose is not vocabulary — the zh/en copy stays; keywords do not.
|
|
71
|
+
const steps = zh
|
|
72
|
+
? ` ${kw.given} TODO 前置\n ${kw.when} TODO 动作\n ${kw.thenText} TODO 断言`
|
|
73
|
+
: ` ${kw.given} TODO precondition\n ${kw.when} TODO action\n ${kw.thenText} TODO assertion`;
|
|
74
|
+
// native v2 skeleton: a rule block (with @req handle) + one nested example
|
|
75
|
+
return (
|
|
76
|
+
`${header}\n\n${kw.feature}: ${capability}\n\n @req:${reqId}\n ${kw.rule}: TODO-rule\n` +
|
|
77
|
+
` ${ruleDesc}\n\n ${kw.scenario}: TODO-acceptance\n${steps}\n`
|
|
78
|
+
);
|
|
55
79
|
}
|
|
56
80
|
|
|
57
|
-
/** `spec skeleton <cap>`: write llmanspec/specs/<cap>.feature
|
|
81
|
+
/** `spec skeleton <cap>`: write llmanspec/specs/<cap>.feature (no repo-root src/). */
|
|
58
82
|
export function scaffoldSpec(
|
|
59
83
|
io: SpecHelperIo,
|
|
60
84
|
specsDir: string,
|
|
@@ -65,7 +89,6 @@ export function scaffoldSpec(
|
|
|
65
89
|
const reqId = nextReqId(io, specsDir);
|
|
66
90
|
const path = `${specsDir}/${capability}.feature`;
|
|
67
91
|
if (!opts.force && io.exists(path)) throw new Error(`spec already exists: ${path}`);
|
|
68
|
-
io.mkdirp('src/');
|
|
69
92
|
io.writeText(path, skeletonContent(capability, reqId, locale));
|
|
70
93
|
return path;
|
|
71
94
|
}
|