@chrisdudek/yg 5.0.0-alpha.4 → 5.0.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/bin.js +15139 -13841
- package/dist/structure.d.ts +40 -18
- package/dist/structure.js +296 -41
- package/graph-schemas/yg-architecture.yaml +8 -3
- package/graph-schemas/yg-aspect.yaml +54 -0
- package/graph-schemas/yg-config.yaml +8 -8
- package/graph-schemas/yg-node.yaml +5 -6
- package/package.json +4 -1
package/dist/structure.d.ts
CHANGED
|
@@ -207,11 +207,6 @@ interface ArchitectureDef {
|
|
|
207
207
|
}
|
|
208
208
|
interface QualityConfig {
|
|
209
209
|
max_direct_relations: number;
|
|
210
|
-
/** Per-node character budget. A node whose mapped source files plus the
|
|
211
|
-
* reference files of its effective aspects exceed this total is an
|
|
212
|
-
* `oversized-node` error. Default 40000. Binary files do not count; a node
|
|
213
|
-
* may opt out via `sizeExempt`. */
|
|
214
|
-
max_node_chars?: number;
|
|
215
210
|
}
|
|
216
211
|
type RelationType = 'uses' | 'calls' | 'extends' | 'implements' | 'emits' | 'listens';
|
|
217
212
|
/** Port on a target node — consumers must satisfy port's aspects */
|
|
@@ -234,13 +229,13 @@ interface LlmConfig {
|
|
|
234
229
|
consensus: number;
|
|
235
230
|
/** CLI providers: subprocess timeout in ms. Default: 120_000. */
|
|
236
231
|
timeout?: number;
|
|
237
|
-
/**
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Optional per-tier cap on assembled reviewer-prompt length in characters.
|
|
234
|
+
* Absent = unlimited. This is a GATE checked deterministically before the LLM
|
|
235
|
+
* call — it never participates in verdict identity or hash computation (excluded
|
|
236
|
+
* from canonicalTierJson like api_key and timeout).
|
|
237
|
+
*/
|
|
238
|
+
max_prompt_chars?: number;
|
|
244
239
|
}
|
|
245
240
|
interface NodeMeta {
|
|
246
241
|
name: string;
|
|
@@ -255,12 +250,6 @@ interface NodeMeta {
|
|
|
255
250
|
relations?: Relation[];
|
|
256
251
|
/** Flat list of file/directory paths relative to repo root */
|
|
257
252
|
mapping?: string[];
|
|
258
|
-
/** Documented opt-out from the per-node character budget (`oversized-node`).
|
|
259
|
-
* Use ONLY for nodes mapping an unsplittable generated or binary artifact
|
|
260
|
-
* (lockfile, append-only changelog, image). Requires a justification. */
|
|
261
|
-
sizeExempt?: {
|
|
262
|
-
reason: string;
|
|
263
|
-
};
|
|
264
253
|
}
|
|
265
254
|
interface Relation {
|
|
266
255
|
target: string;
|
|
@@ -305,6 +294,11 @@ interface AspectReviewerSpec {
|
|
|
305
294
|
type AspectStatus = 'draft' | 'advisory' | 'enforced';
|
|
306
295
|
/** Propagation modifier on implies edges. */
|
|
307
296
|
type StatusInherit = 'strictest' | 'own-default';
|
|
297
|
+
/** Review scope of an aspect: whole-node or per-file, with an optional file filter. */
|
|
298
|
+
interface ScopeDef {
|
|
299
|
+
per: 'node' | 'file';
|
|
300
|
+
files?: FileWhenPredicate;
|
|
301
|
+
}
|
|
308
302
|
interface AspectDef {
|
|
309
303
|
name: string;
|
|
310
304
|
id: string;
|
|
@@ -327,6 +321,15 @@ interface AspectDef {
|
|
|
327
321
|
}>;
|
|
328
322
|
/** Aspect-level default status. Absent → 'enforced'. Attach sites may override per the bump rule: bump up OK, downgrade is a validator error. */
|
|
329
323
|
status?: AspectStatus;
|
|
324
|
+
/**
|
|
325
|
+
* Review scope: controls review granularity and the subject-file set.
|
|
326
|
+
* per: node (default) — one review over all subject files.
|
|
327
|
+
* per: file — one review per subject file.
|
|
328
|
+
* files: optional FileWhenPredicate filter; subject set = mapped files ∩ filter.
|
|
329
|
+
* Absent → undefined (semantically equivalent to { per: 'node' }).
|
|
330
|
+
* Forbidden on aggregate aspects (no rule source to scope).
|
|
331
|
+
*/
|
|
332
|
+
scope?: ScopeDef;
|
|
330
333
|
}
|
|
331
334
|
interface FlowDef {
|
|
332
335
|
/** Directory name under flows/, e.g. "checkout-flow" */
|
|
@@ -406,11 +409,30 @@ interface RunStructureAspectParams {
|
|
|
406
409
|
graph: Graph;
|
|
407
410
|
projectRoot: string;
|
|
408
411
|
parseCache?: ParseCache;
|
|
412
|
+
/**
|
|
413
|
+
* Subject-scope override for a `per: file` deterministic pair (spec §1, B2
|
|
414
|
+
* contract #8). When present, it overrides BOTH `ctx.files` (the check sees
|
|
415
|
+
* only these subject files) AND the observation-EXCLUSION set (a read of any
|
|
416
|
+
* OTHER node file folds as a recorded `read:` observation, since it is no
|
|
417
|
+
* longer hashed as a subject input). Repo-relative POSIX paths.
|
|
418
|
+
*
|
|
419
|
+
* `ctx.node.files` and the allow-set stay NODE-scoped regardless — the check
|
|
420
|
+
* may still reach the rest of the node, but those reaches become observations.
|
|
421
|
+
*
|
|
422
|
+
* Absent → byte-identical legacy behavior (the whole node mapping is both the
|
|
423
|
+
* subject set and the exclusion set; `per: node` and `yg aspect-test` paths).
|
|
424
|
+
*/
|
|
425
|
+
subjectScope?: string[];
|
|
409
426
|
}
|
|
410
427
|
interface RunStructureAspectResult {
|
|
411
428
|
violations: Violation[];
|
|
412
429
|
touchedFiles: string[];
|
|
413
430
|
succeeded?: boolean;
|
|
431
|
+
/** Sorted [observationKey, observationHash] pairs recorded during this run. */
|
|
432
|
+
observations: Array<[string, string]>;
|
|
433
|
+
/** True when the same path was observed with different content during the run
|
|
434
|
+
* (file changed mid-run) — a tainted result must never be cached. */
|
|
435
|
+
observationsTainted: boolean;
|
|
414
436
|
}
|
|
415
437
|
declare class StructureRunnerError extends Error {
|
|
416
438
|
readonly code: string;
|
package/dist/structure.js
CHANGED
|
@@ -61,6 +61,7 @@ var UndeclaredFsReadError = class extends Error {
|
|
|
61
61
|
this.path = path9;
|
|
62
62
|
this.name = "UndeclaredFsReadError";
|
|
63
63
|
}
|
|
64
|
+
path;
|
|
64
65
|
};
|
|
65
66
|
function isAllowed(p, set) {
|
|
66
67
|
if (p === "") return false;
|
|
@@ -106,36 +107,64 @@ function resolveAllowedReadPath(raw, allowedSet, projectRoot) {
|
|
|
106
107
|
return rel;
|
|
107
108
|
}
|
|
108
109
|
function createCtxFs(params) {
|
|
109
|
-
const { allowedSet, projectRoot, touchedFiles } = params;
|
|
110
|
+
const { allowedSet, projectRoot, touchedFiles, recorder, subjectFiles } = params;
|
|
110
111
|
function assertAllowed(raw) {
|
|
111
112
|
const p = resolveAllowedReadPath(raw, allowedSet, projectRoot);
|
|
112
113
|
touchedFiles.push(p);
|
|
113
114
|
return p;
|
|
114
115
|
}
|
|
116
|
+
function isSubjectFile(p) {
|
|
117
|
+
return subjectFiles !== void 0 && subjectFiles.has(p);
|
|
118
|
+
}
|
|
115
119
|
return {
|
|
116
120
|
exists(raw) {
|
|
117
121
|
const p = assertAllowed(raw);
|
|
118
122
|
const abs = path2.resolve(projectRoot, p);
|
|
123
|
+
let result;
|
|
119
124
|
try {
|
|
120
125
|
const stat2 = fs.statSync(abs);
|
|
121
|
-
|
|
126
|
+
result = stat2.isDirectory() ? "dir" : stat2.isFile() ? "file" : false;
|
|
122
127
|
} catch {
|
|
123
|
-
|
|
128
|
+
result = false;
|
|
129
|
+
}
|
|
130
|
+
if (recorder && !isSubjectFile(p)) {
|
|
131
|
+
recorder.recordExists(p, result);
|
|
124
132
|
}
|
|
133
|
+
return result;
|
|
125
134
|
},
|
|
126
135
|
read(raw) {
|
|
127
136
|
const p = assertAllowed(raw);
|
|
128
137
|
const abs = path2.resolve(projectRoot, p);
|
|
129
|
-
|
|
138
|
+
let bytes;
|
|
139
|
+
try {
|
|
140
|
+
bytes = fs.readFileSync(abs);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (recorder && !isSubjectFile(p)) recorder.recordReadAbsent(p);
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
if (recorder && !isSubjectFile(p)) {
|
|
146
|
+
recorder.recordRead(p, bytes);
|
|
147
|
+
}
|
|
148
|
+
return bytes.toString("utf8");
|
|
130
149
|
},
|
|
131
150
|
list(raw) {
|
|
132
151
|
const p = assertAllowed(raw);
|
|
133
152
|
const abs = path2.resolve(projectRoot, p);
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
let dirents;
|
|
154
|
+
try {
|
|
155
|
+
dirents = fs.readdirSync(abs, { withFileTypes: true });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (recorder && !isSubjectFile(p)) recorder.recordListAbsent(p);
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
const entries = dirents.map((e) => ({
|
|
136
161
|
name: e.name,
|
|
137
162
|
kind: e.isDirectory() ? "dir" : "file"
|
|
138
163
|
}));
|
|
164
|
+
if (recorder && !isSubjectFile(p)) {
|
|
165
|
+
recorder.recordList(p, entries);
|
|
166
|
+
}
|
|
167
|
+
return entries;
|
|
139
168
|
}
|
|
140
169
|
};
|
|
141
170
|
}
|
|
@@ -158,6 +187,7 @@ var UndeclaredGraphReadError = class extends Error {
|
|
|
158
187
|
this.nodePath = nodePath;
|
|
159
188
|
this.name = "UndeclaredGraphReadError";
|
|
160
189
|
}
|
|
190
|
+
nodePath;
|
|
161
191
|
};
|
|
162
192
|
function computeAllowedNodePaths(currentPath, graph) {
|
|
163
193
|
const allowed = /* @__PURE__ */ new Set([currentPath]);
|
|
@@ -188,11 +218,26 @@ function computeAllowedNodePaths(currentPath, graph) {
|
|
|
188
218
|
return allowed;
|
|
189
219
|
}
|
|
190
220
|
function createCtxGraph(params) {
|
|
191
|
-
const { currentNodePath, graph, projectRoot, touchedFiles, expandedFilesByNode } = params;
|
|
221
|
+
const { currentNodePath, graph, projectRoot, touchedFiles, expandedFilesByNode, recorder, subjectFiles } = params;
|
|
192
222
|
const allowed = computeAllowedNodePaths(currentNodePath, graph);
|
|
193
223
|
function assertAllowed(id) {
|
|
194
224
|
if (!allowed.has(id)) throw new UndeclaredGraphReadError(id);
|
|
195
225
|
}
|
|
226
|
+
function recordGraphNode(m) {
|
|
227
|
+
if (!recorder) return;
|
|
228
|
+
const ygNodePath = path3.join(projectRoot, ".yggdrasil", "model", m.path, "yg-node.yaml");
|
|
229
|
+
let yamlBytes;
|
|
230
|
+
try {
|
|
231
|
+
yamlBytes = fs2.readFileSync(ygNodePath);
|
|
232
|
+
} catch {
|
|
233
|
+
yamlBytes = m.nodeYamlRaw !== void 0 ? Buffer.from(m.nodeYamlRaw, "utf8") : void 0;
|
|
234
|
+
}
|
|
235
|
+
if (yamlBytes === void 0) {
|
|
236
|
+
recorder.recordGraphNodeAbsent(m.path);
|
|
237
|
+
} else {
|
|
238
|
+
recorder.recordGraphNode(m.path, yamlBytes);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
196
241
|
function toPublicNode(m) {
|
|
197
242
|
const files = [];
|
|
198
243
|
const preExpanded = expandedFilesByNode?.get(m.path);
|
|
@@ -203,13 +248,18 @@ function createCtxGraph(params) {
|
|
|
203
248
|
try {
|
|
204
249
|
const stat2 = fs2.statSync(abs);
|
|
205
250
|
if (stat2.isFile()) {
|
|
206
|
-
const
|
|
251
|
+
const bytes = fs2.readFileSync(abs);
|
|
252
|
+
const content = bytes.toString("utf8");
|
|
207
253
|
files.push({ path: p, content });
|
|
208
254
|
touchedFiles.push(p);
|
|
255
|
+
if (recorder && !subjectFiles?.has(p)) {
|
|
256
|
+
recorder.recordRead(p, bytes);
|
|
257
|
+
}
|
|
209
258
|
}
|
|
210
259
|
} catch {
|
|
211
260
|
}
|
|
212
261
|
}
|
|
262
|
+
recordGraphNode(m);
|
|
213
263
|
return {
|
|
214
264
|
id: m.path,
|
|
215
265
|
type: m.meta.type,
|
|
@@ -222,19 +272,29 @@ function createCtxGraph(params) {
|
|
|
222
272
|
node(id) {
|
|
223
273
|
assertAllowed(id);
|
|
224
274
|
const m = graph.nodes.get(id);
|
|
225
|
-
|
|
275
|
+
if (!m) {
|
|
276
|
+
if (recorder) recorder.recordGraphNodeAbsent(id);
|
|
277
|
+
return void 0;
|
|
278
|
+
}
|
|
279
|
+
return toPublicNode(m);
|
|
226
280
|
},
|
|
227
281
|
nodesByType(type) {
|
|
228
282
|
const out = [];
|
|
283
|
+
const matchedIds = [];
|
|
229
284
|
for (const id of allowed) {
|
|
230
285
|
const m = graph.nodes.get(id);
|
|
231
|
-
if (m && m.meta.type === type)
|
|
286
|
+
if (m && m.meta.type === type) {
|
|
287
|
+
matchedIds.push(m.path);
|
|
288
|
+
out.push(toPublicNode(m));
|
|
289
|
+
}
|
|
232
290
|
}
|
|
291
|
+
if (recorder) recorder.recordGraphNodesByType(type, matchedIds);
|
|
233
292
|
return out;
|
|
234
293
|
},
|
|
235
294
|
relationsFrom(node) {
|
|
236
295
|
assertAllowed(node.id);
|
|
237
296
|
const m = graph.nodes.get(node.id);
|
|
297
|
+
if (m) recordGraphNode(m);
|
|
238
298
|
return m?.meta.relations ?? [];
|
|
239
299
|
},
|
|
240
300
|
relationsTo(node) {
|
|
@@ -242,6 +302,7 @@ function createCtxGraph(params) {
|
|
|
242
302
|
for (const id of allowed) {
|
|
243
303
|
const m = graph.nodes.get(id);
|
|
244
304
|
if (!m) continue;
|
|
305
|
+
recordGraphNode(m);
|
|
245
306
|
for (const rel of m.meta.relations ?? []) {
|
|
246
307
|
if (rel.target === node.id) out.push(rel);
|
|
247
308
|
}
|
|
@@ -251,6 +312,8 @@ function createCtxGraph(params) {
|
|
|
251
312
|
children(node) {
|
|
252
313
|
assertAllowed(node.id);
|
|
253
314
|
const m = graph.nodes.get(node.id);
|
|
315
|
+
const childIds = m ? m.children.map((c) => c.path) : [];
|
|
316
|
+
if (recorder) recorder.recordGraphChildren(node.id, childIds);
|
|
254
317
|
return m ? m.children.map(toPublicNode) : [];
|
|
255
318
|
},
|
|
256
319
|
flowParticipants(flowName) {
|
|
@@ -266,6 +329,7 @@ function createCtxGraph(params) {
|
|
|
266
329
|
return false;
|
|
267
330
|
});
|
|
268
331
|
if (!participates) throw new UndeclaredGraphReadError(`flow:${flowName}`);
|
|
332
|
+
if (recorder) recorder.recordFlowParticipants(flow.name, [...flow.nodes]);
|
|
269
333
|
const out = [];
|
|
270
334
|
for (const nodeId of flow.nodes) {
|
|
271
335
|
const m = graph.nodes.get(nodeId);
|
|
@@ -573,9 +637,10 @@ var ParseAstNotPrewarmedError = class extends Error {
|
|
|
573
637
|
this.filePath = filePath;
|
|
574
638
|
this.name = "ParseAstNotPrewarmedError";
|
|
575
639
|
}
|
|
640
|
+
filePath;
|
|
576
641
|
};
|
|
577
642
|
function createCtxParsers(params) {
|
|
578
|
-
const { allowedSet, projectRoot, touchedFiles, astCache } = params;
|
|
643
|
+
const { allowedSet, projectRoot, touchedFiles, astCache, recorder, subjectFiles } = params;
|
|
579
644
|
function asFile(input) {
|
|
580
645
|
if (typeof input !== "string") {
|
|
581
646
|
touchedFiles.push(input.path);
|
|
@@ -583,8 +648,18 @@ function createCtxParsers(params) {
|
|
|
583
648
|
}
|
|
584
649
|
const p = resolveAllowedReadPath(input, allowedSet, projectRoot);
|
|
585
650
|
const abs = path5.resolve(projectRoot, p);
|
|
586
|
-
|
|
651
|
+
let bytes;
|
|
652
|
+
try {
|
|
653
|
+
bytes = fs3.readFileSync(abs);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (recorder && !subjectFiles?.has(p)) recorder.recordReadAbsent(p);
|
|
656
|
+
throw err;
|
|
657
|
+
}
|
|
658
|
+
const content = bytes.toString("utf8");
|
|
587
659
|
touchedFiles.push(p);
|
|
660
|
+
if (recorder && !subjectFiles?.has(p)) {
|
|
661
|
+
recorder.recordRead(p, bytes);
|
|
662
|
+
}
|
|
588
663
|
return { path: p, content };
|
|
589
664
|
}
|
|
590
665
|
return {
|
|
@@ -707,7 +782,7 @@ import { createRequire as createRequire3 } from "module";
|
|
|
707
782
|
|
|
708
783
|
// src/io/repo-scanner.ts
|
|
709
784
|
import { readFile, readdir } from "fs/promises";
|
|
710
|
-
import { join, relative as relative2, sep } from "path";
|
|
785
|
+
import { join as join2, relative as relative2, sep } from "path";
|
|
711
786
|
import { createRequire as createRequire2 } from "module";
|
|
712
787
|
|
|
713
788
|
// src/utils/debug-log.ts
|
|
@@ -742,7 +817,9 @@ function isIgnoredByStack2(candidatePath, stack) {
|
|
|
742
817
|
function hashString(content) {
|
|
743
818
|
return createHash("sha256").update(content).digest("hex");
|
|
744
819
|
}
|
|
745
|
-
|
|
820
|
+
function hashBytes(bytes) {
|
|
821
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
822
|
+
}
|
|
746
823
|
async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
|
|
747
824
|
let stack = options.gitignoreStack ?? [];
|
|
748
825
|
try {
|
|
@@ -869,6 +946,8 @@ var SuppressMarkerError = class extends Error {
|
|
|
869
946
|
this.line = line;
|
|
870
947
|
this.name = "SuppressMarkerError";
|
|
871
948
|
}
|
|
949
|
+
file;
|
|
950
|
+
line;
|
|
872
951
|
code = "SUPPRESS_MARKER_MISSING_REASON";
|
|
873
952
|
};
|
|
874
953
|
var RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
|
|
@@ -1022,18 +1101,7 @@ function validateCheckModuleExport(mod, opts) {
|
|
|
1022
1101
|
return { ok: true };
|
|
1023
1102
|
}
|
|
1024
1103
|
|
|
1025
|
-
// src/
|
|
1026
|
-
var StructureRunnerError = class extends Error {
|
|
1027
|
-
constructor(code, data) {
|
|
1028
|
-
super(`${code}: ${data.what}
|
|
1029
|
-
${data.why}
|
|
1030
|
-
${data.next}`);
|
|
1031
|
-
this.code = code;
|
|
1032
|
-
this.messageData = data;
|
|
1033
|
-
this.name = "StructureRunnerError";
|
|
1034
|
-
}
|
|
1035
|
-
messageData;
|
|
1036
|
-
};
|
|
1104
|
+
// src/utils/binary-extensions.ts
|
|
1037
1105
|
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1038
1106
|
".gif",
|
|
1039
1107
|
".png",
|
|
@@ -1063,6 +1131,142 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
1063
1131
|
".wasm",
|
|
1064
1132
|
".bin"
|
|
1065
1133
|
]);
|
|
1134
|
+
|
|
1135
|
+
// src/core/pair-hash.ts
|
|
1136
|
+
function observationKey(kind, target) {
|
|
1137
|
+
return `${kind}:${target}`;
|
|
1138
|
+
}
|
|
1139
|
+
var MISSING_OBSERVATION = "missing";
|
|
1140
|
+
function hashNodeSetObservation(nodeIds) {
|
|
1141
|
+
const lines = [...nodeIds].sort((a, b) => a < b ? -1 : a > b ? 1 : 0).join("\n");
|
|
1142
|
+
return hashString(lines);
|
|
1143
|
+
}
|
|
1144
|
+
function hashReadObservation(bytes) {
|
|
1145
|
+
return hashBytes(bytes);
|
|
1146
|
+
}
|
|
1147
|
+
function hashListObservation(entries) {
|
|
1148
|
+
const lines = [...entries].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).map((e) => `${e.name}:${e.kind}`).join("\n");
|
|
1149
|
+
return hashString(lines);
|
|
1150
|
+
}
|
|
1151
|
+
function hashExistsObservation(result) {
|
|
1152
|
+
return hashString(result === false ? "false" : result);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/structure/observations.ts
|
|
1156
|
+
var ObservationRecorder = class {
|
|
1157
|
+
_entries = /* @__PURE__ */ new Map();
|
|
1158
|
+
// key → hash (first-wins)
|
|
1159
|
+
_tainted = false;
|
|
1160
|
+
/** Record a file-read observation. `bytes` is the raw content read. */
|
|
1161
|
+
recordRead(repoRelPosixPath, bytes) {
|
|
1162
|
+
this._record(observationKey("read", repoRelPosixPath), hashReadObservation(bytes));
|
|
1163
|
+
}
|
|
1164
|
+
/** Record a directory-listing observation. */
|
|
1165
|
+
recordList(repoRelPosixDir, entries) {
|
|
1166
|
+
this._record(observationKey("list", repoRelPosixDir), hashListObservation(entries));
|
|
1167
|
+
}
|
|
1168
|
+
/** Record an existence-probe observation (including negative probes where result === false). */
|
|
1169
|
+
recordExists(repoRelPosixPath, result) {
|
|
1170
|
+
this._record(observationKey("exists", repoRelPosixPath), hashExistsObservation(result));
|
|
1171
|
+
}
|
|
1172
|
+
/** Record a graph-node observation by hashing its yg-node.yaml bytes. */
|
|
1173
|
+
recordGraphNode(nodePath, ygNodeYamlBytes) {
|
|
1174
|
+
this._record(observationKey("graph", nodePath), hashReadObservation(ygNodeYamlBytes));
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Record an ABSENT file-read observation: the check attempted a read that threw
|
|
1178
|
+
* (the path passed the allow-check but the file was missing/unreadable at read
|
|
1179
|
+
* time). Folds MISSING_OBSERVATION under the same read:<path> key the verifier
|
|
1180
|
+
* re-observes — so if the check swallowed the throw and treated the file as
|
|
1181
|
+
* absent, a later successful read of that path changes the value ⇒ unverified
|
|
1182
|
+
* (spec §3.1, over-record: a throwing access is still an observation).
|
|
1183
|
+
*/
|
|
1184
|
+
recordReadAbsent(repoRelPosixPath) {
|
|
1185
|
+
this._record(observationKey("read", repoRelPosixPath), MISSING_OBSERVATION);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Record an ABSENT directory-listing observation: the check attempted a list
|
|
1189
|
+
* that threw (path allow-checked but the dir was missing/unreadable at list
|
|
1190
|
+
* time). Folds MISSING_OBSERVATION under the list:<path> key — a later
|
|
1191
|
+
* successful listing changes the value ⇒ unverified (spec §3.1, over-record).
|
|
1192
|
+
*/
|
|
1193
|
+
recordListAbsent(repoRelPosixDir) {
|
|
1194
|
+
this._record(observationKey("list", repoRelPosixDir), MISSING_OBSERVATION);
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Record a NEGATIVE graph-node observation: the check looked up a node that
|
|
1198
|
+
* does not exist. Folds the MISSING_OBSERVATION token so the verifier's
|
|
1199
|
+
* re-observation (which reads that node's yg-node.yaml and also yields
|
|
1200
|
+
* MISSING_OBSERVATION when absent) reproduces it byte-for-byte — and creating
|
|
1201
|
+
* the node later changes the value ⇒ unverified (spec §3.1, over-record).
|
|
1202
|
+
*/
|
|
1203
|
+
recordGraphNodeAbsent(nodePath) {
|
|
1204
|
+
this._record(observationKey("graph", nodePath), MISSING_OBSERVATION);
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Record a child-set observation for `nodePath`: the SET of node ids returned
|
|
1208
|
+
* by ctx.graph.children(node). Folds membership only — adding/removing a child
|
|
1209
|
+
* invalidates; a content edit to an unchanged child rides its own graph:
|
|
1210
|
+
* observation (spec §3.1).
|
|
1211
|
+
*/
|
|
1212
|
+
recordGraphChildren(nodePath, childIds) {
|
|
1213
|
+
this._record(observationKey("graph-children", nodePath), hashNodeSetObservation(childIds));
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Record a by-type-set observation for `type`: the SET of node ids returned by
|
|
1217
|
+
* ctx.graph.nodesByType(type). Folds membership only — adding/removing a node
|
|
1218
|
+
* of that type invalidates (spec §3.1).
|
|
1219
|
+
*/
|
|
1220
|
+
recordGraphNodesByType(type, nodeIds) {
|
|
1221
|
+
this._record(observationKey("graph-bytype", type), hashNodeSetObservation(nodeIds));
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Record a flow-participant-set observation for `flowName`: the SET of declared
|
|
1225
|
+
* participant ids of the flow. Folds the flow's participant list (the flow
|
|
1226
|
+
* DEFINITION's membership) so adding/removing a participant in the flow file
|
|
1227
|
+
* invalidates the verdict, even when every still-present participant node is
|
|
1228
|
+
* unchanged (spec §3.1, flowParticipants minor).
|
|
1229
|
+
*/
|
|
1230
|
+
recordFlowParticipants(flowName, participantIds) {
|
|
1231
|
+
this._record(observationKey("graph-flow", flowName), hashNodeSetObservation(participantIds));
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Returns a sorted, deduplicated array of [observationKey, observationHash] pairs.
|
|
1235
|
+
* Re-observing the same key with a different hash sets `tainted = true` and keeps
|
|
1236
|
+
* the first hash (first-observation-wins).
|
|
1237
|
+
*/
|
|
1238
|
+
snapshot() {
|
|
1239
|
+
const result = [...this._entries.entries()];
|
|
1240
|
+
result.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1241
|
+
return result;
|
|
1242
|
+
}
|
|
1243
|
+
/** True if the same path was observed with different content during this run. */
|
|
1244
|
+
get tainted() {
|
|
1245
|
+
return this._tainted;
|
|
1246
|
+
}
|
|
1247
|
+
_record(key, hash) {
|
|
1248
|
+
const existing = this._entries.get(key);
|
|
1249
|
+
if (existing === void 0) {
|
|
1250
|
+
this._entries.set(key, hash);
|
|
1251
|
+
} else if (existing !== hash) {
|
|
1252
|
+
this._tainted = true;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
// src/structure/runner.ts
|
|
1258
|
+
var StructureRunnerError = class extends Error {
|
|
1259
|
+
constructor(code, data) {
|
|
1260
|
+
super(`${code}: ${data.what}
|
|
1261
|
+
${data.why}
|
|
1262
|
+
${data.next}`);
|
|
1263
|
+
this.code = code;
|
|
1264
|
+
this.messageData = data;
|
|
1265
|
+
this.name = "StructureRunnerError";
|
|
1266
|
+
}
|
|
1267
|
+
code;
|
|
1268
|
+
messageData;
|
|
1269
|
+
};
|
|
1066
1270
|
async function buildOwnFiles(node, projectRoot, touchedFiles) {
|
|
1067
1271
|
const childMappingEntries = [];
|
|
1068
1272
|
for (const child of node.children) {
|
|
@@ -1078,24 +1282,43 @@ async function buildOwnFiles(node, projectRoot, touchedFiles) {
|
|
|
1078
1282
|
if (childMappingEntries.length > 0 && isPathInMapping(p, childMappingEntries)) continue;
|
|
1079
1283
|
if (BINARY_EXTENSIONS.has(path8.extname(p).toLowerCase())) continue;
|
|
1080
1284
|
const abs = path8.resolve(projectRoot, p);
|
|
1081
|
-
let
|
|
1285
|
+
let bytes;
|
|
1082
1286
|
try {
|
|
1083
|
-
|
|
1287
|
+
bytes = fs4.readFileSync(abs);
|
|
1084
1288
|
} catch {
|
|
1085
1289
|
continue;
|
|
1086
1290
|
}
|
|
1087
|
-
|
|
1291
|
+
const content = bytes.toString("utf8");
|
|
1292
|
+
result.push({ file: { path: p, content }, bytes });
|
|
1088
1293
|
touchedFiles.push(p);
|
|
1089
1294
|
}
|
|
1090
1295
|
return result;
|
|
1091
1296
|
}
|
|
1297
|
+
function wrapNonSubjectFile(f, repoRelPosixPath, bytes, recorder) {
|
|
1298
|
+
if (bytes === void 0) return f;
|
|
1299
|
+
const { content, ...rest } = f;
|
|
1300
|
+
let recorded = false;
|
|
1301
|
+
const wrapped = { ...rest };
|
|
1302
|
+
Object.defineProperty(wrapped, "content", {
|
|
1303
|
+
enumerable: true,
|
|
1304
|
+
configurable: true,
|
|
1305
|
+
get() {
|
|
1306
|
+
if (!recorded) {
|
|
1307
|
+
recorder.recordRead(repoRelPosixPath, bytes);
|
|
1308
|
+
recorded = true;
|
|
1309
|
+
}
|
|
1310
|
+
return content;
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
return wrapped;
|
|
1314
|
+
}
|
|
1092
1315
|
async function enumerateMappedFilesAsync(mappingPaths, projectRoot) {
|
|
1093
1316
|
const normalized = mappingPaths.map(normalizeMappingPath).filter((p) => p !== "");
|
|
1094
1317
|
return expandMappingPaths(projectRoot, normalized);
|
|
1095
1318
|
}
|
|
1096
1319
|
async function runStructureAspect(params) {
|
|
1097
1320
|
ensureLoaderRegistered();
|
|
1098
|
-
const { aspectDir, aspectId, nodePath, graph, projectRoot } = params;
|
|
1321
|
+
const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
|
|
1099
1322
|
const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
|
|
1100
1323
|
const touchedFiles = [];
|
|
1101
1324
|
const node = graph.nodes.get(nodePath);
|
|
@@ -1127,26 +1350,46 @@ async function runStructureAspect(params) {
|
|
|
1127
1350
|
}
|
|
1128
1351
|
const checkFn = mod.check;
|
|
1129
1352
|
const allowedSet = collectAllowedReadsForAspect(nodePath, graph);
|
|
1130
|
-
const
|
|
1353
|
+
const recorder = new ObservationRecorder();
|
|
1354
|
+
const ownFilesRaw = (node.meta.mapping ?? []).map(normalizeMappingPath).filter((p) => p !== "");
|
|
1355
|
+
const ownFilesExpanded = await expandMappingPaths(projectRoot, ownFilesRaw);
|
|
1356
|
+
const subjectFiles = subjectScope !== void 0 ? new Set(subjectScope.map(normalizeMappingPath)) : new Set(ownFilesExpanded);
|
|
1357
|
+
const ctxFs = createCtxFs({ allowedSet, projectRoot, touchedFiles, recorder, subjectFiles });
|
|
1131
1358
|
const expandedFilesByNode = /* @__PURE__ */ new Map();
|
|
1132
1359
|
for (const id of computeAllowedNodePaths(nodePath, graph)) {
|
|
1133
1360
|
const m = graph.nodes.get(id);
|
|
1134
1361
|
if (m) expandedFilesByNode.set(id, await enumerateMappedFilesAsync(m.meta.mapping ?? [], projectRoot));
|
|
1135
1362
|
}
|
|
1136
|
-
const ctxGraph = createCtxGraph({ currentNodePath: nodePath, graph, projectRoot, touchedFiles, expandedFilesByNode });
|
|
1137
|
-
const parsers = createCtxParsers({ allowedSet, projectRoot, touchedFiles, astCache });
|
|
1138
|
-
const
|
|
1363
|
+
const ctxGraph = createCtxGraph({ currentNodePath: nodePath, graph, projectRoot, touchedFiles, expandedFilesByNode, recorder, subjectFiles });
|
|
1364
|
+
const parsers = createCtxParsers({ allowedSet, projectRoot, touchedFiles, astCache, recorder, subjectFiles });
|
|
1365
|
+
const ownFilesWithBytes = await buildOwnFiles(node, projectRoot, touchedFiles);
|
|
1366
|
+
const ownFiles = ownFilesWithBytes.map((x) => x.file);
|
|
1367
|
+
const bytesByPath = /* @__PURE__ */ new Map();
|
|
1368
|
+
for (const x of ownFilesWithBytes) bytesByPath.set(normalizeMappingPath(x.file.path), x.bytes);
|
|
1139
1369
|
await prewarmupAstCache({ astCache, projectRoot, files: ownFiles });
|
|
1140
1370
|
const ownFilesEnriched = enrichFilesWithAst(ownFiles, astCache);
|
|
1371
|
+
let nodeFilesEnriched;
|
|
1372
|
+
let ctxFilesEnriched;
|
|
1373
|
+
if (subjectScope !== void 0) {
|
|
1374
|
+
nodeFilesEnriched = recorder !== void 0 ? ownFilesEnriched.map((f) => {
|
|
1375
|
+
const p = normalizeMappingPath(f.path);
|
|
1376
|
+
if (subjectFiles.has(p)) return f;
|
|
1377
|
+
return wrapNonSubjectFile(f, p, bytesByPath.get(p), recorder);
|
|
1378
|
+
}) : ownFilesEnriched;
|
|
1379
|
+
ctxFilesEnriched = ownFilesEnriched.filter((f) => subjectFiles.has(normalizeMappingPath(f.path)));
|
|
1380
|
+
} else {
|
|
1381
|
+
nodeFilesEnriched = ownFilesEnriched;
|
|
1382
|
+
ctxFilesEnriched = ownFilesEnriched;
|
|
1383
|
+
}
|
|
1141
1384
|
const ctx = {
|
|
1142
1385
|
node: {
|
|
1143
1386
|
id: node.path,
|
|
1144
1387
|
type: node.meta.type,
|
|
1145
1388
|
mapping: node.meta.mapping ?? [],
|
|
1146
|
-
files:
|
|
1389
|
+
files: nodeFilesEnriched,
|
|
1147
1390
|
ports: node.meta.ports ?? {}
|
|
1148
1391
|
},
|
|
1149
|
-
files:
|
|
1392
|
+
files: ctxFilesEnriched,
|
|
1150
1393
|
fs: ctxFs,
|
|
1151
1394
|
graph: ctxGraph,
|
|
1152
1395
|
parseAst: parsers.parseAst,
|
|
@@ -1180,7 +1423,9 @@ async function runStructureAspect(params) {
|
|
|
1180
1423
|
file: `.yggdrasil/aspects/${aspectId}/check.mjs`
|
|
1181
1424
|
}],
|
|
1182
1425
|
touchedFiles: [],
|
|
1183
|
-
succeeded: false
|
|
1426
|
+
succeeded: false,
|
|
1427
|
+
observations: recorder.snapshot(),
|
|
1428
|
+
observationsTainted: recorder.tainted
|
|
1184
1429
|
};
|
|
1185
1430
|
}
|
|
1186
1431
|
if (err instanceof UndeclaredGraphReadError) {
|
|
@@ -1191,7 +1436,9 @@ async function runStructureAspect(params) {
|
|
|
1191
1436
|
file: `.yggdrasil/aspects/${aspectId}/check.mjs`
|
|
1192
1437
|
}],
|
|
1193
1438
|
touchedFiles: [],
|
|
1194
|
-
succeeded: false
|
|
1439
|
+
succeeded: false,
|
|
1440
|
+
observations: recorder.snapshot(),
|
|
1441
|
+
observationsTainted: recorder.tainted
|
|
1195
1442
|
};
|
|
1196
1443
|
}
|
|
1197
1444
|
if (err instanceof ParseAstNotPrewarmedError) {
|
|
@@ -1202,14 +1449,16 @@ async function runStructureAspect(params) {
|
|
|
1202
1449
|
file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
|
|
1203
1450
|
}],
|
|
1204
1451
|
touchedFiles: [],
|
|
1205
|
-
succeeded: false
|
|
1452
|
+
succeeded: false,
|
|
1453
|
+
observations: recorder.snapshot(),
|
|
1454
|
+
observationsTainted: recorder.tainted
|
|
1206
1455
|
};
|
|
1207
1456
|
}
|
|
1208
1457
|
throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
|
|
1209
1458
|
what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
|
|
1210
1459
|
why: `${err.message}
|
|
1211
1460
|
${err.stack ?? ""}`,
|
|
1212
|
-
next: `Fix the bug in check.mjs
|
|
1461
|
+
next: `Fix the bug in check.mjs, then re-run: yg check --approve`
|
|
1213
1462
|
});
|
|
1214
1463
|
}
|
|
1215
1464
|
if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
|
|
@@ -1272,7 +1521,13 @@ ${err.stack ?? ""}`,
|
|
|
1272
1521
|
if (!ranges) return true;
|
|
1273
1522
|
return !isLineSuppressed(ranges, aspectId, v.line);
|
|
1274
1523
|
});
|
|
1275
|
-
return {
|
|
1524
|
+
return {
|
|
1525
|
+
violations: visible,
|
|
1526
|
+
touchedFiles,
|
|
1527
|
+
succeeded: true,
|
|
1528
|
+
observations: recorder.snapshot(),
|
|
1529
|
+
observationsTainted: recorder.tainted
|
|
1530
|
+
};
|
|
1276
1531
|
}
|
|
1277
1532
|
|
|
1278
1533
|
// src/ast/walk.ts
|
|
@@ -38,9 +38,14 @@ node_types:
|
|
|
38
38
|
# Use only for types where missing the type means missing
|
|
39
39
|
# a critical aspect (security, audit, regulatory).
|
|
40
40
|
|
|
41
|
-
log_required: <boolean> # optional — default
|
|
42
|
-
#
|
|
43
|
-
#
|
|
41
|
+
log_required: <boolean> # optional — default false. Enable (true) on types whose
|
|
42
|
+
# changes carry business intent worth capturing — domain
|
|
43
|
+
# logic, command handlers, persistence adapters. When true,
|
|
44
|
+
# a node of this type demands a fresh log entry before
|
|
45
|
+
# `yg check --approve` whenever its mapped source changed
|
|
46
|
+
# since the node's last positive closure. Leave omitted
|
|
47
|
+
# (false) for types whose changes carry no business decision
|
|
48
|
+
# worth forcing an entry for (e.g. config, types, constants).
|
|
44
49
|
|
|
45
50
|
aspects: # optional — aspects automatically applied to every
|
|
46
51
|
# node of this type (channel 3). Two forms per entry:
|