@llman-sdd/core 0.3.0 → 0.5.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.
- 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 +67 -59
- 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 +42 -21
- package/src/report/specs.ts +23 -25
- package/src/review/review.ts +45 -30
- package/src/spec/authoring.ts +91 -63
- package/src/spec/ir.ts +43 -15
- package/src/spec/migrateNative.ts +167 -0
- package/src/spec/parser.ts +73 -77
- package/src/spec/reqRegistry.ts +8 -9
- 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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { parseDeps } from './deps.ts';
|
|
2
|
+
import { collectActiveNodes, collectArchivedNodes, collectNodes, proposalFor } from './nodes.ts';
|
|
3
|
+
import {
|
|
4
|
+
GraphError,
|
|
5
|
+
type DepEdge,
|
|
6
|
+
type GraphFsIo,
|
|
7
|
+
type GraphNode,
|
|
8
|
+
type ScopeKind,
|
|
9
|
+
} from './types.ts';
|
|
10
|
+
|
|
11
|
+
interface RelationMaps {
|
|
12
|
+
depends: Map<string, string[]>;
|
|
13
|
+
reverse: Map<string, string[]>;
|
|
14
|
+
nodeSet: Set<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildMaps(io: GraphFsIo, root: string, nodes: GraphNode[]): RelationMaps {
|
|
18
|
+
const depends = new Map<string, string[]>();
|
|
19
|
+
const reverse = new Map<string, string[]>();
|
|
20
|
+
const nodeSet = new Set<string>();
|
|
21
|
+
for (const node of nodes) {
|
|
22
|
+
nodeSet.add(node.id);
|
|
23
|
+
if (!node.present) continue;
|
|
24
|
+
for (const dep of parseDeps(proposalFor(io, root, node))) {
|
|
25
|
+
depends.set(node.id, [...(depends.get(node.id) ?? []), dep]);
|
|
26
|
+
reverse.set(dep, [...(reverse.get(dep) ?? []), node.id]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { depends, reverse, nodeSet };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildDefaultNodes(
|
|
33
|
+
io: GraphFsIo,
|
|
34
|
+
root: string,
|
|
35
|
+
kinds: ScopeKind[],
|
|
36
|
+
depth: number,
|
|
37
|
+
maxScanDepth?: number,
|
|
38
|
+
): GraphNode[] {
|
|
39
|
+
const nodes = collectNodes(io, root, kinds, maxScanDepth);
|
|
40
|
+
// depth 0 = 仅 scope 内节点(human-readable graph):不拉取任何依赖 target。
|
|
41
|
+
if (depth <= 0) return nodes;
|
|
42
|
+
|
|
43
|
+
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
44
|
+
const all = collectNodes(io, root, ['active', 'archived'], maxScanDepth);
|
|
45
|
+
const allMap = new Map(all.map((n) => [n.id, n]));
|
|
46
|
+
|
|
47
|
+
// BFS 沿 depends_on 从 scope 根展开 depth 层(depth 1 即现状的一层依赖拉取;
|
|
48
|
+
// N >= 2 递归展开)。拉入的依赖 target 若不在 scope 作为节点保留
|
|
49
|
+
// (present/archived 标记由 allMap 已知节点提供,未知 id 记为 phantom)。
|
|
50
|
+
let frontier = nodes.filter((n) => n.present).map((n) => n.id);
|
|
51
|
+
for (let level = 0; level < depth && frontier.length > 0; level++) {
|
|
52
|
+
const next: string[] = [];
|
|
53
|
+
for (const id of frontier) {
|
|
54
|
+
const node = allMap.get(id);
|
|
55
|
+
if (node === undefined || !node.present) continue;
|
|
56
|
+
for (const dep of parseDeps(proposalFor(io, root, node))) {
|
|
57
|
+
if (nodeIds.has(dep)) continue;
|
|
58
|
+
nodeIds.add(dep);
|
|
59
|
+
next.push(dep);
|
|
60
|
+
const existing = allMap.get(dep);
|
|
61
|
+
if (existing !== undefined) nodes.push(existing);
|
|
62
|
+
else nodes.push({ id: dep, archived: false, present: false });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
frontier = next;
|
|
66
|
+
}
|
|
67
|
+
return nodes;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function collectEdges(io: GraphFsIo, root: string, nodes: GraphNode[]): DepEdge[] {
|
|
71
|
+
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
72
|
+
const edges: DepEdge[] = [];
|
|
73
|
+
for (const node of nodes) {
|
|
74
|
+
if (!node.present) continue;
|
|
75
|
+
for (const dep of parseDeps(proposalFor(io, root, node))) {
|
|
76
|
+
if (nodeIds.has(dep)) edges.push({ from: node.id, to: dep });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return edges;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function connectedComponents(nodeIds: string[], edges: DepEdge[]): string[][] {
|
|
83
|
+
if (nodeIds.length === 0) return [];
|
|
84
|
+
const parent = new Map<string, string>(nodeIds.map((id) => [id, id]));
|
|
85
|
+
const find = (x: string): string => {
|
|
86
|
+
let root = parent.get(x) ?? x;
|
|
87
|
+
while (parent.get(root) !== root) root = parent.get(root) ?? root;
|
|
88
|
+
return root;
|
|
89
|
+
};
|
|
90
|
+
const union = (a: string, b: string): void => {
|
|
91
|
+
const ra = find(a);
|
|
92
|
+
const rb = find(b);
|
|
93
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
94
|
+
};
|
|
95
|
+
for (const edge of edges) union(edge.from, edge.to);
|
|
96
|
+
const groups = new Map<string, string[]>();
|
|
97
|
+
for (const id of nodeIds) {
|
|
98
|
+
const root = find(id);
|
|
99
|
+
groups.set(root, [...(groups.get(root) ?? []), id]);
|
|
100
|
+
}
|
|
101
|
+
return [...groups.values()].toSorted((a, b) => b.length - a.length);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Seed neighborhood: bidirectional BFS (depends_on + reverse) over both sets. */
|
|
105
|
+
export function buildSeedNeighborhood(
|
|
106
|
+
io: GraphFsIo,
|
|
107
|
+
root: string,
|
|
108
|
+
seedId: string,
|
|
109
|
+
maxDepth: number,
|
|
110
|
+
maxScanDepth?: number,
|
|
111
|
+
): GraphNode[] {
|
|
112
|
+
const resolved = resolveSeedId(io, root, seedId, maxScanDepth);
|
|
113
|
+
const allNodes = collectNodes(io, root, ['active', 'archived'], maxScanDepth);
|
|
114
|
+
const nodeMap = new Map(allNodes.map((n) => [n.id, n]));
|
|
115
|
+
if (!nodeMap.has(resolved)) {
|
|
116
|
+
const prefix = resolved.split('-')[0] ?? '';
|
|
117
|
+
const suggestions = [...nodeMap.keys()].filter((k) => k.startsWith(prefix));
|
|
118
|
+
const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
|
119
|
+
throw new GraphError(`change '${seedId}' not found.${hint}`);
|
|
120
|
+
}
|
|
121
|
+
const maps = buildMaps(io, root, allNodes);
|
|
122
|
+
const visited = new Set<string>([resolved]);
|
|
123
|
+
const queue: [string, number][] = [[resolved, 0]];
|
|
124
|
+
while (queue.length > 0) {
|
|
125
|
+
const [current, depth] = queue.shift() as [string, number];
|
|
126
|
+
if (depth >= maxDepth) continue;
|
|
127
|
+
const neighbors = [...(maps.depends.get(current) ?? []), ...(maps.reverse.get(current) ?? [])];
|
|
128
|
+
for (const neighbor of neighbors) {
|
|
129
|
+
if (maps.nodeSet.has(neighbor) && !visited.has(neighbor)) {
|
|
130
|
+
visited.add(neighbor);
|
|
131
|
+
queue.push([neighbor, depth + 1]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const result = [...visited]
|
|
136
|
+
.filter((id) => nodeMap.has(id))
|
|
137
|
+
.map((id) => nodeMap.get(id) as GraphNode);
|
|
138
|
+
result.sort((a, b) => a.id.localeCompare(b.id));
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveSeedId(io: GraphFsIo, root: string, seedId: string, maxScanDepth?: number): string {
|
|
143
|
+
const active = collectActiveNodes(io, root, maxScanDepth);
|
|
144
|
+
const archived = collectArchivedNodes(io, root);
|
|
145
|
+
const all = [...active.map((n) => n.id), ...archived.map((n) => n.id)];
|
|
146
|
+
if (all.includes(seedId)) return seedId;
|
|
147
|
+
const viaPrefix = all.filter((id) => id.startsWith(seedId));
|
|
148
|
+
if (viaPrefix.length === 1) return viaPrefix[0] ?? seedId;
|
|
149
|
+
const viaArchivedPrefix = archived.map((n) => n.id).filter((id) => id.startsWith(seedId));
|
|
150
|
+
if (viaArchivedPrefix.length === 1) return viaArchivedPrefix[0] ?? seedId;
|
|
151
|
+
return seedId;
|
|
152
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Extract depends_on entries (flow + block styles, r30). */
|
|
2
|
+
import { extractFrontmatter } from '../../change/frontmatter.ts';
|
|
3
|
+
|
|
4
|
+
export function parseDeps(proposal: string): string[] {
|
|
5
|
+
const fm = extractFrontmatter(proposal);
|
|
6
|
+
const deps: string[] = [];
|
|
7
|
+
if (!fm) return deps;
|
|
8
|
+
let inDeps = false;
|
|
9
|
+
for (const line of fm.split('\n')) {
|
|
10
|
+
const flow = line.match(/^depends_on:\s*\[([^\]]*)\]\s*$/u);
|
|
11
|
+
if (flow) {
|
|
12
|
+
for (const item of flow[1]?.split(',') ?? []) {
|
|
13
|
+
const dep = item.trim().replaceAll(/^['"]|['"]$/gu, '');
|
|
14
|
+
if (dep !== '' && !/^[0-9]+$/u.test(dep)) deps.push(dep);
|
|
15
|
+
}
|
|
16
|
+
inDeps = false;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (/^depends_on:\s*$/u.test(line)) {
|
|
20
|
+
inDeps = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (inDeps) {
|
|
24
|
+
const m = line.match(/^\s*-\s+(\S+)/u);
|
|
25
|
+
if (m?.[1] && !/^[0-9]+$/u.test(m[1])) deps.push(m[1]);
|
|
26
|
+
else if (line.trim() !== '') inDeps = false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return deps;
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { buildDefaultNodes, buildSeedNeighborhood, collectEdges } from './analysis.ts';
|
|
2
|
+
import {
|
|
3
|
+
GraphError,
|
|
4
|
+
type GraphDataIr,
|
|
5
|
+
type GraphEdgeIr,
|
|
6
|
+
type GraphFsIo,
|
|
7
|
+
type GraphNode,
|
|
8
|
+
type GraphNodeIr,
|
|
9
|
+
type GraphOptions,
|
|
10
|
+
type ScopeKind,
|
|
11
|
+
} from './types.ts';
|
|
12
|
+
|
|
13
|
+
export function parseScope(scope: string): ScopeKind[] {
|
|
14
|
+
if (scope === 'all') return ['active', 'archived'];
|
|
15
|
+
const kinds: ScopeKind[] = [];
|
|
16
|
+
for (const part of scope.split(',')) {
|
|
17
|
+
const t = part.trim();
|
|
18
|
+
if (t === 'active') kinds.push('active');
|
|
19
|
+
else if (t === 'archived') kinds.push('archived');
|
|
20
|
+
else {
|
|
21
|
+
throw new GraphError(
|
|
22
|
+
`Unknown scope: '${t}'. Supported: active, archived, all (or comma-separated like active,archived)`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (kinds.length === 0) throw new GraphError('Scope cannot be empty');
|
|
27
|
+
return kinds;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Structured graph IR (cli-output-cleanup): same traversal graphMermaid
|
|
31
|
+
* renders, exposed for `graph --output json|toon`. Empty-node cases yield an
|
|
32
|
+
* empty nodes array (the mermaid placeholder line is a render concern). */
|
|
33
|
+
export function graphData(io: GraphFsIo, root: string, opts: GraphOptions = {}): GraphDataIr {
|
|
34
|
+
const scope = opts.scope ?? 'active';
|
|
35
|
+
const kinds = parseScope(scope);
|
|
36
|
+
|
|
37
|
+
let nodes: GraphNode[];
|
|
38
|
+
if (opts.seed !== undefined) {
|
|
39
|
+
nodes = buildSeedNeighborhood(io, root, opts.seed, opts.depth ?? 1, opts.maxScanDepth);
|
|
40
|
+
} else {
|
|
41
|
+
nodes = buildDefaultNodes(io, root, kinds, opts.depth ?? 1, opts.maxScanDepth);
|
|
42
|
+
}
|
|
43
|
+
const irNodes: GraphNodeIr[] = nodes.map((n) => ({
|
|
44
|
+
id: n.id,
|
|
45
|
+
archived: n.archived,
|
|
46
|
+
present: n.present,
|
|
47
|
+
}));
|
|
48
|
+
const irEdges: GraphEdgeIr[] =
|
|
49
|
+
nodes.length === 0
|
|
50
|
+
? []
|
|
51
|
+
: collectEdges(io, root, nodes).map((e) => ({ from: e.from, to: e.to }));
|
|
52
|
+
return { scope, nodes: irNodes, edges: irEdges };
|
|
53
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { frozenCardIdOf, isFrozenCard } from '../../archive/frozenCard.ts';
|
|
2
|
+
import { CHANGES_DIR } from '../../change/lifecycle.ts';
|
|
3
|
+
import type { GraphFsIo, GraphNode, ScopeKind } from './types.ts';
|
|
4
|
+
|
|
5
|
+
function extractArchivedId(name: string): string | null {
|
|
6
|
+
const m = name.match(/^\d{4}-\d{2}-\d{2}-(.+)$/u);
|
|
7
|
+
return m ? (m[1] ?? null) : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Active nodes: dirs (recursively, depth-limited) with a proposal.md (r58). */
|
|
11
|
+
export function collectActiveNodes(
|
|
12
|
+
io: GraphFsIo,
|
|
13
|
+
root: string,
|
|
14
|
+
maxScanDepth?: number,
|
|
15
|
+
): GraphNode[] {
|
|
16
|
+
const out: GraphNode[] = [];
|
|
17
|
+
const changesDir = `${root}/${CHANGES_DIR}`;
|
|
18
|
+
if (!io.exists(changesDir) || !io.isDirectory(changesDir)) return out;
|
|
19
|
+
const maxDepth = maxScanDepth ?? 8;
|
|
20
|
+
const visit = (dir: string, depth: number): void => {
|
|
21
|
+
if (depth > maxDepth) return;
|
|
22
|
+
for (const name of io.listDir(dir).toSorted()) {
|
|
23
|
+
if (name === 'archive' || name.startsWith('.')) continue;
|
|
24
|
+
const child = `${dir}/${name}`;
|
|
25
|
+
if (!io.isDirectory(child)) continue;
|
|
26
|
+
if (io.exists(`${child}/proposal.md`)) {
|
|
27
|
+
out.push({ id: name, archived: false, present: true });
|
|
28
|
+
} else if (depth < maxDepth) {
|
|
29
|
+
visit(child, depth + 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
visit(changesDir, 1);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Archived nodes: dated dirs (as before) plus flat frozen cards
|
|
39
|
+
* `<YYYY-MM-DD>-<id>.yaml` (r54 extension — cards are archived changes whose
|
|
40
|
+
* body lives in the cold backup; the card filename carries the id).
|
|
41
|
+
*/
|
|
42
|
+
export function collectArchivedNodes(io: GraphFsIo, root: string): GraphNode[] {
|
|
43
|
+
const archiveDir = `${root}/${CHANGES_DIR}/archive`;
|
|
44
|
+
if (!io.exists(archiveDir) || !io.isDirectory(archiveDir)) return [];
|
|
45
|
+
const out: GraphNode[] = [];
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
for (const name of io.listDir(archiveDir)) {
|
|
48
|
+
const child = `${archiveDir}/${name}`;
|
|
49
|
+
if (io.isDirectory(child)) {
|
|
50
|
+
const id = extractArchivedId(name);
|
|
51
|
+
if (id !== null && !seen.has(id)) {
|
|
52
|
+
seen.add(id);
|
|
53
|
+
out.push({ id, archived: true, present: true });
|
|
54
|
+
}
|
|
55
|
+
} else if (isFrozenCard(name)) {
|
|
56
|
+
const id = frozenCardIdOf(name);
|
|
57
|
+
if (id !== null && !seen.has(id)) {
|
|
58
|
+
seen.add(id);
|
|
59
|
+
out.push({ id, archived: true, present: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function collectNodes(
|
|
67
|
+
io: GraphFsIo,
|
|
68
|
+
root: string,
|
|
69
|
+
kinds: ScopeKind[],
|
|
70
|
+
maxScanDepth?: number,
|
|
71
|
+
): GraphNode[] {
|
|
72
|
+
const combined: GraphNode[] = [];
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
for (const kind of kinds) {
|
|
75
|
+
const nodes =
|
|
76
|
+
kind === 'active'
|
|
77
|
+
? collectActiveNodes(io, root, maxScanDepth)
|
|
78
|
+
: collectArchivedNodes(io, root);
|
|
79
|
+
for (const node of nodes) {
|
|
80
|
+
if (!seen.has(node.id)) {
|
|
81
|
+
seen.add(node.id);
|
|
82
|
+
combined.push(node);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return combined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function proposalFor(io: GraphFsIo, root: string, node: GraphNode): string {
|
|
90
|
+
if (node.archived) {
|
|
91
|
+
const archiveDir = `${root}/${CHANGES_DIR}/archive`;
|
|
92
|
+
// latest matching strategy: pick the newest date-prefixed dir
|
|
93
|
+
let best = '';
|
|
94
|
+
let bestDate = '';
|
|
95
|
+
for (const name of io.listDir(archiveDir)) {
|
|
96
|
+
const id = isFrozenCard(name) ? frozenCardIdOf(name) : extractArchivedId(name);
|
|
97
|
+
if (id !== node.id) continue;
|
|
98
|
+
const date = name.slice(0, 10);
|
|
99
|
+
if (best === '' || date > bestDate) {
|
|
100
|
+
best = name;
|
|
101
|
+
bestDate = date;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const p = `${archiveDir}/${best}/proposal.md`;
|
|
105
|
+
if (io.exists(p)) return io.readText(p);
|
|
106
|
+
// Frozen card fallback (r54): dir gone, body in cold backup — read the
|
|
107
|
+
// card (frontmatter incl. depends_on) so dependency edges survive freeze.
|
|
108
|
+
const card = isFrozenCard(best) ? `${archiveDir}/${best}` : `${archiveDir}/${best}.yaml`;
|
|
109
|
+
if (io.exists(card)) return io.readText(card);
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
const changesDir = `${root}/${CHANGES_DIR}`;
|
|
113
|
+
const found: string[] = [];
|
|
114
|
+
const visit = (dir: string): void => {
|
|
115
|
+
for (const name of io.listDir(dir).toSorted()) {
|
|
116
|
+
if (name === 'archive' || name.startsWith('.')) continue;
|
|
117
|
+
const child = `${dir}/${name}`;
|
|
118
|
+
if (!io.isDirectory(child)) continue;
|
|
119
|
+
if (io.exists(`${child}/proposal.md`)) {
|
|
120
|
+
// leaf change dir — do not descend further
|
|
121
|
+
if (name === node.id) found.push(`${child}/proposal.md`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
visit(child);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
visit(changesDir);
|
|
128
|
+
if (found.length === 0) return '';
|
|
129
|
+
return io.readText(found[0] ?? '');
|
|
130
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildDefaultNodes,
|
|
3
|
+
buildSeedNeighborhood,
|
|
4
|
+
collectEdges,
|
|
5
|
+
connectedComponents,
|
|
6
|
+
} from './analysis.ts';
|
|
7
|
+
import { parseScope } from './graphData.ts';
|
|
8
|
+
import type { DepEdge, GraphFsIo, GraphNode, GraphOptions } from './types.ts';
|
|
9
|
+
|
|
10
|
+
function componentLabel(nodes: GraphNode[], ids: Set<string>): string {
|
|
11
|
+
const comp = nodes.filter((n) => ids.has(n.id));
|
|
12
|
+
const allActive = comp.every((n) => !n.archived && n.present);
|
|
13
|
+
const allArchived = comp.every((n) => n.archived);
|
|
14
|
+
if (allActive) return 'Active';
|
|
15
|
+
if (allArchived) return 'Done';
|
|
16
|
+
return 'Mixed';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sanitize = (id: string): string => id.replaceAll('-', '_');
|
|
20
|
+
|
|
21
|
+
export function graphMermaid(io: GraphFsIo, root: string, opts: GraphOptions = {}): string[] {
|
|
22
|
+
const scope = opts.scope ?? 'active';
|
|
23
|
+
const kinds = parseScope(scope);
|
|
24
|
+
|
|
25
|
+
let nodes: GraphNode[];
|
|
26
|
+
if (opts.seed !== undefined) {
|
|
27
|
+
nodes = buildSeedNeighborhood(io, root, opts.seed, opts.depth ?? 1, opts.maxScanDepth);
|
|
28
|
+
} else {
|
|
29
|
+
nodes = buildDefaultNodes(io, root, kinds, opts.depth ?? 1, opts.maxScanDepth);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (nodes.length === 0) {
|
|
33
|
+
return [
|
|
34
|
+
'flowchart TD',
|
|
35
|
+
opts.seed !== undefined
|
|
36
|
+
? ` empty["Change '${opts.seed}' not found or has no relationships"]`
|
|
37
|
+
: ` empty["No changes in scope '${scope}'"]`,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const edges = collectEdges(io, root, nodes);
|
|
42
|
+
const nodeIds = nodes.map((n) => n.id);
|
|
43
|
+
const components = connectedComponents(nodeIds, edges);
|
|
44
|
+
const hasArchived = nodes.some((n) => n.archived);
|
|
45
|
+
const hasMissing = nodes.some((n) => !n.present);
|
|
46
|
+
|
|
47
|
+
const lines = ['flowchart TD'];
|
|
48
|
+
if (components.length > 1) {
|
|
49
|
+
components.forEach((comp, idx) => {
|
|
50
|
+
const ids = new Set(comp);
|
|
51
|
+
const label = componentLabel(nodes, ids);
|
|
52
|
+
lines.push(` subgraph sg${idx + 1}["${label}"]`);
|
|
53
|
+
renderNodesAndEdges(nodes, edges, ids, lines, true);
|
|
54
|
+
lines.push(' end');
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
renderNodesAndEdges(nodes, edges, new Set(nodeIds), lines, false);
|
|
58
|
+
}
|
|
59
|
+
if (hasArchived) lines.push(' classDef archived fill:#d4edda,stroke:#28a745,color:#333');
|
|
60
|
+
if (hasMissing) lines.push(' classDef missing fill:#f8d7da,stroke:#dc3545,color:#333');
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderNodesAndEdges(
|
|
65
|
+
nodes: GraphNode[],
|
|
66
|
+
edges: DepEdge[],
|
|
67
|
+
ids: Set<string>,
|
|
68
|
+
lines: string[],
|
|
69
|
+
indented: boolean,
|
|
70
|
+
): void {
|
|
71
|
+
const pad = indented ? ' ' : ' ';
|
|
72
|
+
for (const node of nodes) {
|
|
73
|
+
if (!ids.has(node.id)) continue;
|
|
74
|
+
if (!node.present) lines.push(`${pad}${sanitize(node.id)}["${node.id} ⚠ missing"]:::missing`);
|
|
75
|
+
else if (node.archived)
|
|
76
|
+
lines.push(`${pad}${sanitize(node.id)}["${node.id} ✓ done"]:::archived`);
|
|
77
|
+
else lines.push(`${pad}${sanitize(node.id)}["${node.id}"]`);
|
|
78
|
+
}
|
|
79
|
+
for (const edge of edges) {
|
|
80
|
+
if (!ids.has(edge.from)) continue;
|
|
81
|
+
lines.push(`${pad}${sanitize(edge.from)} -->|depends on| ${sanitize(edge.to)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface GraphFsIo {
|
|
2
|
+
exists(path: string): boolean;
|
|
3
|
+
readText(path: string): string;
|
|
4
|
+
listDir(path: string): string[];
|
|
5
|
+
isDirectory(path: string): boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface GraphNode {
|
|
9
|
+
id: string;
|
|
10
|
+
archived: boolean;
|
|
11
|
+
/** true = directory on disk; false = phantom (frozen/removed dependency). */
|
|
12
|
+
present: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DepEdge {
|
|
16
|
+
from: string;
|
|
17
|
+
to: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class GraphError extends Error {}
|
|
21
|
+
|
|
22
|
+
export type ScopeKind = 'active' | 'archived';
|
|
23
|
+
|
|
24
|
+
export interface GraphOptions {
|
|
25
|
+
scope?: string;
|
|
26
|
+
depth?: number;
|
|
27
|
+
seed?: string;
|
|
28
|
+
/** r58: max recursion depth when scanning changes/ for proposals (default 8). */
|
|
29
|
+
maxScanDepth?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GraphNodeIr {
|
|
33
|
+
id: string;
|
|
34
|
+
archived: boolean;
|
|
35
|
+
present: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GraphEdgeIr {
|
|
39
|
+
from: string;
|
|
40
|
+
to: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GraphDataIr {
|
|
44
|
+
scope: string;
|
|
45
|
+
nodes: GraphNodeIr[];
|
|
46
|
+
edges: GraphEdgeIr[];
|
|
47
|
+
}
|