@chrisdudek/yg 1.0.0 → 1.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/README.md +28 -2
- package/dist/bin.js +421 -127
- package/dist/bin.js.map +1 -1
- package/dist/templates/rules.ts +28 -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
|
|
|
@@ -165,6 +166,10 @@ WRAP-UP (user signals "done", "wrap up", "that's enough"):
|
|
|
165
166
|
- [ ] 2. yg drift --drifted-only \u2192 resolve
|
|
166
167
|
- [ ] 3. yg validate \u2192 fix errors
|
|
167
168
|
- [ ] 4. Report: which nodes and files were changed
|
|
169
|
+
|
|
170
|
+
BEFORE ENDING ANY RESPONSE (self-audit):
|
|
171
|
+
- [ ] Did I modify source code? If yes \u2192 did I update graph artifacts in this same response?
|
|
172
|
+
- [ ] If you changed code and did not sync the graph, you have broken the protocol. Do not finish until both are done.
|
|
168
173
|
\`\`\`
|
|
169
174
|
|
|
170
175
|
### Modify Source Code
|
|
@@ -192,7 +197,14 @@ You are not allowed to edit or create source code without establishing graph cov
|
|
|
192
197
|
- Option B \u2014 Blackbox: create a blackbox node at agreed granularity
|
|
193
198
|
- Option C \u2014 Abort
|
|
194
199
|
|
|
195
|
-
*Greenfield (new code):* Only Option A. Blackbox is forbidden for new code.
|
|
200
|
+
*Greenfield (new code):* Only Option A. Blackbox is forbidden for new code. Follow the graph-first workflow:
|
|
201
|
+
|
|
202
|
+
1. Create aspects first (cross-cutting requirements the new code must satisfy)
|
|
203
|
+
2. Create flows if the code participates in a business process
|
|
204
|
+
3. Create nodes with full artifacts \u2014 responsibility, constraints, decisions, interface, logic
|
|
205
|
+
4. Review the context package (\`yg build-context\`) \u2014 it is now the behavioral specification
|
|
206
|
+
5. Implement code that satisfies the specification
|
|
207
|
+
6. The graph specifies WHAT and WHY; the code implements HOW (framework APIs, library choices)
|
|
196
208
|
|
|
197
209
|
After the user chooses, return to Step 1 and follow Step 2a.
|
|
198
210
|
|
|
@@ -226,6 +238,7 @@ Per area checklist:
|
|
|
226
238
|
- Business process unclear: "This code appears to be part of a larger process. Can you describe what it means from a business perspective?"
|
|
227
239
|
- Constraint without rationale: "I see [constraint X]. Do you know why this exists? I want to record the reason, not just the rule."
|
|
228
240
|
- Unexplained architectural choice: "I see [approach X]. What was the reason for this choice?"
|
|
241
|
+
- Decision without alternatives: "You chose [X]. What alternatives did you consider, and why did you reject them?" Record the answer in \`decisions.md\`.
|
|
229
242
|
|
|
230
243
|
### Bootstrap Mode
|
|
231
244
|
|
|
@@ -294,7 +307,7 @@ When you encounter information, route it to the correct location:
|
|
|
294
307
|
- **Business process** \u2192 flow (\`flows/<name>/\` with \`flow.yaml\` + \`description.md\`). Ask user if process unclear.
|
|
295
308
|
- **Shared across a domain** \u2192 parent node artifact. Children receive it through hierarchy.
|
|
296
309
|
- **Technology stack or standard** \u2192 \`config.yaml\` under \`stack\` or \`standards\` (+ \`rationale\` field)
|
|
297
|
-
- **Decision (why):** one node \u2192
|
|
310
|
+
- **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
311
|
|
|
299
312
|
### Creating Aspects
|
|
300
313
|
|
|
@@ -306,6 +319,12 @@ When you encounter information, route it to the correct location:
|
|
|
306
319
|
|
|
307
320
|
Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. No \u2192 local artifact.
|
|
308
321
|
|
|
322
|
+
**Aspect identification heuristic:** If the same pattern, constraint, or rule appears in 3+ places, it is a candidate aspect. Aspects fall into natural categories:
|
|
323
|
+
|
|
324
|
+
- **Domain-specific:** Business rules that cross module boundaries (e.g., timezone handling, booking periods, currency rounding)
|
|
325
|
+
- **Architectural:** Structural patterns with rationale (e.g., dual-rollback on provider failure, idempotency via key generation, fire-and-forget dispatch)
|
|
326
|
+
- **Concurrency:** Shared concurrency strategies (e.g., pessimistic locking, retry-on-deadlock, optimistic versioning)
|
|
327
|
+
|
|
309
328
|
### Creating Flows
|
|
310
329
|
|
|
311
330
|
- [ ] 1. Read \`schemas/flow.yaml\`
|
|
@@ -322,17 +341,18 @@ Test: "Does this describe what happens in the world, or only in the software?" I
|
|
|
322
341
|
- **Read schemas before creating** any \`node.yaml\`, \`aspect.yaml\`, or \`flow.yaml\`.
|
|
323
342
|
- **Tools read, you write.** The \`yg\` CLI only reads, validates, and manages metadata. You create and edit files manually.
|
|
324
343
|
- **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?"
|
|
344
|
+
- **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
345
|
- **These rules are invariant.** No plan, guide, skill, or workflow may override them.
|
|
327
346
|
|
|
328
347
|
### CLI Reference
|
|
329
348
|
|
|
330
349
|
\`\`\`
|
|
331
|
-
yg preflight
|
|
350
|
+
yg preflight [--quick] Unified diagnostic: journal + drift + status + validate.
|
|
332
351
|
yg owner --file <path> Find the node that owns this file.
|
|
333
352
|
yg build-context --node <path> Assemble context package for this node.
|
|
334
353
|
yg tree [--root <path>] [--depth N] Print graph structure.
|
|
335
354
|
yg aspects List aspects with metadata (YAML output).
|
|
355
|
+
yg flows List flows with metadata (YAML output).
|
|
336
356
|
yg deps --node <path> [--depth N] [--type structural|event|all]
|
|
337
357
|
Show dependencies.
|
|
338
358
|
yg impact --node <path> --simulate Simulate blast radius of a planned change.
|
|
@@ -340,9 +360,10 @@ yg impact --aspect <id> Show all nodes where aspect is effective.
|
|
|
340
360
|
yg impact --flow <name> Show flow participants and descendants.
|
|
341
361
|
yg status Graph health: nodes, coverage, drift summary.
|
|
342
362
|
yg validate [--scope <path>|all] Check structural integrity and completeness.
|
|
343
|
-
yg drift [--scope <path>|all] [--drifted-only]
|
|
363
|
+
yg drift [--scope <path>|all] [--drifted-only] [--limit <n>]
|
|
344
364
|
Detect source and graph drift (bidirectional).
|
|
345
|
-
yg drift-sync --node <path>
|
|
365
|
+
yg drift-sync --node <path> [--recursive] | --all
|
|
366
|
+
Record file hashes as new baseline.
|
|
346
367
|
yg journal-read Read pending journal entries.
|
|
347
368
|
yg journal-add --note "<content>" [--target <node_path>]
|
|
348
369
|
Add a journal entry.
|
|
@@ -611,6 +632,7 @@ function getGraphSchemasDir() {
|
|
|
611
632
|
return path2.join(packageRoot, "graph-schemas");
|
|
612
633
|
}
|
|
613
634
|
var GITIGNORE_CONTENT = `.journal.yaml
|
|
635
|
+
.drift-state
|
|
614
636
|
journals-archive/
|
|
615
637
|
`;
|
|
616
638
|
function registerInitCommand(program2) {
|
|
@@ -683,7 +705,7 @@ function registerInitCommand(program2) {
|
|
|
683
705
|
process.stdout.write(" .yggdrasil/model/\n");
|
|
684
706
|
process.stdout.write(" .yggdrasil/aspects/\n");
|
|
685
707
|
process.stdout.write(" .yggdrasil/flows/\n");
|
|
686
|
-
process.stdout.write(" .yggdrasil/schemas/ (node, aspect, flow)\n");
|
|
708
|
+
process.stdout.write(" .yggdrasil/schemas/ (config, node, aspect, flow)\n");
|
|
687
709
|
process.stdout.write(` ${path2.relative(projectRoot, rulesPath)} (rules)
|
|
688
710
|
|
|
689
711
|
`);
|
|
@@ -709,6 +731,9 @@ var DEFAULT_QUALITY = {
|
|
|
709
731
|
async function parseConfig(filePath) {
|
|
710
732
|
const content = await readFile3(filePath, "utf-8");
|
|
711
733
|
const raw = parseYaml(content);
|
|
734
|
+
if (!raw || typeof raw !== "object") {
|
|
735
|
+
throw new Error(`config.yaml: file is empty or not a valid YAML mapping`);
|
|
736
|
+
}
|
|
712
737
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
713
738
|
throw new Error(`config.yaml: missing or invalid 'name' field`);
|
|
714
739
|
}
|
|
@@ -802,6 +827,9 @@ function isValidRelationType(t) {
|
|
|
802
827
|
async function parseNodeYaml(filePath) {
|
|
803
828
|
const content = await readFile4(filePath, "utf-8");
|
|
804
829
|
const raw = parseYaml2(content);
|
|
830
|
+
if (!raw || typeof raw !== "object") {
|
|
831
|
+
throw new Error(`node.yaml at ${filePath}: file is empty or not a valid YAML mapping`);
|
|
832
|
+
}
|
|
805
833
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
806
834
|
throw new Error(`node.yaml at ${filePath}: missing or empty 'name'`);
|
|
807
835
|
}
|
|
@@ -922,6 +950,9 @@ async function parseAspect(aspectDir, aspectYamlPath, id) {
|
|
|
922
950
|
}
|
|
923
951
|
const content = await readFile6(aspectYamlPath, "utf-8");
|
|
924
952
|
const raw = parseYaml3(content);
|
|
953
|
+
if (!raw || typeof raw !== "object") {
|
|
954
|
+
throw new Error(`Aspect file ${aspectYamlPath}: file is empty or not a valid YAML mapping`);
|
|
955
|
+
}
|
|
925
956
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
926
957
|
throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'name'`);
|
|
927
958
|
}
|
|
@@ -950,6 +981,9 @@ import { parse as parseYaml4 } from "yaml";
|
|
|
950
981
|
async function parseFlow(flowDir, flowYamlPath) {
|
|
951
982
|
const content = await readFile7(flowYamlPath, "utf-8");
|
|
952
983
|
const raw = parseYaml4(content);
|
|
984
|
+
if (!raw || typeof raw !== "object") {
|
|
985
|
+
throw new Error(`flow.yaml at ${flowYamlPath}: file is empty or not a valid YAML mapping`);
|
|
986
|
+
}
|
|
953
987
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
954
988
|
throw new Error(`flow.yaml at ${flowYamlPath}: missing or empty 'name'`);
|
|
955
989
|
}
|
|
@@ -1036,6 +1070,9 @@ function normalizeProjectRelativePath(projectRoot, rawPath) {
|
|
|
1036
1070
|
}
|
|
1037
1071
|
return relative.split(path6.sep).join("/");
|
|
1038
1072
|
}
|
|
1073
|
+
function projectRootFromGraph(yggRootPath) {
|
|
1074
|
+
return path6.dirname(yggRootPath);
|
|
1075
|
+
}
|
|
1039
1076
|
|
|
1040
1077
|
// src/core/graph-loader.ts
|
|
1041
1078
|
function toModelPath(absolutePath, modelDir) {
|
|
@@ -1228,11 +1265,13 @@ async function buildContext(graph, nodePath) {
|
|
|
1228
1265
|
layers.push(buildHierarchyLayer(ancestor, graph.config, graph));
|
|
1229
1266
|
}
|
|
1230
1267
|
layers.push(await buildOwnLayer(node, graph.config, graph.rootPath, graph));
|
|
1268
|
+
const ancestorPaths = new Set(ancestors.map((a) => a.path));
|
|
1231
1269
|
for (const relation of node.meta.relations ?? []) {
|
|
1232
1270
|
const target = graph.nodes.get(relation.target);
|
|
1233
1271
|
if (!target) {
|
|
1234
1272
|
throw new Error(`Broken relation: ${nodePath} -> ${relation.target} (target not found)`);
|
|
1235
1273
|
}
|
|
1274
|
+
if (ancestorPaths.has(relation.target)) continue;
|
|
1236
1275
|
if (STRUCTURAL_RELATION_TYPES.has(relation.type)) {
|
|
1237
1276
|
layers.push(buildStructuralRelationLayer(target, relation, graph.config));
|
|
1238
1277
|
} else if (EVENT_RELATION_TYPES.has(relation.type)) {
|
|
@@ -1548,6 +1587,7 @@ async function validate(graph, scope = "all") {
|
|
|
1548
1587
|
issues.push(...checkRelationTargets(graph));
|
|
1549
1588
|
issues.push(...checkNoCycles(graph));
|
|
1550
1589
|
issues.push(...checkMappingOverlap(graph));
|
|
1590
|
+
issues.push(...await checkMappingPathsExist(graph));
|
|
1551
1591
|
issues.push(...checkBrokenFlowRefs(graph));
|
|
1552
1592
|
issues.push(...checkFlowAspectIds(graph));
|
|
1553
1593
|
issues.push(...await checkDirectoriesHaveNodeYaml(graph));
|
|
@@ -1557,13 +1597,29 @@ async function validate(graph, scope = "all") {
|
|
|
1557
1597
|
let nodesScanned = graph.nodes.size;
|
|
1558
1598
|
if (scope !== "all" && scope.trim()) {
|
|
1559
1599
|
if (!graph.nodes.has(scope)) {
|
|
1600
|
+
const parseError = (graph.nodeParseErrors ?? []).find(
|
|
1601
|
+
(e) => e.nodePath === scope || scope.startsWith(e.nodePath + "/")
|
|
1602
|
+
);
|
|
1603
|
+
if (parseError) {
|
|
1604
|
+
return {
|
|
1605
|
+
issues: [{
|
|
1606
|
+
severity: "error",
|
|
1607
|
+
code: "E001",
|
|
1608
|
+
rule: "invalid-node-yaml",
|
|
1609
|
+
message: parseError.message,
|
|
1610
|
+
nodePath: parseError.nodePath
|
|
1611
|
+
}],
|
|
1612
|
+
nodesScanned: 0
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1560
1615
|
return {
|
|
1561
1616
|
issues: [{ severity: "error", rule: "invalid-scope", message: `Node not found: ${scope}` }],
|
|
1562
1617
|
nodesScanned: 0
|
|
1563
1618
|
};
|
|
1564
1619
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1620
|
+
const scopePrefix = scope + "/";
|
|
1621
|
+
filtered = issues.filter((i) => !i.nodePath || i.nodePath === scope || i.nodePath.startsWith(scopePrefix));
|
|
1622
|
+
nodesScanned = [...graph.nodes.keys()].filter((p) => p === scope || p.startsWith(scopePrefix)).length;
|
|
1567
1623
|
}
|
|
1568
1624
|
return { issues: filtered, nodesScanned };
|
|
1569
1625
|
}
|
|
@@ -1820,6 +1876,9 @@ function arePathsOverlapping(pathA, pathB) {
|
|
|
1820
1876
|
if (pathA === pathB) return true;
|
|
1821
1877
|
return pathA.startsWith(pathB + "/") || pathB.startsWith(pathA + "/");
|
|
1822
1878
|
}
|
|
1879
|
+
function isAncestorNode(possibleAncestor, possibleDescendant) {
|
|
1880
|
+
return possibleDescendant.startsWith(possibleAncestor + "/");
|
|
1881
|
+
}
|
|
1823
1882
|
function checkMappingOverlap(graph) {
|
|
1824
1883
|
const issues = [];
|
|
1825
1884
|
const ownership = [];
|
|
@@ -1835,6 +1894,9 @@ function checkMappingOverlap(graph) {
|
|
|
1835
1894
|
const candidate = ownership[nestedIndex];
|
|
1836
1895
|
if (current.nodePath === candidate.nodePath) continue;
|
|
1837
1896
|
if (!arePathsOverlapping(current.mappingPath, candidate.mappingPath)) continue;
|
|
1897
|
+
const isContainment = current.mappingPath !== candidate.mappingPath;
|
|
1898
|
+
const isHierarchical = isAncestorNode(current.nodePath, candidate.nodePath) || isAncestorNode(candidate.nodePath, current.nodePath);
|
|
1899
|
+
if (isContainment && isHierarchical) continue;
|
|
1838
1900
|
issues.push({
|
|
1839
1901
|
severity: "error",
|
|
1840
1902
|
code: "E009",
|
|
@@ -1846,6 +1908,29 @@ function checkMappingOverlap(graph) {
|
|
|
1846
1908
|
}
|
|
1847
1909
|
return issues;
|
|
1848
1910
|
}
|
|
1911
|
+
async function checkMappingPathsExist(graph) {
|
|
1912
|
+
const issues = [];
|
|
1913
|
+
const projectRoot = path9.dirname(graph.rootPath);
|
|
1914
|
+
const { access: access4 } = await import("fs/promises");
|
|
1915
|
+
for (const [nodePath, node] of graph.nodes) {
|
|
1916
|
+
const mappingPaths = normalizeMappingPaths(node.meta.mapping);
|
|
1917
|
+
for (const mp of mappingPaths) {
|
|
1918
|
+
const absPath = path9.join(projectRoot, mp);
|
|
1919
|
+
try {
|
|
1920
|
+
await access4(absPath);
|
|
1921
|
+
} catch {
|
|
1922
|
+
issues.push({
|
|
1923
|
+
severity: "warning",
|
|
1924
|
+
code: "W012",
|
|
1925
|
+
rule: "mapping-path-missing",
|
|
1926
|
+
message: `Mapping path '${mp}' does not exist on disk`,
|
|
1927
|
+
nodePath
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
return issues;
|
|
1933
|
+
}
|
|
1849
1934
|
function getIncomingRelationSources(graph, nodePath) {
|
|
1850
1935
|
const sources = [];
|
|
1851
1936
|
for (const [srcPath, node] of graph.nodes) {
|
|
@@ -2077,16 +2162,27 @@ async function checkDirectoriesHaveNodeYaml(graph) {
|
|
|
2077
2162
|
const hasNodeYaml = entries.some((e) => e.isFile() && e.name === "node.yaml");
|
|
2078
2163
|
const dirName = path9.basename(dirPath);
|
|
2079
2164
|
if (RESERVED_DIRS.has(dirName)) return;
|
|
2080
|
-
const
|
|
2165
|
+
const hasFiles = entries.some((e) => e.isFile());
|
|
2166
|
+
const hasSubdirs = entries.some((e) => e.isDirectory() && !RESERVED_DIRS.has(e.name) && !e.name.startsWith("."));
|
|
2081
2167
|
const graphPath = segments.join("/");
|
|
2082
|
-
if (
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2168
|
+
if (!hasNodeYaml && graphPath !== "") {
|
|
2169
|
+
if (hasFiles) {
|
|
2170
|
+
issues.push({
|
|
2171
|
+
severity: "error",
|
|
2172
|
+
code: "E015",
|
|
2173
|
+
rule: "missing-node-yaml",
|
|
2174
|
+
message: `Directory '${graphPath}' has files but no node.yaml`,
|
|
2175
|
+
nodePath: graphPath
|
|
2176
|
+
});
|
|
2177
|
+
} else if (hasSubdirs) {
|
|
2178
|
+
issues.push({
|
|
2179
|
+
severity: "warning",
|
|
2180
|
+
code: "W013",
|
|
2181
|
+
rule: "directory-without-node",
|
|
2182
|
+
message: `Directory '${graphPath}' has subdirectories but no node.yaml \u2014 consider creating a node`,
|
|
2183
|
+
nodePath: graphPath
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2090
2186
|
}
|
|
2091
2187
|
for (const entry of entries) {
|
|
2092
2188
|
if (!entry.isDirectory()) continue;
|
|
@@ -2210,22 +2306,47 @@ function formatContextText(pkg2) {
|
|
|
2210
2306
|
}
|
|
2211
2307
|
|
|
2212
2308
|
// src/cli/build-context.ts
|
|
2309
|
+
function collectRelevantNodePaths(graph, nodePath) {
|
|
2310
|
+
const relevant = /* @__PURE__ */ new Set();
|
|
2311
|
+
const node = graph.nodes.get(nodePath);
|
|
2312
|
+
if (!node) return relevant;
|
|
2313
|
+
relevant.add(nodePath);
|
|
2314
|
+
for (const ancestor of collectAncestors(node)) {
|
|
2315
|
+
relevant.add(ancestor.path);
|
|
2316
|
+
}
|
|
2317
|
+
for (const rel of node.meta.relations ?? []) {
|
|
2318
|
+
relevant.add(rel.target);
|
|
2319
|
+
}
|
|
2320
|
+
return relevant;
|
|
2321
|
+
}
|
|
2213
2322
|
function registerBuildCommand(program2) {
|
|
2214
2323
|
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
2324
|
try {
|
|
2216
2325
|
const graph = await loadGraph(process.cwd());
|
|
2326
|
+
const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/$/, "");
|
|
2327
|
+
const relevantNodes = collectRelevantNodePaths(graph, nodePath);
|
|
2217
2328
|
const validationResult = await validate(graph, "all");
|
|
2218
|
-
const
|
|
2219
|
-
(issue) => issue.severity === "error"
|
|
2329
|
+
const relevantErrors = validationResult.issues.filter(
|
|
2330
|
+
(issue) => issue.severity === "error" && // Global errors (no nodePath) always block — e.g., E012 invalid config
|
|
2331
|
+
(!issue.nodePath || relevantNodes.has(issue.nodePath))
|
|
2220
2332
|
);
|
|
2221
|
-
if (
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
`
|
|
2225
|
-
|
|
2333
|
+
if (relevantErrors.length > 0) {
|
|
2334
|
+
const totalErrors = validationResult.issues.filter((i) => i.severity === "error").length;
|
|
2335
|
+
const skippedErrors = totalErrors - relevantErrors.length;
|
|
2336
|
+
let msg = `Error: build-context blocked by ${relevantErrors.length} error(s) affecting this node's context.
|
|
2337
|
+
`;
|
|
2338
|
+
if (skippedErrors > 0) {
|
|
2339
|
+
msg += `(${skippedErrors} unrelated error(s) in other nodes ignored.)
|
|
2340
|
+
`;
|
|
2341
|
+
}
|
|
2342
|
+
for (const err of relevantErrors) {
|
|
2343
|
+
const loc = err.nodePath ? `${err.nodePath}: ` : "";
|
|
2344
|
+
msg += ` ${err.code ?? ""} ${loc}${err.message}
|
|
2345
|
+
`;
|
|
2346
|
+
}
|
|
2347
|
+
process.stderr.write(msg);
|
|
2226
2348
|
process.exit(1);
|
|
2227
2349
|
}
|
|
2228
|
-
const nodePath = options.node.trim().replace(/\/$/, "");
|
|
2229
2350
|
const pkg2 = await buildContext(graph, nodePath);
|
|
2230
2351
|
const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
|
|
2231
2352
|
const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
|
|
@@ -2254,7 +2375,8 @@ function registerValidateCommand(program2) {
|
|
|
2254
2375
|
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
2376
|
try {
|
|
2256
2377
|
const graph = await loadGraph(process.cwd(), { tolerateInvalidConfig: true });
|
|
2257
|
-
const
|
|
2378
|
+
const rawScope = (options.scope ?? "all").trim() || "all";
|
|
2379
|
+
const scope = rawScope === "all" ? "all" : rawScope.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2258
2380
|
const result = await validate(graph, scope);
|
|
2259
2381
|
process.stdout.write(`${result.nodesScanned} nodes scanned
|
|
2260
2382
|
|
|
@@ -2298,12 +2420,17 @@ import chalk2 from "chalk";
|
|
|
2298
2420
|
// src/io/drift-state-store.ts
|
|
2299
2421
|
import { readFile as readFile11, writeFile as writeFile3 } from "fs/promises";
|
|
2300
2422
|
import path10 from "path";
|
|
2301
|
-
import {
|
|
2423
|
+
import { parse as yamlParse } from "yaml";
|
|
2302
2424
|
var DRIFT_STATE_FILE = ".drift-state";
|
|
2303
2425
|
async function readDriftState(yggRoot) {
|
|
2304
2426
|
try {
|
|
2305
2427
|
const content = await readFile11(path10.join(yggRoot, DRIFT_STATE_FILE), "utf-8");
|
|
2306
|
-
|
|
2428
|
+
let raw;
|
|
2429
|
+
try {
|
|
2430
|
+
raw = JSON.parse(content);
|
|
2431
|
+
} catch {
|
|
2432
|
+
raw = yamlParse(content);
|
|
2433
|
+
}
|
|
2307
2434
|
if (!raw || typeof raw !== "object") return {};
|
|
2308
2435
|
const state = {};
|
|
2309
2436
|
for (const [key, value] of Object.entries(raw)) {
|
|
@@ -2317,7 +2444,7 @@ async function readDriftState(yggRoot) {
|
|
|
2317
2444
|
}
|
|
2318
2445
|
}
|
|
2319
2446
|
async function writeDriftState(yggRoot, state) {
|
|
2320
|
-
const content = stringify(state
|
|
2447
|
+
const content = JSON.stringify(state);
|
|
2321
2448
|
await writeFile3(path10.join(yggRoot, DRIFT_STATE_FILE), content, "utf-8");
|
|
2322
2449
|
}
|
|
2323
2450
|
|
|
@@ -2332,41 +2459,6 @@ async function hashFile(filePath) {
|
|
|
2332
2459
|
const content = await readFile12(filePath);
|
|
2333
2460
|
return createHash("sha256").update(content).digest("hex");
|
|
2334
2461
|
}
|
|
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
2462
|
async function loadRootGitignoreStack(projectRoot) {
|
|
2371
2463
|
if (!projectRoot) return [];
|
|
2372
2464
|
try {
|
|
@@ -2389,33 +2481,95 @@ function isIgnoredByStack(candidatePath, stack) {
|
|
|
2389
2481
|
function hashString(content) {
|
|
2390
2482
|
return createHash("sha256").update(content).digest("hex");
|
|
2391
2483
|
}
|
|
2392
|
-
async function hashTrackedFiles(projectRoot, trackedFiles) {
|
|
2484
|
+
async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes) {
|
|
2393
2485
|
const fileHashes = {};
|
|
2486
|
+
const fileMtimes = {};
|
|
2394
2487
|
const gitignoreStack = await loadRootGitignoreStack(projectRoot);
|
|
2488
|
+
const allFiles = [];
|
|
2395
2489
|
for (const tf of trackedFiles) {
|
|
2396
2490
|
const absPath = path11.join(projectRoot, tf.path);
|
|
2397
2491
|
try {
|
|
2398
2492
|
const st = await stat3(absPath);
|
|
2399
2493
|
if (st.isDirectory()) {
|
|
2400
|
-
const
|
|
2494
|
+
const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
|
|
2401
2495
|
projectRoot,
|
|
2402
2496
|
gitignoreStack
|
|
2403
2497
|
});
|
|
2404
|
-
for (const entry of
|
|
2405
|
-
|
|
2406
|
-
|
|
2498
|
+
for (const entry of dirEntries) {
|
|
2499
|
+
allFiles.push({
|
|
2500
|
+
relPath: path11.join(tf.path, entry.relPath).replace(/\\/g, "/"),
|
|
2501
|
+
absPath: entry.absPath,
|
|
2502
|
+
mtimeMs: entry.mtimeMs
|
|
2503
|
+
});
|
|
2407
2504
|
}
|
|
2408
2505
|
} else {
|
|
2409
|
-
|
|
2506
|
+
allFiles.push({ relPath: tf.path, absPath, mtimeMs: st.mtimeMs });
|
|
2410
2507
|
}
|
|
2411
2508
|
} catch {
|
|
2412
2509
|
continue;
|
|
2413
2510
|
}
|
|
2414
2511
|
}
|
|
2512
|
+
const filtered = excludePrefixes?.length ? allFiles.filter((entry) => !excludePrefixes.some((prefix) => entry.relPath === prefix || entry.relPath.startsWith(prefix + "/"))) : allFiles;
|
|
2513
|
+
const dirty = [];
|
|
2514
|
+
for (const entry of filtered) {
|
|
2515
|
+
const storedMtime = storedFileData?.mtimes[entry.relPath];
|
|
2516
|
+
const storedHash = storedFileData?.hashes[entry.relPath];
|
|
2517
|
+
if (storedMtime !== void 0 && storedHash !== void 0 && entry.mtimeMs === storedMtime) {
|
|
2518
|
+
fileHashes[entry.relPath] = storedHash;
|
|
2519
|
+
} else {
|
|
2520
|
+
dirty.push(entry);
|
|
2521
|
+
}
|
|
2522
|
+
fileMtimes[entry.relPath] = entry.mtimeMs;
|
|
2523
|
+
}
|
|
2524
|
+
const BATCH_SIZE = 256;
|
|
2525
|
+
for (let i = 0; i < dirty.length; i += BATCH_SIZE) {
|
|
2526
|
+
const batch = dirty.slice(i, i + BATCH_SIZE);
|
|
2527
|
+
const hashes = await Promise.all(batch.map((e) => hashFile(e.absPath)));
|
|
2528
|
+
for (let j = 0; j < batch.length; j++) {
|
|
2529
|
+
fileHashes[batch[j].relPath] = hashes[j];
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2415
2532
|
const sorted = Object.entries(fileHashes).sort(([a], [b]) => a.localeCompare(b));
|
|
2416
2533
|
const digest = sorted.map(([p, h]) => `${p}:${h}`).join("\n");
|
|
2417
2534
|
const canonicalHash = hashString(digest);
|
|
2418
|
-
return { canonicalHash, fileHashes };
|
|
2535
|
+
return { canonicalHash, fileHashes, fileMtimes };
|
|
2536
|
+
}
|
|
2537
|
+
async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
|
|
2538
|
+
let stack = options.gitignoreStack ?? [];
|
|
2539
|
+
try {
|
|
2540
|
+
const localContent = await readFile12(path11.join(directoryPath, ".gitignore"), "utf-8");
|
|
2541
|
+
const localMatcher = ignoreFactory();
|
|
2542
|
+
localMatcher.add(localContent);
|
|
2543
|
+
stack = [...stack, { basePath: directoryPath, matcher: localMatcher }];
|
|
2544
|
+
} catch {
|
|
2545
|
+
}
|
|
2546
|
+
const entries = await readdir5(directoryPath, { withFileTypes: true });
|
|
2547
|
+
const dirs = [];
|
|
2548
|
+
const files = [];
|
|
2549
|
+
for (const entry of entries) {
|
|
2550
|
+
const absoluteChildPath = path11.join(directoryPath, entry.name);
|
|
2551
|
+
if (isIgnoredByStack(absoluteChildPath, stack)) continue;
|
|
2552
|
+
if (entry.isDirectory()) dirs.push(absoluteChildPath);
|
|
2553
|
+
else if (entry.isFile()) files.push(absoluteChildPath);
|
|
2554
|
+
}
|
|
2555
|
+
const [dirResults, fileStats] = await Promise.all([
|
|
2556
|
+
Promise.all(dirs.map((d) => collectDirectoryFilePaths(d, rootDirectoryPath, {
|
|
2557
|
+
projectRoot: options.projectRoot,
|
|
2558
|
+
gitignoreStack: stack
|
|
2559
|
+
}))),
|
|
2560
|
+
Promise.all(files.map(async (f) => {
|
|
2561
|
+
const fileStat = await stat3(f);
|
|
2562
|
+
return {
|
|
2563
|
+
relPath: path11.relative(rootDirectoryPath, f),
|
|
2564
|
+
absPath: f,
|
|
2565
|
+
mtimeMs: fileStat.mtimeMs
|
|
2566
|
+
};
|
|
2567
|
+
}))
|
|
2568
|
+
]);
|
|
2569
|
+
const result = [];
|
|
2570
|
+
for (const nested of dirResults) result.push(...nested);
|
|
2571
|
+
result.push(...fileStats);
|
|
2572
|
+
return result;
|
|
2419
2573
|
}
|
|
2420
2574
|
|
|
2421
2575
|
// src/core/context-files.ts
|
|
@@ -2511,12 +2665,32 @@ function collectParticipatingFlows2(graph, node, ancestors) {
|
|
|
2511
2665
|
// src/core/drift-detector.ts
|
|
2512
2666
|
import { access } from "fs/promises";
|
|
2513
2667
|
import path13 from "path";
|
|
2668
|
+
function getChildMappingExclusions(graph, nodePath) {
|
|
2669
|
+
const node = graph.nodes.get(nodePath);
|
|
2670
|
+
if (!node) return [];
|
|
2671
|
+
const parentMappings = normalizeMappingPaths(node.meta.mapping);
|
|
2672
|
+
if (parentMappings.length === 0) return [];
|
|
2673
|
+
const exclusions = [];
|
|
2674
|
+
for (const [childPath, childNode] of graph.nodes) {
|
|
2675
|
+
if (childPath === nodePath) continue;
|
|
2676
|
+
if (!childPath.startsWith(nodePath + "/")) continue;
|
|
2677
|
+
const childMappings = normalizeMappingPaths(childNode.meta.mapping);
|
|
2678
|
+
for (const cm of childMappings) {
|
|
2679
|
+
for (const pm of parentMappings) {
|
|
2680
|
+
if (cm === pm || cm.startsWith(pm + "/")) {
|
|
2681
|
+
exclusions.push(cm);
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
return exclusions;
|
|
2687
|
+
}
|
|
2514
2688
|
async function detectDrift(graph, filterNodePath) {
|
|
2515
2689
|
const projectRoot = path13.dirname(graph.rootPath);
|
|
2516
2690
|
const driftState = await readDriftState(graph.rootPath);
|
|
2517
2691
|
const entries = [];
|
|
2518
2692
|
for (const [nodePath, node] of graph.nodes) {
|
|
2519
|
-
if (filterNodePath && nodePath !== filterNodePath) continue;
|
|
2693
|
+
if (filterNodePath && nodePath !== filterNodePath && !nodePath.startsWith(filterNodePath + "/")) continue;
|
|
2520
2694
|
const mapping = node.meta.mapping;
|
|
2521
2695
|
if (!mapping) continue;
|
|
2522
2696
|
const mappingPaths = normalizeMappingPaths(mapping);
|
|
@@ -2541,7 +2715,9 @@ async function detectDrift(graph, filterNodePath) {
|
|
|
2541
2715
|
continue;
|
|
2542
2716
|
}
|
|
2543
2717
|
const trackedFiles = collectTrackedFiles(node, graph);
|
|
2544
|
-
const
|
|
2718
|
+
const excludePrefixes = getChildMappingExclusions(graph, nodePath);
|
|
2719
|
+
const storedFileData = storedEntry.files ? { hashes: storedEntry.files, mtimes: storedEntry.mtimes ?? {} } : void 0;
|
|
2720
|
+
const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes);
|
|
2545
2721
|
if (canonicalHash === storedEntry.hash) {
|
|
2546
2722
|
entries.push({ nodePath, status: "ok" });
|
|
2547
2723
|
continue;
|
|
@@ -2614,20 +2790,29 @@ async function syncDriftState(graph, nodePath) {
|
|
|
2614
2790
|
if (!node) throw new Error(`Node not found: ${nodePath}`);
|
|
2615
2791
|
if (!node.meta.mapping) throw new Error(`Node has no mapping: ${nodePath}`);
|
|
2616
2792
|
const trackedFiles = collectTrackedFiles(node, graph);
|
|
2617
|
-
const
|
|
2618
|
-
const
|
|
2619
|
-
const
|
|
2620
|
-
|
|
2621
|
-
await
|
|
2793
|
+
const excludePrefixes = getChildMappingExclusions(graph, nodePath);
|
|
2794
|
+
const existingState = await readDriftState(graph.rootPath);
|
|
2795
|
+
const existingEntry = existingState[nodePath];
|
|
2796
|
+
const storedFileData = existingEntry?.files ? { hashes: existingEntry.files, mtimes: existingEntry.mtimes ?? {} } : void 0;
|
|
2797
|
+
const { canonicalHash, fileHashes, fileMtimes } = await hashTrackedFiles(projectRoot, trackedFiles, storedFileData, excludePrefixes);
|
|
2798
|
+
const previousHash = existingEntry?.hash;
|
|
2799
|
+
existingState[nodePath] = { hash: canonicalHash, files: fileHashes, mtimes: fileMtimes };
|
|
2800
|
+
for (const key of Object.keys(existingState)) {
|
|
2801
|
+
if (!graph.nodes.has(key)) {
|
|
2802
|
+
delete existingState[key];
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
await writeDriftState(graph.rootPath, existingState);
|
|
2622
2806
|
return { previousHash, currentHash: canonicalHash };
|
|
2623
2807
|
}
|
|
2624
2808
|
|
|
2625
2809
|
// src/cli/drift.ts
|
|
2626
2810
|
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) => {
|
|
2811
|
+
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
2812
|
try {
|
|
2629
2813
|
const graph = await loadGraph(process.cwd());
|
|
2630
|
-
const
|
|
2814
|
+
const rawScope = (opts.scope ?? "all").trim() || "all";
|
|
2815
|
+
const scope = rawScope === "all" ? "all" : rawScope.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2631
2816
|
if (scope !== "all") {
|
|
2632
2817
|
const node = graph.nodes.get(scope);
|
|
2633
2818
|
if (!node) {
|
|
@@ -2635,7 +2820,8 @@ function registerDriftCommand(program2) {
|
|
|
2635
2820
|
`);
|
|
2636
2821
|
process.exit(1);
|
|
2637
2822
|
}
|
|
2638
|
-
|
|
2823
|
+
const hasAnyMapping = node.meta.mapping || [...graph.nodes.entries()].some(([p, n]) => p.startsWith(scope + "/") && n.meta.mapping);
|
|
2824
|
+
if (!hasAnyMapping) {
|
|
2639
2825
|
process.stderr.write(`Error: Node has no mapping: ${scope}
|
|
2640
2826
|
`);
|
|
2641
2827
|
process.exit(1);
|
|
@@ -2643,7 +2829,7 @@ function registerDriftCommand(program2) {
|
|
|
2643
2829
|
}
|
|
2644
2830
|
const scopeNode = scope === "all" ? void 0 : scope;
|
|
2645
2831
|
const report = await detectDrift(graph, scopeNode);
|
|
2646
|
-
printReport(report, opts.driftedOnly ?? false);
|
|
2832
|
+
printReport(report, opts.driftedOnly ?? false, opts.limit);
|
|
2647
2833
|
const hasIssues = report.sourceDriftCount > 0 || report.graphDriftCount > 0 || report.fullDriftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0;
|
|
2648
2834
|
process.exit(hasIssues ? 1 : 0);
|
|
2649
2835
|
} catch (error) {
|
|
@@ -2653,13 +2839,23 @@ function registerDriftCommand(program2) {
|
|
|
2653
2839
|
}
|
|
2654
2840
|
});
|
|
2655
2841
|
}
|
|
2656
|
-
function printReport(report, driftedOnly) {
|
|
2842
|
+
function printReport(report, driftedOnly, limit) {
|
|
2657
2843
|
const sourceEntries = classifyForSection(report.entries, "source", driftedOnly);
|
|
2658
2844
|
const graphEntries = classifyForSection(report.entries, "graph", driftedOnly);
|
|
2845
|
+
const sourceShown = limit !== void 0 ? sourceEntries.slice(0, limit) : sourceEntries;
|
|
2846
|
+
const graphShown = limit !== void 0 ? graphEntries.slice(0, limit) : graphEntries;
|
|
2659
2847
|
process.stdout.write("Source drift:\n");
|
|
2660
|
-
printSectionEntries(
|
|
2848
|
+
printSectionEntries(sourceShown, "source");
|
|
2849
|
+
if (limit !== void 0 && sourceEntries.length > limit) {
|
|
2850
|
+
process.stdout.write(chalk2.dim(` ... ${sourceEntries.length - limit} more (${sourceEntries.length} total)
|
|
2851
|
+
`));
|
|
2852
|
+
}
|
|
2661
2853
|
process.stdout.write("\nGraph drift:\n");
|
|
2662
|
-
printSectionEntries(
|
|
2854
|
+
printSectionEntries(graphShown, "graph");
|
|
2855
|
+
if (limit !== void 0 && graphEntries.length > limit) {
|
|
2856
|
+
process.stdout.write(chalk2.dim(` ... ${graphEntries.length - limit} more (${graphEntries.length} total)
|
|
2857
|
+
`));
|
|
2858
|
+
}
|
|
2663
2859
|
const parts = [
|
|
2664
2860
|
`${report.sourceDriftCount} source-drift`,
|
|
2665
2861
|
`${report.graphDriftCount} graph-drift`,
|
|
@@ -2745,17 +2941,49 @@ function printChangedFiles(entry, section) {
|
|
|
2745
2941
|
// src/cli/drift-sync.ts
|
|
2746
2942
|
import chalk3 from "chalk";
|
|
2747
2943
|
function registerDriftSyncCommand(program2) {
|
|
2748
|
-
program2.command("drift-sync").description("Record current file hash after resolving drift").
|
|
2944
|
+
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
2945
|
try {
|
|
2946
|
+
if (!options.node && !options.all) {
|
|
2947
|
+
process.stderr.write("Error: either '--node <path>' or '--all' is required\n");
|
|
2948
|
+
process.exit(1);
|
|
2949
|
+
}
|
|
2750
2950
|
const graph = await loadGraph(process.cwd());
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2951
|
+
let nodesToSync;
|
|
2952
|
+
if (options.all) {
|
|
2953
|
+
nodesToSync = [...graph.nodes.entries()].filter(([, n]) => normalizeMappingPaths(n.meta.mapping).length > 0).map(([p]) => p).sort();
|
|
2954
|
+
} else {
|
|
2955
|
+
const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2956
|
+
if (!graph.nodes.has(nodePath)) {
|
|
2957
|
+
await syncDriftState(graph, nodePath);
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
nodesToSync = [nodePath];
|
|
2961
|
+
if (options.recursive) {
|
|
2962
|
+
const prefix = nodePath + "/";
|
|
2963
|
+
for (const [p] of graph.nodes) {
|
|
2964
|
+
if (p.startsWith(prefix)) {
|
|
2965
|
+
nodesToSync.push(p);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
nodesToSync.sort();
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
for (const np of nodesToSync) {
|
|
2972
|
+
const node = graph.nodes.get(np);
|
|
2973
|
+
if (normalizeMappingPaths(node.meta.mapping).length === 0) {
|
|
2974
|
+
if (!options.all && !options.recursive && np === options.node) {
|
|
2975
|
+
await syncDriftState(graph, np);
|
|
2976
|
+
}
|
|
2977
|
+
continue;
|
|
2978
|
+
}
|
|
2979
|
+
const { previousHash, currentHash } = await syncDriftState(graph, np);
|
|
2980
|
+
process.stdout.write(chalk3.green(`Synchronized: ${np}
|
|
2754
2981
|
`));
|
|
2755
|
-
|
|
2756
|
-
|
|
2982
|
+
process.stdout.write(
|
|
2983
|
+
` Hash: ${previousHash ? previousHash.slice(0, 8) : "none"} -> ${currentHash.slice(0, 8)}
|
|
2757
2984
|
`
|
|
2758
|
-
|
|
2985
|
+
);
|
|
2986
|
+
}
|
|
2759
2987
|
} catch (error) {
|
|
2760
2988
|
process.stderr.write(`Error: ${error.message}
|
|
2761
2989
|
`);
|
|
@@ -2872,10 +3100,10 @@ function registerTreeCommand(program2) {
|
|
|
2872
3100
|
let roots;
|
|
2873
3101
|
let showProjectName;
|
|
2874
3102
|
if (options.root?.trim()) {
|
|
2875
|
-
const
|
|
2876
|
-
const node = graph.nodes.get(
|
|
3103
|
+
const path18 = options.root.trim().replace(/\/$/, "");
|
|
3104
|
+
const node = graph.nodes.get(path18);
|
|
2877
3105
|
if (!node) {
|
|
2878
|
-
process.stderr.write(`Error: path '${
|
|
3106
|
+
process.stderr.write(`Error: path '${path18}' not found
|
|
2879
3107
|
`);
|
|
2880
3108
|
process.exit(1);
|
|
2881
3109
|
}
|
|
@@ -2919,6 +3147,8 @@ function printNode(node, prefix, isLast, depth, maxDepth) {
|
|
|
2919
3147
|
}
|
|
2920
3148
|
|
|
2921
3149
|
// src/cli/owner.ts
|
|
3150
|
+
import path14 from "path";
|
|
3151
|
+
import { access as access2 } from "fs/promises";
|
|
2922
3152
|
function normalizeForMatch(inputPath) {
|
|
2923
3153
|
return inputPath.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
2924
3154
|
}
|
|
@@ -2929,7 +3159,7 @@ function findOwner(graph, projectRoot, rawPath) {
|
|
|
2929
3159
|
const mappingPaths = normalizeMappingPaths(node.meta.mapping).map(normalizeForMatch).filter((mappingPath) => mappingPath.length > 0);
|
|
2930
3160
|
for (const mappingPath of mappingPaths) {
|
|
2931
3161
|
if (file === mappingPath) {
|
|
2932
|
-
return { file, nodePath, mappingPath };
|
|
3162
|
+
return { file, nodePath, mappingPath, direct: true };
|
|
2933
3163
|
}
|
|
2934
3164
|
if (file.startsWith(mappingPath + "/")) {
|
|
2935
3165
|
if (!best || best && mappingPath.length > best.mappingPath.length) {
|
|
@@ -2938,20 +3168,42 @@ function findOwner(graph, projectRoot, rawPath) {
|
|
|
2938
3168
|
}
|
|
2939
3169
|
}
|
|
2940
3170
|
}
|
|
2941
|
-
return best ? { file, nodePath: best.nodePath, mappingPath: best.mappingPath } : { file, nodePath: null };
|
|
3171
|
+
return best ? { file, nodePath: best.nodePath, mappingPath: best.mappingPath, direct: false } : { file, nodePath: null };
|
|
2942
3172
|
}
|
|
2943
3173
|
function registerOwnerCommand(program2) {
|
|
2944
3174
|
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
3175
|
try {
|
|
2946
|
-
const
|
|
2947
|
-
const graph = await loadGraph(
|
|
2948
|
-
const
|
|
3176
|
+
const cwd = process.cwd();
|
|
3177
|
+
const graph = await loadGraph(cwd);
|
|
3178
|
+
const repoRoot = projectRootFromGraph(graph.rootPath);
|
|
3179
|
+
const rawPath = options.file.trim();
|
|
3180
|
+
const absolute = path14.resolve(cwd, rawPath);
|
|
3181
|
+
const repoRelative = path14.relative(repoRoot, absolute).split(path14.sep).join("/");
|
|
3182
|
+
const result = findOwner(graph, repoRoot, repoRelative);
|
|
2949
3183
|
if (!result.nodePath) {
|
|
2950
|
-
|
|
3184
|
+
const absPath = path14.resolve(repoRoot, result.file);
|
|
3185
|
+
let exists = true;
|
|
3186
|
+
try {
|
|
3187
|
+
await access2(absPath);
|
|
3188
|
+
} catch {
|
|
3189
|
+
exists = false;
|
|
3190
|
+
}
|
|
3191
|
+
if (exists) {
|
|
3192
|
+
process.stdout.write(`${result.file} -> no graph coverage
|
|
3193
|
+
`);
|
|
3194
|
+
} else {
|
|
3195
|
+
process.stdout.write(`${result.file} -> no graph coverage (file not found)
|
|
2951
3196
|
`);
|
|
3197
|
+
}
|
|
2952
3198
|
} else {
|
|
2953
3199
|
process.stdout.write(`${result.file} -> ${result.nodePath}
|
|
2954
3200
|
`);
|
|
3201
|
+
if (result.direct === false && result.mappingPath) {
|
|
3202
|
+
process.stdout.write(
|
|
3203
|
+
` Plik nie ma w\u0142asnego mapowania; kontekst pochodzi z nadrz\u0119dnego katalogu ${result.mappingPath}. U\u017Cyj: yg build-context --node ${result.nodePath}
|
|
3204
|
+
`
|
|
3205
|
+
);
|
|
3206
|
+
}
|
|
2955
3207
|
}
|
|
2956
3208
|
} catch (error) {
|
|
2957
3209
|
process.stderr.write(`Error: ${error.message}
|
|
@@ -2963,7 +3215,7 @@ function registerOwnerCommand(program2) {
|
|
|
2963
3215
|
|
|
2964
3216
|
// src/core/dependency-resolver.ts
|
|
2965
3217
|
import { execSync } from "child_process";
|
|
2966
|
-
import
|
|
3218
|
+
import path15 from "path";
|
|
2967
3219
|
var STRUCTURAL_RELATION_TYPES3 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
|
|
2968
3220
|
var EVENT_RELATION_TYPES2 = /* @__PURE__ */ new Set(["emits", "listens"]);
|
|
2969
3221
|
function filterRelationType(relType, filter) {
|
|
@@ -3023,7 +3275,7 @@ function registerDepsCommand(program2) {
|
|
|
3023
3275
|
try {
|
|
3024
3276
|
const graph = await loadGraph(process.cwd());
|
|
3025
3277
|
const typeFilter = options.type === "structural" || options.type === "event" || options.type === "all" ? options.type : "all";
|
|
3026
|
-
const nodePath = options.node.trim().replace(
|
|
3278
|
+
const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3027
3279
|
const text = formatDependencyTree(graph, nodePath, {
|
|
3028
3280
|
depth: options.depth,
|
|
3029
3281
|
relationType: typeFilter
|
|
@@ -3040,7 +3292,7 @@ function registerDepsCommand(program2) {
|
|
|
3040
3292
|
// src/core/graph-from-git.ts
|
|
3041
3293
|
import { mkdtemp, rm } from "fs/promises";
|
|
3042
3294
|
import { tmpdir } from "os";
|
|
3043
|
-
import
|
|
3295
|
+
import path16 from "path";
|
|
3044
3296
|
import { execSync as execSync2 } from "child_process";
|
|
3045
3297
|
async function loadGraphFromRef(projectRoot, ref = "HEAD") {
|
|
3046
3298
|
const yggPath = ".yggdrasil";
|
|
@@ -3051,8 +3303,8 @@ async function loadGraphFromRef(projectRoot, ref = "HEAD") {
|
|
|
3051
3303
|
return null;
|
|
3052
3304
|
}
|
|
3053
3305
|
try {
|
|
3054
|
-
tmpDir = await mkdtemp(
|
|
3055
|
-
const archivePath =
|
|
3306
|
+
tmpDir = await mkdtemp(path16.join(tmpdir(), "ygg-git-"));
|
|
3307
|
+
const archivePath = path16.join(tmpDir, "archive.tar");
|
|
3056
3308
|
execSync2(`git archive ${ref} ${yggPath} -o "${archivePath}"`, {
|
|
3057
3309
|
cwd: projectRoot,
|
|
3058
3310
|
stdio: "pipe"
|
|
@@ -3122,14 +3374,14 @@ function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
|
|
|
3122
3374
|
}
|
|
3123
3375
|
const chains = [];
|
|
3124
3376
|
for (const node of transitiveOnly) {
|
|
3125
|
-
const
|
|
3377
|
+
const path18 = [];
|
|
3126
3378
|
let current = node;
|
|
3127
3379
|
while (current) {
|
|
3128
|
-
|
|
3380
|
+
path18.unshift(current);
|
|
3129
3381
|
current = parent.get(current);
|
|
3130
3382
|
}
|
|
3131
|
-
if (
|
|
3132
|
-
chains.push(
|
|
3383
|
+
if (path18.length >= 3) {
|
|
3384
|
+
chains.push(path18.slice(1).map((p) => `<- ${p}`).join(" "));
|
|
3133
3385
|
}
|
|
3134
3386
|
}
|
|
3135
3387
|
return chains.sort();
|
|
@@ -3331,7 +3583,7 @@ function registerImpactCommand(program2) {
|
|
|
3331
3583
|
await handleFlowImpact(graph, options.flow.trim(), options.simulate);
|
|
3332
3584
|
return;
|
|
3333
3585
|
}
|
|
3334
|
-
const nodePath = options.node.trim().replace(
|
|
3586
|
+
const nodePath = options.node.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3335
3587
|
if (!graph.nodes.has(nodePath)) {
|
|
3336
3588
|
process.stderr.write(`Node not found: ${nodePath}
|
|
3337
3589
|
`);
|
|
@@ -3463,14 +3715,47 @@ function registerAspectsCommand(program2) {
|
|
|
3463
3715
|
});
|
|
3464
3716
|
}
|
|
3465
3717
|
|
|
3718
|
+
// src/cli/flows.ts
|
|
3719
|
+
import { stringify as yamlStringify2 } from "yaml";
|
|
3720
|
+
function registerFlowsCommand(program2) {
|
|
3721
|
+
program2.command("flows").description("List flows with metadata (YAML output)").action(async () => {
|
|
3722
|
+
try {
|
|
3723
|
+
const yggRoot = await findYggRoot(process.cwd());
|
|
3724
|
+
const graph = await loadGraph(yggRoot);
|
|
3725
|
+
const output = graph.flows.sort((a, b) => a.name.localeCompare(b.name)).map((flow) => {
|
|
3726
|
+
const entry = {
|
|
3727
|
+
name: flow.name,
|
|
3728
|
+
participants: flow.nodes.length,
|
|
3729
|
+
nodes: flow.nodes.sort()
|
|
3730
|
+
};
|
|
3731
|
+
if (flow.aspects && flow.aspects.length > 0) entry.aspects = flow.aspects;
|
|
3732
|
+
return entry;
|
|
3733
|
+
});
|
|
3734
|
+
process.stdout.write(yamlStringify2(output));
|
|
3735
|
+
} catch (error) {
|
|
3736
|
+
const err = error;
|
|
3737
|
+
if (err.code === "ENOENT") {
|
|
3738
|
+
process.stderr.write(
|
|
3739
|
+
`Error: No .yggdrasil/ directory found. Run 'yg init' first.
|
|
3740
|
+
`
|
|
3741
|
+
);
|
|
3742
|
+
} else {
|
|
3743
|
+
process.stderr.write(`Error: ${error.message}
|
|
3744
|
+
`);
|
|
3745
|
+
}
|
|
3746
|
+
process.exit(1);
|
|
3747
|
+
}
|
|
3748
|
+
});
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3466
3751
|
// src/io/journal-store.ts
|
|
3467
|
-
import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as
|
|
3752
|
+
import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access3 } from "fs/promises";
|
|
3468
3753
|
import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
|
|
3469
|
-
import
|
|
3754
|
+
import path17 from "path";
|
|
3470
3755
|
var JOURNAL_FILE = ".journal.yaml";
|
|
3471
3756
|
var ARCHIVE_DIR = "journals-archive";
|
|
3472
3757
|
async function readJournal(yggRoot) {
|
|
3473
|
-
const filePath =
|
|
3758
|
+
const filePath = path17.join(yggRoot, JOURNAL_FILE);
|
|
3474
3759
|
try {
|
|
3475
3760
|
const content = await readFile13(filePath, "utf-8");
|
|
3476
3761
|
const raw = parseYaml6(content);
|
|
@@ -3485,26 +3770,26 @@ async function appendJournalEntry(yggRoot, note, target) {
|
|
|
3485
3770
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3486
3771
|
const entry = target ? { at, target, note } : { at, note };
|
|
3487
3772
|
entries.push(entry);
|
|
3488
|
-
const filePath =
|
|
3773
|
+
const filePath = path17.join(yggRoot, JOURNAL_FILE);
|
|
3489
3774
|
const content = stringifyYaml({ entries });
|
|
3490
3775
|
await writeFile4(filePath, content, "utf-8");
|
|
3491
3776
|
return entry;
|
|
3492
3777
|
}
|
|
3493
3778
|
async function archiveJournal(yggRoot) {
|
|
3494
|
-
const journalPath =
|
|
3779
|
+
const journalPath = path17.join(yggRoot, JOURNAL_FILE);
|
|
3495
3780
|
try {
|
|
3496
|
-
await
|
|
3781
|
+
await access3(journalPath);
|
|
3497
3782
|
} catch {
|
|
3498
3783
|
return null;
|
|
3499
3784
|
}
|
|
3500
3785
|
const entries = await readJournal(yggRoot);
|
|
3501
3786
|
if (entries.length === 0) return null;
|
|
3502
|
-
const archiveDir =
|
|
3787
|
+
const archiveDir = path17.join(yggRoot, ARCHIVE_DIR);
|
|
3503
3788
|
await mkdir3(archiveDir, { recursive: true });
|
|
3504
3789
|
const now = /* @__PURE__ */ new Date();
|
|
3505
3790
|
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
3791
|
const archiveName = `.journal.${timestamp}.yaml`;
|
|
3507
|
-
const archivePath =
|
|
3792
|
+
const archivePath = path17.join(archiveDir, archiveName);
|
|
3508
3793
|
await rename(journalPath, archivePath);
|
|
3509
3794
|
return { archiveName, entryCount: entries.length };
|
|
3510
3795
|
}
|
|
@@ -3582,14 +3867,13 @@ function registerJournalArchiveCommand(program2) {
|
|
|
3582
3867
|
|
|
3583
3868
|
// src/cli/preflight.ts
|
|
3584
3869
|
function registerPreflightCommand(program2) {
|
|
3585
|
-
program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").action(async () => {
|
|
3870
|
+
program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").option("--quick", "Skip drift detection for faster results").action(async (options) => {
|
|
3586
3871
|
try {
|
|
3587
3872
|
const cwd = process.cwd();
|
|
3588
3873
|
const graph = await loadGraph(cwd);
|
|
3589
3874
|
const yggRoot = await findYggRoot(cwd);
|
|
3590
3875
|
const journalEntries = await readJournal(yggRoot);
|
|
3591
|
-
const
|
|
3592
|
-
const driftedEntries = drift.entries.filter((e) => e.status !== "ok");
|
|
3876
|
+
const driftedEntries = options.quick ? [] : (await detectDrift(graph)).entries.filter((e) => e.status !== "ok");
|
|
3593
3877
|
const nodeCount = graph.nodes.size;
|
|
3594
3878
|
const aspectCount = graph.aspects.length;
|
|
3595
3879
|
const flowCount = graph.flows.length;
|
|
@@ -3613,7 +3897,9 @@ function registerPreflightCommand(program2) {
|
|
|
3613
3897
|
}
|
|
3614
3898
|
}
|
|
3615
3899
|
lines.push("");
|
|
3616
|
-
if (
|
|
3900
|
+
if (options.quick) {
|
|
3901
|
+
lines.push("Drift: skipped (--quick)");
|
|
3902
|
+
} else if (driftedEntries.length === 0) {
|
|
3617
3903
|
lines.push("Drift: clean");
|
|
3618
3904
|
} else {
|
|
3619
3905
|
lines.push(`Drift: ${driftedEntries.length} nodes need attention`);
|
|
@@ -3625,6 +3911,12 @@ function registerPreflightCommand(program2) {
|
|
|
3625
3911
|
lines.push(
|
|
3626
3912
|
`Status: ${nodeCount} nodes, ${aspectCount} aspects, ${flowCount} flows, ${mappedPathCount} mapped paths`
|
|
3627
3913
|
);
|
|
3914
|
+
if (nodeCount === 0) {
|
|
3915
|
+
lines.push("");
|
|
3916
|
+
lines.push(" \u26A1 No nodes found. Enter BOOTSTRAP MODE:");
|
|
3917
|
+
lines.push(" Create nodes under .yggdrasil/model/ for your active work area.");
|
|
3918
|
+
lines.push(" See: yg help build-context");
|
|
3919
|
+
}
|
|
3628
3920
|
lines.push("");
|
|
3629
3921
|
if (errors.length === 0 && warnings.length === 0) {
|
|
3630
3922
|
lines.push("Validation: clean");
|
|
@@ -3635,12 +3927,13 @@ function registerPreflightCommand(program2) {
|
|
|
3635
3927
|
lines.push(`Validation: ${parts.join(", ")}`);
|
|
3636
3928
|
for (const issue of [...errors, ...warnings]) {
|
|
3637
3929
|
const code = issue.code ? `[${issue.code}] ` : "";
|
|
3638
|
-
|
|
3930
|
+
const loc = issue.nodePath ? `${issue.nodePath} -> ` : "";
|
|
3931
|
+
lines.push(` - ${code}${loc}${issue.message}`);
|
|
3639
3932
|
}
|
|
3640
3933
|
}
|
|
3641
3934
|
lines.push("");
|
|
3642
3935
|
process.stdout.write(lines.join("\n"));
|
|
3643
|
-
const hasIssues = journalEntries.length > 0 || driftedEntries.length > 0 || errors.length > 0;
|
|
3936
|
+
const hasIssues = journalEntries.length > 0 || !options.quick && driftedEntries.length > 0 || errors.length > 0;
|
|
3644
3937
|
process.exit(hasIssues ? 1 : 0);
|
|
3645
3938
|
} catch (error) {
|
|
3646
3939
|
process.stderr.write(`Error: ${error.message}
|
|
@@ -3670,6 +3963,7 @@ registerOwnerCommand(program);
|
|
|
3670
3963
|
registerDepsCommand(program);
|
|
3671
3964
|
registerImpactCommand(program);
|
|
3672
3965
|
registerAspectsCommand(program);
|
|
3966
|
+
registerFlowsCommand(program);
|
|
3673
3967
|
registerJournalAddCommand(program);
|
|
3674
3968
|
registerJournalReadCommand(program);
|
|
3675
3969
|
registerJournalArchiveCommand(program);
|