@chrisdudek/yg 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/bin.js +409 -125
- package/dist/bin.js.map +1 -1
- package/dist/templates/rules.ts +24 -7
- package/graph-schemas/config.yaml +39 -0
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -105,7 +105,7 @@ WHEN UNSURE: ask the user. Never guess. Never assume.
|
|
|
105
105
|
1. **Graph first.** Before reading, researching, planning, or modifying mapped files, run \`yg owner\` and \`yg build-context\`. Always. The context package \u2014 not raw source \u2014 is your primary source of understanding.
|
|
106
106
|
2. **Code and graph are one.** Code changed \u2192 graph updated in the same response. Graph changed \u2192 source verified in the same response. No exceptions.
|
|
107
107
|
3. **Never invent why.** The graph captures human intent. If you don't know why something was decided, ask. Never hallucinate rationale.
|
|
108
|
-
4. **Always capture why.** When the user explains a reason, record it in the graph immediately. Conversation evaporates; graph persists.
|
|
108
|
+
4. **Always capture why \u2014 especially why NOT.** When the user explains a reason, record it in the graph immediately. When a design choice is made, also record rejected alternatives: "Chose X over Y because Z." Rejected alternatives are the highest-value information \u2014 invisible in code and irrecoverable once forgotten. Conversation evaporates; graph persists.
|
|
109
109
|
5. **Ask before resolving ambiguity.** When multiple valid interpretations exist, stop, list options, ask the user. Never silently choose.
|
|
110
110
|
|
|
111
111
|
### Failure States
|
|
@@ -126,6 +126,7 @@ You have broken Yggdrasil if you do any of the following:
|
|
|
126
126
|
- \u274C Answered a question about a mapped file without running \`yg build-context\` first.
|
|
127
127
|
- \u274C Read mapped source files to plan or research changes without running \`yg build-context\` first.
|
|
128
128
|
- \u274C Deferred \`yg drift-sync\` to the end of a multi-step task instead of running it incrementally after each logical group of changes.
|
|
129
|
+
- \u274C Recorded a design decision without documenting which alternatives were rejected and why.
|
|
129
130
|
|
|
130
131
|
### Escape Hatch
|
|
131
132
|
|
|
@@ -192,7 +193,14 @@ You are not allowed to edit or create source code without establishing graph cov
|
|
|
192
193
|
- Option B \u2014 Blackbox: create a blackbox node at agreed granularity
|
|
193
194
|
- Option C \u2014 Abort
|
|
194
195
|
|
|
195
|
-
*Greenfield (new code):* Only Option A. Blackbox is forbidden for new code.
|
|
196
|
+
*Greenfield (new code):* Only Option A. Blackbox is forbidden for new code. Follow the graph-first workflow:
|
|
197
|
+
|
|
198
|
+
1. Create aspects first (cross-cutting requirements the new code must satisfy)
|
|
199
|
+
2. Create flows if the code participates in a business process
|
|
200
|
+
3. Create nodes with full artifacts \u2014 responsibility, constraints, decisions, interface, logic
|
|
201
|
+
4. Review the context package (\`yg build-context\`) \u2014 it is now the behavioral specification
|
|
202
|
+
5. Implement code that satisfies the specification
|
|
203
|
+
6. The graph specifies WHAT and WHY; the code implements HOW (framework APIs, library choices)
|
|
196
204
|
|
|
197
205
|
After the user chooses, return to Step 1 and follow Step 2a.
|
|
198
206
|
|
|
@@ -226,6 +234,7 @@ Per area checklist:
|
|
|
226
234
|
- Business process unclear: "This code appears to be part of a larger process. Can you describe what it means from a business perspective?"
|
|
227
235
|
- Constraint without rationale: "I see [constraint X]. Do you know why this exists? I want to record the reason, not just the rule."
|
|
228
236
|
- Unexplained architectural choice: "I see [approach X]. What was the reason for this choice?"
|
|
237
|
+
- Decision without alternatives: "You chose [X]. What alternatives did you consider, and why did you reject them?" Record the answer in \`decisions.md\`.
|
|
229
238
|
|
|
230
239
|
### Bootstrap Mode
|
|
231
240
|
|
|
@@ -294,7 +303,7 @@ When you encounter information, route it to the correct location:
|
|
|
294
303
|
- **Business process** \u2192 flow (\`flows/<name>/\` with \`flow.yaml\` + \`description.md\`). Ask user if process unclear.
|
|
295
304
|
- **Shared across a domain** \u2192 parent node artifact. Children receive it through hierarchy.
|
|
296
305
|
- **Technology stack or standard** \u2192 \`config.yaml\` under \`stack\` or \`standards\` (+ \`rationale\` field)
|
|
297
|
-
- **Decision (why):** one node \u2192
|
|
306
|
+
- **Decision (why + why NOT):** one node \u2192 \`decisions.md\` with format "Chose X over Y because Z"; category of nodes \u2192 aspect content files; tech choice \u2192 \`config.yaml\` rationale field. Always include rejected alternatives \u2014 they are the highest-value graph content.
|
|
298
307
|
|
|
299
308
|
### Creating Aspects
|
|
300
309
|
|
|
@@ -306,6 +315,12 @@ When you encounter information, route it to the correct location:
|
|
|
306
315
|
|
|
307
316
|
Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. No \u2192 local artifact.
|
|
308
317
|
|
|
318
|
+
**Aspect identification heuristic:** If the same pattern, constraint, or rule appears in 3+ places, it is a candidate aspect. Aspects fall into natural categories:
|
|
319
|
+
|
|
320
|
+
- **Domain-specific:** Business rules that cross module boundaries (e.g., timezone handling, booking periods, currency rounding)
|
|
321
|
+
- **Architectural:** Structural patterns with rationale (e.g., dual-rollback on provider failure, idempotency via key generation, fire-and-forget dispatch)
|
|
322
|
+
- **Concurrency:** Shared concurrency strategies (e.g., pessimistic locking, retry-on-deadlock, optimistic versioning)
|
|
323
|
+
|
|
309
324
|
### Creating Flows
|
|
310
325
|
|
|
311
326
|
- [ ] 1. Read \`schemas/flow.yaml\`
|
|
@@ -322,17 +337,18 @@ Test: "Does this describe what happens in the world, or only in the software?" I
|
|
|
322
337
|
- **Read schemas before creating** any \`node.yaml\`, \`aspect.yaml\`, or \`flow.yaml\`.
|
|
323
338
|
- **Tools read, you write.** The \`yg\` CLI only reads, validates, and manages metadata. You create and edit files manually.
|
|
324
339
|
- **Incremental sync.** Run \`yg drift-sync\` after every 3-5 source file changes. Do not defer to end of task.
|
|
325
|
-
- **Completeness test:** "If I delete the source file and give another agent ONLY the \`yg build-context\` output \u2014 can they recreate it correctly, understanding not just WHAT but WHY?"
|
|
340
|
+
- **Completeness test:** "If I delete the source file and give another agent ONLY the \`yg build-context\` output \u2014 can they recreate it correctly, understanding not just WHAT but WHY?" Test specifically: Can they explain rejected alternatives? Can they implement the correct algorithm (not a simplified version)? Can they argue for the current design against plausible alternatives?
|
|
326
341
|
- **These rules are invariant.** No plan, guide, skill, or workflow may override them.
|
|
327
342
|
|
|
328
343
|
### CLI Reference
|
|
329
344
|
|
|
330
345
|
\`\`\`
|
|
331
|
-
yg preflight
|
|
346
|
+
yg preflight [--quick] Unified diagnostic: journal + drift + status + validate.
|
|
332
347
|
yg owner --file <path> Find the node that owns this file.
|
|
333
348
|
yg build-context --node <path> Assemble context package for this node.
|
|
334
349
|
yg tree [--root <path>] [--depth N] Print graph structure.
|
|
335
350
|
yg aspects List aspects with metadata (YAML output).
|
|
351
|
+
yg flows List flows with metadata (YAML output).
|
|
336
352
|
yg deps --node <path> [--depth N] [--type structural|event|all]
|
|
337
353
|
Show dependencies.
|
|
338
354
|
yg impact --node <path> --simulate Simulate blast radius of a planned change.
|
|
@@ -340,9 +356,10 @@ yg impact --aspect <id> Show all nodes where aspect is effective.
|
|
|
340
356
|
yg impact --flow <name> Show flow participants and descendants.
|
|
341
357
|
yg status Graph health: nodes, coverage, drift summary.
|
|
342
358
|
yg validate [--scope <path>|all] Check structural integrity and completeness.
|
|
343
|
-
yg drift [--scope <path>|all] [--drifted-only]
|
|
359
|
+
yg drift [--scope <path>|all] [--drifted-only] [--limit <n>]
|
|
344
360
|
Detect source and graph drift (bidirectional).
|
|
345
|
-
yg drift-sync --node <path>
|
|
361
|
+
yg drift-sync --node <path> [--recursive] | --all
|
|
362
|
+
Record file hashes as new baseline.
|
|
346
363
|
yg journal-read Read pending journal entries.
|
|
347
364
|
yg journal-add --note "<content>" [--target <node_path>]
|
|
348
365
|
Add a journal entry.
|
|
@@ -611,6 +628,7 @@ function getGraphSchemasDir() {
|
|
|
611
628
|
return path2.join(packageRoot, "graph-schemas");
|
|
612
629
|
}
|
|
613
630
|
var GITIGNORE_CONTENT = `.journal.yaml
|
|
631
|
+
.drift-state
|
|
614
632
|
journals-archive/
|
|
615
633
|
`;
|
|
616
634
|
function registerInitCommand(program2) {
|
|
@@ -683,7 +701,7 @@ function registerInitCommand(program2) {
|
|
|
683
701
|
process.stdout.write(" .yggdrasil/model/\n");
|
|
684
702
|
process.stdout.write(" .yggdrasil/aspects/\n");
|
|
685
703
|
process.stdout.write(" .yggdrasil/flows/\n");
|
|
686
|
-
process.stdout.write(" .yggdrasil/schemas/ (node, aspect, flow)\n");
|
|
704
|
+
process.stdout.write(" .yggdrasil/schemas/ (config, node, aspect, flow)\n");
|
|
687
705
|
process.stdout.write(` ${path2.relative(projectRoot, rulesPath)} (rules)
|
|
688
706
|
|
|
689
707
|
`);
|
|
@@ -709,6 +727,9 @@ var DEFAULT_QUALITY = {
|
|
|
709
727
|
async function parseConfig(filePath) {
|
|
710
728
|
const content = await readFile3(filePath, "utf-8");
|
|
711
729
|
const raw = parseYaml(content);
|
|
730
|
+
if (!raw || typeof raw !== "object") {
|
|
731
|
+
throw new Error(`config.yaml: file is empty or not a valid YAML mapping`);
|
|
732
|
+
}
|
|
712
733
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
713
734
|
throw new Error(`config.yaml: missing or invalid 'name' field`);
|
|
714
735
|
}
|
|
@@ -802,6 +823,9 @@ function isValidRelationType(t) {
|
|
|
802
823
|
async function parseNodeYaml(filePath) {
|
|
803
824
|
const content = await readFile4(filePath, "utf-8");
|
|
804
825
|
const raw = parseYaml2(content);
|
|
826
|
+
if (!raw || typeof raw !== "object") {
|
|
827
|
+
throw new Error(`node.yaml at ${filePath}: file is empty or not a valid YAML mapping`);
|
|
828
|
+
}
|
|
805
829
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
806
830
|
throw new Error(`node.yaml at ${filePath}: missing or empty 'name'`);
|
|
807
831
|
}
|
|
@@ -922,6 +946,9 @@ async function parseAspect(aspectDir, aspectYamlPath, id) {
|
|
|
922
946
|
}
|
|
923
947
|
const content = await readFile6(aspectYamlPath, "utf-8");
|
|
924
948
|
const raw = parseYaml3(content);
|
|
949
|
+
if (!raw || typeof raw !== "object") {
|
|
950
|
+
throw new Error(`Aspect file ${aspectYamlPath}: file is empty or not a valid YAML mapping`);
|
|
951
|
+
}
|
|
925
952
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
926
953
|
throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'name'`);
|
|
927
954
|
}
|
|
@@ -950,6 +977,9 @@ import { parse as parseYaml4 } from "yaml";
|
|
|
950
977
|
async function parseFlow(flowDir, flowYamlPath) {
|
|
951
978
|
const content = await readFile7(flowYamlPath, "utf-8");
|
|
952
979
|
const raw = parseYaml4(content);
|
|
980
|
+
if (!raw || typeof raw !== "object") {
|
|
981
|
+
throw new Error(`flow.yaml at ${flowYamlPath}: file is empty or not a valid YAML mapping`);
|
|
982
|
+
}
|
|
953
983
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
954
984
|
throw new Error(`flow.yaml at ${flowYamlPath}: missing or empty 'name'`);
|
|
955
985
|
}
|
|
@@ -1036,6 +1066,9 @@ function normalizeProjectRelativePath(projectRoot, rawPath) {
|
|
|
1036
1066
|
}
|
|
1037
1067
|
return relative.split(path6.sep).join("/");
|
|
1038
1068
|
}
|
|
1069
|
+
function projectRootFromGraph(yggRootPath) {
|
|
1070
|
+
return path6.dirname(yggRootPath);
|
|
1071
|
+
}
|
|
1039
1072
|
|
|
1040
1073
|
// src/core/graph-loader.ts
|
|
1041
1074
|
function toModelPath(absolutePath, modelDir) {
|
|
@@ -1228,11 +1261,13 @@ async function buildContext(graph, nodePath) {
|
|
|
1228
1261
|
layers.push(buildHierarchyLayer(ancestor, graph.config, graph));
|
|
1229
1262
|
}
|
|
1230
1263
|
layers.push(await buildOwnLayer(node, graph.config, graph.rootPath, graph));
|
|
1264
|
+
const ancestorPaths = new Set(ancestors.map((a) => a.path));
|
|
1231
1265
|
for (const relation of node.meta.relations ?? []) {
|
|
1232
1266
|
const target = graph.nodes.get(relation.target);
|
|
1233
1267
|
if (!target) {
|
|
1234
1268
|
throw new Error(`Broken relation: ${nodePath} -> ${relation.target} (target not found)`);
|
|
1235
1269
|
}
|
|
1270
|
+
if (ancestorPaths.has(relation.target)) continue;
|
|
1236
1271
|
if (STRUCTURAL_RELATION_TYPES.has(relation.type)) {
|
|
1237
1272
|
layers.push(buildStructuralRelationLayer(target, relation, graph.config));
|
|
1238
1273
|
} else if (EVENT_RELATION_TYPES.has(relation.type)) {
|
|
@@ -1548,6 +1583,7 @@ async function validate(graph, scope = "all") {
|
|
|
1548
1583
|
issues.push(...checkRelationTargets(graph));
|
|
1549
1584
|
issues.push(...checkNoCycles(graph));
|
|
1550
1585
|
issues.push(...checkMappingOverlap(graph));
|
|
1586
|
+
issues.push(...await checkMappingPathsExist(graph));
|
|
1551
1587
|
issues.push(...checkBrokenFlowRefs(graph));
|
|
1552
1588
|
issues.push(...checkFlowAspectIds(graph));
|
|
1553
1589
|
issues.push(...await checkDirectoriesHaveNodeYaml(graph));
|
|
@@ -1557,13 +1593,29 @@ async function validate(graph, scope = "all") {
|
|
|
1557
1593
|
let nodesScanned = graph.nodes.size;
|
|
1558
1594
|
if (scope !== "all" && scope.trim()) {
|
|
1559
1595
|
if (!graph.nodes.has(scope)) {
|
|
1596
|
+
const parseError = (graph.nodeParseErrors ?? []).find(
|
|
1597
|
+
(e) => e.nodePath === scope || scope.startsWith(e.nodePath + "/")
|
|
1598
|
+
);
|
|
1599
|
+
if (parseError) {
|
|
1600
|
+
return {
|
|
1601
|
+
issues: [{
|
|
1602
|
+
severity: "error",
|
|
1603
|
+
code: "E001",
|
|
1604
|
+
rule: "invalid-node-yaml",
|
|
1605
|
+
message: parseError.message,
|
|
1606
|
+
nodePath: parseError.nodePath
|
|
1607
|
+
}],
|
|
1608
|
+
nodesScanned: 0
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1560
1611
|
return {
|
|
1561
1612
|
issues: [{ severity: "error", rule: "invalid-scope", message: `Node not found: ${scope}` }],
|
|
1562
1613
|
nodesScanned: 0
|
|
1563
1614
|
};
|
|
1564
1615
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1616
|
+
const scopePrefix = scope + "/";
|
|
1617
|
+
filtered = issues.filter((i) => !i.nodePath || i.nodePath === scope || i.nodePath.startsWith(scopePrefix));
|
|
1618
|
+
nodesScanned = [...graph.nodes.keys()].filter((p) => p === scope || p.startsWith(scopePrefix)).length;
|
|
1567
1619
|
}
|
|
1568
1620
|
return { issues: filtered, nodesScanned };
|
|
1569
1621
|
}
|
|
@@ -1820,6 +1872,9 @@ function arePathsOverlapping(pathA, pathB) {
|
|
|
1820
1872
|
if (pathA === pathB) return true;
|
|
1821
1873
|
return pathA.startsWith(pathB + "/") || pathB.startsWith(pathA + "/");
|
|
1822
1874
|
}
|
|
1875
|
+
function isAncestorNode(possibleAncestor, possibleDescendant) {
|
|
1876
|
+
return possibleDescendant.startsWith(possibleAncestor + "/");
|
|
1877
|
+
}
|
|
1823
1878
|
function checkMappingOverlap(graph) {
|
|
1824
1879
|
const issues = [];
|
|
1825
1880
|
const ownership = [];
|
|
@@ -1835,6 +1890,9 @@ function checkMappingOverlap(graph) {
|
|
|
1835
1890
|
const candidate = ownership[nestedIndex];
|
|
1836
1891
|
if (current.nodePath === candidate.nodePath) continue;
|
|
1837
1892
|
if (!arePathsOverlapping(current.mappingPath, candidate.mappingPath)) continue;
|
|
1893
|
+
const isContainment = current.mappingPath !== candidate.mappingPath;
|
|
1894
|
+
const isHierarchical = isAncestorNode(current.nodePath, candidate.nodePath) || isAncestorNode(candidate.nodePath, current.nodePath);
|
|
1895
|
+
if (isContainment && isHierarchical) continue;
|
|
1838
1896
|
issues.push({
|
|
1839
1897
|
severity: "error",
|
|
1840
1898
|
code: "E009",
|
|
@@ -1846,6 +1904,29 @@ function checkMappingOverlap(graph) {
|
|
|
1846
1904
|
}
|
|
1847
1905
|
return issues;
|
|
1848
1906
|
}
|
|
1907
|
+
async function checkMappingPathsExist(graph) {
|
|
1908
|
+
const issues = [];
|
|
1909
|
+
const projectRoot = path9.dirname(graph.rootPath);
|
|
1910
|
+
const { access: access4 } = await import("fs/promises");
|
|
1911
|
+
for (const [nodePath, node] of graph.nodes) {
|
|
1912
|
+
const mappingPaths = normalizeMappingPaths(node.meta.mapping);
|
|
1913
|
+
for (const mp of mappingPaths) {
|
|
1914
|
+
const absPath = path9.join(projectRoot, mp);
|
|
1915
|
+
try {
|
|
1916
|
+
await access4(absPath);
|
|
1917
|
+
} catch {
|
|
1918
|
+
issues.push({
|
|
1919
|
+
severity: "warning",
|
|
1920
|
+
code: "W012",
|
|
1921
|
+
rule: "mapping-path-missing",
|
|
1922
|
+
message: `Mapping path '${mp}' does not exist on disk`,
|
|
1923
|
+
nodePath
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
return issues;
|
|
1929
|
+
}
|
|
1849
1930
|
function getIncomingRelationSources(graph, nodePath) {
|
|
1850
1931
|
const sources = [];
|
|
1851
1932
|
for (const [srcPath, node] of graph.nodes) {
|
|
@@ -2077,16 +2158,27 @@ async function checkDirectoriesHaveNodeYaml(graph) {
|
|
|
2077
2158
|
const hasNodeYaml = entries.some((e) => e.isFile() && e.name === "node.yaml");
|
|
2078
2159
|
const dirName = path9.basename(dirPath);
|
|
2079
2160
|
if (RESERVED_DIRS.has(dirName)) return;
|
|
2080
|
-
const
|
|
2161
|
+
const hasFiles = entries.some((e) => e.isFile());
|
|
2162
|
+
const hasSubdirs = entries.some((e) => e.isDirectory() && !RESERVED_DIRS.has(e.name) && !e.name.startsWith("."));
|
|
2081
2163
|
const graphPath = segments.join("/");
|
|
2082
|
-
if (
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2164
|
+
if (!hasNodeYaml && graphPath !== "") {
|
|
2165
|
+
if (hasFiles) {
|
|
2166
|
+
issues.push({
|
|
2167
|
+
severity: "error",
|
|
2168
|
+
code: "E015",
|
|
2169
|
+
rule: "missing-node-yaml",
|
|
2170
|
+
message: `Directory '${graphPath}' has files but no node.yaml`,
|
|
2171
|
+
nodePath: graphPath
|
|
2172
|
+
});
|
|
2173
|
+
} else if (hasSubdirs) {
|
|
2174
|
+
issues.push({
|
|
2175
|
+
severity: "warning",
|
|
2176
|
+
code: "W013",
|
|
2177
|
+
rule: "directory-without-node",
|
|
2178
|
+
message: `Directory '${graphPath}' has subdirectories but no node.yaml \u2014 consider creating a node`,
|
|
2179
|
+
nodePath: graphPath
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2090
2182
|
}
|
|
2091
2183
|
for (const entry of entries) {
|
|
2092
2184
|
if (!entry.isDirectory()) continue;
|
|
@@ -2210,22 +2302,47 @@ function formatContextText(pkg2) {
|
|
|
2210
2302
|
}
|
|
2211
2303
|
|
|
2212
2304
|
// src/cli/build-context.ts
|
|
2305
|
+
function collectRelevantNodePaths(graph, nodePath) {
|
|
2306
|
+
const relevant = /* @__PURE__ */ new Set();
|
|
2307
|
+
const node = graph.nodes.get(nodePath);
|
|
2308
|
+
if (!node) return relevant;
|
|
2309
|
+
relevant.add(nodePath);
|
|
2310
|
+
for (const ancestor of collectAncestors(node)) {
|
|
2311
|
+
relevant.add(ancestor.path);
|
|
2312
|
+
}
|
|
2313
|
+
for (const rel of node.meta.relations ?? []) {
|
|
2314
|
+
relevant.add(rel.target);
|
|
2315
|
+
}
|
|
2316
|
+
return relevant;
|
|
2317
|
+
}
|
|
2213
2318
|
function registerBuildCommand(program2) {
|
|
2214
2319
|
program2.command("build-context").description("Assemble a context package for one node").requiredOption("--node <node-path>", "Node path relative to .yggdrasil/model/").action(async (options) => {
|
|
2215
2320
|
try {
|
|
2216
2321
|
const graph = await loadGraph(process.cwd());
|
|
2322
|
+
const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/$/, "");
|
|
2323
|
+
const relevantNodes = collectRelevantNodePaths(graph, nodePath);
|
|
2217
2324
|
const validationResult = await validate(graph, "all");
|
|
2218
|
-
const
|
|
2219
|
-
(issue) => issue.severity === "error"
|
|
2325
|
+
const relevantErrors = validationResult.issues.filter(
|
|
2326
|
+
(issue) => issue.severity === "error" && // Global errors (no nodePath) always block — e.g., E012 invalid config
|
|
2327
|
+
(!issue.nodePath || relevantNodes.has(issue.nodePath))
|
|
2220
2328
|
);
|
|
2221
|
-
if (
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
`
|
|
2225
|
-
|
|
2329
|
+
if (relevantErrors.length > 0) {
|
|
2330
|
+
const totalErrors = validationResult.issues.filter((i) => i.severity === "error").length;
|
|
2331
|
+
const skippedErrors = totalErrors - relevantErrors.length;
|
|
2332
|
+
let msg = `Error: build-context blocked by ${relevantErrors.length} error(s) affecting this node's context.
|
|
2333
|
+
`;
|
|
2334
|
+
if (skippedErrors > 0) {
|
|
2335
|
+
msg += `(${skippedErrors} unrelated error(s) in other nodes ignored.)
|
|
2336
|
+
`;
|
|
2337
|
+
}
|
|
2338
|
+
for (const err of relevantErrors) {
|
|
2339
|
+
const loc = err.nodePath ? `${err.nodePath}: ` : "";
|
|
2340
|
+
msg += ` ${err.code ?? ""} ${loc}${err.message}
|
|
2341
|
+
`;
|
|
2342
|
+
}
|
|
2343
|
+
process.stderr.write(msg);
|
|
2226
2344
|
process.exit(1);
|
|
2227
2345
|
}
|
|
2228
|
-
const nodePath = options.node.trim().replace(/\/$/, "");
|
|
2229
2346
|
const pkg2 = await buildContext(graph, nodePath);
|
|
2230
2347
|
const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
|
|
2231
2348
|
const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
|
|
@@ -2254,7 +2371,8 @@ function registerValidateCommand(program2) {
|
|
|
2254
2371
|
program2.command("validate").description("Validate graph structural integrity and completeness signals").option("--scope <scope>", "Scope: all or node-path (default: all)", "all").action(async (options) => {
|
|
2255
2372
|
try {
|
|
2256
2373
|
const graph = await loadGraph(process.cwd(), { tolerateInvalidConfig: true });
|
|
2257
|
-
const
|
|
2374
|
+
const rawScope = (options.scope ?? "all").trim() || "all";
|
|
2375
|
+
const scope = rawScope === "all" ? "all" : rawScope.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2258
2376
|
const result = await validate(graph, scope);
|
|
2259
2377
|
process.stdout.write(`${result.nodesScanned} nodes scanned
|
|
2260
2378
|
|
|
@@ -2298,12 +2416,17 @@ import chalk2 from "chalk";
|
|
|
2298
2416
|
// src/io/drift-state-store.ts
|
|
2299
2417
|
import { readFile as readFile11, writeFile as writeFile3 } from "fs/promises";
|
|
2300
2418
|
import path10 from "path";
|
|
2301
|
-
import {
|
|
2419
|
+
import { parse as yamlParse } from "yaml";
|
|
2302
2420
|
var DRIFT_STATE_FILE = ".drift-state";
|
|
2303
2421
|
async function readDriftState(yggRoot) {
|
|
2304
2422
|
try {
|
|
2305
2423
|
const content = await readFile11(path10.join(yggRoot, DRIFT_STATE_FILE), "utf-8");
|
|
2306
|
-
|
|
2424
|
+
let raw;
|
|
2425
|
+
try {
|
|
2426
|
+
raw = JSON.parse(content);
|
|
2427
|
+
} catch {
|
|
2428
|
+
raw = yamlParse(content);
|
|
2429
|
+
}
|
|
2307
2430
|
if (!raw || typeof raw !== "object") return {};
|
|
2308
2431
|
const state = {};
|
|
2309
2432
|
for (const [key, value] of Object.entries(raw)) {
|
|
@@ -2317,7 +2440,7 @@ async function readDriftState(yggRoot) {
|
|
|
2317
2440
|
}
|
|
2318
2441
|
}
|
|
2319
2442
|
async function writeDriftState(yggRoot, state) {
|
|
2320
|
-
const content = stringify(state
|
|
2443
|
+
const content = JSON.stringify(state);
|
|
2321
2444
|
await writeFile3(path10.join(yggRoot, DRIFT_STATE_FILE), content, "utf-8");
|
|
2322
2445
|
}
|
|
2323
2446
|
|
|
@@ -2332,41 +2455,6 @@ async function hashFile(filePath) {
|
|
|
2332
2455
|
const content = await readFile12(filePath);
|
|
2333
2456
|
return createHash("sha256").update(content).digest("hex");
|
|
2334
2457
|
}
|
|
2335
|
-
async function collectDirectoryFileHashes(directoryPath, rootDirectoryPath, options) {
|
|
2336
|
-
let stack = options.gitignoreStack ?? [];
|
|
2337
|
-
try {
|
|
2338
|
-
const localContent = await readFile12(path11.join(directoryPath, ".gitignore"), "utf-8");
|
|
2339
|
-
const localMatcher = ignoreFactory();
|
|
2340
|
-
localMatcher.add(localContent);
|
|
2341
|
-
stack = [...stack, { basePath: directoryPath, matcher: localMatcher }];
|
|
2342
|
-
} catch {
|
|
2343
|
-
}
|
|
2344
|
-
const entries = await readdir5(directoryPath, { withFileTypes: true });
|
|
2345
|
-
const result = [];
|
|
2346
|
-
for (const entry of entries) {
|
|
2347
|
-
const absoluteChildPath = path11.join(directoryPath, entry.name);
|
|
2348
|
-
if (isIgnoredByStack(absoluteChildPath, stack)) {
|
|
2349
|
-
continue;
|
|
2350
|
-
}
|
|
2351
|
-
if (entry.isDirectory()) {
|
|
2352
|
-
const nested = await collectDirectoryFileHashes(
|
|
2353
|
-
absoluteChildPath,
|
|
2354
|
-
rootDirectoryPath,
|
|
2355
|
-
{ projectRoot: options.projectRoot, gitignoreStack: stack }
|
|
2356
|
-
);
|
|
2357
|
-
result.push(...nested);
|
|
2358
|
-
continue;
|
|
2359
|
-
}
|
|
2360
|
-
if (!entry.isFile()) {
|
|
2361
|
-
continue;
|
|
2362
|
-
}
|
|
2363
|
-
result.push({
|
|
2364
|
-
path: path11.relative(rootDirectoryPath, absoluteChildPath),
|
|
2365
|
-
hash: await hashFile(absoluteChildPath)
|
|
2366
|
-
});
|
|
2367
|
-
}
|
|
2368
|
-
return result;
|
|
2369
|
-
}
|
|
2370
2458
|
async function loadRootGitignoreStack(projectRoot) {
|
|
2371
2459
|
if (!projectRoot) return [];
|
|
2372
2460
|
try {
|
|
@@ -2389,33 +2477,95 @@ function isIgnoredByStack(candidatePath, stack) {
|
|
|
2389
2477
|
function hashString(content) {
|
|
2390
2478
|
return createHash("sha256").update(content).digest("hex");
|
|
2391
2479
|
}
|
|
2392
|
-
async function hashTrackedFiles(projectRoot, trackedFiles) {
|
|
2480
|
+
async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes) {
|
|
2393
2481
|
const fileHashes = {};
|
|
2482
|
+
const fileMtimes = {};
|
|
2394
2483
|
const gitignoreStack = await loadRootGitignoreStack(projectRoot);
|
|
2484
|
+
const allFiles = [];
|
|
2395
2485
|
for (const tf of trackedFiles) {
|
|
2396
2486
|
const absPath = path11.join(projectRoot, tf.path);
|
|
2397
2487
|
try {
|
|
2398
2488
|
const st = await stat3(absPath);
|
|
2399
2489
|
if (st.isDirectory()) {
|
|
2400
|
-
const
|
|
2490
|
+
const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
|
|
2401
2491
|
projectRoot,
|
|
2402
2492
|
gitignoreStack
|
|
2403
2493
|
});
|
|
2404
|
-
for (const entry of
|
|
2405
|
-
|
|
2406
|
-
|
|
2494
|
+
for (const entry of dirEntries) {
|
|
2495
|
+
allFiles.push({
|
|
2496
|
+
relPath: path11.join(tf.path, entry.relPath).replace(/\\/g, "/"),
|
|
2497
|
+
absPath: entry.absPath,
|
|
2498
|
+
mtimeMs: entry.mtimeMs
|
|
2499
|
+
});
|
|
2407
2500
|
}
|
|
2408
2501
|
} else {
|
|
2409
|
-
|
|
2502
|
+
allFiles.push({ relPath: tf.path, absPath, mtimeMs: st.mtimeMs });
|
|
2410
2503
|
}
|
|
2411
2504
|
} catch {
|
|
2412
2505
|
continue;
|
|
2413
2506
|
}
|
|
2414
2507
|
}
|
|
2508
|
+
const filtered = excludePrefixes?.length ? allFiles.filter((entry) => !excludePrefixes.some((prefix) => entry.relPath === prefix || entry.relPath.startsWith(prefix + "/"))) : allFiles;
|
|
2509
|
+
const dirty = [];
|
|
2510
|
+
for (const entry of filtered) {
|
|
2511
|
+
const storedMtime = storedFileData?.mtimes[entry.relPath];
|
|
2512
|
+
const storedHash = storedFileData?.hashes[entry.relPath];
|
|
2513
|
+
if (storedMtime !== void 0 && storedHash !== void 0 && entry.mtimeMs === storedMtime) {
|
|
2514
|
+
fileHashes[entry.relPath] = storedHash;
|
|
2515
|
+
} else {
|
|
2516
|
+
dirty.push(entry);
|
|
2517
|
+
}
|
|
2518
|
+
fileMtimes[entry.relPath] = entry.mtimeMs;
|
|
2519
|
+
}
|
|
2520
|
+
const BATCH_SIZE = 256;
|
|
2521
|
+
for (let i = 0; i < dirty.length; i += BATCH_SIZE) {
|
|
2522
|
+
const batch = dirty.slice(i, i + BATCH_SIZE);
|
|
2523
|
+
const hashes = await Promise.all(batch.map((e) => hashFile(e.absPath)));
|
|
2524
|
+
for (let j = 0; j < batch.length; j++) {
|
|
2525
|
+
fileHashes[batch[j].relPath] = hashes[j];
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2415
2528
|
const sorted = Object.entries(fileHashes).sort(([a], [b]) => a.localeCompare(b));
|
|
2416
2529
|
const digest = sorted.map(([p, h]) => `${p}:${h}`).join("\n");
|
|
2417
2530
|
const canonicalHash = hashString(digest);
|
|
2418
|
-
return { canonicalHash, fileHashes };
|
|
2531
|
+
return { canonicalHash, fileHashes, fileMtimes };
|
|
2532
|
+
}
|
|
2533
|
+
async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
|
|
2534
|
+
let stack = options.gitignoreStack ?? [];
|
|
2535
|
+
try {
|
|
2536
|
+
const localContent = await readFile12(path11.join(directoryPath, ".gitignore"), "utf-8");
|
|
2537
|
+
const localMatcher = ignoreFactory();
|
|
2538
|
+
localMatcher.add(localContent);
|
|
2539
|
+
stack = [...stack, { basePath: directoryPath, matcher: localMatcher }];
|
|
2540
|
+
} catch {
|
|
2541
|
+
}
|
|
2542
|
+
const entries = await readdir5(directoryPath, { withFileTypes: true });
|
|
2543
|
+
const dirs = [];
|
|
2544
|
+
const files = [];
|
|
2545
|
+
for (const entry of entries) {
|
|
2546
|
+
const absoluteChildPath = path11.join(directoryPath, entry.name);
|
|
2547
|
+
if (isIgnoredByStack(absoluteChildPath, stack)) continue;
|
|
2548
|
+
if (entry.isDirectory()) dirs.push(absoluteChildPath);
|
|
2549
|
+
else if (entry.isFile()) files.push(absoluteChildPath);
|
|
2550
|
+
}
|
|
2551
|
+
const [dirResults, fileStats] = await Promise.all([
|
|
2552
|
+
Promise.all(dirs.map((d) => collectDirectoryFilePaths(d, rootDirectoryPath, {
|
|
2553
|
+
projectRoot: options.projectRoot,
|
|
2554
|
+
gitignoreStack: stack
|
|
2555
|
+
}))),
|
|
2556
|
+
Promise.all(files.map(async (f) => {
|
|
2557
|
+
const fileStat = await stat3(f);
|
|
2558
|
+
return {
|
|
2559
|
+
relPath: path11.relative(rootDirectoryPath, f),
|
|
2560
|
+
absPath: f,
|
|
2561
|
+
mtimeMs: fileStat.mtimeMs
|
|
2562
|
+
};
|
|
2563
|
+
}))
|
|
2564
|
+
]);
|
|
2565
|
+
const result = [];
|
|
2566
|
+
for (const nested of dirResults) result.push(...nested);
|
|
2567
|
+
result.push(...fileStats);
|
|
2568
|
+
return result;
|
|
2419
2569
|
}
|
|
2420
2570
|
|
|
2421
2571
|
// src/core/context-files.ts
|
|
@@ -2511,12 +2661,32 @@ function collectParticipatingFlows2(graph, node, ancestors) {
|
|
|
2511
2661
|
// src/core/drift-detector.ts
|
|
2512
2662
|
import { access } from "fs/promises";
|
|
2513
2663
|
import path13 from "path";
|
|
2664
|
+
function getChildMappingExclusions(graph, nodePath) {
|
|
2665
|
+
const node = graph.nodes.get(nodePath);
|
|
2666
|
+
if (!node) return [];
|
|
2667
|
+
const parentMappings = normalizeMappingPaths(node.meta.mapping);
|
|
2668
|
+
if (parentMappings.length === 0) return [];
|
|
2669
|
+
const exclusions = [];
|
|
2670
|
+
for (const [childPath, childNode] of graph.nodes) {
|
|
2671
|
+
if (childPath === nodePath) continue;
|
|
2672
|
+
if (!childPath.startsWith(nodePath + "/")) continue;
|
|
2673
|
+
const childMappings = normalizeMappingPaths(childNode.meta.mapping);
|
|
2674
|
+
for (const cm of childMappings) {
|
|
2675
|
+
for (const pm of parentMappings) {
|
|
2676
|
+
if (cm === pm || cm.startsWith(pm + "/")) {
|
|
2677
|
+
exclusions.push(cm);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
return exclusions;
|
|
2683
|
+
}
|
|
2514
2684
|
async function detectDrift(graph, filterNodePath) {
|
|
2515
2685
|
const projectRoot = path13.dirname(graph.rootPath);
|
|
2516
2686
|
const driftState = await readDriftState(graph.rootPath);
|
|
2517
2687
|
const entries = [];
|
|
2518
2688
|
for (const [nodePath, node] of graph.nodes) {
|
|
2519
|
-
if (filterNodePath && nodePath !== filterNodePath) continue;
|
|
2689
|
+
if (filterNodePath && nodePath !== filterNodePath && !nodePath.startsWith(filterNodePath + "/")) continue;
|
|
2520
2690
|
const mapping = node.meta.mapping;
|
|
2521
2691
|
if (!mapping) continue;
|
|
2522
2692
|
const mappingPaths = normalizeMappingPaths(mapping);
|
|
@@ -2541,7 +2711,9 @@ async function detectDrift(graph, filterNodePath) {
|
|
|
2541
2711
|
continue;
|
|
2542
2712
|
}
|
|
2543
2713
|
const trackedFiles = collectTrackedFiles(node, graph);
|
|
2544
|
-
const
|
|
2714
|
+
const excludePrefixes = getChildMappingExclusions(graph, nodePath);
|
|
2715
|
+
const storedFileData = storedEntry.files ? { hashes: storedEntry.files, mtimes: storedEntry.mtimes ?? {} } : void 0;
|
|
2716
|
+
const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes);
|
|
2545
2717
|
if (canonicalHash === storedEntry.hash) {
|
|
2546
2718
|
entries.push({ nodePath, status: "ok" });
|
|
2547
2719
|
continue;
|
|
@@ -2614,20 +2786,29 @@ async function syncDriftState(graph, nodePath) {
|
|
|
2614
2786
|
if (!node) throw new Error(`Node not found: ${nodePath}`);
|
|
2615
2787
|
if (!node.meta.mapping) throw new Error(`Node has no mapping: ${nodePath}`);
|
|
2616
2788
|
const trackedFiles = collectTrackedFiles(node, graph);
|
|
2617
|
-
const
|
|
2618
|
-
const
|
|
2619
|
-
const
|
|
2620
|
-
|
|
2621
|
-
await
|
|
2789
|
+
const excludePrefixes = getChildMappingExclusions(graph, nodePath);
|
|
2790
|
+
const existingState = await readDriftState(graph.rootPath);
|
|
2791
|
+
const existingEntry = existingState[nodePath];
|
|
2792
|
+
const storedFileData = existingEntry?.files ? { hashes: existingEntry.files, mtimes: existingEntry.mtimes ?? {} } : void 0;
|
|
2793
|
+
const { canonicalHash, fileHashes, fileMtimes } = await hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes);
|
|
2794
|
+
const previousHash = existingEntry?.hash;
|
|
2795
|
+
existingState[nodePath] = { hash: canonicalHash, files: fileHashes, mtimes: fileMtimes };
|
|
2796
|
+
for (const key of Object.keys(existingState)) {
|
|
2797
|
+
if (!graph.nodes.has(key)) {
|
|
2798
|
+
delete existingState[key];
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
await writeDriftState(graph.rootPath, existingState);
|
|
2622
2802
|
return { previousHash, currentHash: canonicalHash };
|
|
2623
2803
|
}
|
|
2624
2804
|
|
|
2625
2805
|
// src/cli/drift.ts
|
|
2626
2806
|
function registerDriftCommand(program2) {
|
|
2627
|
-
program2.command("drift").description("Detect divergences between graph and mapped files").option("--scope <scope>", 'Scope: "all" or node path', "all").option("--drifted-only", "Show only nodes with drift (hide ok entries)").action(async (opts) => {
|
|
2807
|
+
program2.command("drift").description("Detect divergences between graph and mapped files").option("--scope <scope>", 'Scope: "all" or node path', "all").option("--drifted-only", "Show only nodes with drift (hide ok entries)").option("--limit <n>", "Maximum number of entries to show per section", parseInt).action(async (opts) => {
|
|
2628
2808
|
try {
|
|
2629
2809
|
const graph = await loadGraph(process.cwd());
|
|
2630
|
-
const
|
|
2810
|
+
const rawScope = (opts.scope ?? "all").trim() || "all";
|
|
2811
|
+
const scope = rawScope === "all" ? "all" : rawScope.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2631
2812
|
if (scope !== "all") {
|
|
2632
2813
|
const node = graph.nodes.get(scope);
|
|
2633
2814
|
if (!node) {
|
|
@@ -2635,7 +2816,8 @@ function registerDriftCommand(program2) {
|
|
|
2635
2816
|
`);
|
|
2636
2817
|
process.exit(1);
|
|
2637
2818
|
}
|
|
2638
|
-
|
|
2819
|
+
const hasAnyMapping = node.meta.mapping || [...graph.nodes.entries()].some(([p, n]) => p.startsWith(scope + "/") && n.meta.mapping);
|
|
2820
|
+
if (!hasAnyMapping) {
|
|
2639
2821
|
process.stderr.write(`Error: Node has no mapping: ${scope}
|
|
2640
2822
|
`);
|
|
2641
2823
|
process.exit(1);
|
|
@@ -2643,7 +2825,7 @@ function registerDriftCommand(program2) {
|
|
|
2643
2825
|
}
|
|
2644
2826
|
const scopeNode = scope === "all" ? void 0 : scope;
|
|
2645
2827
|
const report = await detectDrift(graph, scopeNode);
|
|
2646
|
-
printReport(report, opts.driftedOnly ?? false);
|
|
2828
|
+
printReport(report, opts.driftedOnly ?? false, opts.limit);
|
|
2647
2829
|
const hasIssues = report.sourceDriftCount > 0 || report.graphDriftCount > 0 || report.fullDriftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0;
|
|
2648
2830
|
process.exit(hasIssues ? 1 : 0);
|
|
2649
2831
|
} catch (error) {
|
|
@@ -2653,13 +2835,23 @@ function registerDriftCommand(program2) {
|
|
|
2653
2835
|
}
|
|
2654
2836
|
});
|
|
2655
2837
|
}
|
|
2656
|
-
function printReport(report, driftedOnly) {
|
|
2838
|
+
function printReport(report, driftedOnly, limit) {
|
|
2657
2839
|
const sourceEntries = classifyForSection(report.entries, "source", driftedOnly);
|
|
2658
2840
|
const graphEntries = classifyForSection(report.entries, "graph", driftedOnly);
|
|
2841
|
+
const sourceShown = limit !== void 0 ? sourceEntries.slice(0, limit) : sourceEntries;
|
|
2842
|
+
const graphShown = limit !== void 0 ? graphEntries.slice(0, limit) : graphEntries;
|
|
2659
2843
|
process.stdout.write("Source drift:\n");
|
|
2660
|
-
printSectionEntries(
|
|
2844
|
+
printSectionEntries(sourceShown, "source");
|
|
2845
|
+
if (limit !== void 0 && sourceEntries.length > limit) {
|
|
2846
|
+
process.stdout.write(chalk2.dim(` ... ${sourceEntries.length - limit} more (${sourceEntries.length} total)
|
|
2847
|
+
`));
|
|
2848
|
+
}
|
|
2661
2849
|
process.stdout.write("\nGraph drift:\n");
|
|
2662
|
-
printSectionEntries(
|
|
2850
|
+
printSectionEntries(graphShown, "graph");
|
|
2851
|
+
if (limit !== void 0 && graphEntries.length > limit) {
|
|
2852
|
+
process.stdout.write(chalk2.dim(` ... ${graphEntries.length - limit} more (${graphEntries.length} total)
|
|
2853
|
+
`));
|
|
2854
|
+
}
|
|
2663
2855
|
const parts = [
|
|
2664
2856
|
`${report.sourceDriftCount} source-drift`,
|
|
2665
2857
|
`${report.graphDriftCount} graph-drift`,
|
|
@@ -2745,17 +2937,49 @@ function printChangedFiles(entry, section) {
|
|
|
2745
2937
|
// src/cli/drift-sync.ts
|
|
2746
2938
|
import chalk3 from "chalk";
|
|
2747
2939
|
function registerDriftSyncCommand(program2) {
|
|
2748
|
-
program2.command("drift-sync").description("Record current file hash after resolving drift").
|
|
2940
|
+
program2.command("drift-sync").description("Record current file hash after resolving drift").option("--node <path>", "Node path to sync").option("--recursive", "Also sync all descendant nodes").option("--all", "Sync all nodes with mappings").action(async (options) => {
|
|
2749
2941
|
try {
|
|
2942
|
+
if (!options.node && !options.all) {
|
|
2943
|
+
process.stderr.write("Error: either '--node <path>' or '--all' is required\n");
|
|
2944
|
+
process.exit(1);
|
|
2945
|
+
}
|
|
2750
2946
|
const graph = await loadGraph(process.cwd());
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2947
|
+
let nodesToSync;
|
|
2948
|
+
if (options.all) {
|
|
2949
|
+
nodesToSync = [...graph.nodes.entries()].filter(([, n]) => normalizeMappingPaths(n.meta.mapping).length > 0).map(([p]) => p).sort();
|
|
2950
|
+
} else {
|
|
2951
|
+
const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2952
|
+
if (!graph.nodes.has(nodePath)) {
|
|
2953
|
+
await syncDriftState(graph, nodePath);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
nodesToSync = [nodePath];
|
|
2957
|
+
if (options.recursive) {
|
|
2958
|
+
const prefix = nodePath + "/";
|
|
2959
|
+
for (const [p] of graph.nodes) {
|
|
2960
|
+
if (p.startsWith(prefix)) {
|
|
2961
|
+
nodesToSync.push(p);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
nodesToSync.sort();
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
for (const np of nodesToSync) {
|
|
2968
|
+
const node = graph.nodes.get(np);
|
|
2969
|
+
if (normalizeMappingPaths(node.meta.mapping).length === 0) {
|
|
2970
|
+
if (!options.all && !options.recursive && np === options.node) {
|
|
2971
|
+
await syncDriftState(graph, np);
|
|
2972
|
+
}
|
|
2973
|
+
continue;
|
|
2974
|
+
}
|
|
2975
|
+
const { previousHash, currentHash } = await syncDriftState(graph, np);
|
|
2976
|
+
process.stdout.write(chalk3.green(`Synchronized: ${np}
|
|
2754
2977
|
`));
|
|
2755
|
-
|
|
2756
|
-
|
|
2978
|
+
process.stdout.write(
|
|
2979
|
+
` Hash: ${previousHash ? previousHash.slice(0, 8) : "none"} -> ${currentHash.slice(0, 8)}
|
|
2757
2980
|
`
|
|
2758
|
-
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2759
2983
|
} catch (error) {
|
|
2760
2984
|
process.stderr.write(`Error: ${error.message}
|
|
2761
2985
|
`);
|
|
@@ -2872,10 +3096,10 @@ function registerTreeCommand(program2) {
|
|
|
2872
3096
|
let roots;
|
|
2873
3097
|
let showProjectName;
|
|
2874
3098
|
if (options.root?.trim()) {
|
|
2875
|
-
const
|
|
2876
|
-
const node = graph.nodes.get(
|
|
3099
|
+
const path18 = options.root.trim().replace(/\/$/, "");
|
|
3100
|
+
const node = graph.nodes.get(path18);
|
|
2877
3101
|
if (!node) {
|
|
2878
|
-
process.stderr.write(`Error: path '${
|
|
3102
|
+
process.stderr.write(`Error: path '${path18}' not found
|
|
2879
3103
|
`);
|
|
2880
3104
|
process.exit(1);
|
|
2881
3105
|
}
|
|
@@ -2919,6 +3143,8 @@ function printNode(node, prefix, isLast, depth, maxDepth) {
|
|
|
2919
3143
|
}
|
|
2920
3144
|
|
|
2921
3145
|
// src/cli/owner.ts
|
|
3146
|
+
import path14 from "path";
|
|
3147
|
+
import { access as access2 } from "fs/promises";
|
|
2922
3148
|
function normalizeForMatch(inputPath) {
|
|
2923
3149
|
return inputPath.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
2924
3150
|
}
|
|
@@ -2943,12 +3169,28 @@ function findOwner(graph, projectRoot, rawPath) {
|
|
|
2943
3169
|
function registerOwnerCommand(program2) {
|
|
2944
3170
|
program2.command("owner").description("Find which graph node owns a source file").requiredOption("--file <path>", "File path (relative to repository root)").action(async (options) => {
|
|
2945
3171
|
try {
|
|
2946
|
-
const
|
|
2947
|
-
const graph = await loadGraph(
|
|
2948
|
-
const
|
|
3172
|
+
const cwd = process.cwd();
|
|
3173
|
+
const graph = await loadGraph(cwd);
|
|
3174
|
+
const repoRoot = projectRootFromGraph(graph.rootPath);
|
|
3175
|
+
const rawPath = options.file.trim();
|
|
3176
|
+
const absolute = path14.resolve(cwd, rawPath);
|
|
3177
|
+
const repoRelative = path14.relative(repoRoot, absolute).split(path14.sep).join("/");
|
|
3178
|
+
const result = findOwner(graph, repoRoot, repoRelative);
|
|
2949
3179
|
if (!result.nodePath) {
|
|
2950
|
-
|
|
3180
|
+
const absPath = path14.resolve(repoRoot, result.file);
|
|
3181
|
+
let exists = true;
|
|
3182
|
+
try {
|
|
3183
|
+
await access2(absPath);
|
|
3184
|
+
} catch {
|
|
3185
|
+
exists = false;
|
|
3186
|
+
}
|
|
3187
|
+
if (exists) {
|
|
3188
|
+
process.stdout.write(`${result.file} -> no graph coverage
|
|
3189
|
+
`);
|
|
3190
|
+
} else {
|
|
3191
|
+
process.stdout.write(`${result.file} -> no graph coverage (file not found)
|
|
2951
3192
|
`);
|
|
3193
|
+
}
|
|
2952
3194
|
} else {
|
|
2953
3195
|
process.stdout.write(`${result.file} -> ${result.nodePath}
|
|
2954
3196
|
`);
|
|
@@ -2963,7 +3205,7 @@ function registerOwnerCommand(program2) {
|
|
|
2963
3205
|
|
|
2964
3206
|
// src/core/dependency-resolver.ts
|
|
2965
3207
|
import { execSync } from "child_process";
|
|
2966
|
-
import
|
|
3208
|
+
import path15 from "path";
|
|
2967
3209
|
var STRUCTURAL_RELATION_TYPES3 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
|
|
2968
3210
|
var EVENT_RELATION_TYPES2 = /* @__PURE__ */ new Set(["emits", "listens"]);
|
|
2969
3211
|
function filterRelationType(relType, filter) {
|
|
@@ -3023,7 +3265,7 @@ function registerDepsCommand(program2) {
|
|
|
3023
3265
|
try {
|
|
3024
3266
|
const graph = await loadGraph(process.cwd());
|
|
3025
3267
|
const typeFilter = options.type === "structural" || options.type === "event" || options.type === "all" ? options.type : "all";
|
|
3026
|
-
const nodePath = options.node.trim().replace(
|
|
3268
|
+
const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3027
3269
|
const text = formatDependencyTree(graph, nodePath, {
|
|
3028
3270
|
depth: options.depth,
|
|
3029
3271
|
relationType: typeFilter
|
|
@@ -3040,7 +3282,7 @@ function registerDepsCommand(program2) {
|
|
|
3040
3282
|
// src/core/graph-from-git.ts
|
|
3041
3283
|
import { mkdtemp, rm } from "fs/promises";
|
|
3042
3284
|
import { tmpdir } from "os";
|
|
3043
|
-
import
|
|
3285
|
+
import path16 from "path";
|
|
3044
3286
|
import { execSync as execSync2 } from "child_process";
|
|
3045
3287
|
async function loadGraphFromRef(projectRoot, ref = "HEAD") {
|
|
3046
3288
|
const yggPath = ".yggdrasil";
|
|
@@ -3051,8 +3293,8 @@ async function loadGraphFromRef(projectRoot, ref = "HEAD") {
|
|
|
3051
3293
|
return null;
|
|
3052
3294
|
}
|
|
3053
3295
|
try {
|
|
3054
|
-
tmpDir = await mkdtemp(
|
|
3055
|
-
const archivePath =
|
|
3296
|
+
tmpDir = await mkdtemp(path16.join(tmpdir(), "ygg-git-"));
|
|
3297
|
+
const archivePath = path16.join(tmpDir, "archive.tar");
|
|
3056
3298
|
execSync2(`git archive ${ref} ${yggPath} -o "${archivePath}"`, {
|
|
3057
3299
|
cwd: projectRoot,
|
|
3058
3300
|
stdio: "pipe"
|
|
@@ -3122,14 +3364,14 @@ function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
|
|
|
3122
3364
|
}
|
|
3123
3365
|
const chains = [];
|
|
3124
3366
|
for (const node of transitiveOnly) {
|
|
3125
|
-
const
|
|
3367
|
+
const path18 = [];
|
|
3126
3368
|
let current = node;
|
|
3127
3369
|
while (current) {
|
|
3128
|
-
|
|
3370
|
+
path18.unshift(current);
|
|
3129
3371
|
current = parent.get(current);
|
|
3130
3372
|
}
|
|
3131
|
-
if (
|
|
3132
|
-
chains.push(
|
|
3373
|
+
if (path18.length >= 3) {
|
|
3374
|
+
chains.push(path18.slice(1).map((p) => `<- ${p}`).join(" "));
|
|
3133
3375
|
}
|
|
3134
3376
|
}
|
|
3135
3377
|
return chains.sort();
|
|
@@ -3331,7 +3573,7 @@ function registerImpactCommand(program2) {
|
|
|
3331
3573
|
await handleFlowImpact(graph, options.flow.trim(), options.simulate);
|
|
3332
3574
|
return;
|
|
3333
3575
|
}
|
|
3334
|
-
const nodePath = options.node.trim().replace(
|
|
3576
|
+
const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3335
3577
|
if (!graph.nodes.has(nodePath)) {
|
|
3336
3578
|
process.stderr.write(`Node not found: ${nodePath}
|
|
3337
3579
|
`);
|
|
@@ -3463,14 +3705,47 @@ function registerAspectsCommand(program2) {
|
|
|
3463
3705
|
});
|
|
3464
3706
|
}
|
|
3465
3707
|
|
|
3708
|
+
// src/cli/flows.ts
|
|
3709
|
+
import { stringify as yamlStringify2 } from "yaml";
|
|
3710
|
+
function registerFlowsCommand(program2) {
|
|
3711
|
+
program2.command("flows").description("List flows with metadata (YAML output)").action(async () => {
|
|
3712
|
+
try {
|
|
3713
|
+
const yggRoot = await findYggRoot(process.cwd());
|
|
3714
|
+
const graph = await loadGraph(yggRoot);
|
|
3715
|
+
const output = graph.flows.sort((a, b) => a.name.localeCompare(b.name)).map((flow) => {
|
|
3716
|
+
const entry = {
|
|
3717
|
+
name: flow.name,
|
|
3718
|
+
participants: flow.nodes.length,
|
|
3719
|
+
nodes: flow.nodes.sort()
|
|
3720
|
+
};
|
|
3721
|
+
if (flow.aspects && flow.aspects.length > 0) entry.aspects = flow.aspects;
|
|
3722
|
+
return entry;
|
|
3723
|
+
});
|
|
3724
|
+
process.stdout.write(yamlStringify2(output));
|
|
3725
|
+
} catch (error) {
|
|
3726
|
+
const err = error;
|
|
3727
|
+
if (err.code === "ENOENT") {
|
|
3728
|
+
process.stderr.write(
|
|
3729
|
+
`Error: No .yggdrasil/ directory found. Run 'yg init' first.
|
|
3730
|
+
`
|
|
3731
|
+
);
|
|
3732
|
+
} else {
|
|
3733
|
+
process.stderr.write(`Error: ${error.message}
|
|
3734
|
+
`);
|
|
3735
|
+
}
|
|
3736
|
+
process.exit(1);
|
|
3737
|
+
}
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3466
3741
|
// src/io/journal-store.ts
|
|
3467
|
-
import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as
|
|
3742
|
+
import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access3 } from "fs/promises";
|
|
3468
3743
|
import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
|
|
3469
|
-
import
|
|
3744
|
+
import path17 from "path";
|
|
3470
3745
|
var JOURNAL_FILE = ".journal.yaml";
|
|
3471
3746
|
var ARCHIVE_DIR = "journals-archive";
|
|
3472
3747
|
async function readJournal(yggRoot) {
|
|
3473
|
-
const filePath =
|
|
3748
|
+
const filePath = path17.join(yggRoot, JOURNAL_FILE);
|
|
3474
3749
|
try {
|
|
3475
3750
|
const content = await readFile13(filePath, "utf-8");
|
|
3476
3751
|
const raw = parseYaml6(content);
|
|
@@ -3485,26 +3760,26 @@ async function appendJournalEntry(yggRoot, note, target) {
|
|
|
3485
3760
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3486
3761
|
const entry = target ? { at, target, note } : { at, note };
|
|
3487
3762
|
entries.push(entry);
|
|
3488
|
-
const filePath =
|
|
3763
|
+
const filePath = path17.join(yggRoot, JOURNAL_FILE);
|
|
3489
3764
|
const content = stringifyYaml({ entries });
|
|
3490
3765
|
await writeFile4(filePath, content, "utf-8");
|
|
3491
3766
|
return entry;
|
|
3492
3767
|
}
|
|
3493
3768
|
async function archiveJournal(yggRoot) {
|
|
3494
|
-
const journalPath =
|
|
3769
|
+
const journalPath = path17.join(yggRoot, JOURNAL_FILE);
|
|
3495
3770
|
try {
|
|
3496
|
-
await
|
|
3771
|
+
await access3(journalPath);
|
|
3497
3772
|
} catch {
|
|
3498
3773
|
return null;
|
|
3499
3774
|
}
|
|
3500
3775
|
const entries = await readJournal(yggRoot);
|
|
3501
3776
|
if (entries.length === 0) return null;
|
|
3502
|
-
const archiveDir =
|
|
3777
|
+
const archiveDir = path17.join(yggRoot, ARCHIVE_DIR);
|
|
3503
3778
|
await mkdir3(archiveDir, { recursive: true });
|
|
3504
3779
|
const now = /* @__PURE__ */ new Date();
|
|
3505
3780
|
const timestamp = `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(now.getUTCDate()).padStart(2, "0")}-${String(now.getUTCHours()).padStart(2, "0")}${String(now.getUTCMinutes()).padStart(2, "0")}${String(now.getUTCSeconds()).padStart(2, "0")}`;
|
|
3506
3781
|
const archiveName = `.journal.${timestamp}.yaml`;
|
|
3507
|
-
const archivePath =
|
|
3782
|
+
const archivePath = path17.join(archiveDir, archiveName);
|
|
3508
3783
|
await rename(journalPath, archivePath);
|
|
3509
3784
|
return { archiveName, entryCount: entries.length };
|
|
3510
3785
|
}
|
|
@@ -3582,14 +3857,13 @@ function registerJournalArchiveCommand(program2) {
|
|
|
3582
3857
|
|
|
3583
3858
|
// src/cli/preflight.ts
|
|
3584
3859
|
function registerPreflightCommand(program2) {
|
|
3585
|
-
program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").action(async () => {
|
|
3860
|
+
program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").option("--quick", "Skip drift detection for faster results").action(async (options) => {
|
|
3586
3861
|
try {
|
|
3587
3862
|
const cwd = process.cwd();
|
|
3588
3863
|
const graph = await loadGraph(cwd);
|
|
3589
3864
|
const yggRoot = await findYggRoot(cwd);
|
|
3590
3865
|
const journalEntries = await readJournal(yggRoot);
|
|
3591
|
-
const
|
|
3592
|
-
const driftedEntries = drift.entries.filter((e) => e.status !== "ok");
|
|
3866
|
+
const driftedEntries = options.quick ? [] : (await detectDrift(graph)).entries.filter((e) => e.status !== "ok");
|
|
3593
3867
|
const nodeCount = graph.nodes.size;
|
|
3594
3868
|
const aspectCount = graph.aspects.length;
|
|
3595
3869
|
const flowCount = graph.flows.length;
|
|
@@ -3613,7 +3887,9 @@ function registerPreflightCommand(program2) {
|
|
|
3613
3887
|
}
|
|
3614
3888
|
}
|
|
3615
3889
|
lines.push("");
|
|
3616
|
-
if (
|
|
3890
|
+
if (options.quick) {
|
|
3891
|
+
lines.push("Drift: skipped (--quick)");
|
|
3892
|
+
} else if (driftedEntries.length === 0) {
|
|
3617
3893
|
lines.push("Drift: clean");
|
|
3618
3894
|
} else {
|
|
3619
3895
|
lines.push(`Drift: ${driftedEntries.length} nodes need attention`);
|
|
@@ -3625,6 +3901,12 @@ function registerPreflightCommand(program2) {
|
|
|
3625
3901
|
lines.push(
|
|
3626
3902
|
`Status: ${nodeCount} nodes, ${aspectCount} aspects, ${flowCount} flows, ${mappedPathCount} mapped paths`
|
|
3627
3903
|
);
|
|
3904
|
+
if (nodeCount === 0) {
|
|
3905
|
+
lines.push("");
|
|
3906
|
+
lines.push(" \u26A1 No nodes found. Enter BOOTSTRAP MODE:");
|
|
3907
|
+
lines.push(" Create nodes under .yggdrasil/model/ for your active work area.");
|
|
3908
|
+
lines.push(" See: yg help build-context");
|
|
3909
|
+
}
|
|
3628
3910
|
lines.push("");
|
|
3629
3911
|
if (errors.length === 0 && warnings.length === 0) {
|
|
3630
3912
|
lines.push("Validation: clean");
|
|
@@ -3635,12 +3917,13 @@ function registerPreflightCommand(program2) {
|
|
|
3635
3917
|
lines.push(`Validation: ${parts.join(", ")}`);
|
|
3636
3918
|
for (const issue of [...errors, ...warnings]) {
|
|
3637
3919
|
const code = issue.code ? `[${issue.code}] ` : "";
|
|
3638
|
-
|
|
3920
|
+
const loc = issue.nodePath ? `${issue.nodePath} -> ` : "";
|
|
3921
|
+
lines.push(` - ${code}${loc}${issue.message}`);
|
|
3639
3922
|
}
|
|
3640
3923
|
}
|
|
3641
3924
|
lines.push("");
|
|
3642
3925
|
process.stdout.write(lines.join("\n"));
|
|
3643
|
-
const hasIssues = journalEntries.length > 0 || driftedEntries.length > 0 || errors.length > 0;
|
|
3926
|
+
const hasIssues = journalEntries.length > 0 || !options.quick && driftedEntries.length > 0 || errors.length > 0;
|
|
3644
3927
|
process.exit(hasIssues ? 1 : 0);
|
|
3645
3928
|
} catch (error) {
|
|
3646
3929
|
process.stderr.write(`Error: ${error.message}
|
|
@@ -3670,6 +3953,7 @@ registerOwnerCommand(program);
|
|
|
3670
3953
|
registerDepsCommand(program);
|
|
3671
3954
|
registerImpactCommand(program);
|
|
3672
3955
|
registerAspectsCommand(program);
|
|
3956
|
+
registerFlowsCommand(program);
|
|
3673
3957
|
registerJournalAddCommand(program);
|
|
3674
3958
|
registerJournalReadCommand(program);
|
|
3675
3959
|
registerJournalArchiveCommand(program);
|