create-quiver 0.7.0 → 0.9.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/.claude/settings.local.json +52 -0
- package/.github/workflows/ci.yml +2 -2
- package/BACKLOG.md +139 -0
- package/CHANGELOG.md +20 -1
- package/README.md +31 -1
- package/README_FOR_AI.md +25 -13
- package/ROADMAP.md +28 -6
- package/docs/AI_ONBOARDING_PROMPT.md.template +4 -0
- package/docs/COMMANDS.md.template +25 -0
- package/docs/INDEX.md.template +2 -0
- package/docs/SUPPORT_MATRIX.md.template +9 -0
- package/docs/WORKFLOW.md.template +4 -0
- package/docs/examples/graph.md.template +62 -0
- package/docs/examples/next.md.template +27 -0
- package/docs/examples/plan.md.template +28 -0
- package/package.json +5 -2
- package/package.template.json +8 -3
- package/scripts/check-slice-readiness.sh +6 -4
- package/scripts/init-docs.sh +57 -9
- package/specs/[project-name]/HANDOFF.md.template +37 -0
- package/specs/[project-name]/slices/slice-template/slice.json +5 -0
- package/specs/quiver-v08-agent-onboarding-analysis/slices/slice-01-project-scan-command/slice.json +1 -1
- package/specs/quiver-v08-agent-onboarding-analysis/slices/slice-02-ai-onboarding-prompt/slice.json +1 -1
- package/specs/quiver-v08-agent-onboarding-analysis/slices/slice-03-doctor-readme-adoption-flow/slice.json +1 -1
- package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-01-cross-platform-support-contract/slice.json +1 -1
- package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-02-node-init-docs-runtime/slice.json +1 -1
- package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-03-node-migrate-analyze-doctor-flow/slice.json +1 -1
- package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-04-node-slice-lifecycle-commands/slice.json +1 -1
- package/specs/quiver-v12-cross-platform-native-runtime/slices/slice-05-generated-project-scripts-and-migration/slice.json +1 -1
- package/specs/quiver-v13-token-efficient-ai-context/EVIDENCE_REPORT.md +1 -1
- package/specs/quiver-v13-token-efficient-ai-context/SPEC.md +1 -1
- package/specs/quiver-v15-init-required-before-migrate/EVIDENCE_REPORT.md +26 -0
- package/specs/quiver-v15-init-required-before-migrate/SPEC.md +66 -0
- package/specs/quiver-v15-init-required-before-migrate/STATUS.md +26 -0
- package/specs/quiver-v15-init-required-before-migrate/slices/slice-01-migrate-initialization-precondition/slice.json +65 -0
- package/specs/quiver-v15-init-required-before-migrate/slices/slice-02-doctor-not-initialized-guidance/slice.json +61 -0
- package/specs/quiver-v15-init-required-before-migrate/slices/slice-03-docs-smokes-init-before-migrate/slice.json +64 -0
- package/specs/quiver-v16-handoff-contract/EVIDENCE_REPORT.md +26 -0
- package/specs/quiver-v16-handoff-contract/SPEC.md +68 -0
- package/specs/quiver-v16-handoff-contract/STATUS.md +26 -0
- package/specs/quiver-v16-handoff-contract/slices/slice-01-handoff-template-and-contract/slice.json +66 -0
- package/specs/quiver-v16-handoff-contract/slices/slice-02-check-handoff-command/slice.json +70 -0
- package/specs/quiver-v16-handoff-contract/slices/slice-03-handoff-scaffold-optional/slice.json +67 -0
- package/specs/quiver-v17-orchestration-foundation/EVIDENCE_REPORT.md +32 -0
- package/specs/quiver-v17-orchestration-foundation/SPEC.md +79 -0
- package/specs/quiver-v17-orchestration-foundation/STATUS.md +31 -0
- package/specs/quiver-v17-orchestration-foundation/slices/slice-01-ci-matrix-verified/slice.json +68 -0
- package/specs/quiver-v17-orchestration-foundation/slices/slice-02-slice-graph-library/slice.json +65 -0
- package/specs/quiver-v17-orchestration-foundation/slices/slice-03-depends-on-validation/slice.json +72 -0
- package/specs/quiver-v18-slice-orchestration/EVIDENCE_REPORT.md +38 -0
- package/specs/quiver-v18-slice-orchestration/SPEC.md +91 -0
- package/specs/quiver-v18-slice-orchestration/STATUS.md +33 -0
- package/specs/quiver-v18-slice-orchestration/slices/slice-01-plan-command/slice.json +79 -0
- package/specs/quiver-v18-slice-orchestration/slices/slice-02-graph-mvp-tree/slice.json +75 -0
- package/specs/quiver-v18-slice-orchestration/slices/slice-03-graph-extended-formats/slice.json +70 -0
- package/specs/quiver-v18-slice-orchestration/slices/slice-04-next-command/slice.json +73 -0
- package/specs/quiver-v18-stabilization/EVIDENCE_REPORT.md +26 -0
- package/specs/quiver-v18-stabilization/SPEC.md +62 -0
- package/specs/quiver-v18-stabilization/STATUS.md +30 -0
- package/specs/quiver-v18-stabilization/slices/slice-01-fix-legacy-dependency-resolution/CLOSURE_BRIEF.md +29 -0
- package/specs/quiver-v18-stabilization/slices/slice-01-fix-legacy-dependency-resolution/EXECUTION_BRIEF.md +134 -0
- package/specs/quiver-v18-stabilization/slices/slice-01-fix-legacy-dependency-resolution/slice.json +56 -0
- package/specs/quiver-v18-stabilization/slices/slice-02-roadmap-and-branch-cleanup/CLOSURE_BRIEF.md +29 -0
- package/specs/quiver-v18-stabilization/slices/slice-02-roadmap-and-branch-cleanup/EXECUTION_BRIEF.md +118 -0
- package/specs/quiver-v18-stabilization/slices/slice-02-roadmap-and-branch-cleanup/slice.json +57 -0
- package/specs/quiver-v18-stabilization/slices/slice-03-publish-drafts-branch/CLOSURE_BRIEF.md +23 -0
- package/specs/quiver-v18-stabilization/slices/slice-03-publish-drafts-branch/EXECUTION_BRIEF.md +73 -0
- package/specs/quiver-v18-stabilization/slices/slice-03-publish-drafts-branch/slice.json +49 -0
- package/specs/quiver-v19-self-install-dev-dep/EVIDENCE_REPORT.md +19 -0
- package/specs/quiver-v19-self-install-dev-dep/SPEC.md +51 -0
- package/specs/quiver-v19-self-install-dev-dep/STATUS.md +20 -0
- package/specs/quiver-v19-self-install-dev-dep/slices/slice-01-auto-install-dev-dep/CLOSURE_BRIEF.md +29 -0
- package/specs/quiver-v19-self-install-dev-dep/slices/slice-01-auto-install-dev-dep/EXECUTION_BRIEF.md +287 -0
- package/specs/quiver-v19-self-install-dev-dep/slices/slice-01-auto-install-dev-dep/slice.json +56 -0
- package/src/create-quiver/commands/graph.js +97 -0
- package/src/create-quiver/commands/next.js +134 -0
- package/src/create-quiver/commands/plan.js +205 -0
- package/src/create-quiver/index.js +203 -3
- package/src/create-quiver/lib/handoff.js +104 -0
- package/src/create-quiver/lib/init-docs.js +108 -13
- package/src/create-quiver/lib/json.js +14 -0
- package/src/create-quiver/lib/lifecycle.js +3 -2
- package/src/create-quiver/lib/readiness.js +55 -1
- package/src/create-quiver/lib/renderers/dot.js +129 -0
- package/src/create-quiver/lib/renderers/mermaid.js +119 -0
- package/src/create-quiver/lib/renderers/tree.js +116 -0
- package/src/create-quiver/lib/slice-graph.js +453 -0
- package/src/create-quiver/lib/slice.js +2 -1
- package/src/create-quiver/lib/state.js +50 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice graph library.
|
|
3
|
+
*
|
|
4
|
+
* API:
|
|
5
|
+
* - readAllSlices(rootDir): collect every slice.json under specs/ and specs-fix/
|
|
6
|
+
* - inferDependencies(slices): normalize declared deps and infer missing ones
|
|
7
|
+
* - buildGraph(slices): build a node/edge graph with cycle discovery
|
|
8
|
+
* - topoSort(graph): return slices in dependency order
|
|
9
|
+
* - computeLevels(graph): group slices into parallel-ready levels
|
|
10
|
+
* - detectFileConflicts(levelSlices): group same-level slices that overlap on files[]
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { parseJsonWithComments } = require('./json');
|
|
15
|
+
|
|
16
|
+
class SliceGraphError extends Error {
|
|
17
|
+
constructor(message, code) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'SliceGraphError';
|
|
20
|
+
this.code = code || 'SLICE_GRAPH_ERROR';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isPlaceholderSpecDir(name) {
|
|
25
|
+
return name.startsWith('[') && name.endsWith(']');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isPlaceholderSliceDir(name) {
|
|
29
|
+
return name === 'slice-template' || name.startsWith('slice-template-');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function naturalNumberFromSliceId(sliceId) {
|
|
33
|
+
const match = String(sliceId || '').match(/^slice-(\d+)/);
|
|
34
|
+
return match ? Number.parseInt(match[1], 10) : Number.POSITIVE_INFINITY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compareSliceRefs(a, b) {
|
|
38
|
+
const left = String(a || '');
|
|
39
|
+
const right = String(b || '');
|
|
40
|
+
const leftSlash = left.indexOf('/');
|
|
41
|
+
const rightSlash = right.indexOf('/');
|
|
42
|
+
const leftSpec = leftSlash === -1 ? left : left.slice(0, leftSlash);
|
|
43
|
+
const rightSpec = rightSlash === -1 ? right : right.slice(0, rightSlash);
|
|
44
|
+
|
|
45
|
+
if (leftSpec !== rightSpec) {
|
|
46
|
+
return leftSpec.localeCompare(rightSpec);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const leftSliceId = leftSlash === -1 ? '' : left.slice(leftSlash + 1);
|
|
50
|
+
const rightSliceId = rightSlash === -1 ? '' : right.slice(rightSlash + 1);
|
|
51
|
+
const leftNumber = naturalNumberFromSliceId(leftSliceId);
|
|
52
|
+
const rightNumber = naturalNumberFromSliceId(rightSliceId);
|
|
53
|
+
|
|
54
|
+
if (leftNumber !== rightNumber) {
|
|
55
|
+
return leftNumber - rightNumber;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return leftSliceId.localeCompare(rightSliceId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sortFileList(files) {
|
|
62
|
+
return Array.from(new Set((Array.isArray(files) ? files : []).map((file) => String(file)).filter(Boolean))).sort((a, b) => a.localeCompare(b));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeDependencyRef(slice, dependency) {
|
|
66
|
+
const dep = String(dependency || '').trim();
|
|
67
|
+
if (!dep) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (dep.includes('/')) {
|
|
72
|
+
// Valid slice ref: the segment after the first slash must start with "slice-".
|
|
73
|
+
// Entries like "docs/root-first-docs-flow" are legacy branch-name artifacts; drop silently.
|
|
74
|
+
const afterSlash = dep.slice(dep.indexOf('/') + 1);
|
|
75
|
+
if (!afterSlash.startsWith('slice-')) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return dep;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!slice || !slice.specSlug) {
|
|
82
|
+
return dep;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Legacy bare spec names (e.g. "quiver-v01") have no "slice-" prefix.
|
|
86
|
+
// They are spec-level refs already completed; drop them silently.
|
|
87
|
+
if (!dep.startsWith('slice-')) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return `${slice.specSlug}/${dep}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readAllSlices(rootDir) {
|
|
95
|
+
const roots = ['specs', 'specs-fix'];
|
|
96
|
+
const slices = [];
|
|
97
|
+
|
|
98
|
+
for (const rootName of roots) {
|
|
99
|
+
const rootPath = path.join(rootDir, rootName);
|
|
100
|
+
if (!fs.existsSync(rootPath)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const specEntry of fs.readdirSync(rootPath, { withFileTypes: true })) {
|
|
105
|
+
if (!specEntry.isDirectory() || isPlaceholderSpecDir(specEntry.name)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const specSlug = specEntry.name;
|
|
110
|
+
const slicesDir = path.join(rootPath, specSlug, 'slices');
|
|
111
|
+
if (!fs.existsSync(slicesDir)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const sliceEntry of fs.readdirSync(slicesDir, { withFileTypes: true })) {
|
|
116
|
+
if (!sliceEntry.isDirectory() || isPlaceholderSliceDir(sliceEntry.name)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sliceDir = path.join(slicesDir, sliceEntry.name);
|
|
121
|
+
const slicePath = path.join(sliceDir, 'slice.json');
|
|
122
|
+
if (!fs.existsSync(slicePath)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const json = parseJsonWithComments(fs.readFileSync(slicePath, 'utf8'));
|
|
127
|
+
const sliceId = String(json.slice_id || sliceEntry.name).trim();
|
|
128
|
+
const ref = `${specSlug}/${sliceId}`;
|
|
129
|
+
slices.push({
|
|
130
|
+
ref,
|
|
131
|
+
specFamily: rootName,
|
|
132
|
+
specSlug,
|
|
133
|
+
sliceId,
|
|
134
|
+
slicePath,
|
|
135
|
+
sliceDir,
|
|
136
|
+
files: sortFileList(json.files),
|
|
137
|
+
dependencies: Array.isArray(json.dependencies) ? json.dependencies.map((item) => String(item).trim()).filter(Boolean) : [],
|
|
138
|
+
depends_on: Array.isArray(json.depends_on) ? json.depends_on.map((item) => String(item).trim()).filter(Boolean) : [],
|
|
139
|
+
parallel_safe: typeof json.parallel_safe === 'string' ? json.parallel_safe : null,
|
|
140
|
+
parallel_safe_reason: typeof json.parallel_safe_reason === 'string' ? json.parallel_safe_reason : null,
|
|
141
|
+
status: typeof json.status === 'string' ? json.status : 'draft',
|
|
142
|
+
title: typeof json.title === 'string' ? json.title : sliceId,
|
|
143
|
+
json,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
slices.sort((left, right) => compareSliceRefs(left.ref, right.ref));
|
|
150
|
+
return slices;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeDeclaredDependencies(slice, raw) {
|
|
154
|
+
return Array.isArray(raw) ? raw.map((dep) => normalizeDependencyRef(slice, dep)).filter(Boolean) : [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function declaredDependenciesForSlice(slice) {
|
|
158
|
+
if (slice?.json && Object.prototype.hasOwnProperty.call(slice.json, 'depends_on')) {
|
|
159
|
+
return normalizeDeclaredDependencies(slice, slice.json.depends_on);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (slice?.json && Object.prototype.hasOwnProperty.call(slice.json, 'dependencies')) {
|
|
163
|
+
return normalizeDeclaredDependencies(slice, slice.json.dependencies);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function inferDependencies(slices) {
|
|
170
|
+
const bySpec = new Map();
|
|
171
|
+
const normalized = slices.map((slice) => ({
|
|
172
|
+
...slice,
|
|
173
|
+
files: sortFileList(slice.files),
|
|
174
|
+
depends_on: [],
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
for (const slice of normalized) {
|
|
178
|
+
const specKey = slice.specSlug || '';
|
|
179
|
+
if (!bySpec.has(specKey)) {
|
|
180
|
+
bySpec.set(specKey, []);
|
|
181
|
+
}
|
|
182
|
+
bySpec.get(specKey).push(slice);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const specSlices of bySpec.values()) {
|
|
186
|
+
specSlices.sort((left, right) => naturalNumberFromSliceId(left.sliceId) - naturalNumberFromSliceId(right.sliceId) || left.sliceId.localeCompare(right.sliceId));
|
|
187
|
+
const sliceByRef = new Map(specSlices.map((slice) => [slice.ref, slice]));
|
|
188
|
+
|
|
189
|
+
for (const slice of specSlices) {
|
|
190
|
+
const explicit = declaredDependenciesForSlice(slice);
|
|
191
|
+
if (explicit !== null) {
|
|
192
|
+
slice.depends_on = Array.from(new Set(explicit)).sort(compareSliceRefs);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const deps = [];
|
|
197
|
+
for (const candidate of specSlices) {
|
|
198
|
+
if (candidate.ref === slice.ref) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (naturalNumberFromSliceId(candidate.sliceId) >= naturalNumberFromSliceId(slice.sliceId)) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const overlap = candidate.files.filter((file) => slice.files.includes(file));
|
|
205
|
+
if (overlap.length > 0) {
|
|
206
|
+
deps.push(candidate.ref);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
slice.depends_on = Array.from(new Set(deps)).sort(compareSliceRefs);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const slice of specSlices) {
|
|
214
|
+
slice.depends_on = slice.depends_on.filter((dep) => sliceByRef.has(dep) || dep.includes('/'));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
normalized.sort((left, right) => compareSliceRefs(left.ref, right.ref));
|
|
219
|
+
return normalized;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildGraph(slices) {
|
|
223
|
+
const normalized = inferDependencies(slices);
|
|
224
|
+
const nodes = normalized.map((slice) => ({
|
|
225
|
+
...slice,
|
|
226
|
+
depends_on: Array.from(new Set(slice.depends_on)).sort(compareSliceRefs),
|
|
227
|
+
}));
|
|
228
|
+
const nodeByRef = new Map(nodes.map((node) => [node.ref, node]));
|
|
229
|
+
const edges = [];
|
|
230
|
+
const missing = [];
|
|
231
|
+
|
|
232
|
+
for (const node of nodes) {
|
|
233
|
+
for (const dep of node.depends_on) {
|
|
234
|
+
if (!nodeByRef.has(dep)) {
|
|
235
|
+
missing.push({ from: node.ref, to: dep });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
edges.push({ from: dep, to: node.ref });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (missing.length > 0) {
|
|
243
|
+
const details = missing.map((edge) => `${edge.from} -> ${edge.to}`).join(', ');
|
|
244
|
+
throw new SliceGraphError(`Missing dependency reference(s): ${details}`, 'MISSING_DEPENDENCY');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
nodes,
|
|
249
|
+
edges: edges.sort((left, right) => compareSliceRefs(left.from, right.from) || compareSliceRefs(left.to, right.to)),
|
|
250
|
+
cycles: findCycles(nodes, edges),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function findCycles(nodes, edges) {
|
|
255
|
+
const adjacency = new Map(nodes.map((node) => [node.ref, []]));
|
|
256
|
+
for (const edge of edges) {
|
|
257
|
+
adjacency.get(edge.from)?.push(edge.to);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const visiting = new Set();
|
|
261
|
+
const visited = new Set();
|
|
262
|
+
const stack = [];
|
|
263
|
+
const cycles = [];
|
|
264
|
+
const recorded = new Set();
|
|
265
|
+
|
|
266
|
+
function visit(ref) {
|
|
267
|
+
if (visited.has(ref)) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (visiting.has(ref)) {
|
|
272
|
+
const start = stack.indexOf(ref);
|
|
273
|
+
if (start !== -1) {
|
|
274
|
+
const cycle = [...stack.slice(start), ref];
|
|
275
|
+
const key = cycle.join('>');
|
|
276
|
+
if (!recorded.has(key)) {
|
|
277
|
+
recorded.add(key);
|
|
278
|
+
cycles.push(cycle);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
visiting.add(ref);
|
|
285
|
+
stack.push(ref);
|
|
286
|
+
|
|
287
|
+
for (const next of adjacency.get(ref) || []) {
|
|
288
|
+
visit(next);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
stack.pop();
|
|
292
|
+
visiting.delete(ref);
|
|
293
|
+
visited.add(ref);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const node of nodes) {
|
|
297
|
+
visit(node.ref);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
cycles.sort((left, right) => left.join('>').localeCompare(right.join('>')));
|
|
301
|
+
return cycles;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function topoSort(graph) {
|
|
305
|
+
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
|
|
306
|
+
const edges = Array.isArray(graph?.edges) ? graph.edges : [];
|
|
307
|
+
const nodeByRef = new Map(nodes.map((node) => [node.ref, node]));
|
|
308
|
+
const incoming = new Map(nodes.map((node) => [node.ref, 0]));
|
|
309
|
+
const outgoing = new Map(nodes.map((node) => [node.ref, []]));
|
|
310
|
+
|
|
311
|
+
for (const edge of edges) {
|
|
312
|
+
if (!nodeByRef.has(edge.from) || !nodeByRef.has(edge.to)) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
incoming.set(edge.to, (incoming.get(edge.to) || 0) + 1);
|
|
316
|
+
outgoing.get(edge.from).push(edge.to);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const queue = nodes
|
|
320
|
+
.filter((node) => (incoming.get(node.ref) || 0) === 0)
|
|
321
|
+
.map((node) => node.ref)
|
|
322
|
+
.sort(compareSliceRefs);
|
|
323
|
+
|
|
324
|
+
const ordered = [];
|
|
325
|
+
const nextQueue = queue.slice();
|
|
326
|
+
|
|
327
|
+
while (nextQueue.length > 0) {
|
|
328
|
+
const current = nextQueue.shift();
|
|
329
|
+
ordered.push(nodeByRef.get(current));
|
|
330
|
+
|
|
331
|
+
for (const neighbor of outgoing.get(current) || []) {
|
|
332
|
+
incoming.set(neighbor, (incoming.get(neighbor) || 0) - 1);
|
|
333
|
+
if (incoming.get(neighbor) === 0) {
|
|
334
|
+
nextQueue.push(neighbor);
|
|
335
|
+
nextQueue.sort(compareSliceRefs);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (ordered.length !== nodes.length) {
|
|
341
|
+
const cycles = Array.isArray(graph?.cycles) && graph.cycles.length > 0 ? graph.cycles : findCycles(nodes, edges);
|
|
342
|
+
const cycle = cycles[0] || [];
|
|
343
|
+
const message = cycle.length > 0
|
|
344
|
+
? `Slice graph contains a cycle: ${cycle.join(' -> ')}`
|
|
345
|
+
: 'Slice graph contains a cycle.';
|
|
346
|
+
throw new SliceGraphError(message, 'CYCLE_DETECTED');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return ordered;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function computeLevels(graph) {
|
|
353
|
+
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
|
|
354
|
+
const edges = Array.isArray(graph?.edges) ? graph.edges : [];
|
|
355
|
+
const nodeByRef = new Map(nodes.map((node) => [node.ref, node]));
|
|
356
|
+
const incoming = new Map(nodes.map((node) => [node.ref, 0]));
|
|
357
|
+
const outgoing = new Map(nodes.map((node) => [node.ref, []]));
|
|
358
|
+
|
|
359
|
+
for (const edge of edges) {
|
|
360
|
+
if (!nodeByRef.has(edge.from) || !nodeByRef.has(edge.to)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
incoming.set(edge.to, (incoming.get(edge.to) || 0) + 1);
|
|
364
|
+
outgoing.get(edge.from).push(edge.to);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const remaining = new Set(nodes.map((node) => node.ref));
|
|
368
|
+
const levels = [];
|
|
369
|
+
|
|
370
|
+
while (remaining.size > 0) {
|
|
371
|
+
const currentLevel = Array.from(remaining)
|
|
372
|
+
.filter((ref) => (incoming.get(ref) || 0) === 0)
|
|
373
|
+
.sort(compareSliceRefs);
|
|
374
|
+
|
|
375
|
+
if (currentLevel.length === 0) {
|
|
376
|
+
const cycles = Array.isArray(graph?.cycles) && graph.cycles.length > 0 ? graph.cycles : findCycles(nodes, edges);
|
|
377
|
+
const cycle = cycles[0] || [];
|
|
378
|
+
const message = cycle.length > 0
|
|
379
|
+
? `Slice graph contains a cycle: ${cycle.join(' -> ')}`
|
|
380
|
+
: 'Slice graph contains a cycle.';
|
|
381
|
+
throw new SliceGraphError(message, 'CYCLE_DETECTED');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
levels.push(currentLevel.map((ref) => nodeByRef.get(ref)));
|
|
385
|
+
|
|
386
|
+
for (const ref of currentLevel) {
|
|
387
|
+
remaining.delete(ref);
|
|
388
|
+
for (const neighbor of outgoing.get(ref) || []) {
|
|
389
|
+
incoming.set(neighbor, (incoming.get(neighbor) || 0) - 1);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return levels;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function detectFileConflicts(slicesInLevel) {
|
|
398
|
+
const slices = Array.isArray(slicesInLevel) ? slicesInLevel.map((slice) => ({
|
|
399
|
+
...slice,
|
|
400
|
+
files: sortFileList(slice.files),
|
|
401
|
+
})) : [];
|
|
402
|
+
const visited = new Set();
|
|
403
|
+
const groups = [];
|
|
404
|
+
|
|
405
|
+
for (let index = 0; index < slices.length; index += 1) {
|
|
406
|
+
const start = slices[index];
|
|
407
|
+
if (visited.has(start.ref)) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const queue = [start];
|
|
412
|
+
const component = [];
|
|
413
|
+
visited.add(start.ref);
|
|
414
|
+
|
|
415
|
+
while (queue.length > 0) {
|
|
416
|
+
const current = queue.shift();
|
|
417
|
+
component.push(current);
|
|
418
|
+
|
|
419
|
+
for (const candidate of slices) {
|
|
420
|
+
if (visited.has(candidate.ref) || candidate.ref === current.ref) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const overlap = current.files.filter((file) => candidate.files.includes(file));
|
|
425
|
+
if (overlap.length > 0) {
|
|
426
|
+
visited.add(candidate.ref);
|
|
427
|
+
queue.push(candidate);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (component.length > 1) {
|
|
433
|
+
const files = Array.from(new Set(component.flatMap((slice) => slice.files))).sort((left, right) => left.localeCompare(right));
|
|
434
|
+
groups.push({
|
|
435
|
+
files,
|
|
436
|
+
slices: component.map((slice) => slice.ref).sort(compareSliceRefs),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
groups.sort((left, right) => left.slices.join('>').localeCompare(right.slices.join('>')));
|
|
442
|
+
return groups;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
module.exports = {
|
|
446
|
+
SliceGraphError,
|
|
447
|
+
buildGraph,
|
|
448
|
+
computeLevels,
|
|
449
|
+
detectFileConflicts,
|
|
450
|
+
inferDependencies,
|
|
451
|
+
readAllSlices,
|
|
452
|
+
topoSort,
|
|
453
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const { parseJsonWithComments } = require('./json');
|
|
3
4
|
const { resolveTargetRoot, toPosixPath } = require('./paths');
|
|
4
5
|
|
|
5
6
|
function readJson(filePath) {
|
|
@@ -31,7 +32,7 @@ function resolveSlicePath(sliceInput) {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function readSliceMeta(slicePath) {
|
|
34
|
-
const json =
|
|
35
|
+
const json = parseJsonWithComments(fs.readFileSync(slicePath, 'utf8'));
|
|
35
36
|
const ticket = typeof json.ticket === 'string' ? json.ticket.trim() : '';
|
|
36
37
|
const git = json.git ?? {};
|
|
37
38
|
const branchType = typeof git.branch_type === 'string' ? git.branch_type.trim() : '';
|
|
@@ -19,6 +19,52 @@ function readState(projectRoot) {
|
|
|
19
19
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function hasInitializedStateMetadata(state) {
|
|
23
|
+
return Boolean(
|
|
24
|
+
state
|
|
25
|
+
&& typeof state.initialized_version === 'string'
|
|
26
|
+
&& state.initialized_version.length > 0
|
|
27
|
+
&& typeof state.last_initialized_at === 'string'
|
|
28
|
+
&& state.last_initialized_at.length > 0,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasGeneratedProjectSpec(projectRoot) {
|
|
33
|
+
const specsDir = path.join(projectRoot, 'specs');
|
|
34
|
+
if (!fs.existsSync(specsDir)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return fs.readdirSync(specsDir, { withFileTypes: true })
|
|
39
|
+
.filter((entry) => entry.isDirectory())
|
|
40
|
+
.map((entry) => entry.name)
|
|
41
|
+
.filter((entry) => entry !== '[project-name]' && !entry.startsWith('quiver-'))
|
|
42
|
+
.some((entry) => (
|
|
43
|
+
fs.existsSync(path.join(specsDir, entry, 'SPEC.md'))
|
|
44
|
+
&& fs.existsSync(path.join(specsDir, entry, 'STATUS.md'))
|
|
45
|
+
&& fs.existsSync(path.join(specsDir, entry, 'EVIDENCE_REPORT.md'))
|
|
46
|
+
&& fs.existsSync(path.join(specsDir, entry, 'slices', 'slice-template', 'slice.json'))
|
|
47
|
+
));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasLegacyQuiverInitializationEvidence(projectRoot) {
|
|
51
|
+
const requiredPaths = [
|
|
52
|
+
'docs-template/scripts/init-docs.sh',
|
|
53
|
+
'tools/scripts/start-slice.sh',
|
|
54
|
+
'tools/scripts/check-slice-readiness.sh',
|
|
55
|
+
'.github/pull_request_template.md',
|
|
56
|
+
'docs/INDEX.md',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
return requiredPaths.every((relativePath) => fs.existsSync(path.join(projectRoot, relativePath)))
|
|
60
|
+
&& hasGeneratedProjectSpec(projectRoot);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasQuiverInitializationEvidence(projectRoot) {
|
|
64
|
+
const state = readState(projectRoot);
|
|
65
|
+
return hasInitializedStateMetadata(state) || hasLegacyQuiverInitializationEvidence(projectRoot);
|
|
66
|
+
}
|
|
67
|
+
|
|
22
68
|
function writeState(projectRoot, nextState) {
|
|
23
69
|
const stateDir = path.join(projectRoot, '.quiver');
|
|
24
70
|
ensureDir(stateDir);
|
|
@@ -80,6 +126,10 @@ function updateStateForAnalyze(projectRoot, cliVersion) {
|
|
|
80
126
|
}
|
|
81
127
|
|
|
82
128
|
module.exports = {
|
|
129
|
+
hasGeneratedProjectSpec,
|
|
130
|
+
hasInitializedStateMetadata,
|
|
131
|
+
hasLegacyQuiverInitializationEvidence,
|
|
132
|
+
hasQuiverInitializationEvidence,
|
|
83
133
|
readState,
|
|
84
134
|
statePath,
|
|
85
135
|
updateStateForAnalyze,
|