@llman-sdd/core 0.1.3 → 0.2.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 +1 -1
- package/src/archive/freeze.ts +14 -9
- package/src/archive/sevenzip.ts +34 -5
- package/src/change/id.ts +51 -31
- package/src/change/lifecycle.ts +178 -23
- package/src/change/nextId.ts +55 -0
- package/src/config/changeId.ts +87 -0
- package/src/config/surface.ts +59 -0
- package/src/git/spawnGit.ts +12 -0
- package/src/index.ts +53 -2
- package/src/report/collect.ts +30 -14
- package/src/report/graph.ts +339 -43
- package/src/report/show.ts +32 -13
- package/src/report/specHelpers.ts +13 -5
- package/src/review/review.ts +34 -15
- package/src/spec/authoring.ts +206 -0
- package/src/templates/embedded.ts +13 -12
- package/src/validation/changeCheck.ts +310 -0
- package/src/validation/staleness.ts +158 -0
- package/src/validation/validate.ts +117 -42
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `config` command surface (r37/r38): read-only overview rendering and
|
|
3
|
+
* non-interactive extra_skills management with comment-preserving writeback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseDocument } from 'yaml';
|
|
7
|
+
|
|
8
|
+
import { loadConfig } from './load.ts';
|
|
9
|
+
import { EXTRA_SKILLS } from './schema.ts';
|
|
10
|
+
|
|
11
|
+
/** Render the five-element overview (v1 parity wording). */
|
|
12
|
+
export function renderConfigOverview(source: string): string[] {
|
|
13
|
+
const config = loadConfig(source);
|
|
14
|
+
const enabled = (config.extra_skills ?? []).length;
|
|
15
|
+
const archive = config.archive;
|
|
16
|
+
const archiveConfigured =
|
|
17
|
+
archive !== null &&
|
|
18
|
+
archive !== undefined &&
|
|
19
|
+
(archive.strict_defer || archive.min_completion_ratio !== undefined);
|
|
20
|
+
return [
|
|
21
|
+
`schema: ${config.schema}`,
|
|
22
|
+
`locale: ${config.locale}`,
|
|
23
|
+
`extra_skills (enabled/total): ${enabled} / ${EXTRA_SKILLS.length}`,
|
|
24
|
+
`bdd: ${config.bdd ? 'on' : 'off'}`,
|
|
25
|
+
`archive: ${archiveConfigured ? 'configured' : 'default'}`,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ExtraSkillsError extends Error {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Add/remove extra_skills entries with full comment preservation: only the
|
|
33
|
+
* `extra_skills` list node is touched, everything else (including the
|
|
34
|
+
* `$schema` header comment) stays byte-identical.
|
|
35
|
+
*/
|
|
36
|
+
export function setExtraSkills(
|
|
37
|
+
source: string,
|
|
38
|
+
change: { set?: readonly string[]; unset?: readonly string[] },
|
|
39
|
+
): string {
|
|
40
|
+
for (const name of [...(change.set ?? []), ...(change.unset ?? [])]) {
|
|
41
|
+
if (!(EXTRA_SKILLS as readonly string[]).includes(name)) {
|
|
42
|
+
throw new ExtraSkillsError(`unknown extra skill: ${name}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const wanted = new Set<string>(loadConfig(source).extra_skills ?? []);
|
|
46
|
+
for (const name of change.set ?? []) wanted.add(name);
|
|
47
|
+
for (const name of change.unset ?? []) wanted.delete(name);
|
|
48
|
+
|
|
49
|
+
const doc = parseDocument(source);
|
|
50
|
+
const list = [...wanted].toSorted();
|
|
51
|
+
if (list.length > 0) doc.set('extra_skills', list);
|
|
52
|
+
else doc.delete('extra_skills');
|
|
53
|
+
return doc.toString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shape of `config skills --json` (v1 parity). */
|
|
57
|
+
export function skillsJson(source: string): { enabled: string[]; available: readonly string[] } {
|
|
58
|
+
return { enabled: [...(loadConfig(source).extra_skills ?? [])], available: EXTRA_SKILLS };
|
|
59
|
+
}
|
package/src/git/spawnGit.ts
CHANGED
|
@@ -79,3 +79,15 @@ export function defaultBranch(git: GitLike): string {
|
|
|
79
79
|
export function revParseHead(git: GitLike): string {
|
|
80
80
|
return git.run(['rev-parse', 'HEAD']);
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/** Count uncommitted files (v1 "dirty tree: N uncommitted files"). */
|
|
84
|
+
export function dirtyCount(git: GitLike): number {
|
|
85
|
+
const s = git.run(['status', '--porcelain']);
|
|
86
|
+
if (s === '') return 0;
|
|
87
|
+
return s.split('\n').filter((l) => l.trim() !== '').length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fork-point sha of `current` relative to `other` (v1 merge-base semantics). */
|
|
91
|
+
export function mergeBase(git: GitLike, current: string, other: string): string {
|
|
92
|
+
return git.runOpt(['merge-base', current, other]) ?? git.run(['rev-parse', current]);
|
|
93
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,18 @@ export {
|
|
|
33
33
|
type SddConfigInput,
|
|
34
34
|
} from './config/schema.ts';
|
|
35
35
|
export { ConfigValidationError, MAX_REPORTED_ISSUES, loadConfig } from './config/load.ts';
|
|
36
|
+
export {
|
|
37
|
+
ChangeIdError,
|
|
38
|
+
compileChangeIdPattern,
|
|
39
|
+
nextUniqueNumber,
|
|
40
|
+
renderChangeIdTemplate,
|
|
41
|
+
} from './config/changeId.ts';
|
|
42
|
+
export {
|
|
43
|
+
ExtraSkillsError,
|
|
44
|
+
renderConfigOverview,
|
|
45
|
+
setExtraSkills,
|
|
46
|
+
skillsJson,
|
|
47
|
+
} from './config/surface.ts';
|
|
36
48
|
|
|
37
49
|
export type {
|
|
38
50
|
CapabilityDoc,
|
|
@@ -48,6 +60,16 @@ export {
|
|
|
48
60
|
parseFeatureSource,
|
|
49
61
|
} from './spec/parser.ts';
|
|
50
62
|
export { buildReqRegistry, type ReqRegistry, type RegistryDuplicate } from './spec/reqRegistry.ts';
|
|
63
|
+
export {
|
|
64
|
+
addReq,
|
|
65
|
+
addScenario,
|
|
66
|
+
allReqIds,
|
|
67
|
+
AuthoringError,
|
|
68
|
+
planDedupe,
|
|
69
|
+
resolveReq,
|
|
70
|
+
type DedupePlanItem,
|
|
71
|
+
type ResolvedReq,
|
|
72
|
+
} from './spec/authoring.ts';
|
|
51
73
|
export type {
|
|
52
74
|
SpecEntry,
|
|
53
75
|
SpecIo,
|
|
@@ -56,7 +78,26 @@ export type {
|
|
|
56
78
|
ValidationLevel,
|
|
57
79
|
ValidationReport,
|
|
58
80
|
} from './validation/validate.ts';
|
|
59
|
-
export { validateAllSpecs, validateCapability } from './validation/validate.ts';
|
|
81
|
+
export { validateAllSpecs, validateCapability, applyStrict } from './validation/validate.ts';
|
|
82
|
+
export { splitVerb } from './config/changeId.ts';
|
|
83
|
+
export {
|
|
84
|
+
evaluateStaleness,
|
|
85
|
+
notApplicableStaleness,
|
|
86
|
+
type StalenessInfo,
|
|
87
|
+
} from './validation/staleness.ts';
|
|
88
|
+
export {
|
|
89
|
+
checkChangeDoc,
|
|
90
|
+
validateChange,
|
|
91
|
+
expandRunCommand,
|
|
92
|
+
hasPlaceholders,
|
|
93
|
+
STAGE_ORDER,
|
|
94
|
+
type ChangeCheckConfig,
|
|
95
|
+
type ChangeCheckInput,
|
|
96
|
+
type ChangeCheckResult,
|
|
97
|
+
type ChangeIssue,
|
|
98
|
+
type ChangeFsIoLite,
|
|
99
|
+
type StageGate,
|
|
100
|
+
} from './validation/changeCheck.ts';
|
|
60
101
|
export { discoverSpecs, type DiscoveryIo } from './validation/discover.ts';
|
|
61
102
|
export type { ChangeBinding } from './change/frontmatter.ts';
|
|
62
103
|
export { readBinding, writeBinding } from './change/frontmatter.ts';
|
|
@@ -70,6 +111,7 @@ export {
|
|
|
70
111
|
type GitLike,
|
|
71
112
|
} from './git/spawnGit.ts';
|
|
72
113
|
export { DRAFT_PROPOSAL_TEMPLATE, deriveChangeId, isLegalChangeId } from './change/id.ts';
|
|
114
|
+
export { extractUniqueNumber, harvestUniqueNumbers } from './change/nextId.ts';
|
|
73
115
|
export {
|
|
74
116
|
CHANGES_DIR,
|
|
75
117
|
LifecycleError,
|
|
@@ -80,6 +122,9 @@ export {
|
|
|
80
122
|
startChange,
|
|
81
123
|
type FinalizeResult,
|
|
82
124
|
type FsIo,
|
|
125
|
+
archiveChange,
|
|
126
|
+
archiveTaskGate,
|
|
127
|
+
changeDiffInfo,
|
|
83
128
|
} from './change/lifecycle.ts';
|
|
84
129
|
export { normalizeLocale, localeFallbacks } from './templates/locale.ts';
|
|
85
130
|
export {
|
|
@@ -144,7 +189,13 @@ export {
|
|
|
144
189
|
type SpecMorphology,
|
|
145
190
|
type SpecSummary,
|
|
146
191
|
} from './report/specs.ts';
|
|
147
|
-
export {
|
|
192
|
+
export {
|
|
193
|
+
SevenZipError,
|
|
194
|
+
embeddedWasmBinary,
|
|
195
|
+
makeWasmSevenZip,
|
|
196
|
+
resolveEmbeddedWasmB64,
|
|
197
|
+
type SevenZipPort,
|
|
198
|
+
} from './archive/sevenzip.ts';
|
|
148
199
|
export {
|
|
149
200
|
ARCHIVE_DIR_REL,
|
|
150
201
|
FREEZE_ARCHIVE_NAME,
|
package/src/report/collect.ts
CHANGED
|
@@ -37,8 +37,10 @@ export function stageFor(
|
|
|
37
37
|
hasTasks: boolean,
|
|
38
38
|
hasBinding: boolean,
|
|
39
39
|
): ChangeSummary['stage'] {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Monotonic v1 parity (r34): design.md gates designed, tasks.md only
|
|
41
|
+
// upgrades on top of design, binding only upgrades the complete set.
|
|
42
|
+
if (hasDesign && hasTasks && hasBinding) return 'full';
|
|
43
|
+
if (hasDesign && hasTasks) return 'planned';
|
|
42
44
|
if (hasDesign) return 'designed';
|
|
43
45
|
return 'draft';
|
|
44
46
|
}
|
|
@@ -64,17 +66,32 @@ export function firstH1(md: string): string {
|
|
|
64
66
|
return '';
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
export function collectChanges(
|
|
69
|
+
export function collectChanges(
|
|
70
|
+
io: ChangeFsIo,
|
|
71
|
+
root: string,
|
|
72
|
+
now: Date,
|
|
73
|
+
opts: { maxScanDepth?: number } = {},
|
|
74
|
+
): ChangeSummary[] {
|
|
68
75
|
const changesDir = `${root}/${CHANGES_DIR}`;
|
|
69
76
|
if (!io.exists(changesDir) || !io.isDirectory(changesDir)) return [];
|
|
77
|
+
const maxDepth = opts.maxScanDepth ?? 8;
|
|
70
78
|
const out: ChangeSummary[] = [];
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
// r58: recursive, depth-limited proposal discovery (v1 --max-scan-depth parity)
|
|
80
|
+
const visit = (dir: string, depth: number): void => {
|
|
81
|
+
if (depth > maxDepth) return;
|
|
82
|
+
for (const name of io.listDir(dir).toSorted()) {
|
|
83
|
+
if (name === 'archive' || name.startsWith('.')) continue;
|
|
84
|
+
const child = `${dir}/${name}`;
|
|
85
|
+
if (!io.isDirectory(child)) continue;
|
|
86
|
+
if (io.exists(`${child}/proposal.md`)) {
|
|
87
|
+
readChangeDir(child, name);
|
|
88
|
+
} else {
|
|
89
|
+
visit(child, depth + 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const readChangeDir = (dir: string, name: string): void => {
|
|
75
94
|
const proposal = `${dir}/proposal.md`;
|
|
76
|
-
if (!io.exists(proposal)) continue;
|
|
77
|
-
|
|
78
95
|
const hasDesign = io.exists(`${dir}/design.md`);
|
|
79
96
|
const hasTasks = io.exists(`${dir}/tasks.md`);
|
|
80
97
|
const hasBinding = readBinding(io.readText(proposal)) !== null;
|
|
@@ -101,7 +118,8 @@ export function collectChanges(io: ChangeFsIo, root: string, now: Date): ChangeS
|
|
|
101
118
|
lastModified,
|
|
102
119
|
idleDays,
|
|
103
120
|
});
|
|
104
|
-
}
|
|
121
|
+
};
|
|
122
|
+
visit(changesDir, 1);
|
|
105
123
|
// v1 lists newest-first
|
|
106
124
|
return out.toSorted((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
107
125
|
}
|
|
@@ -130,12 +148,10 @@ export function pad(s: string, width: number): string {
|
|
|
130
148
|
export function renderChangesList(changes: readonly ChangeSummary[], now: Date): string[] {
|
|
131
149
|
const lines = ['Active changes:'];
|
|
132
150
|
if (changes.length === 0) return lines;
|
|
133
|
-
const nameWidth = Math.max(...changes.map((c) => c.name.length))
|
|
134
|
-
const stageWidth = Math.max(...changes.map((c) => c.stage.length)) + 7;
|
|
135
|
-
const statusWidth = Math.max(...changes.map((c) => statusHuman(c).length)) + 5;
|
|
151
|
+
const nameWidth = Math.max(...changes.map((c) => c.name.length));
|
|
136
152
|
for (const c of changes) {
|
|
137
153
|
lines.push(
|
|
138
|
-
` ${pad(c.name, nameWidth)}${pad(c.stage,
|
|
154
|
+
` ${pad(c.name, nameWidth)} ${pad(c.stage, 10)} ${pad(statusHuman(c), 12)} ${relativeTime(
|
|
139
155
|
c.lastModified,
|
|
140
156
|
now,
|
|
141
157
|
)}${statusFor(c.totalTasks, c.completedTasks) === 'no-tasks' ? ` (idle ${c.idleDays}d)` : ''}`,
|
package/src/report/graph.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Change dependency graph (peripheral-commands capability, r21):
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Change dependency graph (peripheral-commands capability, r21/r30/r54):
|
|
3
|
+
* v1 `commands/graph.rs` observable contract — scope parsing, connected
|
|
4
|
+
* component subgraphs, conditional classDef, empty placeholder, bidirectional
|
|
5
|
+
* seed BFS, error paths. Pure: IO injected.
|
|
5
6
|
*/
|
|
7
|
+
|
|
6
8
|
import { CHANGES_DIR } from '../change/lifecycle.ts';
|
|
7
9
|
|
|
8
10
|
export interface GraphFsIo {
|
|
@@ -15,77 +17,371 @@ export interface GraphFsIo {
|
|
|
15
17
|
interface GraphNode {
|
|
16
18
|
id: string;
|
|
17
19
|
archived: boolean;
|
|
18
|
-
|
|
20
|
+
/** true = directory on disk; false = phantom (frozen/removed dependency). */
|
|
21
|
+
present: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface DepEdge {
|
|
25
|
+
from: string;
|
|
26
|
+
to: string;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
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[] {
|
|
23
38
|
const fm = proposal.match(/^---\n([\s\S]*?)\n---/u);
|
|
24
39
|
const deps: string[] = [];
|
|
25
40
|
if (!fm?.[1]) return deps;
|
|
26
41
|
let inDeps = false;
|
|
27
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
|
+
}
|
|
28
52
|
if (/^depends_on:\s*$/u.test(line)) {
|
|
29
53
|
inDeps = true;
|
|
30
54
|
continue;
|
|
31
55
|
}
|
|
32
56
|
if (inDeps) {
|
|
33
57
|
const m = line.match(/^\s*-\s+(\S+)/u);
|
|
34
|
-
if (m?.[1]) deps.push(m[1]);
|
|
58
|
+
if (m?.[1] && !/^[0-9]+$/u.test(m[1])) deps.push(m[1]);
|
|
35
59
|
else if (line.trim() !== '') inDeps = false;
|
|
36
60
|
}
|
|
37
61
|
}
|
|
38
62
|
return deps;
|
|
39
63
|
}
|
|
40
64
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 => {
|
|
45
94
|
for (const name of io.listDir(dir).toSorted()) {
|
|
46
|
-
if (name === 'archive') continue;
|
|
47
|
-
const
|
|
48
|
-
if (!io.isDirectory(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|
|
53
103
|
}
|
|
54
104
|
};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
105
|
+
visit(changesDir);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function collectArchivedNodes(io: GraphFsIo, root: string): GraphNode[] {
|
|
58
110
|
const archiveDir = `${root}/${CHANGES_DIR}/archive`;
|
|
59
|
-
if (io.exists(archiveDir)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 });
|
|
68
121
|
}
|
|
69
122
|
}
|
|
70
|
-
return
|
|
123
|
+
return out;
|
|
71
124
|
}
|
|
72
125
|
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
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>();
|
|
76
189
|
for (const node of nodes) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 });
|
|
80
220
|
}
|
|
81
|
-
|
|
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[] = [];
|
|
82
227
|
for (const node of nodes) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
lines.push(` ${node.id.replaceAll('-', '_')} -->|depends on| ${dep.replaceAll('-', '_')}`);
|
|
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 });
|
|
87
231
|
}
|
|
88
232
|
}
|
|
89
|
-
|
|
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');
|
|
90
315
|
return lines;
|
|
91
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
|
+
}
|