@chrisdudek/yg 0.3.4 → 1.0.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/dist/bin.js +1462 -1160
- package/dist/bin.js.map +1 -1
- package/dist/templates/default-config.ts +3 -14
- package/dist/templates/rules.ts +252 -183
- package/graph-schemas/aspect.yaml +9 -0
- package/graph-schemas/flow.yaml +10 -0
- package/graph-schemas/node.yaml +19 -0
- package/package.json +2 -2
- package/graph-templates/aspect.yaml +0 -2
- package/graph-templates/flow.yaml +0 -8
- package/graph-templates/knowledge.yaml +0 -2
- package/graph-templates/node.yaml +0 -17
package/dist/bin.js
CHANGED
|
@@ -17,12 +17,10 @@ stack:
|
|
|
17
17
|
|
|
18
18
|
standards: ""
|
|
19
19
|
|
|
20
|
-
tags: []
|
|
21
|
-
|
|
22
20
|
node_types:
|
|
23
|
-
- module
|
|
24
|
-
- service
|
|
25
|
-
- library
|
|
21
|
+
- name: module
|
|
22
|
+
- name: service
|
|
23
|
+
- name: library
|
|
26
24
|
|
|
27
25
|
artifacts:
|
|
28
26
|
responsibility.md:
|
|
@@ -56,21 +54,12 @@ artifacts:
|
|
|
56
54
|
required: never
|
|
57
55
|
description: "Local design decisions and rationale \u2014 choices specific to this node, not system-wide"
|
|
58
56
|
|
|
59
|
-
knowledge_categories:
|
|
60
|
-
- name: decisions
|
|
61
|
-
description: "Global semantic decisions and their rationale"
|
|
62
|
-
- name: patterns
|
|
63
|
-
description: "Implementation conventions with examples"
|
|
64
|
-
- name: invariants
|
|
65
|
-
description: "System truths that must never be violated"
|
|
66
|
-
|
|
67
57
|
quality:
|
|
68
58
|
min_artifact_length: 50
|
|
69
59
|
max_direct_relations: 10
|
|
70
60
|
context_budget:
|
|
71
61
|
warning: 10000
|
|
72
62
|
error: 20000
|
|
73
|
-
knowledge_staleness_days: 90
|
|
74
63
|
`;
|
|
75
64
|
|
|
76
65
|
// src/templates/platform.ts
|
|
@@ -78,244 +67,302 @@ import { readFile, writeFile, mkdir } from "fs/promises";
|
|
|
78
67
|
import path from "path";
|
|
79
68
|
|
|
80
69
|
// src/templates/rules.ts
|
|
81
|
-
var
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
## 1. CORE PRINCIPLES (NON-NEGOTIABLE)
|
|
103
|
-
|
|
104
|
-
1. **Graph First, Always:** Before answering a question, modifying code, or planning a feature, you MUST consult the graph.
|
|
105
|
-
2. **Context is Sufficient:** If you feel the need to randomly explore source files to understand what a node should do, the graph is incomplete. **Fix the graph** (add decisions, interface details, constraints). Do not bypass the graph by reading raw code.
|
|
106
|
-
3. **Graph is Intended Truth:** If the code and graph diverge, the graph is the truth. If a code change is deliberate, update the graph to match.
|
|
107
|
-
4. **Exhaustive Coverage:** Every source file MUST belong to exactly one graph node. No orphaned files.
|
|
108
|
-
5. **Tools Read, You Write:** The \`yg\` CLI tools only read, validate, and manage metadata. YOU must create and edit graph directories, \`.yaml\` files, and \`.md\` artifacts manually.
|
|
109
|
-
6. **English Only for Artifacts:** All graph artifact files (filenames from \`config.artifacts\`, in the same directory as \`node.yaml\`) MUST be written in English. Conversation can be in the user's language.
|
|
110
|
-
7. **Never Touch Operational Metadata:** NEVER manually edit \`.yggdrasil/.drift-state\` or \`.yggdrasil/.journal.yaml\`.
|
|
111
|
-
8. **Ask, Never Infer:** If graph and code diverge in a way with multiple valid resolutions, or if a required decision is ambiguous \u2014 STOP. State the ambiguity. List interpretations. Ask the user to decide. Never silently choose. Never patch without confirmation. When you stop, always explain the context and available options clearly so the user can make an informed choice.
|
|
112
|
-
|
|
113
|
-
---
|
|
114
|
-
|
|
115
|
-
## 1.5 FAILURE STATES
|
|
70
|
+
var CORE_PROTOCOL = `## CORE PROTOCOL
|
|
71
|
+
|
|
72
|
+
Yggdrasil is persistent semantic memory stored in \`.yggdrasil/\`. It maps the repository and provides deterministic implementation context for every node. This document is your complete operating manual. Follow it strictly.
|
|
73
|
+
|
|
74
|
+
### Quick Start Protocol
|
|
75
|
+
|
|
76
|
+
\`\`\`
|
|
77
|
+
BEFORE reading, researching, planning, OR modifying ANY mapped file:
|
|
78
|
+
1. yg owner --file <path>
|
|
79
|
+
2. yg build-context --node <owner>
|
|
80
|
+
The context package is your primary source of understanding.
|
|
81
|
+
Raw file reads are for implementation details WITHIN a node you
|
|
82
|
+
already understand from its context package.
|
|
83
|
+
If the context package seems insufficient \u2014 enrich the graph.
|
|
84
|
+
Do not bypass it.
|
|
85
|
+
|
|
86
|
+
AFTER modifying:
|
|
87
|
+
3. Update graph artifacts to reflect changes
|
|
88
|
+
4. yg validate \u2014 fix all errors
|
|
89
|
+
5. yg drift-sync --node <owner>
|
|
116
90
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
-
|
|
126
|
-
|
|
91
|
+
EVERY conversation start:
|
|
92
|
+
yg preflight \u2192 act on findings (see Operations)
|
|
93
|
+
|
|
94
|
+
NEVER: modify code without graph coverage.
|
|
95
|
+
NEVER: read mapped source files to understand a component without
|
|
96
|
+
running yg build-context first \u2014 the graph captures intent,
|
|
97
|
+
constraints, and relations that source files cannot.
|
|
98
|
+
NEVER: invent rationale, business rules, or decisions.
|
|
99
|
+
NEVER: auto-resolve drift without asking the user.
|
|
100
|
+
WHEN UNSURE: ask the user. Never guess. Never assume.
|
|
101
|
+
\`\`\`
|
|
127
102
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
## 2. CONVERSATION LIFECYCLE (YOUR HABITS)
|
|
131
|
-
|
|
132
|
-
You do not need explicit "session" commands. Follow these conversational triggers:
|
|
133
|
-
|
|
134
|
-
### A. Preflight (First message of the conversation)
|
|
135
|
-
Always execute these commands before doing anything else. *(Exception: If the user's request is clearly read-only, run ONLY step 1).* **Read-only** means the user asks only for explanation, clarification, analysis, or evaluation \u2014 no code or graph modification will occur. Examples: "explain this", "evaluate X", "what does Y do?", "analyze Z". If unsure, run full preflight.
|
|
136
|
-
1. \`yg journal-read\` -> If entries exist, consolidate them into the graph, then \`yg journal-archive\`.
|
|
137
|
-
2. \`yg drift\` -> If **drift** (code changed, graph baseline stale): run \`yg drift-sync\` for each affected node. Do NOT ask the user \u2014 they do not need to know this step. If **missing** or **unmaterialized**: report and ask the user how to proceed.
|
|
138
|
-
3. \`yg status\` -> Report graph health.
|
|
139
|
-
4. \`yg validate\` -> If W008 stale-knowledge appears, update the knowledge artifacts to reflect current node state.
|
|
103
|
+
### Five Core Rules
|
|
140
104
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
This applies even when you are **not modifying files** \u2014 e.g. when providing code snippets to paste elsewhere, explaining behavior, or suggesting hooks. If the question touches mapped code, build-context first.
|
|
148
|
-
|
|
149
|
-
### C. Session Verification (Wrap-up)
|
|
150
|
-
Triggered by phrases like: "we're done", "wrap up", "that's enough", "done", "ok".
|
|
151
|
-
**Note: The graph should ALREADY be up to date. If the graph requires massive updates at this stage, YOU HAVE FAILED.**
|
|
152
|
-
1. If iterative journal mode was used: consolidate notes to the graph, then \`yg journal-archive\`.
|
|
153
|
-
2. \`yg drift\` -> If drift detected, run \`yg drift-sync\` for each affected node. Do NOT ask \u2014 absorb automatically.
|
|
154
|
-
3. \`yg validate\` -> Fix any structural errors.
|
|
155
|
-
4. Report exactly what nodes and files were changed.
|
|
156
|
-
|
|
157
|
-
---
|
|
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
|
+
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
|
+
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.
|
|
109
|
+
5. **Ask before resolving ambiguity.** When multiple valid interpretations exist, stop, list options, ask the user. Never silently choose.
|
|
158
110
|
|
|
159
|
-
|
|
111
|
+
### Failure States
|
|
160
112
|
|
|
161
|
-
You
|
|
113
|
+
You have broken Yggdrasil if you do any of the following:
|
|
162
114
|
|
|
163
|
-
|
|
115
|
+
- \u274C Modified source code without running \`yg owner --file <path>\` first.
|
|
116
|
+
- \u274C Modified source code without updating graph artifacts in the same response.
|
|
117
|
+
- \u274C Modified graph files without verifying source code alignment in the same response.
|
|
118
|
+
- \u274C Resolved a code-graph inconsistency without asking the user first.
|
|
119
|
+
- \u274C Created or edited a graph element without reading its schema in \`schemas/\` first.
|
|
120
|
+
- \u274C Ran \`yg drift-sync\` before updating graph artifacts.
|
|
121
|
+
- \u274C Wrote a flow description that describes code sequences instead of a business process.
|
|
122
|
+
- \u274C Used an aspect identifier that has no corresponding \`aspects/\` directory.
|
|
123
|
+
- \u274C Placed a cross-cutting requirement in a local node artifact instead of an aspect.
|
|
124
|
+
- \u274C Invented a rationale, business rule, or architectural decision.
|
|
125
|
+
- \u274C Used blackbox coverage for greenfield (new) code.
|
|
126
|
+
- \u274C Answered a question about a mapped file without running \`yg build-context\` first.
|
|
127
|
+
- \u274C Read mapped source files to plan or research changes without running \`yg build-context\` first.
|
|
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.
|
|
164
129
|
|
|
165
|
-
|
|
130
|
+
### Escape Hatch
|
|
166
131
|
|
|
167
|
-
|
|
168
|
-
Whenever you write or edit source code, you MUST output this exact checklist in your response to the user, and execute each step BEFORE finishing your turn:
|
|
132
|
+
If the user explicitly requests a code-only change, comply but:
|
|
169
133
|
|
|
170
|
-
-
|
|
171
|
-
-
|
|
172
|
-
- [ ] 3. Sync Graph Artifacts (manually edit the node's artifact files IMMEDIATELY to match new code behavior)
|
|
173
|
-
- [ ] 4. Baseline Hash (ran \`yg drift-sync\` ONLY AFTER updating the graph)
|
|
134
|
+
- Warn: "This creates drift. Run \`yg drift\` next session to reconcile."
|
|
135
|
+
- Do NOT run \`yg drift-sync\` \u2014 leave the drift visible.
|
|
174
136
|
|
|
175
|
-
|
|
176
|
-
STOP. Do not modify the code. First determine: **Is this greenfield or existing code?**
|
|
137
|
+
### Environment Check
|
|
177
138
|
|
|
178
|
-
|
|
179
|
-
* **If PARTIALLY MAPPED (file is unmapped, but lives inside a mapped module):** Stop and ask the user if this file should be added to the existing node or if a new node is required.
|
|
180
|
-
* **If EXISTING CODE (legacy, third-party):** Present the user with 3 options and wait:
|
|
181
|
-
* **Option 1: Reverse Engineering:** Create/extend standard nodes to map the area fully before modifying.
|
|
182
|
-
* **Option 2: Blackbox Coverage:** Create a \`blackbox: true\` node to establish ownership without deep semantic exploration.
|
|
183
|
-
* **Option 3: Abort/Change Plan:** Do not touch the file.
|
|
139
|
+
Before preflight:
|
|
184
140
|
|
|
185
|
-
|
|
141
|
+
- Verify \`yg\` CLI is available. If not found, inform user and stop.
|
|
142
|
+
- If \`yg preflight\` shows 0 nodes \u2192 enter BOOTSTRAP MODE (see Operations).
|
|
143
|
+
- If drift report shows >10 drifted nodes \u2192 report scope to user, ask which area to prioritize. Do not resolve all at once.`;
|
|
144
|
+
var OPERATIONS = `## OPERATIONS
|
|
186
145
|
|
|
187
|
-
|
|
146
|
+
### Conversation Lifecycle
|
|
188
147
|
|
|
189
|
-
|
|
148
|
+
\`\`\`
|
|
149
|
+
PREFLIGHT (every conversation, before any work):
|
|
150
|
+
- [ ] 1. yg preflight \u2192 read unified report
|
|
151
|
+
- [ ] 2. If journal entries: consolidate to graph, then yg journal-archive
|
|
152
|
+
- [ ] 3. If drift: resolve per Drift Resolution, then yg drift-sync per node
|
|
153
|
+
- [ ] 4. If validation errors: fix, re-run yg validate
|
|
154
|
+
Exception: read-only requests (explain, analyze) \u2014 skip preflight.
|
|
190
155
|
|
|
191
|
-
|
|
156
|
+
UNDERSTANDING mapped code (questions, research, OR planning):
|
|
157
|
+
- [ ] 1. yg owner --file <path>
|
|
158
|
+
- [ ] 2. Owner found \u2192 yg build-context --node <path>. Use context package as primary source.
|
|
159
|
+
- [ ] 3. Owner not found \u2192 use file analysis, state it is not graph-backed.
|
|
160
|
+
Never use grep or raw file reads as primary understanding when graph coverage exists.
|
|
161
|
+
Raw reads supplement the context package \u2014 they do not replace it.
|
|
192
162
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
* W005/W006: Context package too large. Consider splitting the node.
|
|
200
|
-
* W008: Stale semantic memory. Update knowledge artifacts.
|
|
163
|
+
WRAP-UP (user signals "done", "wrap up", "that's enough"):
|
|
164
|
+
- [ ] 1. Consolidate journal if used \u2192 yg journal-archive
|
|
165
|
+
- [ ] 2. yg drift --drifted-only \u2192 resolve
|
|
166
|
+
- [ ] 3. yg validate \u2192 fix errors
|
|
167
|
+
- [ ] 4. Report: which nodes and files were changed
|
|
168
|
+
\`\`\`
|
|
201
169
|
|
|
202
|
-
|
|
203
|
-
Whenever you change the graph structure or semantics, you MUST output and execute this exact checklist:
|
|
170
|
+
### Modify Source Code
|
|
204
171
|
|
|
205
|
-
|
|
206
|
-
- [ ] 2. Edit graph files (\`node.yaml\`, artifacts)
|
|
207
|
-
- [ ] 3. Verify corresponding source files exist and their behavior matches updated artifacts
|
|
208
|
-
- [ ] 4. Validate (ran \`yg validate\` \u2014 fix all Errors)
|
|
209
|
-
- [ ] 5. Baseline Hash (ran \`yg drift-sync\` ONLY AFTER steps 2-3 are confirmed)
|
|
172
|
+
You are not allowed to edit or create source code without establishing graph coverage first.
|
|
210
173
|
|
|
211
|
-
**
|
|
212
|
-
* **Default:** Write changes directly to graph files immediately. Do not defer.
|
|
213
|
-
* **Opt-in:** ONLY if the user says "use iterative mode" or "use journal". Once activated, it remains active for the ENTIRE conversation until wrap-up. Use \`yg journal-add --note "..."\` to buffer intent.
|
|
174
|
+
**Step 1** \u2014 Check coverage: \`yg owner --file <path>\`
|
|
214
175
|
|
|
215
|
-
|
|
176
|
+
**Step 2a** \u2014 Owner found: execute checklist:
|
|
216
177
|
|
|
217
|
-
|
|
178
|
+
- [ ] 1. Read specification: \`yg build-context --node <node_path>\`
|
|
179
|
+
- [ ] 2. Assess blast radius: \`yg impact --node <node_path>\` \u2014 review dependents, descendants, and co-aspect nodes before changing interfaces or shared behavior
|
|
180
|
+
- [ ] 3. Modify source code
|
|
181
|
+
- [ ] 4. Sync graph artifacts \u2014 edit artifact files to reflect the changes
|
|
182
|
+
- [ ] 5. Run \`yg validate\` \u2014 fix all errors (if unfixable after 3 attempts \u2192 stop, report to user)
|
|
183
|
+
- [ ] 6. Run \`yg drift-sync --node <node_path>\` \u2014 only after graph and code are both current
|
|
218
184
|
|
|
219
|
-
|
|
220
|
-
* **Node paths** (used in CLI, relations, flow nodes): Relative to \`.yggdrasil/model/\` (e.g., \`orders/order-service\`).
|
|
221
|
-
* **File paths** (used in mapping, \`yg owner\`): Relative to the repository root (e.g., \`src/modules/orders/order.service.ts\`).
|
|
222
|
-
* **Knowledge paths** (used in node explicit refs): Relative to \`.yggdrasil/knowledge/\` (e.g., \`decisions/001-event-sourcing\`).
|
|
185
|
+
**Step 2b** \u2014 Owner not found: establish coverage first. Present options to the user:
|
|
223
186
|
|
|
224
|
-
|
|
187
|
+
*Partially mapped* (file unmapped but inside a mapped module): ask whether to add to existing node or create new one.
|
|
225
188
|
|
|
226
|
-
|
|
189
|
+
*Existing code:*
|
|
227
190
|
|
|
228
|
-
|
|
191
|
+
- Option A \u2014 Full node: create node(s), map files, write artifacts from code analysis
|
|
192
|
+
- Option B \u2014 Blackbox: create a blackbox node at agreed granularity
|
|
193
|
+
- Option C \u2014 Abort
|
|
229
194
|
|
|
230
|
-
*
|
|
231
|
-
* **\`.yggdrasil/templates/\`**: Schemas for each graph layer \u2014 \`node.yaml\`, \`aspect.yaml\`, \`flow.yaml\`, \`knowledge.yaml\`.
|
|
232
|
-
* **\`.yggdrasil/model/\`**: Node tree. Each node is a directory with \`node.yaml\` and artifact files.
|
|
233
|
-
* **\`.yggdrasil/aspects/\`**: Cross-cutting rules. Directory contains \`aspect.yaml\` and \`.md\` content.
|
|
234
|
-
* **\`.yggdrasil/flows/\`**: End-to-end processes. Directory contains \`flow.yaml\` and \`.md\` content.
|
|
235
|
-
* **\`.yggdrasil/knowledge/\`**: Repo-wide wisdom. Directory contains \`knowledge.yaml\` and \`.md\` content.
|
|
195
|
+
*Greenfield (new code):* Only Option A. Blackbox is forbidden for new code. Create nodes with full artifacts, then materialize.
|
|
236
196
|
|
|
237
|
-
|
|
197
|
+
After the user chooses, return to Step 1 and follow Step 2a.
|
|
238
198
|
|
|
239
|
-
|
|
199
|
+
### Modify Graph
|
|
240
200
|
|
|
241
|
-
|
|
201
|
+
- [ ] 1. Read the relevant schema from \`schemas/\` before touching any YAML
|
|
202
|
+
- [ ] 2. Before changing an aspect or flow, check scope: \`yg impact --aspect <id>\` or \`yg impact --flow <name>\` \u2014 understand which nodes are affected before modifying shared rules or processes
|
|
203
|
+
- [ ] 3. Make changes
|
|
204
|
+
- [ ] 4. Run \`yg validate\` immediately \u2014 fix all errors
|
|
205
|
+
- [ ] 5. Verify affected source files are consistent \u2014 update if needed
|
|
206
|
+
- [ ] 6. Run \`yg drift-sync\` for affected nodes
|
|
242
207
|
|
|
243
|
-
|
|
208
|
+
### Reverse Engineering
|
|
244
209
|
|
|
245
|
-
|
|
246
|
-
The graph is not just a structural map; it is the semantic meaning of the system. Code explains "what" and "how". The graph MUST explain "WHY".
|
|
210
|
+
**Order:** aspects (cross-cutting patterns) \u2192 flows (business processes) \u2192 model nodes. Never create nodes before aspects and flows are understood.
|
|
247
211
|
|
|
248
|
-
|
|
249
|
-
2. **NEVER Invent the "Why":** Artifacts that imply human judgment (e.g. local decisions, \`knowledge/invariants\`) must reflect ACTUAL human choices.
|
|
250
|
-
3. **NO Hallucinations:** You MUST NEVER infer or hallucinate a rationale, an architectural decision, or a business rule.
|
|
251
|
-
4. **Ask if Missing:** If the user requests a significant architectural or business logic change but does not provide the rationale, you MUST ask them "Why are we making this change?" before documenting the decision in the graph.
|
|
212
|
+
Per area checklist:
|
|
252
213
|
|
|
253
|
-
|
|
214
|
+
- [ ] 1. \`yg owner --file <path>\` \u2014 confirm no coverage
|
|
215
|
+
- [ ] 2. Determine node granularity \u2014 propose to user if unclear
|
|
216
|
+
- [ ] 3. Create node directory, read \`schemas/node.yaml\`, create \`node.yaml\`
|
|
217
|
+
- [ ] 4. Analyze source \u2014 for each artifact type in \`config.artifacts\`: extract content, do not invent
|
|
218
|
+
- [ ] 5. Identify relations \u2014 add to \`node.yaml\`
|
|
219
|
+
- [ ] 6. Identify cross-cutting requirements \u2014 add matching aspects, create if needed
|
|
220
|
+
- [ ] 7. Identify business process participation \u2014 add to flow, ask user if process unclear
|
|
221
|
+
- [ ] 8. \`yg validate\` \u2014 fix errors
|
|
222
|
+
- [ ] 9. \`yg drift-sync --node <path>\`
|
|
254
223
|
|
|
255
|
-
|
|
256
|
-
* **What goes here:** Things exclusively true for this specific node.
|
|
257
|
-
* **Routing:** **DO NOT ASSUME FILE NAMES.** You MUST read \`.yggdrasil/config.yaml\` (the \`artifacts\` section) to see the exact allowed filenames for the current project and their requirement conditions (e.g., \`required: always\` vs \`when: has_incoming_relations\`). Write local node knowledge ONLY into these configured files next to \`node.yaml\`.
|
|
258
|
-
* For each artifact in \`config.artifacts\`, use its \`description\` to decide what content belongs there. Create optional artifacts (those with \`required: never\`) when the node has matching content. Extract from source; do not invent.
|
|
224
|
+
**When to ask:**
|
|
259
225
|
|
|
260
|
-
|
|
226
|
+
- Business process unclear: "This code appears to be part of a larger process. Can you describe what it means from a business perspective?"
|
|
227
|
+
- Constraint without rationale: "I see [constraint X]. Do you know why this exists? I want to record the reason, not just the rule."
|
|
228
|
+
- Unexplained architectural choice: "I see [approach X]. What was the reason for this choice?"
|
|
261
229
|
|
|
262
|
-
###
|
|
230
|
+
### Bootstrap Mode
|
|
263
231
|
|
|
264
|
-
|
|
232
|
+
Trigger: \`yg preflight\` shows 0 nodes, or no nodes cover the active work area.
|
|
265
233
|
|
|
266
|
-
|
|
234
|
+
- [ ] 1. Identify the active work area (files the user wants to modify)
|
|
235
|
+
- [ ] 2. Scan for cross-cutting patterns \u2192 create aspects
|
|
236
|
+
- [ ] 3. Ask user about business processes \u2192 create flows if applicable
|
|
237
|
+
- [ ] 4. Propose node structure for the area
|
|
238
|
+
- [ ] 5. Create node(s) with initial artifacts, map files
|
|
239
|
+
- [ ] 6. \`yg validate\`, \`yg drift-sync\`
|
|
240
|
+
- [ ] 7. Proceed with user's original request
|
|
267
241
|
|
|
268
|
-
|
|
242
|
+
Constraint: Do NOT map the entire repository. Focus on the active area. Expand incrementally.
|
|
269
243
|
|
|
270
|
-
|
|
244
|
+
### Drift Resolution
|
|
271
245
|
|
|
272
|
-
|
|
273
|
-
* **What goes here:** How this node interacts with others. You must not duplicate external interfaces locally.
|
|
274
|
-
* **Routing:**
|
|
275
|
-
* If it calls another module: Add an outgoing structural \`relation\` in \`node.yaml\`. (The engine will automatically fetch the target's structural-context artifacts: responsibility, interface, constraints, errors).
|
|
276
|
-
* If it participates in an end-to-end process: Do not explain the whole process locally. Ensure the node is listed in \`.yggdrasil/flows/<flow_name>/flow.yaml\`. The engine will attach the flow knowledge automatically.
|
|
277
|
-
* **Flows \u2014 writing flow content:** When creating or editing flow artifacts (e.g. \`description.md\` in \`flows/<name>/\`), write business-first: describe the process from user/business perspective. Technical details only as inserts when they clarify the flow. Not technical-first with business inserts.
|
|
246
|
+
Always ask the user before resolving drift. Never auto-resolve.
|
|
278
247
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
248
|
+
- **Source drift** (source files changed) \u2192 update graph artifacts to match source, then \`yg drift-sync\`
|
|
249
|
+
- **Graph drift** (graph artifacts changed) \u2192 review affected source, update if needed, then \`yg drift-sync\`
|
|
250
|
+
- **Full drift** (both changed) \u2192 present both sides to user, ask which direction wins
|
|
251
|
+
- **Missing** \u2192 ask: re-materialize or remove mapping?
|
|
252
|
+
- **Unmaterialized** \u2192 ask user how to proceed
|
|
282
253
|
|
|
283
|
-
|
|
284
|
-
* **What goes here:** Horizontal requirements like logging, auth, rate-limiting, or specific frameworks.
|
|
285
|
-
* **Routing:** Do NOT write generic rules like "This node must log all errors" in local artifacts. Instead, read \`config.yaml\` for available \`tags\`. Add the relevant tag (e.g., \`requires-audit\`) to \`node.yaml\`. The engine will automatically attach the aspect knowledge.
|
|
254
|
+
Threshold: >10 drifted nodes \u2192 ask user which area to prioritize. Do not resolve all at once.
|
|
286
255
|
|
|
287
|
-
###
|
|
288
|
-
* **What goes here:** Global architectural decisions, design patterns, and systemic invariants.
|
|
289
|
-
* **Routing:** Read \`config.yaml\` (the \`knowledge_categories\` section) to know what categories exist.
|
|
290
|
-
* If the file implements a standard pattern: Do not describe the pattern locally. Add a \`knowledge\` reference in \`node.yaml\` to the existing pattern.
|
|
291
|
-
* If the file reveals an undocumented global invariant or decision: Ask the user to confirm it. If confirmed, create it under \`.yggdrasil/knowledge/<category>/\` so all future nodes inherit it.
|
|
256
|
+
### Error Recovery
|
|
292
257
|
|
|
293
|
-
**
|
|
294
|
-
|
|
295
|
-
-
|
|
296
|
-
-
|
|
258
|
+
- **\`yg\` not found** \u2192 inform user: "yg CLI is not installed or not in PATH." Stop.
|
|
259
|
+
- **Unfixable validate errors** \u2192 if not resolved after 3 attempts, stop and report to user. Do not loop.
|
|
260
|
+
- **Budget exceeded** \u2192 if \`yg build-context\` exits with error (context package exceeds budget), warn user: "This node should be split." Do not proceed with implementation.
|
|
261
|
+
- **Corrupted \`.yggdrasil/\` files** \u2192 report to user. Do not attempt repair.
|
|
262
|
+
- **Incremental sync** \u2192 run \`yg drift-sync\` every 3-5 source files during multi-file tasks. Do not defer to end.`;
|
|
263
|
+
var KNOWLEDGE_BASE = `## KNOWLEDGE BASE
|
|
297
264
|
|
|
298
|
-
|
|
265
|
+
### Graph Structure
|
|
299
266
|
|
|
300
|
-
|
|
267
|
+
\`\`\`
|
|
268
|
+
.yggdrasil/
|
|
269
|
+
config.yaml \u2190 vocabulary, stack, node types, artifact rules, required aspects
|
|
270
|
+
model/ \u2190 what exists: nodes, hierarchy, relations, file mappings
|
|
271
|
+
aspects/ \u2190 what must: cross-cutting requirements with rationale and guidance
|
|
272
|
+
flows/ \u2190 why and in what process: business processes with node participation
|
|
273
|
+
schemas/ \u2190 YAML schemas \u2014 read before creating any graph element
|
|
274
|
+
.drift-state \u2190 generated by CLI; never edit manually
|
|
275
|
+
.journal.yaml \u2190 generated by CLI; never edit manually
|
|
276
|
+
\`\`\`
|
|
301
277
|
|
|
302
|
-
|
|
278
|
+
Key facts:
|
|
279
|
+
|
|
280
|
+
- **Hierarchy:** nodes nest in \`model/\`. Children inherit parent context. Do not repeat parent content in children.
|
|
281
|
+
- **Aspect id = directory path** under \`aspects/\`. Each aspect has \`aspect.yaml\` + content \`.md\` files. No automatic parent-child \u2014 use \`implies\` explicitly.
|
|
282
|
+
- **Flows = business processes.** A flow describes what happens in the world, not code sequences. Flow aspects propagate to all participants.
|
|
303
283
|
|
|
304
|
-
|
|
305
|
-
* \`yg build-context --node <node_path>\` -> Assemble strict specification.
|
|
306
|
-
* \`yg tree [--root <node_path>] [--depth N]\` -> Print graph structure.
|
|
307
|
-
* \`yg deps --node <node_path> [--type structural|event|all]\` -> Show dependencies.
|
|
308
|
-
* \`yg impact --node <node_path> --simulate\` -> Simulate blast radius.
|
|
309
|
-
* \`yg status\` -> Graph health metrics.
|
|
310
|
-
* \`yg validate [--scope <node_path>|all]\` -> Compile/check graph. Run after EVERY graph edit.
|
|
311
|
-
* \`yg drift [--scope <node_path>|all]\` -> Check code vs graph baseline.
|
|
312
|
-
* \`yg drift-sync --node <node_path>\` -> Save current file hash as new baseline. Run ONLY after ensuring graph artifacts match the code.
|
|
284
|
+
### Context Assembly
|
|
313
285
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
286
|
+
Run \`yg build-context --node <path>\` to get the deterministic context package for a node. Trust the package \u2014 it assembles global config, hierarchy, own artifacts, aspects, and relational context. If the package is insufficient, enrich the graph. Do not bypass it with raw file exploration.
|
|
287
|
+
|
|
288
|
+
### Information Routing
|
|
289
|
+
|
|
290
|
+
When you encounter information, route it to the correct location:
|
|
291
|
+
|
|
292
|
+
- **Specific to this node** \u2192 local node artifact (check \`config.yaml artifacts\` for available types)
|
|
293
|
+
- **Rule for many nodes** \u2192 aspect (\`aspects/<id>/\` with \`aspect.yaml\` + content \`.md\` files). If applies to ALL nodes of a type \u2192 \`node_types[*].required_aspects\` in \`config.yaml\`
|
|
294
|
+
- **Business process** \u2192 flow (\`flows/<name>/\` with \`flow.yaml\` + \`description.md\`). Ask user if process unclear.
|
|
295
|
+
- **Shared across a domain** \u2192 parent node artifact. Children receive it through hierarchy.
|
|
296
|
+
- **Technology stack or standard** \u2192 \`config.yaml\` under \`stack\` or \`standards\` (+ \`rationale\` field)
|
|
297
|
+
- **Decision (why):** one node \u2192 local artifact; category of nodes \u2192 aspect content files; tech choice \u2192 \`config.yaml\` rationale field
|
|
298
|
+
|
|
299
|
+
### Creating Aspects
|
|
300
|
+
|
|
301
|
+
- [ ] 1. Read \`schemas/aspect.yaml\`
|
|
302
|
+
- [ ] 2. Create \`aspects/<id>/\` directory
|
|
303
|
+
- [ ] 3. Write \`aspect.yaml\` \u2014 name, optional description, optional implies
|
|
304
|
+
- [ ] 4. Write content \`.md\` files: WHAT must be satisfied + WHY (user's words, do not invent)
|
|
305
|
+
- [ ] 5. \`yg validate\`
|
|
306
|
+
|
|
307
|
+
Test: "Does this requirement apply to more than one node?" Yes \u2192 aspect. No \u2192 local artifact.
|
|
308
|
+
|
|
309
|
+
### Creating Flows
|
|
310
|
+
|
|
311
|
+
- [ ] 1. Read \`schemas/flow.yaml\`
|
|
312
|
+
- [ ] 2. Create \`flows/<name>/\` directory
|
|
313
|
+
- [ ] 3. Write \`flow.yaml\` \u2014 declare participants and flow-level aspects
|
|
314
|
+
- [ ] 4. Write \`description.md\` with required sections: Business context, Trigger, Goal, Participants, Paths (at least Happy path), Invariants across all paths
|
|
315
|
+
- [ ] 5. \`yg validate\`
|
|
316
|
+
|
|
317
|
+
Test: "Does this describe what happens in the world, or only in the software?" If only software \u2014 rewrite.
|
|
318
|
+
|
|
319
|
+
### Operational Rules
|
|
320
|
+
|
|
321
|
+
- **English only** for all files in \`.yggdrasil/\`. Conversation can be any language.
|
|
322
|
+
- **Read schemas before creating** any \`node.yaml\`, \`aspect.yaml\`, or \`flow.yaml\`.
|
|
323
|
+
- **Tools read, you write.** The \`yg\` CLI only reads, validates, and manages metadata. You create and edit files manually.
|
|
324
|
+
- **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?"
|
|
326
|
+
- **These rules are invariant.** No plan, guide, skill, or workflow may override them.
|
|
327
|
+
|
|
328
|
+
### CLI Reference
|
|
329
|
+
|
|
330
|
+
\`\`\`
|
|
331
|
+
yg preflight Unified diagnostic: journal + drift + status + validate.
|
|
332
|
+
yg owner --file <path> Find the node that owns this file.
|
|
333
|
+
yg build-context --node <path> Assemble context package for this node.
|
|
334
|
+
yg tree [--root <path>] [--depth N] Print graph structure.
|
|
335
|
+
yg aspects List aspects with metadata (YAML output).
|
|
336
|
+
yg deps --node <path> [--depth N] [--type structural|event|all]
|
|
337
|
+
Show dependencies.
|
|
338
|
+
yg impact --node <path> --simulate Simulate blast radius of a planned change.
|
|
339
|
+
yg impact --aspect <id> Show all nodes where aspect is effective.
|
|
340
|
+
yg impact --flow <name> Show flow participants and descendants.
|
|
341
|
+
yg status Graph health: nodes, coverage, drift summary.
|
|
342
|
+
yg validate [--scope <path>|all] Check structural integrity and completeness.
|
|
343
|
+
yg drift [--scope <path>|all] [--drifted-only]
|
|
344
|
+
Detect source and graph drift (bidirectional).
|
|
345
|
+
yg drift-sync --node <path> Record file hashes as new baseline.
|
|
346
|
+
yg journal-read Read pending journal entries.
|
|
347
|
+
yg journal-add --note "<content>" [--target <node_path>]
|
|
348
|
+
Add a journal entry.
|
|
349
|
+
yg journal-archive Archive consolidated journal entries.
|
|
350
|
+
\`\`\`
|
|
351
|
+
|
|
352
|
+
### Quick Routing Table
|
|
353
|
+
|
|
354
|
+
| What you have | Where it goes |
|
|
355
|
+
|---|---|
|
|
356
|
+
| Information specific to this node | Local node artifact (read \`config.yaml artifacts\` for types) |
|
|
357
|
+
| Rule that applies to many nodes | Aspect (content \`.md\` files in \`aspects/<id>/\`) |
|
|
358
|
+
| Architectural invariant for a node type | Required aspect in \`config.yaml node_types\` |
|
|
359
|
+
| Business process participation | Flow (\`flow.yaml participants\`) |
|
|
360
|
+
| Process-level requirement | Flow \`aspects\` + aspect directory |
|
|
361
|
+
| Context shared across a domain | Parent node artifact |
|
|
362
|
+
| Technology stack | \`config.yaml stack\` (+ \`rationale\` field) |
|
|
363
|
+
| Global coding standards | \`config.yaml standards\` |
|
|
318
364
|
`;
|
|
365
|
+
var AGENT_RULES_CONTENT = [CORE_PROTOCOL, OPERATIONS, KNOWLEDGE_BASE].join("\n\n---\n\n");
|
|
319
366
|
|
|
320
367
|
// src/templates/platform.ts
|
|
321
368
|
var AGENT_RULES_IMPORT = "@.yggdrasil/agent-rules.md";
|
|
@@ -558,10 +605,10 @@ function escapeRegex(s) {
|
|
|
558
605
|
}
|
|
559
606
|
|
|
560
607
|
// src/cli/init.ts
|
|
561
|
-
function
|
|
608
|
+
function getGraphSchemasDir() {
|
|
562
609
|
const currentDir = path2.dirname(fileURLToPath(import.meta.url));
|
|
563
610
|
const packageRoot = path2.join(currentDir, "..");
|
|
564
|
-
return path2.join(packageRoot, "graph-
|
|
611
|
+
return path2.join(packageRoot, "graph-schemas");
|
|
565
612
|
}
|
|
566
613
|
var GITIGNORE_CONTENT = `.journal.yaml
|
|
567
614
|
journals-archive/
|
|
@@ -609,23 +656,20 @@ function registerInitCommand(program2) {
|
|
|
609
656
|
await mkdir2(path2.join(yggRoot, "model"), { recursive: true });
|
|
610
657
|
await mkdir2(path2.join(yggRoot, "aspects"), { recursive: true });
|
|
611
658
|
await mkdir2(path2.join(yggRoot, "flows"), { recursive: true });
|
|
612
|
-
|
|
613
|
-
await mkdir2(
|
|
614
|
-
|
|
615
|
-
const templatesDir = path2.join(yggRoot, "templates");
|
|
616
|
-
await mkdir2(templatesDir, { recursive: true });
|
|
617
|
-
const graphTemplatesDir = getGraphTemplatesDir();
|
|
659
|
+
const schemasDir = path2.join(yggRoot, "schemas");
|
|
660
|
+
await mkdir2(schemasDir, { recursive: true });
|
|
661
|
+
const graphSchemasDir = getGraphSchemasDir();
|
|
618
662
|
try {
|
|
619
|
-
const entries = await readdir(
|
|
620
|
-
const
|
|
621
|
-
for (const file of
|
|
622
|
-
const srcPath = path2.join(
|
|
663
|
+
const entries = await readdir(graphSchemasDir, { withFileTypes: true });
|
|
664
|
+
const schemaFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
665
|
+
for (const file of schemaFiles) {
|
|
666
|
+
const srcPath = path2.join(graphSchemasDir, file);
|
|
623
667
|
const content = await readFile2(srcPath, "utf-8");
|
|
624
|
-
await writeFile2(path2.join(
|
|
668
|
+
await writeFile2(path2.join(schemasDir, file), content, "utf-8");
|
|
625
669
|
}
|
|
626
670
|
} catch (err) {
|
|
627
671
|
process.stderr.write(
|
|
628
|
-
`Warning: Could not copy graph
|
|
672
|
+
`Warning: Could not copy graph schemas from ${graphSchemasDir}: ${err.message}
|
|
629
673
|
`
|
|
630
674
|
);
|
|
631
675
|
}
|
|
@@ -639,10 +683,7 @@ function registerInitCommand(program2) {
|
|
|
639
683
|
process.stdout.write(" .yggdrasil/model/\n");
|
|
640
684
|
process.stdout.write(" .yggdrasil/aspects/\n");
|
|
641
685
|
process.stdout.write(" .yggdrasil/flows/\n");
|
|
642
|
-
process.stdout.write(" .yggdrasil/
|
|
643
|
-
process.stdout.write(
|
|
644
|
-
" .yggdrasil/templates/ (node, aspect, flow, knowledge)\n"
|
|
645
|
-
);
|
|
686
|
+
process.stdout.write(" .yggdrasil/schemas/ (node, aspect, flow)\n");
|
|
646
687
|
process.stdout.write(` ${path2.relative(projectRoot, rulesPath)} (rules)
|
|
647
688
|
|
|
648
689
|
`);
|
|
@@ -654,8 +695,8 @@ function registerInitCommand(program2) {
|
|
|
654
695
|
}
|
|
655
696
|
|
|
656
697
|
// src/core/graph-loader.ts
|
|
657
|
-
import { readdir as readdir3 } from "fs/promises";
|
|
658
|
-
import
|
|
698
|
+
import { readdir as readdir3, readFile as readFile9 } from "fs/promises";
|
|
699
|
+
import path7 from "path";
|
|
659
700
|
|
|
660
701
|
// src/io/config-parser.ts
|
|
661
702
|
import { readFile as readFile3 } from "fs/promises";
|
|
@@ -663,8 +704,7 @@ import { parse as parseYaml } from "yaml";
|
|
|
663
704
|
var DEFAULT_QUALITY = {
|
|
664
705
|
min_artifact_length: 50,
|
|
665
706
|
max_direct_relations: 10,
|
|
666
|
-
context_budget: { warning: 1e4, error: 2e4 }
|
|
667
|
-
knowledge_staleness_days: 90
|
|
707
|
+
context_budget: { warning: 1e4, error: 2e4 }
|
|
668
708
|
};
|
|
669
709
|
async function parseConfig(filePath) {
|
|
670
710
|
const content = await readFile3(filePath, "utf-8");
|
|
@@ -672,18 +712,34 @@ async function parseConfig(filePath) {
|
|
|
672
712
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
673
713
|
throw new Error(`config.yaml: missing or invalid 'name' field`);
|
|
674
714
|
}
|
|
675
|
-
const
|
|
676
|
-
if (!Array.isArray(
|
|
715
|
+
const nodeTypesRaw = raw.node_types;
|
|
716
|
+
if (!Array.isArray(nodeTypesRaw) || nodeTypesRaw.length === 0) {
|
|
677
717
|
throw new Error(`config.yaml: 'node_types' must be a non-empty array`);
|
|
678
718
|
}
|
|
719
|
+
const nodeTypes = nodeTypesRaw.map((item) => {
|
|
720
|
+
if (typeof item === "string") {
|
|
721
|
+
return { name: item };
|
|
722
|
+
}
|
|
723
|
+
if (typeof item === "object" && item !== null && "name" in item && typeof item.name === "string") {
|
|
724
|
+
const obj = item;
|
|
725
|
+
const requiredAspects = Array.isArray(obj.required_aspects) ? obj.required_aspects.filter((t) => typeof t === "string") : Array.isArray(obj.required_tags) ? obj.required_tags.filter((t) => typeof t === "string") : void 0;
|
|
726
|
+
return {
|
|
727
|
+
name: obj.name,
|
|
728
|
+
required_aspects: requiredAspects && requiredAspects.length > 0 ? requiredAspects : void 0
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
throw new Error(
|
|
732
|
+
`config.yaml: node_types entry must be string or { name, required_aspects? }`
|
|
733
|
+
);
|
|
734
|
+
});
|
|
679
735
|
const artifacts = raw.artifacts;
|
|
680
736
|
if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts) || Object.keys(artifacts).length === 0) {
|
|
681
737
|
throw new Error(`config.yaml: 'artifacts' must be a non-empty object`);
|
|
682
738
|
}
|
|
683
739
|
const artifactsMap = {};
|
|
684
740
|
for (const [key, val] of Object.entries(artifacts)) {
|
|
685
|
-
if (key === "node") {
|
|
686
|
-
throw new Error(`config.yaml: artifact name 'node' is reserved`);
|
|
741
|
+
if (key === "node.yaml") {
|
|
742
|
+
throw new Error(`config.yaml: artifact name 'node.yaml' is reserved`);
|
|
687
743
|
}
|
|
688
744
|
const a = val;
|
|
689
745
|
const required = a.required;
|
|
@@ -692,10 +748,10 @@ async function parseConfig(filePath) {
|
|
|
692
748
|
}
|
|
693
749
|
if (typeof required === "object" && required && "when" in required) {
|
|
694
750
|
const when = required.when;
|
|
695
|
-
const validWhen = when === "has_incoming_relations" || when === "has_outgoing_relations" || typeof when === "string" && when.startsWith("has_tag:");
|
|
751
|
+
const validWhen = when === "has_incoming_relations" || when === "has_outgoing_relations" || typeof when === "string" && (when.startsWith("has_aspect:") || when.startsWith("has_tag:"));
|
|
696
752
|
if (!validWhen) {
|
|
697
753
|
throw new Error(
|
|
698
|
-
`config.yaml: artifact '${key}' has invalid 'required.when': must be has_incoming_relations, has_outgoing_relations, or
|
|
754
|
+
`config.yaml: artifact '${key}' has invalid 'required.when': must be has_incoming_relations, has_outgoing_relations, or has_aspect:<name>`
|
|
699
755
|
);
|
|
700
756
|
}
|
|
701
757
|
}
|
|
@@ -705,24 +761,6 @@ async function parseConfig(filePath) {
|
|
|
705
761
|
structural_context: a.structural_context ?? false
|
|
706
762
|
};
|
|
707
763
|
}
|
|
708
|
-
if (!("knowledge_categories" in raw)) {
|
|
709
|
-
throw new Error(
|
|
710
|
-
`config.yaml: missing 'knowledge_categories' field (required, may be empty list)`
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
const knowledgeCategoriesRaw = raw.knowledge_categories;
|
|
714
|
-
if (!Array.isArray(knowledgeCategoriesRaw)) {
|
|
715
|
-
throw new Error(`config.yaml: 'knowledge_categories' must be an array`);
|
|
716
|
-
}
|
|
717
|
-
const knowledgeCategories = knowledgeCategoriesRaw;
|
|
718
|
-
const categoryNames = /* @__PURE__ */ new Set();
|
|
719
|
-
for (const kc of knowledgeCategories) {
|
|
720
|
-
if (!kc?.name || typeof kc.name !== "string") continue;
|
|
721
|
-
if (categoryNames.has(kc.name)) {
|
|
722
|
-
throw new Error(`config.yaml: duplicate knowledge category '${kc.name}'`);
|
|
723
|
-
}
|
|
724
|
-
categoryNames.add(kc.name);
|
|
725
|
-
}
|
|
726
764
|
const qualityRaw = raw.quality;
|
|
727
765
|
const quality = qualityRaw ? {
|
|
728
766
|
min_artifact_length: qualityRaw.min_artifact_length ?? DEFAULT_QUALITY.min_artifact_length,
|
|
@@ -730,30 +768,19 @@ async function parseConfig(filePath) {
|
|
|
730
768
|
context_budget: {
|
|
731
769
|
warning: qualityRaw.context_budget?.warning ?? DEFAULT_QUALITY.context_budget.warning,
|
|
732
770
|
error: qualityRaw.context_budget?.error ?? DEFAULT_QUALITY.context_budget.error
|
|
733
|
-
}
|
|
734
|
-
knowledge_staleness_days: qualityRaw.knowledge_staleness_days ?? DEFAULT_QUALITY.knowledge_staleness_days
|
|
771
|
+
}
|
|
735
772
|
} : DEFAULT_QUALITY;
|
|
736
773
|
if (quality.context_budget.error < quality.context_budget.warning) {
|
|
737
774
|
throw new Error(
|
|
738
775
|
`config.yaml: quality.context_budget.error (${quality.context_budget.error}) must be >= warning (${quality.context_budget.warning})`
|
|
739
776
|
);
|
|
740
777
|
}
|
|
741
|
-
if (!("tags" in raw)) {
|
|
742
|
-
throw new Error(`config.yaml: missing 'tags' field (required, may be empty list)`);
|
|
743
|
-
}
|
|
744
|
-
const tags = raw.tags;
|
|
745
|
-
if (!Array.isArray(tags)) {
|
|
746
|
-
throw new Error(`config.yaml: 'tags' must be an array`);
|
|
747
|
-
}
|
|
748
|
-
const tagsList = tags.filter((t) => typeof t === "string");
|
|
749
778
|
return {
|
|
750
779
|
name: raw.name.trim(),
|
|
751
780
|
stack: raw.stack ?? {},
|
|
752
781
|
standards: typeof raw.standards === "string" ? raw.standards : "",
|
|
753
|
-
tags: tagsList,
|
|
754
782
|
node_types: nodeTypes,
|
|
755
783
|
artifacts: artifactsMap,
|
|
756
|
-
knowledge_categories: knowledgeCategories.filter((kc) => kc?.name),
|
|
757
784
|
quality
|
|
758
785
|
};
|
|
759
786
|
}
|
|
@@ -786,10 +813,9 @@ async function parseNodeYaml(filePath) {
|
|
|
786
813
|
return {
|
|
787
814
|
name: raw.name.trim(),
|
|
788
815
|
type: raw.type.trim(),
|
|
789
|
-
|
|
816
|
+
aspects: parseStringArray(raw.aspects) ?? parseStringArray(raw.tags),
|
|
790
817
|
blackbox: raw.blackbox ?? false,
|
|
791
818
|
relations: relations.length > 0 ? relations : void 0,
|
|
792
|
-
knowledge: parseStringArray(raw.knowledge),
|
|
793
819
|
mapping
|
|
794
820
|
};
|
|
795
821
|
}
|
|
@@ -889,25 +915,37 @@ async function readArtifacts(dirPath, excludeFiles = ["node.yaml"], includeFiles
|
|
|
889
915
|
}
|
|
890
916
|
|
|
891
917
|
// src/io/aspect-parser.ts
|
|
892
|
-
async function parseAspect(aspectDir, aspectYamlPath) {
|
|
918
|
+
async function parseAspect(aspectDir, aspectYamlPath, id) {
|
|
919
|
+
const idTrimmed = id?.trim() ?? "";
|
|
920
|
+
if (!idTrimmed) {
|
|
921
|
+
throw new Error(`Aspect id must be non-empty (relative path in aspects/)`);
|
|
922
|
+
}
|
|
893
923
|
const content = await readFile6(aspectYamlPath, "utf-8");
|
|
894
924
|
const raw = parseYaml3(content);
|
|
895
925
|
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
896
926
|
throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'name'`);
|
|
897
927
|
}
|
|
898
|
-
|
|
899
|
-
throw new Error(`Aspect file ${aspectYamlPath}: missing or empty 'tag'`);
|
|
900
|
-
}
|
|
928
|
+
const description = typeof raw.description === "string" ? raw.description.trim() : void 0;
|
|
901
929
|
const artifacts = await readArtifacts(aspectDir, ["aspect.yaml"]);
|
|
930
|
+
let implies;
|
|
931
|
+
if (raw.implies !== void 0) {
|
|
932
|
+
if (!Array.isArray(raw.implies)) {
|
|
933
|
+
throw new Error(`Aspect file ${aspectYamlPath}: 'implies' must be an array of strings`);
|
|
934
|
+
}
|
|
935
|
+
implies = raw.implies.filter((t) => typeof t === "string");
|
|
936
|
+
}
|
|
902
937
|
return {
|
|
903
938
|
name: raw.name.trim(),
|
|
904
|
-
|
|
939
|
+
id: idTrimmed,
|
|
940
|
+
description,
|
|
941
|
+
implies,
|
|
905
942
|
artifacts
|
|
906
943
|
};
|
|
907
944
|
}
|
|
908
945
|
|
|
909
946
|
// src/io/flow-parser.ts
|
|
910
947
|
import { readFile as readFile7 } from "fs/promises";
|
|
948
|
+
import path4 from "path";
|
|
911
949
|
import { parse as parseYaml4 } from "yaml";
|
|
912
950
|
async function parseFlow(flowDir, flowYamlPath) {
|
|
913
951
|
const content = await readFile7(flowYamlPath, "utf-8");
|
|
@@ -923,79 +961,44 @@ async function parseFlow(flowDir, flowYamlPath) {
|
|
|
923
961
|
if (nodePaths.length === 0) {
|
|
924
962
|
throw new Error(`flow.yaml at ${flowYamlPath}: 'nodes' must contain string node paths`);
|
|
925
963
|
}
|
|
926
|
-
|
|
964
|
+
let aspects;
|
|
965
|
+
if (raw.aspects !== void 0) {
|
|
966
|
+
if (!Array.isArray(raw.aspects)) {
|
|
967
|
+
throw new Error(`flow.yaml at ${flowYamlPath}: 'aspects' must be an array of strings`);
|
|
968
|
+
}
|
|
969
|
+
const aspectTags = raw.aspects.filter((a) => typeof a === "string");
|
|
970
|
+
aspects = aspectTags.length > 0 ? aspectTags : [];
|
|
971
|
+
}
|
|
927
972
|
const artifacts = await readArtifacts(flowDir, ["flow.yaml"]);
|
|
928
973
|
return {
|
|
974
|
+
path: path4.basename(flowDir),
|
|
929
975
|
name: raw.name.trim(),
|
|
930
976
|
nodes: nodePaths,
|
|
931
|
-
|
|
977
|
+
...aspects !== void 0 && { aspects },
|
|
932
978
|
artifacts
|
|
933
979
|
};
|
|
934
980
|
}
|
|
935
981
|
|
|
936
|
-
// src/io/
|
|
982
|
+
// src/io/schema-parser.ts
|
|
937
983
|
import { readFile as readFile8 } from "fs/promises";
|
|
984
|
+
import path5 from "path";
|
|
938
985
|
import { parse as parseYaml5 } from "yaml";
|
|
939
|
-
async function parseKnowledge(knowledgeDir, knowledgeYamlPath, category, relativePath) {
|
|
940
|
-
const content = await readFile8(knowledgeYamlPath, "utf-8");
|
|
941
|
-
const raw = parseYaml5(content);
|
|
942
|
-
if (!raw.name || typeof raw.name !== "string" || raw.name.trim() === "") {
|
|
943
|
-
throw new Error(`knowledge.yaml at ${knowledgeYamlPath}: missing or empty 'name'`);
|
|
944
|
-
}
|
|
945
|
-
const scope = parseScope(raw.scope, knowledgeYamlPath);
|
|
946
|
-
const artifacts = await readArtifacts(knowledgeDir, ["knowledge.yaml"]);
|
|
947
|
-
return {
|
|
948
|
-
name: raw.name.trim(),
|
|
949
|
-
scope,
|
|
950
|
-
category,
|
|
951
|
-
path: relativePath,
|
|
952
|
-
artifacts
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
function parseScope(raw, filePath) {
|
|
956
|
-
if (raw === "global") {
|
|
957
|
-
return "global";
|
|
958
|
-
}
|
|
959
|
-
if (raw && typeof raw === "object") {
|
|
960
|
-
const obj = raw;
|
|
961
|
-
if (Array.isArray(obj.tags)) {
|
|
962
|
-
const tags = obj.tags.filter((t) => typeof t === "string");
|
|
963
|
-
if (tags.length === 0) {
|
|
964
|
-
throw new Error(`knowledge.yaml at ${filePath}: scope.tags must be a non-empty array`);
|
|
965
|
-
}
|
|
966
|
-
return { tags };
|
|
967
|
-
}
|
|
968
|
-
if (Array.isArray(obj.nodes)) {
|
|
969
|
-
const nodes = obj.nodes.filter((n) => typeof n === "string");
|
|
970
|
-
if (nodes.length === 0) {
|
|
971
|
-
throw new Error(`knowledge.yaml at ${filePath}: scope.nodes must be a non-empty array`);
|
|
972
|
-
}
|
|
973
|
-
return { nodes };
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
throw new Error(`knowledge.yaml at ${filePath}: invalid 'scope' value`);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// src/io/template-parser.ts
|
|
980
|
-
import { readFile as readFile9 } from "fs/promises";
|
|
981
|
-
import path4 from "path";
|
|
982
|
-
import { parse as parseYaml6 } from "yaml";
|
|
983
986
|
async function parseSchema(filePath) {
|
|
984
|
-
const content = await
|
|
985
|
-
|
|
986
|
-
const schemaType =
|
|
987
|
+
const content = await readFile8(filePath, "utf-8");
|
|
988
|
+
parseYaml5(content);
|
|
989
|
+
const schemaType = path5.basename(filePath, path5.extname(filePath));
|
|
987
990
|
return { schemaType };
|
|
988
991
|
}
|
|
989
992
|
|
|
990
993
|
// src/utils/paths.ts
|
|
991
|
-
import
|
|
994
|
+
import path6 from "path";
|
|
992
995
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
993
996
|
import { stat as stat2 } from "fs/promises";
|
|
994
997
|
async function findYggRoot(projectRoot) {
|
|
995
|
-
let current =
|
|
996
|
-
const root =
|
|
998
|
+
let current = path6.resolve(projectRoot);
|
|
999
|
+
const root = path6.parse(current).root;
|
|
997
1000
|
while (true) {
|
|
998
|
-
const yggPath =
|
|
1001
|
+
const yggPath = path6.join(current, ".yggdrasil");
|
|
999
1002
|
try {
|
|
1000
1003
|
const st = await stat2(yggPath);
|
|
1001
1004
|
if (!st.isDirectory()) {
|
|
@@ -1009,7 +1012,7 @@ async function findYggRoot(projectRoot) {
|
|
|
1009
1012
|
if (current === root) {
|
|
1010
1013
|
throw new Error(`No .yggdrasil/ directory found. Run 'yg init' first.`, { cause: err });
|
|
1011
1014
|
}
|
|
1012
|
-
current =
|
|
1015
|
+
current = path6.dirname(current);
|
|
1013
1016
|
continue;
|
|
1014
1017
|
}
|
|
1015
1018
|
throw err;
|
|
@@ -1025,41 +1028,39 @@ function normalizeProjectRelativePath(projectRoot, rawPath) {
|
|
|
1025
1028
|
if (normalizedInput.length === 0) {
|
|
1026
1029
|
throw new Error("Path cannot be empty");
|
|
1027
1030
|
}
|
|
1028
|
-
const absolute =
|
|
1029
|
-
const relative =
|
|
1030
|
-
const isOutside = relative.startsWith("..") ||
|
|
1031
|
+
const absolute = path6.resolve(projectRoot, normalizedInput);
|
|
1032
|
+
const relative = path6.relative(projectRoot, absolute);
|
|
1033
|
+
const isOutside = relative.startsWith("..") || path6.isAbsolute(relative);
|
|
1031
1034
|
if (isOutside) {
|
|
1032
1035
|
throw new Error(`Path is outside project root: ${rawPath}`);
|
|
1033
1036
|
}
|
|
1034
|
-
return relative.split(
|
|
1037
|
+
return relative.split(path6.sep).join("/");
|
|
1035
1038
|
}
|
|
1036
1039
|
|
|
1037
1040
|
// src/core/graph-loader.ts
|
|
1038
1041
|
function toModelPath(absolutePath, modelDir) {
|
|
1039
|
-
return
|
|
1042
|
+
return path7.relative(modelDir, absolutePath).split(path7.sep).join("/");
|
|
1040
1043
|
}
|
|
1041
1044
|
var FALLBACK_CONFIG = {
|
|
1042
1045
|
name: "",
|
|
1043
1046
|
stack: {},
|
|
1044
1047
|
standards: "",
|
|
1045
|
-
tags: [],
|
|
1046
1048
|
node_types: [],
|
|
1047
|
-
artifacts: {}
|
|
1048
|
-
knowledge_categories: []
|
|
1049
|
+
artifacts: {}
|
|
1049
1050
|
};
|
|
1050
1051
|
async function loadGraph(projectRoot, options = {}) {
|
|
1051
1052
|
const yggRoot = await findYggRoot(projectRoot);
|
|
1052
1053
|
let configError;
|
|
1053
1054
|
let config = FALLBACK_CONFIG;
|
|
1054
1055
|
try {
|
|
1055
|
-
config = await parseConfig(
|
|
1056
|
+
config = await parseConfig(path7.join(yggRoot, "config.yaml"));
|
|
1056
1057
|
} catch (error) {
|
|
1057
1058
|
if (!options.tolerateInvalidConfig) {
|
|
1058
1059
|
throw error;
|
|
1059
1060
|
}
|
|
1060
1061
|
configError = error.message;
|
|
1061
1062
|
}
|
|
1062
|
-
const modelDir =
|
|
1063
|
+
const modelDir = path7.join(yggRoot, "model");
|
|
1063
1064
|
const nodes = /* @__PURE__ */ new Map();
|
|
1064
1065
|
const nodeParseErrors = [];
|
|
1065
1066
|
const artifactFilenames = Object.keys(config.artifacts ?? {});
|
|
@@ -1073,13 +1074,9 @@ async function loadGraph(projectRoot, options = {}) {
|
|
|
1073
1074
|
}
|
|
1074
1075
|
throw err;
|
|
1075
1076
|
}
|
|
1076
|
-
const aspects = await loadAspects(
|
|
1077
|
-
const flows = await loadFlows(
|
|
1078
|
-
const
|
|
1079
|
-
path6.join(yggRoot, "knowledge"),
|
|
1080
|
-
config.knowledge_categories
|
|
1081
|
-
);
|
|
1082
|
-
const schemas = await loadSchemas(path6.join(yggRoot, "templates"));
|
|
1077
|
+
const aspects = await loadAspects(path7.join(yggRoot, "aspects"));
|
|
1078
|
+
const flows = await loadFlows(path7.join(yggRoot, "flows"));
|
|
1079
|
+
const schemas = await loadSchemas(path7.join(yggRoot, "schemas"));
|
|
1083
1080
|
return {
|
|
1084
1081
|
config,
|
|
1085
1082
|
configError,
|
|
@@ -1087,7 +1084,6 @@ async function loadGraph(projectRoot, options = {}) {
|
|
|
1087
1084
|
nodes,
|
|
1088
1085
|
aspects,
|
|
1089
1086
|
flows,
|
|
1090
|
-
knowledge,
|
|
1091
1087
|
schemas,
|
|
1092
1088
|
rootPath: yggRoot
|
|
1093
1089
|
};
|
|
@@ -1100,9 +1096,12 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
|
|
|
1100
1096
|
}
|
|
1101
1097
|
if (hasNodeYaml) {
|
|
1102
1098
|
const graphPath = toModelPath(dirPath, modelDir);
|
|
1099
|
+
const nodeYamlPath = path7.join(dirPath, "node.yaml");
|
|
1103
1100
|
let meta;
|
|
1101
|
+
let nodeYamlRaw;
|
|
1104
1102
|
try {
|
|
1105
|
-
|
|
1103
|
+
nodeYamlRaw = await readFile9(nodeYamlPath, "utf-8");
|
|
1104
|
+
meta = await parseNodeYaml(nodeYamlPath);
|
|
1106
1105
|
} catch (err) {
|
|
1107
1106
|
nodeParseErrors.push({
|
|
1108
1107
|
nodePath: graphPath,
|
|
@@ -1114,6 +1113,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
|
|
|
1114
1113
|
const node = {
|
|
1115
1114
|
path: graphPath,
|
|
1116
1115
|
meta,
|
|
1116
|
+
nodeYamlRaw,
|
|
1117
1117
|
artifacts,
|
|
1118
1118
|
children: [],
|
|
1119
1119
|
parent
|
|
@@ -1126,7 +1126,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
|
|
|
1126
1126
|
if (!entry.isDirectory()) continue;
|
|
1127
1127
|
if (entry.name.startsWith(".")) continue;
|
|
1128
1128
|
await scanModelDirectory(
|
|
1129
|
-
|
|
1129
|
+
path7.join(dirPath, entry.name),
|
|
1130
1130
|
modelDir,
|
|
1131
1131
|
node,
|
|
1132
1132
|
nodes,
|
|
@@ -1139,7 +1139,7 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
|
|
|
1139
1139
|
if (!entry.isDirectory()) continue;
|
|
1140
1140
|
if (entry.name.startsWith(".")) continue;
|
|
1141
1141
|
await scanModelDirectory(
|
|
1142
|
-
|
|
1142
|
+
path7.join(dirPath, entry.name),
|
|
1143
1143
|
modelDir,
|
|
1144
1144
|
null,
|
|
1145
1145
|
nodes,
|
|
@@ -1151,27 +1151,36 @@ async function scanModelDirectory(dirPath, modelDir, parent, nodes, nodeParseErr
|
|
|
1151
1151
|
}
|
|
1152
1152
|
async function loadAspects(aspectsDir) {
|
|
1153
1153
|
try {
|
|
1154
|
-
const entries = await readdir3(aspectsDir, { withFileTypes: true });
|
|
1155
1154
|
const aspects = [];
|
|
1156
|
-
|
|
1157
|
-
if (!entry.isDirectory()) continue;
|
|
1158
|
-
const aspectYamlPath = path6.join(aspectsDir, entry.name, "aspect.yaml");
|
|
1159
|
-
const aspect = await parseAspect(path6.join(aspectsDir, entry.name), aspectYamlPath);
|
|
1160
|
-
aspects.push(aspect);
|
|
1161
|
-
}
|
|
1155
|
+
await scanAspectsDirectory(aspectsDir, aspectsDir, aspects);
|
|
1162
1156
|
return aspects;
|
|
1163
1157
|
} catch {
|
|
1164
1158
|
return [];
|
|
1165
1159
|
}
|
|
1166
1160
|
}
|
|
1161
|
+
async function scanAspectsDirectory(dirPath, aspectsRoot, aspects) {
|
|
1162
|
+
const entries = await readdir3(dirPath, { withFileTypes: true });
|
|
1163
|
+
const hasAspectYaml = entries.some((e) => e.isFile() && e.name === "aspect.yaml");
|
|
1164
|
+
if (hasAspectYaml) {
|
|
1165
|
+
const id = path7.relative(aspectsRoot, dirPath).split(path7.sep).join("/");
|
|
1166
|
+
const aspectYamlPath = path7.join(dirPath, "aspect.yaml");
|
|
1167
|
+
const aspect = await parseAspect(dirPath, aspectYamlPath, id);
|
|
1168
|
+
aspects.push(aspect);
|
|
1169
|
+
}
|
|
1170
|
+
for (const entry of entries) {
|
|
1171
|
+
if (!entry.isDirectory()) continue;
|
|
1172
|
+
if (entry.name.startsWith(".")) continue;
|
|
1173
|
+
await scanAspectsDirectory(path7.join(dirPath, entry.name), aspectsRoot, aspects);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1167
1176
|
async function loadFlows(flowsDir) {
|
|
1168
1177
|
try {
|
|
1169
1178
|
const entries = await readdir3(flowsDir, { withFileTypes: true });
|
|
1170
1179
|
const flows = [];
|
|
1171
1180
|
for (const entry of entries) {
|
|
1172
1181
|
if (!entry.isDirectory()) continue;
|
|
1173
|
-
const flowYamlPath =
|
|
1174
|
-
const flow = await parseFlow(
|
|
1182
|
+
const flowYamlPath = path7.join(flowsDir, entry.name, "flow.yaml");
|
|
1183
|
+
const flow = await parseFlow(path7.join(flowsDir, entry.name), flowYamlPath);
|
|
1175
1184
|
flows.push(flow);
|
|
1176
1185
|
}
|
|
1177
1186
|
return flows;
|
|
@@ -1179,37 +1188,14 @@ async function loadFlows(flowsDir) {
|
|
|
1179
1188
|
return [];
|
|
1180
1189
|
}
|
|
1181
1190
|
}
|
|
1182
|
-
async function
|
|
1183
|
-
const items = [];
|
|
1184
|
-
const categorySet = new Set(categories.map((c) => c.name));
|
|
1185
|
-
try {
|
|
1186
|
-
const catEntries = await readdir3(knowledgeDir, { withFileTypes: true });
|
|
1187
|
-
for (const catEntry of catEntries) {
|
|
1188
|
-
if (!catEntry.isDirectory()) continue;
|
|
1189
|
-
if (!categorySet.has(catEntry.name)) continue;
|
|
1190
|
-
const catPath = path6.join(knowledgeDir, catEntry.name);
|
|
1191
|
-
const itemEntries = await readdir3(catPath, { withFileTypes: true });
|
|
1192
|
-
for (const itemEntry of itemEntries) {
|
|
1193
|
-
if (!itemEntry.isDirectory()) continue;
|
|
1194
|
-
const itemDir = path6.join(catPath, itemEntry.name);
|
|
1195
|
-
const knowledgeYamlPath = path6.join(itemDir, "knowledge.yaml");
|
|
1196
|
-
const relativePath = `${catEntry.name}/${itemEntry.name}`;
|
|
1197
|
-
const item = await parseKnowledge(itemDir, knowledgeYamlPath, catEntry.name, relativePath);
|
|
1198
|
-
items.push(item);
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
} catch {
|
|
1202
|
-
}
|
|
1203
|
-
return items;
|
|
1204
|
-
}
|
|
1205
|
-
async function loadSchemas(templatesDir) {
|
|
1191
|
+
async function loadSchemas(schemasDir) {
|
|
1206
1192
|
try {
|
|
1207
|
-
const entries = await readdir3(
|
|
1193
|
+
const entries = await readdir3(schemasDir, { withFileTypes: true });
|
|
1208
1194
|
const schemas = [];
|
|
1209
1195
|
for (const entry of entries) {
|
|
1210
1196
|
if (!entry.isFile()) continue;
|
|
1211
1197
|
if (!entry.name.endsWith(".yaml") && !entry.name.endsWith(".yml")) continue;
|
|
1212
|
-
const s = await parseSchema(
|
|
1198
|
+
const s = await parseSchema(path7.join(schemasDir, entry.name));
|
|
1213
1199
|
schemas.push(s);
|
|
1214
1200
|
}
|
|
1215
1201
|
return schemas;
|
|
@@ -1220,7 +1206,7 @@ async function loadSchemas(templatesDir) {
|
|
|
1220
1206
|
|
|
1221
1207
|
// src/core/context-builder.ts
|
|
1222
1208
|
import { readFile as readFile10 } from "fs/promises";
|
|
1223
|
-
import
|
|
1209
|
+
import path8 from "path";
|
|
1224
1210
|
|
|
1225
1211
|
// src/utils/tokens.ts
|
|
1226
1212
|
function estimateTokens(text) {
|
|
@@ -1235,18 +1221,13 @@ async function buildContext(graph, nodePath) {
|
|
|
1235
1221
|
if (!node) {
|
|
1236
1222
|
throw new Error(`Node not found: ${nodePath}`);
|
|
1237
1223
|
}
|
|
1238
|
-
const nodeTags = new Set(node.meta.tags ?? []);
|
|
1239
|
-
const seenKnowledge = /* @__PURE__ */ new Set();
|
|
1240
1224
|
const layers = [];
|
|
1241
1225
|
layers.push(buildGlobalLayer(graph.config));
|
|
1242
|
-
for (const k of collectKnowledgeItems(graph, nodePath, nodeTags, seenKnowledge)) {
|
|
1243
|
-
layers.push(buildKnowledgeLayer(k));
|
|
1244
|
-
}
|
|
1245
1226
|
const ancestors = collectAncestors(node);
|
|
1246
1227
|
for (const ancestor of ancestors) {
|
|
1247
|
-
layers.push(buildHierarchyLayer(ancestor, graph.config));
|
|
1228
|
+
layers.push(buildHierarchyLayer(ancestor, graph.config, graph));
|
|
1248
1229
|
}
|
|
1249
|
-
layers.push(await buildOwnLayer(node, graph.config, graph.rootPath));
|
|
1230
|
+
layers.push(await buildOwnLayer(node, graph.config, graph.rootPath, graph));
|
|
1250
1231
|
for (const relation of node.meta.relations ?? []) {
|
|
1251
1232
|
const target = graph.nodes.get(relation.target);
|
|
1252
1233
|
if (!target) {
|
|
@@ -1258,24 +1239,22 @@ async function buildContext(graph, nodePath) {
|
|
|
1258
1239
|
layers.push(buildEventRelationLayer(target, relation));
|
|
1259
1240
|
}
|
|
1260
1241
|
}
|
|
1261
|
-
for (const tag of nodeTags) {
|
|
1262
|
-
for (const aspect of graph.aspects) {
|
|
1263
|
-
if (aspect.tag === tag) {
|
|
1264
|
-
layers.push(buildAspectLayer(aspect));
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
1242
|
for (const flow of collectParticipatingFlows(graph, node)) {
|
|
1269
|
-
layers.push(buildFlowLayer(flow));
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1243
|
+
layers.push(buildFlowLayer(flow, graph));
|
|
1244
|
+
}
|
|
1245
|
+
const allAspectIds = /* @__PURE__ */ new Set();
|
|
1246
|
+
for (const l of layers) {
|
|
1247
|
+
const aspects = l.attrs?.aspects;
|
|
1248
|
+
if (aspects) {
|
|
1249
|
+
for (const id of aspects.split(",").map((t) => t.trim()).filter(Boolean)) {
|
|
1250
|
+
allAspectIds.add(id);
|
|
1276
1251
|
}
|
|
1277
1252
|
}
|
|
1278
1253
|
}
|
|
1254
|
+
const aspectsToInclude = resolveAspects(allAspectIds, graph.aspects);
|
|
1255
|
+
for (const aspect of aspectsToInclude) {
|
|
1256
|
+
layers.push(buildAspectLayer(aspect));
|
|
1257
|
+
}
|
|
1279
1258
|
const fullText = layers.map((l) => l.content).join("\n\n");
|
|
1280
1259
|
const tokenCount = estimateTokens(fullText);
|
|
1281
1260
|
const mapping = normalizeMappingPaths(node.meta.mapping);
|
|
@@ -1289,47 +1268,46 @@ async function buildContext(graph, nodePath) {
|
|
|
1289
1268
|
tokenCount
|
|
1290
1269
|
};
|
|
1291
1270
|
}
|
|
1292
|
-
function
|
|
1271
|
+
function collectParticipatingFlows(graph, node) {
|
|
1272
|
+
const paths = /* @__PURE__ */ new Set([node.path, ...collectAncestors(node).map((a) => a.path)]);
|
|
1273
|
+
return graph.flows.filter((f) => f.nodes.some((n) => paths.has(n)));
|
|
1274
|
+
}
|
|
1275
|
+
function expandAspects(aspectIds, aspects) {
|
|
1276
|
+
const idToAspect = /* @__PURE__ */ new Map();
|
|
1277
|
+
for (const a of aspects) {
|
|
1278
|
+
idToAspect.set(a.id, a);
|
|
1279
|
+
}
|
|
1293
1280
|
const result = [];
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
}
|
|
1300
|
-
for (const k of graph.knowledge) {
|
|
1301
|
-
if (typeof k.scope === "object" && "tags" in k.scope) {
|
|
1302
|
-
const overlap = k.scope.tags.some((t) => nodeTags.has(t));
|
|
1303
|
-
if (overlap && !seenKnowledge.has(k.path)) {
|
|
1304
|
-
seenKnowledge.add(k.path);
|
|
1305
|
-
result.push(k);
|
|
1306
|
-
}
|
|
1281
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1282
|
+
const stack = /* @__PURE__ */ new Set();
|
|
1283
|
+
function collect(id) {
|
|
1284
|
+
if (stack.has(id)) {
|
|
1285
|
+
throw new Error(`Aspect implies cycle detected involving aspect '${id}'`);
|
|
1307
1286
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1287
|
+
if (visited.has(id)) return;
|
|
1288
|
+
stack.add(id);
|
|
1289
|
+
visited.add(id);
|
|
1290
|
+
result.push(id);
|
|
1291
|
+
const aspect = idToAspect.get(id);
|
|
1292
|
+
if (aspect) {
|
|
1293
|
+
for (const implied of aspect.implies ?? []) {
|
|
1294
|
+
collect(implied);
|
|
1314
1295
|
}
|
|
1315
1296
|
}
|
|
1297
|
+
stack.delete(id);
|
|
1316
1298
|
}
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
for (const kPath of node.meta.knowledge) {
|
|
1320
|
-
const norm = kPath.replace(/\/$/, "");
|
|
1321
|
-
const k = graph.knowledge.find((item) => item.path === norm || item.path === kPath);
|
|
1322
|
-
if (k && !seenKnowledge.has(k.path)) {
|
|
1323
|
-
seenKnowledge.add(k.path);
|
|
1324
|
-
result.push(k);
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1299
|
+
for (const id of aspectIds) {
|
|
1300
|
+
collect(id);
|
|
1327
1301
|
}
|
|
1328
1302
|
return result;
|
|
1329
1303
|
}
|
|
1330
|
-
function
|
|
1331
|
-
const
|
|
1332
|
-
|
|
1304
|
+
function resolveAspects(aspectIds, aspects) {
|
|
1305
|
+
const idToAspect = /* @__PURE__ */ new Map();
|
|
1306
|
+
for (const a of aspects) {
|
|
1307
|
+
idToAspect.set(a.id, a);
|
|
1308
|
+
}
|
|
1309
|
+
const expandedIds = expandAspects([...aspectIds], aspects);
|
|
1310
|
+
return expandedIds.map((id) => idToAspect.get(id)).filter((a) => a !== void 0);
|
|
1333
1311
|
}
|
|
1334
1312
|
function buildGlobalLayer(config) {
|
|
1335
1313
|
let content = `**Project:** ${config.name}
|
|
@@ -1347,41 +1325,39 @@ ${config.standards || "(none)"}
|
|
|
1347
1325
|
`;
|
|
1348
1326
|
return { type: "global", label: "Global Context", content };
|
|
1349
1327
|
}
|
|
1350
|
-
function buildKnowledgeLayer(k, fromFlow) {
|
|
1351
|
-
const categoryLabel = k.category.charAt(0).toUpperCase() + k.category.slice(1);
|
|
1352
|
-
const content = k.artifacts.map((a) => `### ${a.filename}
|
|
1353
|
-
${a.content}`).join("\n\n");
|
|
1354
|
-
const label = fromFlow ? `Long-term Memory (from flow): ${k.name}` : `${categoryLabel}: ${k.name}`;
|
|
1355
|
-
return {
|
|
1356
|
-
type: "knowledge",
|
|
1357
|
-
label,
|
|
1358
|
-
content
|
|
1359
|
-
};
|
|
1360
|
-
}
|
|
1361
1328
|
function filterArtifactsByConfig(artifacts, config) {
|
|
1362
1329
|
const allowed = new Set(Object.keys(config.artifacts ?? {}));
|
|
1363
1330
|
return artifacts.filter((a) => allowed.has(a.filename));
|
|
1364
1331
|
}
|
|
1365
|
-
function buildHierarchyLayer(ancestor, config) {
|
|
1332
|
+
function buildHierarchyLayer(ancestor, config, graph) {
|
|
1366
1333
|
const filtered = filterArtifactsByConfig(ancestor.artifacts, config);
|
|
1367
1334
|
const content = filtered.map((a) => `### ${a.filename}
|
|
1368
1335
|
${a.content}`).join("\n\n");
|
|
1336
|
+
const nodeAspects = ancestor.meta.aspects ?? [];
|
|
1337
|
+
const expanded = expandAspects(nodeAspects, graph.aspects);
|
|
1338
|
+
const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
|
|
1369
1339
|
return {
|
|
1370
1340
|
type: "hierarchy",
|
|
1371
1341
|
label: `Module Context (${ancestor.path}/)`,
|
|
1372
|
-
content
|
|
1342
|
+
content,
|
|
1343
|
+
attrs
|
|
1373
1344
|
};
|
|
1374
1345
|
}
|
|
1375
|
-
async function buildOwnLayer(node, config, graphRootPath) {
|
|
1346
|
+
async function buildOwnLayer(node, config, graphRootPath, graph) {
|
|
1376
1347
|
const parts = [];
|
|
1377
|
-
|
|
1378
|
-
try {
|
|
1379
|
-
const nodeYamlContent = await readFile10(nodeYamlPath, "utf-8");
|
|
1348
|
+
if (node.nodeYamlRaw) {
|
|
1380
1349
|
parts.push(`### node.yaml
|
|
1350
|
+
${node.nodeYamlRaw.trim()}`);
|
|
1351
|
+
} else {
|
|
1352
|
+
const nodeYamlPath = path8.join(graphRootPath, "model", node.path, "node.yaml");
|
|
1353
|
+
try {
|
|
1354
|
+
const nodeYamlContent = await readFile10(nodeYamlPath, "utf-8");
|
|
1355
|
+
parts.push(`### node.yaml
|
|
1381
1356
|
${nodeYamlContent.trim()}`);
|
|
1382
|
-
|
|
1383
|
-
|
|
1357
|
+
} catch {
|
|
1358
|
+
parts.push(`### node.yaml
|
|
1384
1359
|
(not found)`);
|
|
1360
|
+
}
|
|
1385
1361
|
}
|
|
1386
1362
|
const filtered = filterArtifactsByConfig(node.artifacts, config);
|
|
1387
1363
|
for (const a of filtered) {
|
|
@@ -1389,10 +1365,14 @@ ${nodeYamlContent.trim()}`);
|
|
|
1389
1365
|
${a.content}`);
|
|
1390
1366
|
}
|
|
1391
1367
|
const content = parts.join("\n\n");
|
|
1368
|
+
const nodeAspects = node.meta.aspects ?? [];
|
|
1369
|
+
const expanded = expandAspects(nodeAspects, graph.aspects);
|
|
1370
|
+
const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
|
|
1392
1371
|
return {
|
|
1393
1372
|
type: "own",
|
|
1394
1373
|
label: `Node: ${node.meta.name}`,
|
|
1395
|
-
content
|
|
1374
|
+
content,
|
|
1375
|
+
attrs
|
|
1396
1376
|
};
|
|
1397
1377
|
}
|
|
1398
1378
|
function buildStructuralRelationLayer(target, relation, config) {
|
|
@@ -1420,10 +1400,17 @@ ${a.content}`).join("\n\n");
|
|
|
1420
1400
|
content += filtered.map((a) => `### ${a.filename}
|
|
1421
1401
|
${a.content}`).join("\n\n");
|
|
1422
1402
|
}
|
|
1403
|
+
const attrs = {
|
|
1404
|
+
target: target.path,
|
|
1405
|
+
type: relation.type
|
|
1406
|
+
};
|
|
1407
|
+
if (relation.consumes?.length) attrs.consumes = relation.consumes.join(", ");
|
|
1408
|
+
if (relation.failure) attrs.failure = relation.failure;
|
|
1423
1409
|
return {
|
|
1424
1410
|
type: "relational",
|
|
1425
1411
|
label: `Dependency: ${target.meta.name} (${relation.type}) \u2014 ${target.path}`,
|
|
1426
|
-
content: content.trim()
|
|
1412
|
+
content: content.trim(),
|
|
1413
|
+
attrs
|
|
1427
1414
|
};
|
|
1428
1415
|
}
|
|
1429
1416
|
function buildEventRelationLayer(target, relation) {
|
|
@@ -1436,10 +1423,17 @@ You listen for ${eventName}.`;
|
|
|
1436
1423
|
content += `
|
|
1437
1424
|
Consumes: ${relation.consumes.join(", ")}`;
|
|
1438
1425
|
}
|
|
1426
|
+
const attrs = {
|
|
1427
|
+
target: target.path,
|
|
1428
|
+
type: relation.type,
|
|
1429
|
+
"event-name": eventName
|
|
1430
|
+
};
|
|
1431
|
+
if (relation.consumes?.length) attrs.consumes = relation.consumes.join(", ");
|
|
1439
1432
|
return {
|
|
1440
1433
|
type: "relational",
|
|
1441
1434
|
label: `Event: ${eventName} [${relation.type}]`,
|
|
1442
|
-
content
|
|
1435
|
+
content,
|
|
1436
|
+
attrs
|
|
1443
1437
|
};
|
|
1444
1438
|
}
|
|
1445
1439
|
function buildAspectLayer(aspect) {
|
|
@@ -1447,17 +1441,21 @@ function buildAspectLayer(aspect) {
|
|
|
1447
1441
|
${a.content}`).join("\n\n");
|
|
1448
1442
|
return {
|
|
1449
1443
|
type: "aspects",
|
|
1450
|
-
label: `${aspect.name} (
|
|
1444
|
+
label: `${aspect.name} (aspect: ${aspect.id})`,
|
|
1451
1445
|
content
|
|
1452
1446
|
};
|
|
1453
1447
|
}
|
|
1454
|
-
function buildFlowLayer(flow) {
|
|
1448
|
+
function buildFlowLayer(flow, graph) {
|
|
1455
1449
|
const content = flow.artifacts.map((a) => `### ${a.filename}
|
|
1456
1450
|
${a.content}`).join("\n\n");
|
|
1451
|
+
const flowAspects = flow.aspects ?? [];
|
|
1452
|
+
const expanded = expandAspects(flowAspects, graph.aspects);
|
|
1453
|
+
const attrs = expanded.length > 0 ? { aspects: expanded.join(",") } : void 0;
|
|
1457
1454
|
return {
|
|
1458
1455
|
type: "flows",
|
|
1459
1456
|
label: `Flow: ${flow.name}`,
|
|
1460
|
-
content: content || "(no artifacts)"
|
|
1457
|
+
content: content || "(no artifacts)",
|
|
1458
|
+
attrs
|
|
1461
1459
|
};
|
|
1462
1460
|
}
|
|
1463
1461
|
function buildSections(layers, mapping) {
|
|
@@ -1471,12 +1469,16 @@ function buildSections(layers, mapping) {
|
|
|
1471
1469
|
}
|
|
1472
1470
|
return [
|
|
1473
1471
|
{ key: "Global", layers: layers.filter((l) => l.type === "global") },
|
|
1474
|
-
{ key: "Knowledge", layers: layers.filter((l) => l.type === "knowledge") },
|
|
1475
1472
|
{ key: "Hierarchy", layers: layers.filter((l) => l.type === "hierarchy") },
|
|
1476
1473
|
{ key: "OwnArtifacts", layers: ownLayers },
|
|
1477
|
-
{ key: "Dependencies", layers: layers.filter((l) => l.type === "relational") },
|
|
1478
1474
|
{ key: "Aspects", layers: layers.filter((l) => l.type === "aspects") },
|
|
1479
|
-
{
|
|
1475
|
+
{
|
|
1476
|
+
key: "Relational",
|
|
1477
|
+
layers: [
|
|
1478
|
+
...layers.filter((l) => l.type === "relational"),
|
|
1479
|
+
...layers.filter((l) => l.type === "flows")
|
|
1480
|
+
]
|
|
1481
|
+
}
|
|
1480
1482
|
];
|
|
1481
1483
|
}
|
|
1482
1484
|
function collectAncestors(node) {
|
|
@@ -1488,30 +1490,27 @@ function collectAncestors(node) {
|
|
|
1488
1490
|
}
|
|
1489
1491
|
return ancestors;
|
|
1490
1492
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1506
|
-
});
|
|
1507
|
-
const ts = parseInt(out.trim(), 10);
|
|
1508
|
-
return Number.isNaN(ts) ? null : ts;
|
|
1509
|
-
} catch {
|
|
1510
|
-
return null;
|
|
1493
|
+
function collectEffectiveAspectIds(graph, nodePath) {
|
|
1494
|
+
const node = graph.nodes.get(nodePath);
|
|
1495
|
+
if (!node) return /* @__PURE__ */ new Set();
|
|
1496
|
+
const raw = new Set(node.meta.aspects ?? []);
|
|
1497
|
+
let ancestor = node.parent;
|
|
1498
|
+
while (ancestor) {
|
|
1499
|
+
for (const id of ancestor.meta.aspects ?? []) raw.add(id);
|
|
1500
|
+
ancestor = ancestor.parent;
|
|
1501
|
+
}
|
|
1502
|
+
const ancestorPaths = /* @__PURE__ */ new Set([nodePath, ...collectAncestors(node).map((a) => a.path)]);
|
|
1503
|
+
for (const flow of graph.flows) {
|
|
1504
|
+
if (flow.nodes.some((n) => ancestorPaths.has(n))) {
|
|
1505
|
+
for (const id of flow.aspects ?? []) raw.add(id);
|
|
1506
|
+
}
|
|
1511
1507
|
}
|
|
1508
|
+
return new Set(expandAspects([...raw], graph.aspects));
|
|
1512
1509
|
}
|
|
1513
1510
|
|
|
1514
1511
|
// src/core/validator.ts
|
|
1512
|
+
import { readdir as readdir4 } from "fs/promises";
|
|
1513
|
+
import path9 from "path";
|
|
1515
1514
|
var RESERVED_DIRS = /* @__PURE__ */ new Set();
|
|
1516
1515
|
async function validate(graph, scope = "all") {
|
|
1517
1516
|
const issues = [];
|
|
@@ -1534,28 +1533,25 @@ async function validate(graph, scope = "all") {
|
|
|
1534
1533
|
}
|
|
1535
1534
|
if (!graph.configError) {
|
|
1536
1535
|
issues.push(...checkNodeTypes(graph));
|
|
1537
|
-
issues.push(...
|
|
1538
|
-
issues.push(...
|
|
1539
|
-
issues.push(...
|
|
1536
|
+
issues.push(...checkAspectsDefined(graph));
|
|
1537
|
+
issues.push(...checkAspectIds(graph));
|
|
1538
|
+
issues.push(...checkAspectIdUniqueness(graph));
|
|
1539
|
+
issues.push(...checkImpliedAspectsExist(graph));
|
|
1540
|
+
issues.push(...checkImpliesNoCycles(graph));
|
|
1541
|
+
issues.push(...checkRequiredAspectsCoverage(graph));
|
|
1540
1542
|
issues.push(...checkRequiredArtifacts(graph));
|
|
1541
|
-
issues.push(...await checkUnknownKnowledgeCategories(graph));
|
|
1542
1543
|
issues.push(...checkInvalidArtifactConditions(graph));
|
|
1543
|
-
issues.push(...checkScopeTagsDefined(graph));
|
|
1544
|
-
issues.push(...await checkMissingPatternExamples(graph));
|
|
1545
1544
|
issues.push(...await checkContextBudget(graph));
|
|
1546
1545
|
issues.push(...checkHighFanOut(graph));
|
|
1547
|
-
issues.push(...await checkStaleKnowledge(graph));
|
|
1548
1546
|
}
|
|
1549
1547
|
issues.push(...checkSchemas(graph));
|
|
1550
1548
|
issues.push(...checkRelationTargets(graph));
|
|
1551
1549
|
issues.push(...checkNoCycles(graph));
|
|
1552
1550
|
issues.push(...checkMappingOverlap(graph));
|
|
1553
|
-
issues.push(...checkBrokenKnowledgeRefs(graph));
|
|
1554
1551
|
issues.push(...checkBrokenFlowRefs(graph));
|
|
1555
|
-
issues.push(...
|
|
1552
|
+
issues.push(...checkFlowAspectIds(graph));
|
|
1556
1553
|
issues.push(...await checkDirectoriesHaveNodeYaml(graph));
|
|
1557
1554
|
issues.push(...await checkShallowArtifacts(graph));
|
|
1558
|
-
issues.push(...await checkUnreachableKnowledge(graph));
|
|
1559
1555
|
issues.push(...checkUnpairedEvents(graph));
|
|
1560
1556
|
let filtered = issues;
|
|
1561
1557
|
let nodesScanned = graph.nodes.size;
|
|
@@ -1573,7 +1569,7 @@ async function validate(graph, scope = "all") {
|
|
|
1573
1569
|
}
|
|
1574
1570
|
function checkNodeTypes(graph) {
|
|
1575
1571
|
const issues = [];
|
|
1576
|
-
const allowedTypes = new Set(graph.config.node_types ?? []);
|
|
1572
|
+
const allowedTypes = new Set((graph.config.node_types ?? []).map((t) => t.name));
|
|
1577
1573
|
for (const [nodePath, node] of graph.nodes) {
|
|
1578
1574
|
if (!allowedTypes.has(node.meta.type)) {
|
|
1579
1575
|
issues.push({
|
|
@@ -1636,17 +1632,17 @@ function checkRelationTargets(graph) {
|
|
|
1636
1632
|
}
|
|
1637
1633
|
return issues;
|
|
1638
1634
|
}
|
|
1639
|
-
function
|
|
1635
|
+
function checkAspectsDefined(graph) {
|
|
1640
1636
|
const issues = [];
|
|
1641
|
-
const
|
|
1637
|
+
const validAspectIds = new Set(graph.aspects.map((a) => a.id));
|
|
1642
1638
|
for (const [nodePath, node] of graph.nodes) {
|
|
1643
|
-
for (const
|
|
1644
|
-
if (!
|
|
1639
|
+
for (const aspectId of node.meta.aspects ?? []) {
|
|
1640
|
+
if (!validAspectIds.has(aspectId)) {
|
|
1645
1641
|
issues.push({
|
|
1646
1642
|
severity: "error",
|
|
1647
1643
|
code: "E003",
|
|
1648
|
-
rule: "unknown-
|
|
1649
|
-
message: `
|
|
1644
|
+
rule: "unknown-aspect",
|
|
1645
|
+
message: `Aspect '${aspectId}' has no corresponding directory in aspects/`,
|
|
1650
1646
|
nodePath
|
|
1651
1647
|
});
|
|
1652
1648
|
}
|
|
@@ -1654,40 +1650,124 @@ function checkTagsDefined(graph) {
|
|
|
1654
1650
|
}
|
|
1655
1651
|
return issues;
|
|
1656
1652
|
}
|
|
1657
|
-
function
|
|
1658
|
-
|
|
1659
|
-
const definedTags = new Set(graph.config.tags ?? []);
|
|
1660
|
-
for (const aspect of graph.aspects) {
|
|
1661
|
-
if (!definedTags.has(aspect.tag)) {
|
|
1662
|
-
issues.push({
|
|
1663
|
-
severity: "error",
|
|
1664
|
-
code: "E007",
|
|
1665
|
-
rule: "broken-aspect-tag",
|
|
1666
|
-
message: `Aspect '${aspect.name}' references undefined tag '${aspect.tag}'`
|
|
1667
|
-
});
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
return issues;
|
|
1653
|
+
function checkAspectIds(_graph) {
|
|
1654
|
+
return [];
|
|
1671
1655
|
}
|
|
1672
|
-
function
|
|
1656
|
+
function checkAspectIdUniqueness(graph) {
|
|
1673
1657
|
const issues = [];
|
|
1674
|
-
const
|
|
1658
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1675
1659
|
for (const aspect of graph.aspects) {
|
|
1676
|
-
const names =
|
|
1660
|
+
const names = byId.get(aspect.id) ?? [];
|
|
1677
1661
|
names.push(aspect.name);
|
|
1678
|
-
|
|
1662
|
+
byId.set(aspect.id, names);
|
|
1679
1663
|
}
|
|
1680
|
-
for (const [
|
|
1664
|
+
for (const [id, names] of byId) {
|
|
1681
1665
|
if (names.length <= 1) continue;
|
|
1682
1666
|
issues.push({
|
|
1683
1667
|
severity: "error",
|
|
1684
1668
|
code: "E014",
|
|
1685
1669
|
rule: "duplicate-aspect-binding",
|
|
1686
|
-
message: `
|
|
1670
|
+
message: `Aspect '${id}' is bound to multiple aspects (${names.join(", ")})`
|
|
1687
1671
|
});
|
|
1688
1672
|
}
|
|
1689
1673
|
return issues;
|
|
1690
1674
|
}
|
|
1675
|
+
function checkImpliedAspectsExist(graph) {
|
|
1676
|
+
const issues = [];
|
|
1677
|
+
const idToAspect = /* @__PURE__ */ new Map();
|
|
1678
|
+
for (const a of graph.aspects) {
|
|
1679
|
+
idToAspect.set(a.id, { name: a.name });
|
|
1680
|
+
}
|
|
1681
|
+
for (const aspect of graph.aspects) {
|
|
1682
|
+
for (const impliedId of aspect.implies ?? []) {
|
|
1683
|
+
if (!idToAspect.has(impliedId)) {
|
|
1684
|
+
issues.push({
|
|
1685
|
+
severity: "error",
|
|
1686
|
+
code: "E016",
|
|
1687
|
+
rule: "implied-aspect-missing",
|
|
1688
|
+
message: `Aspect '${aspect.name}' implies '${impliedId}' but no aspect with that id exists in aspects/`
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return issues;
|
|
1694
|
+
}
|
|
1695
|
+
function checkImpliesNoCycles(graph) {
|
|
1696
|
+
const idToAspect = /* @__PURE__ */ new Map();
|
|
1697
|
+
for (const a of graph.aspects) {
|
|
1698
|
+
idToAspect.set(a.id, { implies: a.implies });
|
|
1699
|
+
}
|
|
1700
|
+
const WHITE = 0;
|
|
1701
|
+
const GRAY = 1;
|
|
1702
|
+
const BLACK = 2;
|
|
1703
|
+
const color = /* @__PURE__ */ new Map();
|
|
1704
|
+
for (const id of idToAspect.keys()) color.set(id, WHITE);
|
|
1705
|
+
const issues = [];
|
|
1706
|
+
function dfs(id, pathArr) {
|
|
1707
|
+
color.set(id, GRAY);
|
|
1708
|
+
pathArr.push(id);
|
|
1709
|
+
const aspect = idToAspect.get(id);
|
|
1710
|
+
for (const implied of aspect?.implies ?? []) {
|
|
1711
|
+
if (color.get(implied) === GRAY) {
|
|
1712
|
+
const cycle = pathArr.slice(pathArr.indexOf(implied)).concat(implied);
|
|
1713
|
+
issues.push({
|
|
1714
|
+
severity: "error",
|
|
1715
|
+
code: "E017",
|
|
1716
|
+
rule: "aspect-implies-cycle",
|
|
1717
|
+
message: `Aspect implies cycle: ${cycle.join(" \u2192 ")}`
|
|
1718
|
+
});
|
|
1719
|
+
pathArr.pop();
|
|
1720
|
+
color.set(id, BLACK);
|
|
1721
|
+
return true;
|
|
1722
|
+
}
|
|
1723
|
+
if (color.get(implied) === WHITE && dfs(implied, pathArr)) {
|
|
1724
|
+
pathArr.pop();
|
|
1725
|
+
color.set(id, BLACK);
|
|
1726
|
+
return true;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
pathArr.pop();
|
|
1730
|
+
color.set(id, BLACK);
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
for (const id of idToAspect.keys()) {
|
|
1734
|
+
if (color.get(id) === WHITE) {
|
|
1735
|
+
dfs(id, []);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
return issues;
|
|
1739
|
+
}
|
|
1740
|
+
function checkRequiredAspectsCoverage(graph) {
|
|
1741
|
+
const issues = [];
|
|
1742
|
+
const typeConfig = new Map(
|
|
1743
|
+
(graph.config.node_types ?? []).map((t) => [t.name, t.required_aspects ?? []])
|
|
1744
|
+
);
|
|
1745
|
+
for (const [nodePath, node] of graph.nodes) {
|
|
1746
|
+
if (node.meta.blackbox) continue;
|
|
1747
|
+
const requiredAspects = typeConfig.get(node.meta.type);
|
|
1748
|
+
if (!requiredAspects || requiredAspects.length === 0) continue;
|
|
1749
|
+
const nodeAspects = node.meta.aspects ?? [];
|
|
1750
|
+
let effectiveAspects;
|
|
1751
|
+
try {
|
|
1752
|
+
effectiveAspects = resolveAspects(nodeAspects, graph.aspects);
|
|
1753
|
+
} catch {
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
const effectiveAspectIds = new Set(effectiveAspects.map((a) => a.id));
|
|
1757
|
+
for (const required of requiredAspects) {
|
|
1758
|
+
if (!effectiveAspectIds.has(required)) {
|
|
1759
|
+
issues.push({
|
|
1760
|
+
severity: "warning",
|
|
1761
|
+
code: "W011",
|
|
1762
|
+
rule: "missing-required-aspect-coverage",
|
|
1763
|
+
message: `Node '${nodePath}' (type: ${node.meta.type}) missing required aspect coverage for '${required}'`,
|
|
1764
|
+
nodePath
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
return issues;
|
|
1770
|
+
}
|
|
1691
1771
|
function checkNoCycles(graph) {
|
|
1692
1772
|
const WHITE = 0;
|
|
1693
1773
|
const GRAY = 1;
|
|
@@ -1789,9 +1869,10 @@ function artifactRequiredReason(graph, nodePath, node, required) {
|
|
|
1789
1869
|
const count = node.meta.relations?.length ?? 0;
|
|
1790
1870
|
return count > 0 ? `${count} outgoing relation(s)` : null;
|
|
1791
1871
|
}
|
|
1792
|
-
if (when.startsWith("has_tag:")) {
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1872
|
+
if (when.startsWith("has_aspect:") || when.startsWith("has_tag:")) {
|
|
1873
|
+
const prefix = when.startsWith("has_aspect:") ? "has_aspect:" : "has_tag:";
|
|
1874
|
+
const aspectId = when.slice(prefix.length);
|
|
1875
|
+
return (node.meta.aspects ?? []).includes(aspectId) ? `node has aspect '${aspectId}'` : null;
|
|
1795
1876
|
}
|
|
1796
1877
|
return null;
|
|
1797
1878
|
}
|
|
@@ -1831,29 +1912,9 @@ function checkRequiredArtifacts(graph) {
|
|
|
1831
1912
|
}
|
|
1832
1913
|
return issues;
|
|
1833
1914
|
}
|
|
1834
|
-
function checkBrokenKnowledgeRefs(graph) {
|
|
1835
|
-
const issues = [];
|
|
1836
|
-
const knowledgePaths = new Set(graph.knowledge.map((k) => k.path));
|
|
1837
|
-
for (const [nodePath, node] of graph.nodes) {
|
|
1838
|
-
for (const kPath of node.meta.knowledge ?? []) {
|
|
1839
|
-
const norm = kPath.replace(/\/$/, "");
|
|
1840
|
-
if (!knowledgePaths.has(norm) && !knowledgePaths.has(kPath)) {
|
|
1841
|
-
issues.push({
|
|
1842
|
-
severity: "error",
|
|
1843
|
-
code: "E005",
|
|
1844
|
-
rule: "broken-knowledge-ref",
|
|
1845
|
-
message: `Knowledge ref '${kPath}' does not resolve to existing knowledge item`,
|
|
1846
|
-
nodePath
|
|
1847
|
-
});
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
return issues;
|
|
1852
|
-
}
|
|
1853
1915
|
function checkBrokenFlowRefs(graph) {
|
|
1854
1916
|
const issues = [];
|
|
1855
1917
|
const nodePaths = new Set(graph.nodes.keys());
|
|
1856
|
-
const knowledgePaths = new Set(graph.knowledge.map((k) => k.path));
|
|
1857
1918
|
for (const flow of graph.flows) {
|
|
1858
1919
|
for (const n of flow.nodes) {
|
|
1859
1920
|
if (!nodePaths.has(n)) {
|
|
@@ -1865,107 +1926,43 @@ function checkBrokenFlowRefs(graph) {
|
|
|
1865
1926
|
});
|
|
1866
1927
|
}
|
|
1867
1928
|
}
|
|
1868
|
-
for (const kPath of flow.knowledge ?? []) {
|
|
1869
|
-
const norm = kPath.replace(/\/$/, "");
|
|
1870
|
-
if (!knowledgePaths.has(norm) && !knowledgePaths.has(kPath)) {
|
|
1871
|
-
issues.push({
|
|
1872
|
-
severity: "error",
|
|
1873
|
-
code: "E005",
|
|
1874
|
-
rule: "broken-knowledge-ref",
|
|
1875
|
-
message: `Flow '${flow.name}' references non-existent knowledge '${kPath}'`,
|
|
1876
|
-
nodePath: `flows/${flow.name}`
|
|
1877
|
-
});
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
return issues;
|
|
1882
|
-
}
|
|
1883
|
-
function checkBrokenScopeRefs(graph) {
|
|
1884
|
-
const issues = [];
|
|
1885
|
-
const nodePaths = new Set(graph.nodes.keys());
|
|
1886
|
-
for (const k of graph.knowledge) {
|
|
1887
|
-
if (typeof k.scope === "object" && "nodes" in k.scope) {
|
|
1888
|
-
for (const n of k.scope.nodes) {
|
|
1889
|
-
if (!nodePaths.has(n)) {
|
|
1890
|
-
issues.push({
|
|
1891
|
-
severity: "error",
|
|
1892
|
-
code: "E008",
|
|
1893
|
-
rule: "broken-scope-ref",
|
|
1894
|
-
message: `Knowledge '${k.path}' scope references non-existent node '${n}'`
|
|
1895
|
-
});
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
return issues;
|
|
1901
|
-
}
|
|
1902
|
-
function checkScopeTagsDefined(graph) {
|
|
1903
|
-
const issues = [];
|
|
1904
|
-
const definedTags = new Set(graph.config.tags ?? []);
|
|
1905
|
-
for (const k of graph.knowledge) {
|
|
1906
|
-
if (typeof k.scope !== "object" || !("tags" in k.scope)) continue;
|
|
1907
|
-
for (const tag of k.scope.tags) {
|
|
1908
|
-
if (definedTags.has(tag)) continue;
|
|
1909
|
-
issues.push({
|
|
1910
|
-
severity: "error",
|
|
1911
|
-
code: "E008",
|
|
1912
|
-
rule: "broken-scope-ref",
|
|
1913
|
-
message: `Knowledge '${k.path}' scope references undefined tag '${tag}'`
|
|
1914
|
-
});
|
|
1915
|
-
}
|
|
1916
1929
|
}
|
|
1917
1930
|
return issues;
|
|
1918
1931
|
}
|
|
1919
|
-
|
|
1932
|
+
function checkFlowAspectIds(graph) {
|
|
1920
1933
|
const issues = [];
|
|
1921
|
-
const
|
|
1922
|
-
const
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
const entries = await readdir4(knowledgeDir, { withFileTypes: true });
|
|
1926
|
-
for (const e of entries) {
|
|
1927
|
-
if (!e.isDirectory()) continue;
|
|
1928
|
-
if (e.name.startsWith(".")) continue;
|
|
1929
|
-
existingDirs.add(e.name);
|
|
1930
|
-
if (!categorySet.has(e.name)) {
|
|
1934
|
+
const validAspectIds = new Set(graph.aspects.map((a) => a.id));
|
|
1935
|
+
for (const flow of graph.flows) {
|
|
1936
|
+
for (const aspectId of flow.aspects ?? []) {
|
|
1937
|
+
if (!validAspectIds.has(aspectId)) {
|
|
1931
1938
|
issues.push({
|
|
1932
1939
|
severity: "error",
|
|
1933
|
-
code: "
|
|
1934
|
-
rule: "
|
|
1935
|
-
message: `
|
|
1940
|
+
code: "E007",
|
|
1941
|
+
rule: "broken-aspect-ref",
|
|
1942
|
+
message: `Flow '${flow.name}' references aspect '${aspectId}' but no aspect with that id exists in aspects/`
|
|
1936
1943
|
});
|
|
1937
1944
|
}
|
|
1938
1945
|
}
|
|
1939
|
-
} catch {
|
|
1940
|
-
}
|
|
1941
|
-
for (const cat of graph.config.knowledge_categories ?? []) {
|
|
1942
|
-
if (!existingDirs.has(cat.name)) {
|
|
1943
|
-
issues.push({
|
|
1944
|
-
severity: "error",
|
|
1945
|
-
code: "E017",
|
|
1946
|
-
rule: "missing-knowledge-category-dir",
|
|
1947
|
-
message: `Category '${cat.name}' in config has no knowledge/${cat.name}/ directory`
|
|
1948
|
-
});
|
|
1949
|
-
}
|
|
1950
1946
|
}
|
|
1951
1947
|
return issues;
|
|
1952
1948
|
}
|
|
1953
1949
|
function checkInvalidArtifactConditions(graph) {
|
|
1954
1950
|
const issues = [];
|
|
1955
|
-
const
|
|
1951
|
+
const validAspectIds = new Set(graph.aspects.map((a) => a.id));
|
|
1956
1952
|
const artifacts = graph.config.artifacts ?? {};
|
|
1957
1953
|
for (const [artifactName, config] of Object.entries(artifacts)) {
|
|
1958
1954
|
const required = config.required;
|
|
1959
1955
|
if (typeof required === "object" && required && "when" in required) {
|
|
1960
1956
|
const when = required.when;
|
|
1961
|
-
if (when.startsWith("has_tag:")) {
|
|
1962
|
-
const
|
|
1963
|
-
|
|
1957
|
+
if (when.startsWith("has_aspect:") || when.startsWith("has_tag:")) {
|
|
1958
|
+
const prefix = when.startsWith("has_aspect:") ? "has_aspect:" : "has_tag:";
|
|
1959
|
+
const aspectId = when.slice(prefix.length);
|
|
1960
|
+
if (!validAspectIds.has(aspectId)) {
|
|
1964
1961
|
issues.push({
|
|
1965
1962
|
severity: "error",
|
|
1966
1963
|
code: "E013",
|
|
1967
1964
|
rule: "invalid-artifact-condition",
|
|
1968
|
-
message: `Artifact '${artifactName}' condition
|
|
1965
|
+
message: `Artifact '${artifactName}' condition has_aspect:${aspectId} has no corresponding aspect in aspects/`
|
|
1969
1966
|
});
|
|
1970
1967
|
}
|
|
1971
1968
|
}
|
|
@@ -1983,7 +1980,7 @@ async function checkShallowArtifacts(graph) {
|
|
|
1983
1980
|
severity: "warning",
|
|
1984
1981
|
code: "W002",
|
|
1985
1982
|
rule: "shallow-artifact",
|
|
1986
|
-
message: `Artifact '${art.filename}' is below minimum length (${art.content.length} < ${minLen})`,
|
|
1983
|
+
message: `Artifact '${art.filename}' is below minimum length (${art.content.trim().length} < ${minLen})`,
|
|
1987
1984
|
nodePath
|
|
1988
1985
|
});
|
|
1989
1986
|
}
|
|
@@ -1991,100 +1988,7 @@ async function checkShallowArtifacts(graph) {
|
|
|
1991
1988
|
}
|
|
1992
1989
|
return issues;
|
|
1993
1990
|
}
|
|
1994
|
-
|
|
1995
|
-
const issues = [];
|
|
1996
|
-
const nodePaths = new Set(graph.nodes.keys());
|
|
1997
|
-
const nodeTags = /* @__PURE__ */ new Map();
|
|
1998
|
-
for (const [p, n] of graph.nodes) {
|
|
1999
|
-
nodeTags.set(p, new Set(n.meta.tags ?? []));
|
|
2000
|
-
}
|
|
2001
|
-
const knowledgeReachable = /* @__PURE__ */ new Set();
|
|
2002
|
-
for (const k of graph.knowledge) {
|
|
2003
|
-
if (k.scope === "global") {
|
|
2004
|
-
knowledgeReachable.add(k.path);
|
|
2005
|
-
continue;
|
|
2006
|
-
}
|
|
2007
|
-
if (typeof k.scope === "object" && "tags" in k.scope) {
|
|
2008
|
-
for (const [, tags] of nodeTags) {
|
|
2009
|
-
if (k.scope.tags.some((t) => tags.has(t))) {
|
|
2010
|
-
knowledgeReachable.add(k.path);
|
|
2011
|
-
break;
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
if (typeof k.scope === "object" && "nodes" in k.scope) {
|
|
2016
|
-
if (k.scope.nodes.some((n) => nodePaths.has(n))) {
|
|
2017
|
-
knowledgeReachable.add(k.path);
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
for (const [, node] of graph.nodes) {
|
|
2022
|
-
for (const kPath of node.meta.knowledge ?? []) {
|
|
2023
|
-
const k = graph.knowledge.find(
|
|
2024
|
-
(i) => i.path === kPath || i.path === kPath.replace(/\/$/, "")
|
|
2025
|
-
);
|
|
2026
|
-
if (k) knowledgeReachable.add(k.path);
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
for (const flow of graph.flows) {
|
|
2030
|
-
for (const kPath of flow.knowledge ?? []) {
|
|
2031
|
-
const k = graph.knowledge.find(
|
|
2032
|
-
(i) => i.path === kPath || i.path === kPath.replace(/\/$/, "")
|
|
2033
|
-
);
|
|
2034
|
-
if (k) knowledgeReachable.add(k.path);
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
for (const k of graph.knowledge) {
|
|
2038
|
-
if (!knowledgeReachable.has(k.path)) {
|
|
2039
|
-
issues.push({
|
|
2040
|
-
severity: "warning",
|
|
2041
|
-
code: "W003",
|
|
2042
|
-
rule: "unreachable-knowledge",
|
|
2043
|
-
message: `Knowledge '${k.path}' does not reach any context package`
|
|
2044
|
-
});
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
return issues;
|
|
2048
|
-
}
|
|
2049
|
-
async function checkMissingPatternExamples(graph) {
|
|
2050
|
-
const issues = [];
|
|
2051
|
-
const hasPatterns = (graph.config.knowledge_categories ?? []).some((c) => c.name === "patterns");
|
|
2052
|
-
if (!hasPatterns) return issues;
|
|
2053
|
-
const patternsDir = path9.join(graph.rootPath, "knowledge", "patterns");
|
|
2054
|
-
try {
|
|
2055
|
-
const entries = await readdir4(patternsDir, { withFileTypes: true });
|
|
2056
|
-
const exampleExtensions = /* @__PURE__ */ new Set([
|
|
2057
|
-
".ts",
|
|
2058
|
-
".js",
|
|
2059
|
-
".tsx",
|
|
2060
|
-
".jsx",
|
|
2061
|
-
".py",
|
|
2062
|
-
".go",
|
|
2063
|
-
".rs",
|
|
2064
|
-
".java",
|
|
2065
|
-
".kt"
|
|
2066
|
-
]);
|
|
2067
|
-
for (const e of entries) {
|
|
2068
|
-
if (!e.isDirectory()) continue;
|
|
2069
|
-
const itemDir = path9.join(patternsDir, e.name);
|
|
2070
|
-
const itemEntries = await readdir4(itemDir, { withFileTypes: true });
|
|
2071
|
-
const hasExample = itemEntries.some(
|
|
2072
|
-
(f) => f.isFile() && f.name !== "knowledge.yaml" && (f.name.startsWith("example") || exampleExtensions.has(path9.extname(f.name).toLowerCase()))
|
|
2073
|
-
);
|
|
2074
|
-
if (!hasExample) {
|
|
2075
|
-
issues.push({
|
|
2076
|
-
severity: "warning",
|
|
2077
|
-
code: "W004",
|
|
2078
|
-
rule: "missing-example",
|
|
2079
|
-
message: `Pattern 'patterns/${e.name}' has no example file`
|
|
2080
|
-
});
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
} catch {
|
|
2084
|
-
}
|
|
2085
|
-
return issues;
|
|
2086
|
-
}
|
|
2087
|
-
function checkHighFanOut(graph) {
|
|
1991
|
+
function checkHighFanOut(graph) {
|
|
2088
1992
|
const issues = [];
|
|
2089
1993
|
const maxRel = graph.config.quality?.max_direct_relations ?? 10;
|
|
2090
1994
|
for (const [nodePath, node] of graph.nodes) {
|
|
@@ -2101,57 +2005,6 @@ function checkHighFanOut(graph) {
|
|
|
2101
2005
|
}
|
|
2102
2006
|
return issues;
|
|
2103
2007
|
}
|
|
2104
|
-
function getNodesInScope(k, graph) {
|
|
2105
|
-
if (k.scope === "global") {
|
|
2106
|
-
return [...graph.nodes.keys()];
|
|
2107
|
-
}
|
|
2108
|
-
if (typeof k.scope === "object" && "nodes" in k.scope && k.scope.nodes) {
|
|
2109
|
-
return k.scope.nodes.filter((p) => graph.nodes.has(p));
|
|
2110
|
-
}
|
|
2111
|
-
if (typeof k.scope === "object" && "tags" in k.scope && k.scope.tags) {
|
|
2112
|
-
const tagSet = new Set(k.scope.tags);
|
|
2113
|
-
return [...graph.nodes.keys()].filter((p) => {
|
|
2114
|
-
const node = graph.nodes.get(p);
|
|
2115
|
-
return (node.meta.tags ?? []).some((t) => tagSet.has(t));
|
|
2116
|
-
});
|
|
2117
|
-
}
|
|
2118
|
-
return [];
|
|
2119
|
-
}
|
|
2120
|
-
async function checkStaleKnowledge(graph) {
|
|
2121
|
-
const issues = [];
|
|
2122
|
-
const stalenessDays = graph.config.quality?.knowledge_staleness_days ?? 90;
|
|
2123
|
-
const projectRoot = path9.dirname(graph.rootPath);
|
|
2124
|
-
const yggRel = path9.relative(projectRoot, graph.rootPath).replace(/\\/g, "/") || ".yggdrasil";
|
|
2125
|
-
for (const k of graph.knowledge) {
|
|
2126
|
-
const scopeNodes = getNodesInScope(k, graph);
|
|
2127
|
-
if (scopeNodes.length === 0) continue;
|
|
2128
|
-
const kPath = `${yggRel}/knowledge/${k.path}`;
|
|
2129
|
-
const tK = getLastCommitTimestamp(projectRoot, kPath);
|
|
2130
|
-
if (tK === null) continue;
|
|
2131
|
-
let maxTp = 0;
|
|
2132
|
-
let latestNode = "";
|
|
2133
|
-
for (const nodePath of scopeNodes) {
|
|
2134
|
-
const nodePathRel = `${yggRel}/model/${nodePath}`;
|
|
2135
|
-
const tP = getLastCommitTimestamp(projectRoot, nodePathRel);
|
|
2136
|
-
if (tP !== null && tP > maxTp) {
|
|
2137
|
-
maxTp = tP;
|
|
2138
|
-
latestNode = nodePath;
|
|
2139
|
-
}
|
|
2140
|
-
}
|
|
2141
|
-
if (maxTp === 0) continue;
|
|
2142
|
-
const diffDays = (maxTp - tK) / (60 * 60 * 24);
|
|
2143
|
-
if (diffDays > stalenessDays) {
|
|
2144
|
-
issues.push({
|
|
2145
|
-
severity: "warning",
|
|
2146
|
-
code: "W008",
|
|
2147
|
-
rule: "stale-knowledge",
|
|
2148
|
-
message: `Knowledge '${k.path}' may be stale: node '${latestNode}' modified ${Math.floor(diffDays)} days later (Git commits)`,
|
|
2149
|
-
nodePath: latestNode
|
|
2150
|
-
});
|
|
2151
|
-
}
|
|
2152
|
-
}
|
|
2153
|
-
return issues;
|
|
2154
|
-
}
|
|
2155
2008
|
function checkUnpairedEvents(graph) {
|
|
2156
2009
|
const issues = [];
|
|
2157
2010
|
const emitsTo = /* @__PURE__ */ new Map();
|
|
@@ -2200,7 +2053,7 @@ function checkUnpairedEvents(graph) {
|
|
|
2200
2053
|
}
|
|
2201
2054
|
return issues;
|
|
2202
2055
|
}
|
|
2203
|
-
var REQUIRED_SCHEMAS = ["node", "aspect", "flow"
|
|
2056
|
+
var REQUIRED_SCHEMAS = ["node", "aspect", "flow"];
|
|
2204
2057
|
function checkSchemas(graph) {
|
|
2205
2058
|
const issues = [];
|
|
2206
2059
|
const present = new Set(graph.schemas.map((s) => s.schemaType));
|
|
@@ -2210,7 +2063,7 @@ function checkSchemas(graph) {
|
|
|
2210
2063
|
severity: "warning",
|
|
2211
2064
|
code: "W010",
|
|
2212
2065
|
rule: "missing-schema",
|
|
2213
|
-
message: `Schema '${required}.yaml' missing from .yggdrasil/
|
|
2066
|
+
message: `Schema '${required}.yaml' missing from .yggdrasil/schemas/`
|
|
2214
2067
|
});
|
|
2215
2068
|
}
|
|
2216
2069
|
}
|
|
@@ -2255,26 +2108,26 @@ async function checkDirectoriesHaveNodeYaml(graph) {
|
|
|
2255
2108
|
}
|
|
2256
2109
|
async function checkContextBudget(graph) {
|
|
2257
2110
|
const issues = [];
|
|
2258
|
-
const warningThreshold = graph.config.quality?.context_budget.warning ??
|
|
2259
|
-
const errorThreshold = graph.config.quality?.context_budget.error ??
|
|
2111
|
+
const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
|
|
2112
|
+
const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
|
|
2260
2113
|
for (const [nodePath, node] of graph.nodes) {
|
|
2261
2114
|
if (node.meta.blackbox) continue;
|
|
2262
2115
|
try {
|
|
2263
|
-
const
|
|
2264
|
-
if (
|
|
2116
|
+
const pkg2 = await buildContext(graph, nodePath);
|
|
2117
|
+
if (pkg2.tokenCount >= errorThreshold) {
|
|
2265
2118
|
issues.push({
|
|
2266
2119
|
severity: "warning",
|
|
2267
2120
|
code: "W006",
|
|
2268
2121
|
rule: "budget-error",
|
|
2269
|
-
message: `Context is ${
|
|
2122
|
+
message: `Context is ${pkg2.tokenCount.toLocaleString()} tokens (error threshold: ${errorThreshold.toLocaleString()}) \u2014 blocks materialization, node must be split`,
|
|
2270
2123
|
nodePath
|
|
2271
2124
|
});
|
|
2272
|
-
} else if (
|
|
2125
|
+
} else if (pkg2.tokenCount >= warningThreshold) {
|
|
2273
2126
|
issues.push({
|
|
2274
2127
|
severity: "warning",
|
|
2275
2128
|
code: "W005",
|
|
2276
2129
|
rule: "budget-warning",
|
|
2277
|
-
message: `Context is ${
|
|
2130
|
+
message: `Context is ${pkg2.tokenCount.toLocaleString()} tokens (warning threshold: ${warningThreshold.toLocaleString()}). Consider splitting the node or reducing dependencies.`,
|
|
2278
2131
|
nodePath
|
|
2279
2132
|
});
|
|
2280
2133
|
}
|
|
@@ -2284,42 +2137,76 @@ async function checkContextBudget(graph) {
|
|
|
2284
2137
|
return issues;
|
|
2285
2138
|
}
|
|
2286
2139
|
|
|
2287
|
-
// src/formatters/
|
|
2288
|
-
function
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2140
|
+
// src/formatters/context-text.ts
|
|
2141
|
+
function escapeAttr(val) {
|
|
2142
|
+
return val.replace(/"/g, """);
|
|
2143
|
+
}
|
|
2144
|
+
function formatLayer(layer) {
|
|
2145
|
+
switch (layer.type) {
|
|
2146
|
+
case "global":
|
|
2147
|
+
return `<global>
|
|
2148
|
+
${layer.content}
|
|
2149
|
+
</global>`;
|
|
2150
|
+
case "hierarchy": {
|
|
2151
|
+
const pathMatch = layer.label.match(/\((.+)\/\)/);
|
|
2152
|
+
const pathAttr = pathMatch ? ` path="${escapeAttr(pathMatch[1])}"` : "";
|
|
2153
|
+
const aspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
|
|
2154
|
+
return `<hierarchy${pathAttr}${aspectsAttr}>
|
|
2155
|
+
${layer.content}
|
|
2156
|
+
</hierarchy>`;
|
|
2157
|
+
}
|
|
2158
|
+
case "own": {
|
|
2159
|
+
if (layer.label === "Materialization Target") {
|
|
2160
|
+
return `<materialization-target paths="${escapeAttr(layer.content)}" />`;
|
|
2161
|
+
}
|
|
2162
|
+
const ownAspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
|
|
2163
|
+
return `<own-artifacts${ownAspectsAttr}>
|
|
2164
|
+
${layer.content}
|
|
2165
|
+
</own-artifacts>`;
|
|
2166
|
+
}
|
|
2167
|
+
case "aspects": {
|
|
2168
|
+
const nameMatch = layer.label.match(/^(.+?) \(aspect: (.+)\)$/);
|
|
2169
|
+
const name = nameMatch ? escapeAttr(nameMatch[1]) : "";
|
|
2170
|
+
const id = nameMatch ? escapeAttr(nameMatch[2]) : "";
|
|
2171
|
+
return `<aspect name="${name}" id="${id}">
|
|
2172
|
+
${layer.content}
|
|
2173
|
+
</aspect>`;
|
|
2174
|
+
}
|
|
2175
|
+
case "relational": {
|
|
2176
|
+
const attrs = layer.attrs ?? {};
|
|
2177
|
+
const attrStr = Object.entries(attrs).map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
|
|
2178
|
+
const tagName = attrs.type && ["emits", "listens"].includes(attrs.type) ? "event" : "dependency";
|
|
2179
|
+
return `<${tagName}${attrStr}>
|
|
2180
|
+
${layer.content}
|
|
2181
|
+
</${tagName}>`;
|
|
2182
|
+
}
|
|
2183
|
+
case "flows": {
|
|
2184
|
+
const flowName = layer.label.replace(/^Flow: /, "").trim();
|
|
2185
|
+
const flowAspectsAttr = layer.attrs?.aspects ? ` aspects="${escapeAttr(layer.attrs.aspects)}"` : "";
|
|
2186
|
+
return `<flow name="${escapeAttr(flowName)}"${flowAspectsAttr}>
|
|
2187
|
+
${layer.content}
|
|
2188
|
+
</flow>`;
|
|
2189
|
+
}
|
|
2190
|
+
default:
|
|
2191
|
+
return layer.content;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
function formatContextText(pkg2) {
|
|
2195
|
+
const attrs = [
|
|
2196
|
+
`node-path="${escapeAttr(pkg2.nodePath)}"`,
|
|
2197
|
+
`node-name="${escapeAttr(pkg2.nodeName)}"`,
|
|
2198
|
+
`token-count="${pkg2.tokenCount}"`
|
|
2199
|
+
].join(" ");
|
|
2200
|
+
let out = `<context-package ${attrs}>
|
|
2303
2201
|
|
|
2304
2202
|
`;
|
|
2203
|
+
for (const section of pkg2.sections) {
|
|
2305
2204
|
for (const layer of section.layers) {
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
`;
|
|
2309
|
-
md += layer.content;
|
|
2310
|
-
md += `
|
|
2311
|
-
|
|
2312
|
-
`;
|
|
2205
|
+
out += formatLayer(layer) + "\n\n";
|
|
2313
2206
|
}
|
|
2314
|
-
md += `---
|
|
2315
|
-
|
|
2316
|
-
`;
|
|
2317
2207
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
md += `Layers: ${pkg.layers.map((l) => l.type).join(", ")}
|
|
2321
|
-
`;
|
|
2322
|
-
return md;
|
|
2208
|
+
out += "</context-package>";
|
|
2209
|
+
return out;
|
|
2323
2210
|
}
|
|
2324
2211
|
|
|
2325
2212
|
// src/cli/build-context.ts
|
|
@@ -2339,20 +2226,19 @@ function registerBuildCommand(program2) {
|
|
|
2339
2226
|
process.exit(1);
|
|
2340
2227
|
}
|
|
2341
2228
|
const nodePath = options.node.trim().replace(/\/$/, "");
|
|
2342
|
-
const
|
|
2343
|
-
const warningThreshold = graph.config.quality?.context_budget.warning ??
|
|
2344
|
-
const errorThreshold = graph.config.quality?.context_budget.error ??
|
|
2345
|
-
const budgetStatus =
|
|
2346
|
-
let output =
|
|
2229
|
+
const pkg2 = await buildContext(graph, nodePath);
|
|
2230
|
+
const warningThreshold = graph.config.quality?.context_budget.warning ?? 1e4;
|
|
2231
|
+
const errorThreshold = graph.config.quality?.context_budget.error ?? 2e4;
|
|
2232
|
+
const budgetStatus = pkg2.tokenCount >= errorThreshold ? "error" : pkg2.tokenCount >= warningThreshold ? "warning" : "ok";
|
|
2233
|
+
let output = formatContextText(pkg2);
|
|
2347
2234
|
output += `Budget status: ${budgetStatus}
|
|
2348
2235
|
`;
|
|
2349
2236
|
process.stdout.write(output);
|
|
2350
2237
|
if (budgetStatus === "error") {
|
|
2351
2238
|
process.stderr.write(
|
|
2352
|
-
`
|
|
2239
|
+
`Warning: context package exceeds error budget (${pkg2.tokenCount} >= ${errorThreshold}). Consider splitting the node.
|
|
2353
2240
|
`
|
|
2354
2241
|
);
|
|
2355
|
-
process.exit(1);
|
|
2356
2242
|
}
|
|
2357
2243
|
} catch (error) {
|
|
2358
2244
|
process.stderr.write(`Error: ${error.message}
|
|
@@ -2411,40 +2297,28 @@ import chalk2 from "chalk";
|
|
|
2411
2297
|
|
|
2412
2298
|
// src/io/drift-state-store.ts
|
|
2413
2299
|
import { readFile as readFile11, writeFile as writeFile3 } from "fs/promises";
|
|
2414
|
-
import { parse as parseYaml7, stringify as stringifyYaml } from "yaml";
|
|
2415
2300
|
import path10 from "path";
|
|
2301
|
+
import { stringify, parse } from "yaml";
|
|
2416
2302
|
var DRIFT_STATE_FILE = ".drift-state";
|
|
2417
|
-
function getCanonicalHash(entry) {
|
|
2418
|
-
return typeof entry === "string" ? entry : entry.hash;
|
|
2419
|
-
}
|
|
2420
|
-
function getFileHashes(entry) {
|
|
2421
|
-
return typeof entry === "object" ? entry.files : void 0;
|
|
2422
|
-
}
|
|
2423
2303
|
async function readDriftState(yggRoot) {
|
|
2424
|
-
const filePath = path10.join(yggRoot, DRIFT_STATE_FILE);
|
|
2425
2304
|
try {
|
|
2426
|
-
const content = await readFile11(
|
|
2427
|
-
const raw =
|
|
2428
|
-
if (raw
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
} else if (typeof k === "string" && typeof v === "object" && v !== null && "hash" in v) {
|
|
2434
|
-
result[k] = v;
|
|
2435
|
-
}
|
|
2305
|
+
const content = await readFile11(path10.join(yggRoot, DRIFT_STATE_FILE), "utf-8");
|
|
2306
|
+
const raw = parse(content);
|
|
2307
|
+
if (!raw || typeof raw !== "object") return {};
|
|
2308
|
+
const state = {};
|
|
2309
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
2310
|
+
if (typeof value === "object" && value !== null && "hash" in value) {
|
|
2311
|
+
state[key] = value;
|
|
2436
2312
|
}
|
|
2437
|
-
return result;
|
|
2438
2313
|
}
|
|
2439
|
-
return
|
|
2314
|
+
return state;
|
|
2440
2315
|
} catch {
|
|
2441
2316
|
return {};
|
|
2442
2317
|
}
|
|
2443
2318
|
}
|
|
2444
2319
|
async function writeDriftState(yggRoot, state) {
|
|
2445
|
-
const
|
|
2446
|
-
|
|
2447
|
-
await writeFile3(filePath, content, "utf-8");
|
|
2320
|
+
const content = stringify(state, { lineWidth: 0 });
|
|
2321
|
+
await writeFile3(path10.join(yggRoot, DRIFT_STATE_FILE), content, "utf-8");
|
|
2448
2322
|
}
|
|
2449
2323
|
|
|
2450
2324
|
// src/utils/hash.ts
|
|
@@ -2458,46 +2332,29 @@ async function hashFile(filePath) {
|
|
|
2458
2332
|
const content = await readFile12(filePath);
|
|
2459
2333
|
return createHash("sha256").update(content).digest("hex");
|
|
2460
2334
|
}
|
|
2461
|
-
async function hashPath(targetPath, options = {}) {
|
|
2462
|
-
const projectRoot = options.projectRoot ? path11.resolve(options.projectRoot) : void 0;
|
|
2463
|
-
const gitignoreMatcher = await loadGitignoreMatcher(projectRoot);
|
|
2464
|
-
const targetStat = await stat3(targetPath);
|
|
2465
|
-
if (targetStat.isFile()) {
|
|
2466
|
-
if (isIgnoredPath(targetPath, projectRoot, gitignoreMatcher)) {
|
|
2467
|
-
return hashString("");
|
|
2468
|
-
}
|
|
2469
|
-
return hashFile(targetPath);
|
|
2470
|
-
}
|
|
2471
|
-
if (targetStat.isDirectory()) {
|
|
2472
|
-
const fileHashes = await collectDirectoryFileHashes(targetPath, targetPath, {
|
|
2473
|
-
projectRoot,
|
|
2474
|
-
gitignoreMatcher
|
|
2475
|
-
});
|
|
2476
|
-
const digestInput = fileHashes.sort((a, b) => a.path.localeCompare(b.path)).map((entry) => `${entry.path}:${entry.hash}`).join("\n");
|
|
2477
|
-
return hashString(digestInput);
|
|
2478
|
-
}
|
|
2479
|
-
throw new Error(`Unsupported mapping path type: ${targetPath}`);
|
|
2480
|
-
}
|
|
2481
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
|
+
}
|
|
2482
2344
|
const entries = await readdir5(directoryPath, { withFileTypes: true });
|
|
2483
2345
|
const result = [];
|
|
2484
2346
|
for (const entry of entries) {
|
|
2485
2347
|
const absoluteChildPath = path11.join(directoryPath, entry.name);
|
|
2486
|
-
if (
|
|
2348
|
+
if (isIgnoredByStack(absoluteChildPath, stack)) {
|
|
2487
2349
|
continue;
|
|
2488
2350
|
}
|
|
2489
2351
|
if (entry.isDirectory()) {
|
|
2490
2352
|
const nested = await collectDirectoryFileHashes(
|
|
2491
2353
|
absoluteChildPath,
|
|
2492
2354
|
rootDirectoryPath,
|
|
2493
|
-
options
|
|
2355
|
+
{ projectRoot: options.projectRoot, gitignoreStack: stack }
|
|
2494
2356
|
);
|
|
2495
|
-
|
|
2496
|
-
result.push({
|
|
2497
|
-
path: path11.relative(rootDirectoryPath, path11.join(absoluteChildPath, nestedEntry.path)),
|
|
2498
|
-
hash: nestedEntry.hash
|
|
2499
|
-
});
|
|
2500
|
-
}
|
|
2357
|
+
result.push(...nested);
|
|
2501
2358
|
continue;
|
|
2502
2359
|
}
|
|
2503
2360
|
if (!entry.isFile()) {
|
|
@@ -2510,83 +2367,152 @@ async function collectDirectoryFileHashes(directoryPath, rootDirectoryPath, opti
|
|
|
2510
2367
|
}
|
|
2511
2368
|
return result;
|
|
2512
2369
|
}
|
|
2513
|
-
async function
|
|
2514
|
-
if (!projectRoot)
|
|
2515
|
-
return void 0;
|
|
2516
|
-
}
|
|
2370
|
+
async function loadRootGitignoreStack(projectRoot) {
|
|
2371
|
+
if (!projectRoot) return [];
|
|
2517
2372
|
try {
|
|
2518
|
-
const
|
|
2519
|
-
const gitignoreContent = await readFile12(gitignorePath, "utf-8");
|
|
2373
|
+
const content = await readFile12(path11.join(projectRoot, ".gitignore"), "utf-8");
|
|
2520
2374
|
const matcher = ignoreFactory();
|
|
2521
|
-
matcher.add(
|
|
2522
|
-
return matcher;
|
|
2375
|
+
matcher.add(content);
|
|
2376
|
+
return [{ basePath: projectRoot, matcher }];
|
|
2523
2377
|
} catch {
|
|
2524
|
-
return
|
|
2378
|
+
return [];
|
|
2525
2379
|
}
|
|
2526
2380
|
}
|
|
2527
|
-
function
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
if (relativePath === "" || relativePath.startsWith("..")) {
|
|
2533
|
-
return false;
|
|
2381
|
+
function isIgnoredByStack(candidatePath, stack) {
|
|
2382
|
+
for (const { basePath, matcher } of stack) {
|
|
2383
|
+
const relativePath = path11.relative(basePath, candidatePath);
|
|
2384
|
+
if (relativePath === "" || relativePath.startsWith("..")) continue;
|
|
2385
|
+
if (matcher.ignores(relativePath) || matcher.ignores(relativePath + "/")) return true;
|
|
2534
2386
|
}
|
|
2535
|
-
return
|
|
2387
|
+
return false;
|
|
2536
2388
|
}
|
|
2537
2389
|
function hashString(content) {
|
|
2538
2390
|
return createHash("sha256").update(content).digest("hex");
|
|
2539
2391
|
}
|
|
2540
|
-
async function
|
|
2541
|
-
const
|
|
2542
|
-
const
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
} else if (st.isDirectory()) {
|
|
2552
|
-
const hashes = await collectDirectoryFileHashes(absPath, absPath, {
|
|
2553
|
-
projectRoot: root,
|
|
2554
|
-
gitignoreMatcher
|
|
2555
|
-
});
|
|
2556
|
-
for (const h of hashes) {
|
|
2557
|
-
result.push({
|
|
2558
|
-
path: path11.join(p, h.path).split(path11.sep).join("/"),
|
|
2559
|
-
hash: h.hash
|
|
2392
|
+
async function hashTrackedFiles(projectRoot, trackedFiles) {
|
|
2393
|
+
const fileHashes = {};
|
|
2394
|
+
const gitignoreStack = await loadRootGitignoreStack(projectRoot);
|
|
2395
|
+
for (const tf of trackedFiles) {
|
|
2396
|
+
const absPath = path11.join(projectRoot, tf.path);
|
|
2397
|
+
try {
|
|
2398
|
+
const st = await stat3(absPath);
|
|
2399
|
+
if (st.isDirectory()) {
|
|
2400
|
+
const dirHashes = await collectDirectoryFileHashes(absPath, absPath, {
|
|
2401
|
+
projectRoot,
|
|
2402
|
+
gitignoreStack
|
|
2560
2403
|
});
|
|
2404
|
+
for (const entry of dirHashes) {
|
|
2405
|
+
const fullRelPath = path11.join(tf.path, entry.path).replace(/\\/g, "/");
|
|
2406
|
+
fileHashes[fullRelPath] = entry.hash;
|
|
2407
|
+
}
|
|
2408
|
+
} else {
|
|
2409
|
+
fileHashes[tf.path] = await hashFile(absPath);
|
|
2561
2410
|
}
|
|
2411
|
+
} catch {
|
|
2412
|
+
continue;
|
|
2562
2413
|
}
|
|
2563
2414
|
}
|
|
2564
|
-
|
|
2415
|
+
const sorted = Object.entries(fileHashes).sort(([a], [b]) => a.localeCompare(b));
|
|
2416
|
+
const digest = sorted.map(([p, h]) => `${p}:${h}`).join("\n");
|
|
2417
|
+
const canonicalHash = hashString(digest);
|
|
2418
|
+
return { canonicalHash, fileHashes };
|
|
2565
2419
|
}
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2420
|
+
|
|
2421
|
+
// src/core/context-files.ts
|
|
2422
|
+
import path12 from "path";
|
|
2423
|
+
var STRUCTURAL_RELATION_TYPES2 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
|
|
2424
|
+
function collectTrackedFiles(node, graph) {
|
|
2425
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2426
|
+
const result = [];
|
|
2427
|
+
const projectRoot = path12.dirname(graph.rootPath);
|
|
2428
|
+
const yggPrefix = path12.relative(projectRoot, graph.rootPath);
|
|
2429
|
+
const yggPrefixNormalized = yggPrefix.split(path12.sep).join("/");
|
|
2430
|
+
const configArtifactKeys = new Set(Object.keys(graph.config.artifacts ?? {}));
|
|
2431
|
+
function addFile(filePath, category) {
|
|
2432
|
+
if (seen.has(filePath)) return;
|
|
2433
|
+
seen.add(filePath);
|
|
2434
|
+
result.push({ path: filePath, category });
|
|
2435
|
+
}
|
|
2436
|
+
function graphPath(...segments) {
|
|
2437
|
+
return [yggPrefixNormalized, ...segments].join("/");
|
|
2438
|
+
}
|
|
2439
|
+
function addNodeFiles(n) {
|
|
2440
|
+
addFile(graphPath("model", n.path, "node.yaml"), "graph");
|
|
2441
|
+
for (const art of n.artifacts) {
|
|
2442
|
+
if (configArtifactKeys.has(art.filename)) {
|
|
2443
|
+
addFile(graphPath("model", n.path, art.filename), "graph");
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
addNodeFiles(node);
|
|
2448
|
+
const ancestors = collectAncestors(node);
|
|
2449
|
+
for (const ancestor of ancestors) {
|
|
2450
|
+
addNodeFiles(ancestor);
|
|
2451
|
+
}
|
|
2452
|
+
const allAspectIds = /* @__PURE__ */ new Set();
|
|
2453
|
+
for (const id of node.meta.aspects ?? []) {
|
|
2454
|
+
allAspectIds.add(id);
|
|
2455
|
+
}
|
|
2456
|
+
for (const ancestor of ancestors) {
|
|
2457
|
+
for (const id of ancestor.meta.aspects ?? []) {
|
|
2458
|
+
allAspectIds.add(id);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
const participatingFlows = collectParticipatingFlows2(graph, node, ancestors);
|
|
2462
|
+
for (const flow of participatingFlows) {
|
|
2463
|
+
for (const id of flow.aspects ?? []) {
|
|
2464
|
+
allAspectIds.add(id);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
const resolvedAspects = resolveAspects(allAspectIds, graph.aspects);
|
|
2468
|
+
for (const aspect of resolvedAspects) {
|
|
2469
|
+
addFile(graphPath("aspects", aspect.id, "aspect.yaml"), "graph");
|
|
2470
|
+
for (const art of aspect.artifacts) {
|
|
2471
|
+
addFile(graphPath("aspects", aspect.id, art.filename), "graph");
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
for (const relation of node.meta.relations ?? []) {
|
|
2475
|
+
if (!STRUCTURAL_RELATION_TYPES2.has(relation.type)) continue;
|
|
2476
|
+
const target = graph.nodes.get(relation.target);
|
|
2477
|
+
if (!target) continue;
|
|
2478
|
+
const structuralFilenames = Object.entries(graph.config.artifacts ?? {}).filter(([, c]) => c.structural_context).map(([filename]) => filename);
|
|
2479
|
+
const structuralArts = structuralFilenames.filter(
|
|
2480
|
+
(filename) => target.artifacts.some((a) => a.filename === filename)
|
|
2481
|
+
);
|
|
2482
|
+
if (structuralArts.length > 0) {
|
|
2483
|
+
for (const filename of structuralArts) {
|
|
2484
|
+
addFile(graphPath("model", target.path, filename), "graph");
|
|
2485
|
+
}
|
|
2486
|
+
} else {
|
|
2487
|
+
for (const art of target.artifacts) {
|
|
2488
|
+
if (configArtifactKeys.has(art.filename)) {
|
|
2489
|
+
addFile(graphPath("model", target.path, art.filename), "graph");
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
for (const flow of participatingFlows) {
|
|
2495
|
+
addFile(graphPath("flows", flow.path, "flow.yaml"), "graph");
|
|
2496
|
+
for (const art of flow.artifacts) {
|
|
2497
|
+
addFile(graphPath("flows", flow.path, art.filename), "graph");
|
|
2579
2498
|
}
|
|
2580
2499
|
}
|
|
2581
|
-
const
|
|
2582
|
-
|
|
2500
|
+
const mappingPaths = normalizeMappingPaths(node.meta.mapping);
|
|
2501
|
+
for (const p of mappingPaths) {
|
|
2502
|
+
addFile(p, "source");
|
|
2503
|
+
}
|
|
2504
|
+
return result;
|
|
2505
|
+
}
|
|
2506
|
+
function collectParticipatingFlows2(graph, node, ancestors) {
|
|
2507
|
+
const paths = /* @__PURE__ */ new Set([node.path, ...ancestors.map((a) => a.path)]);
|
|
2508
|
+
return graph.flows.filter((f) => f.nodes.some((n) => paths.has(n)));
|
|
2583
2509
|
}
|
|
2584
2510
|
|
|
2585
2511
|
// src/core/drift-detector.ts
|
|
2586
2512
|
import { access } from "fs/promises";
|
|
2587
|
-
import
|
|
2513
|
+
import path13 from "path";
|
|
2588
2514
|
async function detectDrift(graph, filterNodePath) {
|
|
2589
|
-
const projectRoot =
|
|
2515
|
+
const projectRoot = path13.dirname(graph.rootPath);
|
|
2590
2516
|
const driftState = await readDriftState(graph.rootPath);
|
|
2591
2517
|
const entries = [];
|
|
2592
2518
|
for (const [nodePath, node] of graph.nodes) {
|
|
@@ -2600,67 +2526,80 @@ async function detectDrift(graph, filterNodePath) {
|
|
|
2600
2526
|
const allMissing = await allPathsMissing(projectRoot, mappingPaths);
|
|
2601
2527
|
entries.push({
|
|
2602
2528
|
nodePath,
|
|
2603
|
-
|
|
2604
|
-
status: allMissing ? "unmaterialized" : "drift",
|
|
2529
|
+
status: allMissing ? "unmaterialized" : "source-drift",
|
|
2605
2530
|
details: allMissing ? "No drift state recorded, files do not exist" : "No drift state recorded, files exist (run drift-sync after materialization)"
|
|
2606
2531
|
});
|
|
2607
2532
|
continue;
|
|
2608
2533
|
}
|
|
2609
|
-
const
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2534
|
+
const sourceFilesMissing = await allPathsMissing(projectRoot, mappingPaths);
|
|
2535
|
+
if (sourceFilesMissing) {
|
|
2536
|
+
entries.push({
|
|
2537
|
+
nodePath,
|
|
2538
|
+
status: "missing",
|
|
2539
|
+
details: "All source mapping paths are missing"
|
|
2540
|
+
});
|
|
2541
|
+
continue;
|
|
2542
|
+
}
|
|
2543
|
+
const trackedFiles = collectTrackedFiles(node, graph);
|
|
2544
|
+
const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles);
|
|
2545
|
+
if (canonicalHash === storedEntry.hash) {
|
|
2546
|
+
entries.push({ nodePath, status: "ok" });
|
|
2547
|
+
continue;
|
|
2548
|
+
}
|
|
2549
|
+
const changedFiles = [];
|
|
2550
|
+
const storedFiles = storedEntry.files;
|
|
2551
|
+
for (const [filePath, hash] of Object.entries(fileHashes)) {
|
|
2552
|
+
const storedHash = storedFiles[filePath];
|
|
2553
|
+
if (!storedHash || storedHash !== hash) {
|
|
2554
|
+
changedFiles.push({
|
|
2555
|
+
filePath,
|
|
2556
|
+
category: categorizeFile(filePath, graph.rootPath, projectRoot)
|
|
2557
|
+
});
|
|
2622
2558
|
}
|
|
2623
|
-
} catch {
|
|
2624
|
-
status = "missing";
|
|
2625
|
-
details = "Mapped path(s) do not exist";
|
|
2626
2559
|
}
|
|
2627
|
-
|
|
2560
|
+
for (const storedPath of Object.keys(storedFiles)) {
|
|
2561
|
+
if (!(storedPath in fileHashes)) {
|
|
2562
|
+
changedFiles.push({
|
|
2563
|
+
filePath: `${storedPath} (deleted)`,
|
|
2564
|
+
category: categorizeFile(storedPath, graph.rootPath, projectRoot)
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
const hasSourceChanges = changedFiles.some((f) => f.category === "source");
|
|
2569
|
+
const hasGraphChanges = changedFiles.some((f) => f.category === "graph");
|
|
2570
|
+
let status;
|
|
2571
|
+
if (hasSourceChanges && hasGraphChanges) {
|
|
2572
|
+
status = "full-drift";
|
|
2573
|
+
} else if (hasGraphChanges) {
|
|
2574
|
+
status = "graph-drift";
|
|
2575
|
+
} else if (hasSourceChanges) {
|
|
2576
|
+
status = "source-drift";
|
|
2577
|
+
} else {
|
|
2578
|
+
status = "source-drift";
|
|
2579
|
+
}
|
|
2580
|
+
const details = changedFiles.length > 0 ? `Changed files: ${changedFiles.map((f) => f.filePath).join(", ")}` : "File(s) modified since last sync";
|
|
2581
|
+
entries.push({ nodePath, status, details, changedFiles });
|
|
2628
2582
|
}
|
|
2629
2583
|
return {
|
|
2630
2584
|
entries,
|
|
2631
2585
|
totalChecked: entries.length,
|
|
2632
2586
|
okCount: entries.filter((e) => e.status === "ok").length,
|
|
2633
|
-
|
|
2587
|
+
sourceDriftCount: entries.filter((e) => e.status === "source-drift").length,
|
|
2588
|
+
graphDriftCount: entries.filter((e) => e.status === "graph-drift").length,
|
|
2589
|
+
fullDriftCount: entries.filter((e) => e.status === "full-drift").length,
|
|
2634
2590
|
missingCount: entries.filter((e) => e.status === "missing").length,
|
|
2635
2591
|
unmaterializedCount: entries.filter((e) => e.status === "unmaterialized").length
|
|
2636
2592
|
};
|
|
2637
2593
|
}
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
}
|
|
2644
|
-
const changed = [];
|
|
2645
|
-
const storedPaths = new Set(Object.keys(storedFileHashes));
|
|
2646
|
-
for (const { path: filePath, hash } of currentHashes) {
|
|
2647
|
-
const stored = storedFileHashes[filePath];
|
|
2648
|
-
if (!stored || stored !== hash) {
|
|
2649
|
-
changed.push(filePath);
|
|
2650
|
-
}
|
|
2651
|
-
storedPaths.delete(filePath);
|
|
2652
|
-
}
|
|
2653
|
-
for (const removed of storedPaths) {
|
|
2654
|
-
changed.push(`${removed} (deleted)`);
|
|
2655
|
-
}
|
|
2656
|
-
return changed.sort();
|
|
2657
|
-
} catch {
|
|
2658
|
-
return [];
|
|
2659
|
-
}
|
|
2594
|
+
function categorizeFile(filePath, _rootPath, projectRoot) {
|
|
2595
|
+
const yggPrefix = path13.relative(projectRoot, _rootPath);
|
|
2596
|
+
const normalizedPrefix = yggPrefix.split(path13.sep).join("/");
|
|
2597
|
+
const normalizedFilePath = filePath.replace(/\\/g, "/");
|
|
2598
|
+
return normalizedFilePath.startsWith(normalizedPrefix) ? "graph" : "source";
|
|
2660
2599
|
}
|
|
2661
2600
|
async function allPathsMissing(projectRoot, mappingPaths) {
|
|
2662
2601
|
for (const mp of mappingPaths) {
|
|
2663
|
-
const absPath =
|
|
2602
|
+
const absPath = path13.join(projectRoot, mp);
|
|
2664
2603
|
try {
|
|
2665
2604
|
await access(absPath);
|
|
2666
2605
|
return false;
|
|
@@ -2670,82 +2609,43 @@ async function allPathsMissing(projectRoot, mappingPaths) {
|
|
|
2670
2609
|
return true;
|
|
2671
2610
|
}
|
|
2672
2611
|
async function syncDriftState(graph, nodePath) {
|
|
2673
|
-
const projectRoot =
|
|
2612
|
+
const projectRoot = path13.dirname(graph.rootPath);
|
|
2674
2613
|
const node = graph.nodes.get(nodePath);
|
|
2675
2614
|
if (!node) throw new Error(`Node not found: ${nodePath}`);
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
const
|
|
2679
|
-
const
|
|
2680
|
-
const
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
for (const fh of fileHashes) {
|
|
2685
|
-
files[fh.path] = fh.hash;
|
|
2686
|
-
}
|
|
2687
|
-
const newEntry = { hash: currentHash, files };
|
|
2688
|
-
driftState[nodePath] = newEntry;
|
|
2689
|
-
await writeDriftState(graph.rootPath, driftState);
|
|
2690
|
-
return { previousHash, currentHash };
|
|
2615
|
+
if (!node.meta.mapping) throw new Error(`Node has no mapping: ${nodePath}`);
|
|
2616
|
+
const trackedFiles = collectTrackedFiles(node, graph);
|
|
2617
|
+
const { canonicalHash, fileHashes } = await hashTrackedFiles(projectRoot, trackedFiles);
|
|
2618
|
+
const state = await readDriftState(graph.rootPath);
|
|
2619
|
+
const previousHash = state[nodePath]?.hash;
|
|
2620
|
+
state[nodePath] = { hash: canonicalHash, files: fileHashes };
|
|
2621
|
+
await writeDriftState(graph.rootPath, state);
|
|
2622
|
+
return { previousHash, currentHash: canonicalHash };
|
|
2691
2623
|
}
|
|
2692
2624
|
|
|
2693
2625
|
// src/cli/drift.ts
|
|
2694
2626
|
function registerDriftCommand(program2) {
|
|
2695
|
-
program2.command("drift").description("Detect
|
|
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) => {
|
|
2696
2628
|
try {
|
|
2697
2629
|
const graph = await loadGraph(process.cwd());
|
|
2698
|
-
const scope = (
|
|
2699
|
-
if (scope
|
|
2700
|
-
|
|
2630
|
+
const scope = (opts.scope ?? "all").trim() || "all";
|
|
2631
|
+
if (scope !== "all") {
|
|
2632
|
+
const node = graph.nodes.get(scope);
|
|
2633
|
+
if (!node) {
|
|
2634
|
+
process.stderr.write(`Error: Node not found: ${scope}
|
|
2701
2635
|
`);
|
|
2702
|
-
process.exit(1);
|
|
2703
|
-
}
|
|
2704
|
-
if (scope && scope !== "all") {
|
|
2705
|
-
const scopedNode = graph.nodes.get(scope);
|
|
2706
|
-
if (!scopedNode.meta.mapping) {
|
|
2707
|
-
process.stderr.write(
|
|
2708
|
-
`Error: Node has no mapping (does not participate in drift detection): ${options.scope}
|
|
2709
|
-
`
|
|
2710
|
-
);
|
|
2711
2636
|
process.exit(1);
|
|
2712
2637
|
}
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
const report = await detectDrift(graph, scopeNode);
|
|
2716
|
-
process.stdout.write("Drift:\n");
|
|
2717
|
-
for (const entry of report.entries) {
|
|
2718
|
-
const paths = entry.mappingPaths.join(", ");
|
|
2719
|
-
switch (entry.status) {
|
|
2720
|
-
case "ok":
|
|
2721
|
-
process.stdout.write(chalk2.green(` ok ${entry.nodePath} -> ${paths}
|
|
2722
|
-
`));
|
|
2723
|
-
break;
|
|
2724
|
-
case "drift":
|
|
2725
|
-
process.stdout.write(chalk2.red(` drift ${entry.nodePath} -> ${paths}
|
|
2726
|
-
`));
|
|
2727
|
-
if (entry.details) process.stdout.write(` ${entry.details}
|
|
2638
|
+
if (!node.meta.mapping) {
|
|
2639
|
+
process.stderr.write(`Error: Node has no mapping: ${scope}
|
|
2728
2640
|
`);
|
|
2729
|
-
|
|
2730
|
-
case "missing":
|
|
2731
|
-
process.stdout.write(chalk2.yellow(` missing ${entry.nodePath} -> ${paths}
|
|
2732
|
-
`));
|
|
2733
|
-
break;
|
|
2734
|
-
case "unmaterialized":
|
|
2735
|
-
process.stdout.write(chalk2.dim(` unmat. ${entry.nodePath} -> ${paths}
|
|
2736
|
-
`));
|
|
2737
|
-
break;
|
|
2641
|
+
process.exit(1);
|
|
2738
2642
|
}
|
|
2739
2643
|
}
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
);
|
|
2745
|
-
if (report.driftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0) {
|
|
2746
|
-
process.exit(1);
|
|
2747
|
-
}
|
|
2748
|
-
process.exit(0);
|
|
2644
|
+
const scopeNode = scope === "all" ? void 0 : scope;
|
|
2645
|
+
const report = await detectDrift(graph, scopeNode);
|
|
2646
|
+
printReport(report, opts.driftedOnly ?? false);
|
|
2647
|
+
const hasIssues = report.sourceDriftCount > 0 || report.graphDriftCount > 0 || report.fullDriftCount > 0 || report.missingCount > 0 || report.unmaterializedCount > 0;
|
|
2648
|
+
process.exit(hasIssues ? 1 : 0);
|
|
2749
2649
|
} catch (error) {
|
|
2750
2650
|
process.stderr.write(`Error: ${error.message}
|
|
2751
2651
|
`);
|
|
@@ -2753,6 +2653,94 @@ Summary: ${report.driftCount} drift, ${report.missingCount} missing, ${report.un
|
|
|
2753
2653
|
}
|
|
2754
2654
|
});
|
|
2755
2655
|
}
|
|
2656
|
+
function printReport(report, driftedOnly) {
|
|
2657
|
+
const sourceEntries = classifyForSection(report.entries, "source", driftedOnly);
|
|
2658
|
+
const graphEntries = classifyForSection(report.entries, "graph", driftedOnly);
|
|
2659
|
+
process.stdout.write("Source drift:\n");
|
|
2660
|
+
printSectionEntries(sourceEntries, "source");
|
|
2661
|
+
process.stdout.write("\nGraph drift:\n");
|
|
2662
|
+
printSectionEntries(graphEntries, "graph");
|
|
2663
|
+
const parts = [
|
|
2664
|
+
`${report.sourceDriftCount} source-drift`,
|
|
2665
|
+
`${report.graphDriftCount} graph-drift`,
|
|
2666
|
+
`${report.fullDriftCount} full-drift`,
|
|
2667
|
+
`${report.missingCount} missing`,
|
|
2668
|
+
`${report.unmaterializedCount} unmaterialized`
|
|
2669
|
+
];
|
|
2670
|
+
let summary = `
|
|
2671
|
+
Summary: ${parts.join(", ")}`;
|
|
2672
|
+
if (driftedOnly && report.okCount > 0) {
|
|
2673
|
+
summary += ` (${report.okCount} ok hidden)`;
|
|
2674
|
+
} else {
|
|
2675
|
+
summary += `, ${report.okCount} ok`;
|
|
2676
|
+
}
|
|
2677
|
+
process.stdout.write(summary + "\n");
|
|
2678
|
+
}
|
|
2679
|
+
function classifyForSection(entries, section, driftedOnly) {
|
|
2680
|
+
return entries.filter((entry) => {
|
|
2681
|
+
if (section === "source") {
|
|
2682
|
+
if (entry.status === "graph-drift") return false;
|
|
2683
|
+
if (entry.status === "ok" && driftedOnly) return false;
|
|
2684
|
+
return true;
|
|
2685
|
+
} else {
|
|
2686
|
+
if (entry.status === "source-drift" || entry.status === "missing" || entry.status === "unmaterialized")
|
|
2687
|
+
return false;
|
|
2688
|
+
if (entry.status === "ok" && driftedOnly) return false;
|
|
2689
|
+
return true;
|
|
2690
|
+
}
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
function printSectionEntries(entries, section) {
|
|
2694
|
+
if (entries.length === 0) {
|
|
2695
|
+
process.stdout.write(chalk2.dim(" (none)\n"));
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
for (const entry of entries) {
|
|
2699
|
+
printEntryLine(entry);
|
|
2700
|
+
printChangedFiles(entry, section);
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
function printEntryLine(entry) {
|
|
2704
|
+
const pad = 13;
|
|
2705
|
+
switch (entry.status) {
|
|
2706
|
+
case "ok":
|
|
2707
|
+
process.stdout.write(chalk2.green(` ${"[ok]".padEnd(pad)}${entry.nodePath}
|
|
2708
|
+
`));
|
|
2709
|
+
break;
|
|
2710
|
+
case "source-drift":
|
|
2711
|
+
process.stdout.write(chalk2.red(` ${"[drift]".padEnd(pad)}${entry.nodePath}
|
|
2712
|
+
`));
|
|
2713
|
+
break;
|
|
2714
|
+
case "graph-drift":
|
|
2715
|
+
process.stdout.write(chalk2.magenta(` ${"[drift]".padEnd(pad)}${entry.nodePath}
|
|
2716
|
+
`));
|
|
2717
|
+
break;
|
|
2718
|
+
case "full-drift":
|
|
2719
|
+
process.stdout.write(chalk2.red(` ${"[drift]".padEnd(pad)}${entry.nodePath}
|
|
2720
|
+
`));
|
|
2721
|
+
break;
|
|
2722
|
+
case "missing":
|
|
2723
|
+
process.stdout.write(chalk2.yellow(` ${"[missing]".padEnd(pad)}${entry.nodePath}
|
|
2724
|
+
`));
|
|
2725
|
+
break;
|
|
2726
|
+
case "unmaterialized":
|
|
2727
|
+
process.stdout.write(chalk2.dim(` ${"[unmat.]".padEnd(pad)}${entry.nodePath}
|
|
2728
|
+
`));
|
|
2729
|
+
break;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
function printChangedFiles(entry, section) {
|
|
2733
|
+
if (!entry.changedFiles || entry.changedFiles.length === 0) return;
|
|
2734
|
+
const indent = " ".repeat(15);
|
|
2735
|
+
const relevantFiles = entry.changedFiles.filter((f) => {
|
|
2736
|
+
if (section === "source") return f.category === "source";
|
|
2737
|
+
return f.category === "graph";
|
|
2738
|
+
});
|
|
2739
|
+
for (const file of relevantFiles) {
|
|
2740
|
+
process.stdout.write(chalk2.dim(`${indent}${file.filePath} (changed)
|
|
2741
|
+
`));
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2756
2744
|
|
|
2757
2745
|
// src/cli/drift-sync.ts
|
|
2758
2746
|
import chalk3 from "chalk";
|
|
@@ -2790,20 +2778,40 @@ function registerStatusCommand(program2) {
|
|
|
2790
2778
|
let structuralRelations = 0;
|
|
2791
2779
|
let eventRelations = 0;
|
|
2792
2780
|
const structuralTypes = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
|
|
2781
|
+
let maxRelCount = 0;
|
|
2782
|
+
let maxRelNode = "";
|
|
2793
2783
|
for (const node of graph.nodes.values()) {
|
|
2784
|
+
const relCount = (node.meta.relations ?? []).length;
|
|
2785
|
+
if (relCount > maxRelCount) {
|
|
2786
|
+
maxRelCount = relCount;
|
|
2787
|
+
maxRelNode = node.path;
|
|
2788
|
+
}
|
|
2794
2789
|
for (const rel of node.meta.relations ?? []) {
|
|
2795
2790
|
if (structuralTypes.has(rel.type)) structuralRelations += 1;
|
|
2796
2791
|
else eventRelations += 1;
|
|
2797
2792
|
}
|
|
2798
2793
|
}
|
|
2799
2794
|
const flowCount = graph.flows.length;
|
|
2800
|
-
const knowledgeCount = graph.knowledge.length;
|
|
2801
2795
|
const drift = await detectDrift(graph);
|
|
2802
2796
|
const validation = await validate(graph, "all");
|
|
2803
2797
|
const errorCount = validation.issues.filter((issue) => issue.severity === "error").length;
|
|
2804
2798
|
const warningCount = validation.issues.filter(
|
|
2805
2799
|
(issue) => issue.severity === "warning"
|
|
2806
2800
|
).length;
|
|
2801
|
+
const configuredArtifactTypes = Object.keys(graph.config.artifacts ?? {});
|
|
2802
|
+
const totalSlots = graph.nodes.size * configuredArtifactTypes.length;
|
|
2803
|
+
let filledSlots = 0;
|
|
2804
|
+
let mappedNodeCount = 0;
|
|
2805
|
+
for (const node of graph.nodes.values()) {
|
|
2806
|
+
const allowed = new Set(configuredArtifactTypes);
|
|
2807
|
+
filledSlots += node.artifacts.filter((a) => allowed.has(a.filename)).length;
|
|
2808
|
+
if (normalizeMappingPaths(node.meta.mapping).length > 0) mappedNodeCount++;
|
|
2809
|
+
}
|
|
2810
|
+
let aspectCoveredNodes = 0;
|
|
2811
|
+
for (const node of graph.nodes.values()) {
|
|
2812
|
+
const effective = collectEffectiveAspectIds(graph, node.path);
|
|
2813
|
+
if (effective.size > 0) aspectCoveredNodes++;
|
|
2814
|
+
}
|
|
2807
2815
|
process.stdout.write(`Graph: ${graph.config.name}
|
|
2808
2816
|
`);
|
|
2809
2817
|
const pluralize = (word, count) => count === 1 ? word : word.endsWith("y") ? word.slice(0, -1) + "ies" : word + "s";
|
|
@@ -2817,15 +2825,37 @@ function registerStatusCommand(program2) {
|
|
|
2817
2825
|
`
|
|
2818
2826
|
);
|
|
2819
2827
|
process.stdout.write(
|
|
2820
|
-
`Aspects: ${graph.aspects.length} Flows: ${flowCount}
|
|
2828
|
+
`Aspects: ${graph.aspects.length} Flows: ${flowCount}
|
|
2821
2829
|
`
|
|
2822
2830
|
);
|
|
2823
2831
|
process.stdout.write(
|
|
2824
|
-
`Drift: ${drift.
|
|
2832
|
+
`Drift: ${drift.sourceDriftCount} source-drift, ${drift.graphDriftCount} graph-drift, ${drift.fullDriftCount} full-drift, ${drift.missingCount} missing, ${drift.unmaterializedCount} unmaterialized, ${drift.okCount} ok
|
|
2825
2833
|
`
|
|
2826
2834
|
);
|
|
2827
2835
|
process.stdout.write(`Validation: ${errorCount} errors, ${warningCount} warnings
|
|
2828
2836
|
`);
|
|
2837
|
+
const fillPct = totalSlots > 0 ? Math.round(filledSlots / totalSlots * 100) : 0;
|
|
2838
|
+
const totalRelations = structuralRelations + eventRelations;
|
|
2839
|
+
const avgRel = graph.nodes.size > 0 ? (totalRelations / graph.nodes.size).toFixed(1) : "0";
|
|
2840
|
+
process.stdout.write(`
|
|
2841
|
+
Quality:
|
|
2842
|
+
`);
|
|
2843
|
+
process.stdout.write(
|
|
2844
|
+
` Artifacts: ${filledSlots}/${totalSlots} slots filled (${fillPct}%) \u2014 ${configuredArtifactTypes.length} types \xD7 ${graph.nodes.size} nodes
|
|
2845
|
+
`
|
|
2846
|
+
);
|
|
2847
|
+
process.stdout.write(
|
|
2848
|
+
` Relations: avg ${avgRel}/node, max ${maxRelCount}${maxRelNode ? ` (${maxRelNode})` : ""}
|
|
2849
|
+
`
|
|
2850
|
+
);
|
|
2851
|
+
process.stdout.write(
|
|
2852
|
+
` Mapping: ${mappedNodeCount}/${graph.nodes.size} nodes mapped to source
|
|
2853
|
+
`
|
|
2854
|
+
);
|
|
2855
|
+
process.stdout.write(
|
|
2856
|
+
` Aspects: ${aspectCoveredNodes}/${graph.nodes.size} nodes have aspect coverage
|
|
2857
|
+
`
|
|
2858
|
+
);
|
|
2829
2859
|
} catch (error) {
|
|
2830
2860
|
process.stderr.write(`Error: ${error.message}
|
|
2831
2861
|
`);
|
|
@@ -2842,10 +2872,10 @@ function registerTreeCommand(program2) {
|
|
|
2842
2872
|
let roots;
|
|
2843
2873
|
let showProjectName;
|
|
2844
2874
|
if (options.root?.trim()) {
|
|
2845
|
-
const
|
|
2846
|
-
const node = graph.nodes.get(
|
|
2875
|
+
const path17 = options.root.trim().replace(/\/$/, "");
|
|
2876
|
+
const node = graph.nodes.get(path17);
|
|
2847
2877
|
if (!node) {
|
|
2848
|
-
process.stderr.write(`Error: path '${
|
|
2878
|
+
process.stderr.write(`Error: path '${path17}' not found
|
|
2849
2879
|
`);
|
|
2850
2880
|
process.exit(1);
|
|
2851
2881
|
}
|
|
@@ -2873,7 +2903,7 @@ function printNode(node, prefix, isLast, depth, maxDepth) {
|
|
|
2873
2903
|
const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
|
2874
2904
|
const name = node.path.split("/").pop() ?? node.path;
|
|
2875
2905
|
const type = `[${node.meta.type}]`;
|
|
2876
|
-
const tags = node.meta.
|
|
2906
|
+
const tags = node.meta.aspects?.length ? ` aspects:${node.meta.aspects.join(",")}` : "";
|
|
2877
2907
|
const blackbox = node.meta.blackbox ? " \u25A0 blackbox" : "";
|
|
2878
2908
|
const relationCount = node.meta.relations?.length ?? 0;
|
|
2879
2909
|
process.stdout.write(
|
|
@@ -2932,13 +2962,13 @@ function registerOwnerCommand(program2) {
|
|
|
2932
2962
|
}
|
|
2933
2963
|
|
|
2934
2964
|
// src/core/dependency-resolver.ts
|
|
2935
|
-
import { execSync
|
|
2936
|
-
import
|
|
2937
|
-
var
|
|
2965
|
+
import { execSync } from "child_process";
|
|
2966
|
+
import path14 from "path";
|
|
2967
|
+
var STRUCTURAL_RELATION_TYPES3 = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
|
|
2938
2968
|
var EVENT_RELATION_TYPES2 = /* @__PURE__ */ new Set(["emits", "listens"]);
|
|
2939
2969
|
function filterRelationType(relType, filter) {
|
|
2940
2970
|
if (filter === "all") return true;
|
|
2941
|
-
if (filter === "structural") return
|
|
2971
|
+
if (filter === "structural") return STRUCTURAL_RELATION_TYPES3.has(relType);
|
|
2942
2972
|
if (filter === "event") return EVENT_RELATION_TYPES2.has(relType);
|
|
2943
2973
|
return false;
|
|
2944
2974
|
}
|
|
@@ -3010,24 +3040,24 @@ function registerDepsCommand(program2) {
|
|
|
3010
3040
|
// src/core/graph-from-git.ts
|
|
3011
3041
|
import { mkdtemp, rm } from "fs/promises";
|
|
3012
3042
|
import { tmpdir } from "os";
|
|
3013
|
-
import
|
|
3014
|
-
import { execSync as
|
|
3043
|
+
import path15 from "path";
|
|
3044
|
+
import { execSync as execSync2 } from "child_process";
|
|
3015
3045
|
async function loadGraphFromRef(projectRoot, ref = "HEAD") {
|
|
3016
3046
|
const yggPath = ".yggdrasil";
|
|
3017
3047
|
let tmpDir = null;
|
|
3018
3048
|
try {
|
|
3019
|
-
|
|
3049
|
+
execSync2(`git rev-parse ${ref}`, { cwd: projectRoot, stdio: "pipe" });
|
|
3020
3050
|
} catch {
|
|
3021
3051
|
return null;
|
|
3022
3052
|
}
|
|
3023
3053
|
try {
|
|
3024
|
-
tmpDir = await mkdtemp(
|
|
3025
|
-
const archivePath =
|
|
3026
|
-
|
|
3054
|
+
tmpDir = await mkdtemp(path15.join(tmpdir(), "ygg-git-"));
|
|
3055
|
+
const archivePath = path15.join(tmpDir, "archive.tar");
|
|
3056
|
+
execSync2(`git archive ${ref} ${yggPath} -o "${archivePath}"`, {
|
|
3027
3057
|
cwd: projectRoot,
|
|
3028
3058
|
stdio: "pipe"
|
|
3029
3059
|
});
|
|
3030
|
-
|
|
3060
|
+
execSync2(`tar -xf "${archivePath}" -C "${tmpDir}"`, { stdio: "pipe" });
|
|
3031
3061
|
const graph = await loadGraph(tmpDir);
|
|
3032
3062
|
return graph;
|
|
3033
3063
|
} catch {
|
|
@@ -3069,14 +3099,14 @@ function collectReverseDependents(graph, targetNode) {
|
|
|
3069
3099
|
}
|
|
3070
3100
|
return {
|
|
3071
3101
|
direct,
|
|
3072
|
-
|
|
3102
|
+
allDependents: [...seen].sort(),
|
|
3073
3103
|
reverse,
|
|
3074
3104
|
relationFrom
|
|
3075
3105
|
};
|
|
3076
3106
|
}
|
|
3077
|
-
function buildTransitiveChains(targetNode, direct,
|
|
3107
|
+
function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
|
|
3078
3108
|
const directSet = new Set(direct);
|
|
3079
|
-
const transitiveOnly =
|
|
3109
|
+
const transitiveOnly = allDependents.filter((t) => !directSet.has(t));
|
|
3080
3110
|
if (transitiveOnly.length === 0) return [];
|
|
3081
3111
|
const parent = /* @__PURE__ */ new Map();
|
|
3082
3112
|
const queue = [targetNode];
|
|
@@ -3092,148 +3122,342 @@ function buildTransitiveChains(targetNode, direct, transitive, reverse) {
|
|
|
3092
3122
|
}
|
|
3093
3123
|
const chains = [];
|
|
3094
3124
|
for (const node of transitiveOnly) {
|
|
3095
|
-
const
|
|
3125
|
+
const path17 = [];
|
|
3096
3126
|
let current = node;
|
|
3097
3127
|
while (current) {
|
|
3098
|
-
|
|
3128
|
+
path17.unshift(current);
|
|
3099
3129
|
current = parent.get(current);
|
|
3100
3130
|
}
|
|
3101
|
-
if (
|
|
3102
|
-
chains.push(
|
|
3131
|
+
if (path17.length >= 3) {
|
|
3132
|
+
chains.push(path17.slice(1).map((p) => `<- ${p}`).join(" "));
|
|
3103
3133
|
}
|
|
3104
3134
|
}
|
|
3105
3135
|
return chains.sort();
|
|
3106
3136
|
}
|
|
3107
|
-
function
|
|
3108
|
-
|
|
3137
|
+
function collectDescendants(graph, nodePath) {
|
|
3138
|
+
const node = graph.nodes.get(nodePath);
|
|
3139
|
+
if (!node) return [];
|
|
3140
|
+
const result = [];
|
|
3141
|
+
const stack = [...node.children];
|
|
3142
|
+
while (stack.length > 0) {
|
|
3143
|
+
const child = stack.pop();
|
|
3144
|
+
result.push(child.path);
|
|
3145
|
+
stack.push(...child.children);
|
|
3146
|
+
}
|
|
3147
|
+
return result.sort();
|
|
3148
|
+
}
|
|
3149
|
+
async function runSimulation(graph, nodePaths, targetNodePath) {
|
|
3150
|
+
const budget = graph.config.quality?.context_budget ?? { warning: 1e4, error: 2e4 };
|
|
3151
|
+
process.stdout.write("\nChanges in context packages:\n\n");
|
|
3152
|
+
const baselineGraph = await loadGraphFromRef(process.cwd(), "HEAD");
|
|
3153
|
+
const driftReport = await detectDrift(graph);
|
|
3154
|
+
const driftByNode = new Map(driftReport.entries.map((e) => [e.nodePath, e]));
|
|
3155
|
+
for (const dep of nodePaths) {
|
|
3109
3156
|
try {
|
|
3110
|
-
const
|
|
3111
|
-
const
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3157
|
+
const pkg2 = await buildContext(graph, dep);
|
|
3158
|
+
const status = pkg2.tokenCount >= budget.error ? "error" : pkg2.tokenCount >= budget.warning ? "warning" : "ok";
|
|
3159
|
+
let baselineTokens = null;
|
|
3160
|
+
if (baselineGraph?.nodes.has(dep)) {
|
|
3161
|
+
try {
|
|
3162
|
+
const baselinePkg = await buildContext(baselineGraph, dep);
|
|
3163
|
+
baselineTokens = baselinePkg.tokenCount;
|
|
3164
|
+
} catch {
|
|
3165
|
+
}
|
|
3116
3166
|
}
|
|
3117
|
-
const
|
|
3118
|
-
|
|
3119
|
-
nodePath
|
|
3167
|
+
const hasDepOnTarget = targetNodePath && graph.nodes.get(dep)?.meta.relations?.some(
|
|
3168
|
+
(r) => r.target === targetNodePath && STRUCTURAL_TYPES.has(r.type)
|
|
3120
3169
|
);
|
|
3121
|
-
const
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3170
|
+
const changedLine = hasDepOnTarget ? ` + Changed dependency interface: ${targetNodePath}
|
|
3171
|
+
` : "";
|
|
3172
|
+
const budgetLine = baselineTokens !== null ? ` Budget: ${baselineTokens} -> ${pkg2.tokenCount} tokens (${status})
|
|
3173
|
+
` : ` Budget: ${pkg2.tokenCount} tokens (${status})
|
|
3174
|
+
`;
|
|
3175
|
+
const driftEntry = driftByNode.get(dep);
|
|
3176
|
+
const driftLine = driftEntry && driftEntry.status !== "ok" ? ` Mapped files (on-disk): ${driftEntry.status}${driftEntry.details ? ` (${driftEntry.details})` : ""}
|
|
3177
|
+
` : driftEntry ? ` Mapped files (on-disk): ok
|
|
3178
|
+
` : "";
|
|
3179
|
+
process.stdout.write(`${dep}:
|
|
3180
|
+
${changedLine}${budgetLine}${driftLine}
|
|
3181
|
+
`);
|
|
3182
|
+
} catch {
|
|
3183
|
+
process.stdout.write(`${dep}:
|
|
3184
|
+
failed to build context
|
|
3185
|
+
|
|
3186
|
+
`);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
async function handleAspectImpact(graph, aspectId, simulate) {
|
|
3191
|
+
const aspect = graph.aspects.find((a) => a.id === aspectId);
|
|
3192
|
+
if (!aspect) {
|
|
3193
|
+
process.stderr.write(`Aspect not found: ${aspectId}
|
|
3194
|
+
`);
|
|
3195
|
+
process.exit(1);
|
|
3196
|
+
}
|
|
3197
|
+
const affected = [];
|
|
3198
|
+
for (const [nodePath] of graph.nodes) {
|
|
3199
|
+
const effective = collectEffectiveAspectIds(graph, nodePath);
|
|
3200
|
+
if (effective.has(aspectId)) {
|
|
3201
|
+
const node = graph.nodes.get(nodePath);
|
|
3202
|
+
const ownAspects = new Set(node.meta.aspects ?? []);
|
|
3203
|
+
if (ownAspects.has(aspectId)) {
|
|
3204
|
+
affected.push({ path: nodePath, source: "own" });
|
|
3205
|
+
} else {
|
|
3206
|
+
let fromHierarchy = false;
|
|
3207
|
+
let anc = node.parent;
|
|
3208
|
+
while (anc) {
|
|
3209
|
+
if ((anc.meta.aspects ?? []).includes(aspectId)) {
|
|
3210
|
+
fromHierarchy = true;
|
|
3211
|
+
break;
|
|
3212
|
+
}
|
|
3213
|
+
anc = anc.parent;
|
|
3126
3214
|
}
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3215
|
+
if (fromHierarchy) {
|
|
3216
|
+
affected.push({ path: nodePath, source: `hierarchy from ${anc.path}` });
|
|
3217
|
+
} else {
|
|
3218
|
+
const ancestorPaths = /* @__PURE__ */ new Set([nodePath, ...collectAncestors(node).map((a) => a.path)]);
|
|
3219
|
+
const flow = graph.flows.find(
|
|
3220
|
+
(f) => (f.aspects ?? []).includes(aspectId) && f.nodes.some((n) => ancestorPaths.has(n))
|
|
3221
|
+
);
|
|
3222
|
+
affected.push({ path: nodePath, source: flow ? `flow: ${flow.name}` : "implied" });
|
|
3134
3223
|
}
|
|
3135
3224
|
}
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
affected.sort((a, b) => a.path.localeCompare(b.path));
|
|
3228
|
+
const propagatingFlows = graph.flows.filter((f) => (f.aspects ?? []).includes(aspectId)).map((f) => f.name);
|
|
3229
|
+
const impliedBy = graph.aspects.filter((a) => (a.implies ?? []).includes(aspectId)).map((a) => a.id);
|
|
3230
|
+
const implies = aspect.implies ?? [];
|
|
3231
|
+
process.stdout.write(`Impact of changes in aspect ${aspectId}:
|
|
3232
|
+
|
|
3233
|
+
`);
|
|
3234
|
+
process.stdout.write(`Affected nodes (${affected.length}):
|
|
3235
|
+
`);
|
|
3236
|
+
if (affected.length === 0) {
|
|
3237
|
+
process.stdout.write(" (none)\n");
|
|
3238
|
+
} else {
|
|
3239
|
+
for (const { path: p, source } of affected) {
|
|
3240
|
+
process.stdout.write(` ${p} (${source})
|
|
3241
|
+
`);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
process.stdout.write(
|
|
3245
|
+
`
|
|
3246
|
+
Flows propagating this aspect: ${propagatingFlows.length > 0 ? propagatingFlows.join(", ") : "(none)"}
|
|
3247
|
+
`
|
|
3248
|
+
);
|
|
3249
|
+
process.stdout.write(`Implied by: ${impliedBy.length > 0 ? impliedBy.join(", ") : "(none)"}
|
|
3250
|
+
`);
|
|
3251
|
+
process.stdout.write(`Implies: ${implies.length > 0 ? implies.join(", ") : "(none)"}
|
|
3252
|
+
`);
|
|
3253
|
+
process.stdout.write(`
|
|
3254
|
+
Total scope: ${affected.length} nodes, ${propagatingFlows.length} flows
|
|
3255
|
+
`);
|
|
3256
|
+
if (simulate && affected.length > 0) {
|
|
3257
|
+
await runSimulation(
|
|
3258
|
+
graph,
|
|
3259
|
+
affected.map((a) => a.path),
|
|
3260
|
+
null
|
|
3261
|
+
);
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
async function handleFlowImpact(graph, flowName, simulate) {
|
|
3265
|
+
const flow = graph.flows.find((f) => f.name === flowName || f.path === flowName);
|
|
3266
|
+
if (!flow) {
|
|
3267
|
+
process.stderr.write(`Flow not found: ${flowName}
|
|
3268
|
+
`);
|
|
3269
|
+
process.exit(1);
|
|
3270
|
+
}
|
|
3271
|
+
const participants = /* @__PURE__ */ new Set();
|
|
3272
|
+
for (const nodePath of flow.nodes) {
|
|
3273
|
+
if (graph.nodes.has(nodePath)) {
|
|
3274
|
+
participants.add(nodePath);
|
|
3275
|
+
for (const desc of collectDescendants(graph, nodePath)) {
|
|
3276
|
+
participants.add(desc);
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
const sorted = [...participants].sort();
|
|
3281
|
+
const flowAspects = flow.aspects ?? [];
|
|
3282
|
+
process.stdout.write(`Impact of changes in flow ${flow.name}:
|
|
3283
|
+
|
|
3284
|
+
`);
|
|
3285
|
+
process.stdout.write("Participants:\n");
|
|
3286
|
+
if (sorted.length === 0) {
|
|
3287
|
+
process.stdout.write(" (none)\n");
|
|
3288
|
+
} else {
|
|
3289
|
+
for (const p of sorted) {
|
|
3290
|
+
const isDeclared = flow.nodes.includes(p);
|
|
3291
|
+
const suffix = isDeclared ? "" : " (descendant)";
|
|
3292
|
+
process.stdout.write(` ${p}${suffix}
|
|
3293
|
+
`);
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
process.stdout.write(
|
|
3297
|
+
`
|
|
3298
|
+
Flow aspects: ${flowAspects.length > 0 ? flowAspects.join(", ") : "(none)"}
|
|
3299
|
+
`
|
|
3300
|
+
);
|
|
3301
|
+
process.stdout.write(`
|
|
3302
|
+
Total scope: ${sorted.length} nodes
|
|
3303
|
+
`);
|
|
3304
|
+
if (simulate && sorted.length > 0) {
|
|
3305
|
+
await runSimulation(graph, sorted, null);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
function registerImpactCommand(program2) {
|
|
3309
|
+
program2.command("impact").description("Show reverse dependency impact for a node, aspect, or flow").option("--node <path>", "Node path relative to .yggdrasil/model/").option("--aspect <id>", "Aspect id (directory path under aspects/)").option("--flow <name>", "Flow name (directory name under flows/)").option("--simulate", "Simulate context package impact (compare HEAD vs current)").action(
|
|
3310
|
+
async (options) => {
|
|
3311
|
+
try {
|
|
3312
|
+
const modeCount = [options.node, options.aspect, options.flow].filter(Boolean).length;
|
|
3313
|
+
if (modeCount === 0) {
|
|
3314
|
+
process.stderr.write(
|
|
3315
|
+
"Error: one of --node, --aspect, or --flow is required\n"
|
|
3316
|
+
);
|
|
3317
|
+
process.exit(1);
|
|
3318
|
+
}
|
|
3319
|
+
if (modeCount > 1) {
|
|
3320
|
+
process.stderr.write(
|
|
3321
|
+
"Error: --node, --aspect, and --flow are mutually exclusive\n"
|
|
3322
|
+
);
|
|
3323
|
+
process.exit(1);
|
|
3324
|
+
}
|
|
3325
|
+
const graph = await loadGraph(process.cwd());
|
|
3326
|
+
if (options.aspect) {
|
|
3327
|
+
await handleAspectImpact(graph, options.aspect.trim(), options.simulate);
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
if (options.flow) {
|
|
3331
|
+
await handleFlowImpact(graph, options.flow.trim(), options.simulate);
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
const nodePath = options.node.trim().replace(/\/$/, "");
|
|
3335
|
+
if (!graph.nodes.has(nodePath)) {
|
|
3336
|
+
process.stderr.write(`Node not found: ${nodePath}
|
|
3337
|
+
`);
|
|
3338
|
+
process.exit(1);
|
|
3141
3339
|
}
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3340
|
+
const { direct, allDependents, reverse, relationFrom } = collectReverseDependents(
|
|
3341
|
+
graph,
|
|
3342
|
+
nodePath
|
|
3343
|
+
);
|
|
3344
|
+
const chains = buildTransitiveChains(nodePath, direct, allDependents, reverse);
|
|
3345
|
+
const flows = [];
|
|
3346
|
+
for (const flow of graph.flows) {
|
|
3347
|
+
if (flow.nodes.includes(nodePath)) {
|
|
3348
|
+
flows.push(flow.name);
|
|
3145
3349
|
}
|
|
3146
|
-
continue;
|
|
3147
3350
|
}
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3351
|
+
const targetEffective = collectEffectiveAspectIds(graph, nodePath);
|
|
3352
|
+
const aspectsInScope = [];
|
|
3353
|
+
for (const aspect of graph.aspects) {
|
|
3354
|
+
if (targetEffective.has(aspect.id)) {
|
|
3355
|
+
aspectsInScope.push(aspect.name);
|
|
3151
3356
|
}
|
|
3152
3357
|
}
|
|
3153
|
-
|
|
3154
|
-
const budget = graph.config.quality?.context_budget ?? { warning: 1e4, error: 2e4 };
|
|
3155
|
-
process.stdout.write(`Impact of changes in ${nodePath}:
|
|
3358
|
+
process.stdout.write(`Impact of changes in ${nodePath}:
|
|
3156
3359
|
|
|
3157
3360
|
`);
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3361
|
+
process.stdout.write("Directly dependent:\n");
|
|
3362
|
+
if (direct.length === 0) {
|
|
3363
|
+
process.stdout.write(" (none)\n");
|
|
3364
|
+
} else {
|
|
3365
|
+
for (const dep of direct) {
|
|
3366
|
+
const rel = relationFrom.get(`${dep}->${nodePath}`);
|
|
3367
|
+
const annot = rel?.consumes?.length ? ` (${rel.type}, you consume: ${rel.consumes.join(", ")})` : rel ? ` (${rel.type})` : "";
|
|
3368
|
+
process.stdout.write(` <- ${dep}${annot}
|
|
3166
3369
|
`);
|
|
3370
|
+
}
|
|
3167
3371
|
}
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
process.stdout.write(` ${chain}
|
|
3372
|
+
process.stdout.write("\nTransitively dependent:\n");
|
|
3373
|
+
if (chains.length === 0) {
|
|
3374
|
+
process.stdout.write(" (none)\n");
|
|
3375
|
+
} else {
|
|
3376
|
+
for (const chain of chains) {
|
|
3377
|
+
process.stdout.write(` ${chain}
|
|
3175
3378
|
`);
|
|
3379
|
+
}
|
|
3176
3380
|
}
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3381
|
+
const descendants = collectDescendants(graph, nodePath);
|
|
3382
|
+
if (descendants.length > 0) {
|
|
3383
|
+
process.stdout.write("\nDescendants (hierarchy impact):\n");
|
|
3384
|
+
for (const desc of descendants) {
|
|
3385
|
+
process.stdout.write(` ${desc}
|
|
3180
3386
|
`);
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
`Knowledge (scope covers node): ${knowledgeInScope.length > 0 ? knowledgeInScope.join(", ") : "(none)"}
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
process.stdout.write(
|
|
3390
|
+
`
|
|
3391
|
+
Flows: ${flows.length > 0 ? flows.join(", ") : "(none)"}
|
|
3187
3392
|
`
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
Total scope: ${transitive.length} nodes, ${flows.length} flows, ${aspectsInScope.length} aspects, ${knowledgeInScope.length} knowledge
|
|
3393
|
+
);
|
|
3394
|
+
process.stdout.write(
|
|
3395
|
+
`Aspects (scope covers node): ${aspectsInScope.length > 0 ? aspectsInScope.join(", ") : "(none)"}
|
|
3192
3396
|
`
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
const status = pkg.tokenCount >= budget.error ? "error" : pkg.tokenCount >= budget.warning ? "warning" : "ok";
|
|
3203
|
-
let baselineTokens = null;
|
|
3204
|
-
if (baselineGraph?.nodes.has(dep)) {
|
|
3205
|
-
try {
|
|
3206
|
-
const baselinePkg = await buildContext(baselineGraph, dep);
|
|
3207
|
-
baselineTokens = baselinePkg.tokenCount;
|
|
3208
|
-
} catch {
|
|
3209
|
-
}
|
|
3397
|
+
);
|
|
3398
|
+
const coAspectNodes = [];
|
|
3399
|
+
if (targetEffective.size > 0) {
|
|
3400
|
+
for (const [p] of graph.nodes) {
|
|
3401
|
+
if (p === nodePath) continue;
|
|
3402
|
+
const nodeEffective = collectEffectiveAspectIds(graph, p);
|
|
3403
|
+
const shared = [...targetEffective].filter((id) => nodeEffective.has(id));
|
|
3404
|
+
if (shared.length > 0) {
|
|
3405
|
+
coAspectNodes.push({ path: p, shared });
|
|
3210
3406
|
}
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
const driftEntry = driftByNode.get(dep);
|
|
3220
|
-
const driftLine = driftEntry && driftEntry.status !== "ok" ? ` Mapped files (on-disk): ${driftEntry.status}${driftEntry.details ? ` (${driftEntry.details})` : ""}
|
|
3221
|
-
` : driftEntry ? ` Mapped files (on-disk): ok
|
|
3222
|
-
` : "";
|
|
3223
|
-
process.stdout.write(`${dep}:
|
|
3224
|
-
${changedLine}${budgetLine}${driftLine}
|
|
3225
|
-
`);
|
|
3226
|
-
} catch {
|
|
3227
|
-
process.stdout.write(`${dep}:
|
|
3228
|
-
failed to build context
|
|
3229
|
-
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
if (coAspectNodes.length > 0) {
|
|
3410
|
+
process.stdout.write("Nodes sharing aspects:\n");
|
|
3411
|
+
for (const { path: p, shared } of coAspectNodes.sort(
|
|
3412
|
+
(a, b) => a.path.localeCompare(b.path)
|
|
3413
|
+
)) {
|
|
3414
|
+
process.stdout.write(` ${p} (${shared.join(", ")})
|
|
3230
3415
|
`);
|
|
3231
3416
|
}
|
|
3232
3417
|
}
|
|
3418
|
+
const allAffected = /* @__PURE__ */ new Set([...allDependents, ...descendants]);
|
|
3419
|
+
process.stdout.write(
|
|
3420
|
+
`
|
|
3421
|
+
Total scope: ${allAffected.size} nodes, ${flows.length} flows, ${aspectsInScope.length} aspects
|
|
3422
|
+
`
|
|
3423
|
+
);
|
|
3424
|
+
if (options.simulate && allAffected.size > 0) {
|
|
3425
|
+
await runSimulation(graph, allAffected, nodePath);
|
|
3426
|
+
}
|
|
3427
|
+
} catch (error) {
|
|
3428
|
+
process.stderr.write(`Error: ${error.message}
|
|
3429
|
+
`);
|
|
3430
|
+
process.exit(1);
|
|
3233
3431
|
}
|
|
3432
|
+
}
|
|
3433
|
+
);
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
// src/cli/aspects.ts
|
|
3437
|
+
import { stringify as yamlStringify } from "yaml";
|
|
3438
|
+
function registerAspectsCommand(program2) {
|
|
3439
|
+
program2.command("aspects").description("List aspects with metadata (YAML output)").action(async () => {
|
|
3440
|
+
try {
|
|
3441
|
+
const yggRoot = await findYggRoot(process.cwd());
|
|
3442
|
+
const graph = await loadGraph(yggRoot);
|
|
3443
|
+
const output = graph.aspects.sort((a, b) => a.id.localeCompare(b.id)).map((aspect) => {
|
|
3444
|
+
const entry = { id: aspect.id, name: aspect.name };
|
|
3445
|
+
if (aspect.description) entry.description = aspect.description;
|
|
3446
|
+
if (aspect.implies && aspect.implies.length > 0) entry.implies = aspect.implies;
|
|
3447
|
+
return entry;
|
|
3448
|
+
});
|
|
3449
|
+
process.stdout.write(yamlStringify(output));
|
|
3234
3450
|
} catch (error) {
|
|
3235
|
-
|
|
3451
|
+
const err = error;
|
|
3452
|
+
if (err.code === "ENOENT") {
|
|
3453
|
+
process.stderr.write(
|
|
3454
|
+
`Error: No .yggdrasil/ directory found. Run 'yg init' first.
|
|
3455
|
+
`
|
|
3456
|
+
);
|
|
3457
|
+
} else {
|
|
3458
|
+
process.stderr.write(`Error: ${error.message}
|
|
3236
3459
|
`);
|
|
3460
|
+
}
|
|
3237
3461
|
process.exit(1);
|
|
3238
3462
|
}
|
|
3239
3463
|
});
|
|
@@ -3241,15 +3465,15 @@ ${changedLine}${budgetLine}${driftLine}
|
|
|
3241
3465
|
|
|
3242
3466
|
// src/io/journal-store.ts
|
|
3243
3467
|
import { readFile as readFile13, writeFile as writeFile4, mkdir as mkdir3, rename, access as access2 } from "fs/promises";
|
|
3244
|
-
import { parse as
|
|
3245
|
-
import
|
|
3468
|
+
import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
|
|
3469
|
+
import path16 from "path";
|
|
3246
3470
|
var JOURNAL_FILE = ".journal.yaml";
|
|
3247
3471
|
var ARCHIVE_DIR = "journals-archive";
|
|
3248
3472
|
async function readJournal(yggRoot) {
|
|
3249
|
-
const filePath =
|
|
3473
|
+
const filePath = path16.join(yggRoot, JOURNAL_FILE);
|
|
3250
3474
|
try {
|
|
3251
3475
|
const content = await readFile13(filePath, "utf-8");
|
|
3252
|
-
const raw =
|
|
3476
|
+
const raw = parseYaml6(content);
|
|
3253
3477
|
const entries = raw.entries ?? [];
|
|
3254
3478
|
return Array.isArray(entries) ? entries : [];
|
|
3255
3479
|
} catch {
|
|
@@ -3261,13 +3485,13 @@ async function appendJournalEntry(yggRoot, note, target) {
|
|
|
3261
3485
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3262
3486
|
const entry = target ? { at, target, note } : { at, note };
|
|
3263
3487
|
entries.push(entry);
|
|
3264
|
-
const filePath =
|
|
3265
|
-
const content =
|
|
3488
|
+
const filePath = path16.join(yggRoot, JOURNAL_FILE);
|
|
3489
|
+
const content = stringifyYaml({ entries });
|
|
3266
3490
|
await writeFile4(filePath, content, "utf-8");
|
|
3267
3491
|
return entry;
|
|
3268
3492
|
}
|
|
3269
3493
|
async function archiveJournal(yggRoot) {
|
|
3270
|
-
const journalPath =
|
|
3494
|
+
const journalPath = path16.join(yggRoot, JOURNAL_FILE);
|
|
3271
3495
|
try {
|
|
3272
3496
|
await access2(journalPath);
|
|
3273
3497
|
} catch {
|
|
@@ -3275,12 +3499,12 @@ async function archiveJournal(yggRoot) {
|
|
|
3275
3499
|
}
|
|
3276
3500
|
const entries = await readJournal(yggRoot);
|
|
3277
3501
|
if (entries.length === 0) return null;
|
|
3278
|
-
const archiveDir =
|
|
3502
|
+
const archiveDir = path16.join(yggRoot, ARCHIVE_DIR);
|
|
3279
3503
|
await mkdir3(archiveDir, { recursive: true });
|
|
3280
3504
|
const now = /* @__PURE__ */ new Date();
|
|
3281
3505
|
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")}`;
|
|
3282
3506
|
const archiveName = `.journal.${timestamp}.yaml`;
|
|
3283
|
-
const archivePath =
|
|
3507
|
+
const archivePath = path16.join(archiveDir, archiveName);
|
|
3284
3508
|
await rename(journalPath, archivePath);
|
|
3285
3509
|
return { archiveName, entryCount: entries.length };
|
|
3286
3510
|
}
|
|
@@ -3356,9 +3580,85 @@ function registerJournalArchiveCommand(program2) {
|
|
|
3356
3580
|
});
|
|
3357
3581
|
}
|
|
3358
3582
|
|
|
3583
|
+
// src/cli/preflight.ts
|
|
3584
|
+
function registerPreflightCommand(program2) {
|
|
3585
|
+
program2.command("preflight").description("Unified diagnostic report: journal, drift, status, validation").action(async () => {
|
|
3586
|
+
try {
|
|
3587
|
+
const cwd = process.cwd();
|
|
3588
|
+
const graph = await loadGraph(cwd);
|
|
3589
|
+
const yggRoot = await findYggRoot(cwd);
|
|
3590
|
+
const journalEntries = await readJournal(yggRoot);
|
|
3591
|
+
const drift = await detectDrift(graph);
|
|
3592
|
+
const driftedEntries = drift.entries.filter((e) => e.status !== "ok");
|
|
3593
|
+
const nodeCount = graph.nodes.size;
|
|
3594
|
+
const aspectCount = graph.aspects.length;
|
|
3595
|
+
const flowCount = graph.flows.length;
|
|
3596
|
+
let mappedPathCount = 0;
|
|
3597
|
+
for (const node of graph.nodes.values()) {
|
|
3598
|
+
mappedPathCount += normalizeMappingPaths(node.meta.mapping).length;
|
|
3599
|
+
}
|
|
3600
|
+
const validation = await validate(graph, "all");
|
|
3601
|
+
const errors = validation.issues.filter((i) => i.severity === "error");
|
|
3602
|
+
const warnings = validation.issues.filter((i) => i.severity === "warning");
|
|
3603
|
+
const lines = [];
|
|
3604
|
+
lines.push("=== Preflight Report ===");
|
|
3605
|
+
lines.push("");
|
|
3606
|
+
if (journalEntries.length === 0) {
|
|
3607
|
+
lines.push("Journal: clean");
|
|
3608
|
+
} else {
|
|
3609
|
+
lines.push(`Journal: ${journalEntries.length} pending entries`);
|
|
3610
|
+
for (const entry of journalEntries) {
|
|
3611
|
+
const target = entry.target ? ` [${entry.target}]` : "";
|
|
3612
|
+
lines.push(` - ${entry.note}${target}`);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
lines.push("");
|
|
3616
|
+
if (driftedEntries.length === 0) {
|
|
3617
|
+
lines.push("Drift: clean");
|
|
3618
|
+
} else {
|
|
3619
|
+
lines.push(`Drift: ${driftedEntries.length} nodes need attention`);
|
|
3620
|
+
for (const entry of driftedEntries) {
|
|
3621
|
+
lines.push(` - ${entry.nodePath}: ${entry.status}`);
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
lines.push("");
|
|
3625
|
+
lines.push(
|
|
3626
|
+
`Status: ${nodeCount} nodes, ${aspectCount} aspects, ${flowCount} flows, ${mappedPathCount} mapped paths`
|
|
3627
|
+
);
|
|
3628
|
+
lines.push("");
|
|
3629
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
3630
|
+
lines.push("Validation: clean");
|
|
3631
|
+
} else {
|
|
3632
|
+
const parts = [];
|
|
3633
|
+
if (errors.length > 0) parts.push(`${errors.length} errors`);
|
|
3634
|
+
if (warnings.length > 0) parts.push(`${warnings.length} warnings`);
|
|
3635
|
+
lines.push(`Validation: ${parts.join(", ")}`);
|
|
3636
|
+
for (const issue of [...errors, ...warnings]) {
|
|
3637
|
+
const code = issue.code ? `[${issue.code}] ` : "";
|
|
3638
|
+
lines.push(` - ${code}${issue.message}`);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
lines.push("");
|
|
3642
|
+
process.stdout.write(lines.join("\n"));
|
|
3643
|
+
const hasIssues = journalEntries.length > 0 || driftedEntries.length > 0 || errors.length > 0;
|
|
3644
|
+
process.exit(hasIssues ? 1 : 0);
|
|
3645
|
+
} catch (error) {
|
|
3646
|
+
process.stderr.write(`Error: ${error.message}
|
|
3647
|
+
`);
|
|
3648
|
+
process.exit(1);
|
|
3649
|
+
}
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3359
3653
|
// src/bin.ts
|
|
3654
|
+
import { readFileSync } from "fs";
|
|
3655
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
3656
|
+
import { dirname, join } from "path";
|
|
3657
|
+
var __filename = fileURLToPath3(import.meta.url);
|
|
3658
|
+
var __dirname = dirname(__filename);
|
|
3659
|
+
var pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
3360
3660
|
var program = new Command();
|
|
3361
|
-
program.name("yg").description("Yggdrasil \u2014 architectural knowledge infrastructure for AI agents").version(
|
|
3661
|
+
program.name("yg").description("Yggdrasil \u2014 architectural knowledge infrastructure for AI agents").version(pkg.version);
|
|
3362
3662
|
registerInitCommand(program);
|
|
3363
3663
|
registerBuildCommand(program);
|
|
3364
3664
|
registerValidateCommand(program);
|
|
@@ -3369,8 +3669,10 @@ registerTreeCommand(program);
|
|
|
3369
3669
|
registerOwnerCommand(program);
|
|
3370
3670
|
registerDepsCommand(program);
|
|
3371
3671
|
registerImpactCommand(program);
|
|
3672
|
+
registerAspectsCommand(program);
|
|
3372
3673
|
registerJournalAddCommand(program);
|
|
3373
3674
|
registerJournalReadCommand(program);
|
|
3374
3675
|
registerJournalArchiveCommand(program);
|
|
3676
|
+
registerPreflightCommand(program);
|
|
3375
3677
|
program.parse();
|
|
3376
3678
|
//# sourceMappingURL=bin.js.map
|