@chrisdudek/yg 5.0.0-alpha.4 → 5.0.0-alpha.5
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 +12243 -13934
- package/dist/structure.d.ts +40 -18
- package/dist/structure.js +290 -41
- package/graph-schemas/yg-architecture.yaml +8 -3
- package/graph-schemas/yg-aspect.yaml +54 -0
- package/graph-schemas/yg-config.yaml +7 -5
- package/graph-schemas/yg-node.yaml +0 -6
- package/package.json +1 -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
|
@@ -106,36 +106,64 @@ function resolveAllowedReadPath(raw, allowedSet, projectRoot) {
|
|
|
106
106
|
return rel;
|
|
107
107
|
}
|
|
108
108
|
function createCtxFs(params) {
|
|
109
|
-
const { allowedSet, projectRoot, touchedFiles } = params;
|
|
109
|
+
const { allowedSet, projectRoot, touchedFiles, recorder, subjectFiles } = params;
|
|
110
110
|
function assertAllowed(raw) {
|
|
111
111
|
const p = resolveAllowedReadPath(raw, allowedSet, projectRoot);
|
|
112
112
|
touchedFiles.push(p);
|
|
113
113
|
return p;
|
|
114
114
|
}
|
|
115
|
+
function isSubjectFile(p) {
|
|
116
|
+
return subjectFiles !== void 0 && subjectFiles.has(p);
|
|
117
|
+
}
|
|
115
118
|
return {
|
|
116
119
|
exists(raw) {
|
|
117
120
|
const p = assertAllowed(raw);
|
|
118
121
|
const abs = path2.resolve(projectRoot, p);
|
|
122
|
+
let result;
|
|
119
123
|
try {
|
|
120
124
|
const stat2 = fs.statSync(abs);
|
|
121
|
-
|
|
125
|
+
result = stat2.isDirectory() ? "dir" : stat2.isFile() ? "file" : false;
|
|
122
126
|
} catch {
|
|
123
|
-
|
|
127
|
+
result = false;
|
|
128
|
+
}
|
|
129
|
+
if (recorder && !isSubjectFile(p)) {
|
|
130
|
+
recorder.recordExists(p, result);
|
|
124
131
|
}
|
|
132
|
+
return result;
|
|
125
133
|
},
|
|
126
134
|
read(raw) {
|
|
127
135
|
const p = assertAllowed(raw);
|
|
128
136
|
const abs = path2.resolve(projectRoot, p);
|
|
129
|
-
|
|
137
|
+
let bytes;
|
|
138
|
+
try {
|
|
139
|
+
bytes = fs.readFileSync(abs);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (recorder && !isSubjectFile(p)) recorder.recordReadAbsent(p);
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
if (recorder && !isSubjectFile(p)) {
|
|
145
|
+
recorder.recordRead(p, bytes);
|
|
146
|
+
}
|
|
147
|
+
return bytes.toString("utf8");
|
|
130
148
|
},
|
|
131
149
|
list(raw) {
|
|
132
150
|
const p = assertAllowed(raw);
|
|
133
151
|
const abs = path2.resolve(projectRoot, p);
|
|
134
|
-
|
|
135
|
-
|
|
152
|
+
let dirents;
|
|
153
|
+
try {
|
|
154
|
+
dirents = fs.readdirSync(abs, { withFileTypes: true });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (recorder && !isSubjectFile(p)) recorder.recordListAbsent(p);
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
const entries = dirents.map((e) => ({
|
|
136
160
|
name: e.name,
|
|
137
161
|
kind: e.isDirectory() ? "dir" : "file"
|
|
138
162
|
}));
|
|
163
|
+
if (recorder && !isSubjectFile(p)) {
|
|
164
|
+
recorder.recordList(p, entries);
|
|
165
|
+
}
|
|
166
|
+
return entries;
|
|
139
167
|
}
|
|
140
168
|
};
|
|
141
169
|
}
|
|
@@ -188,11 +216,26 @@ function computeAllowedNodePaths(currentPath, graph) {
|
|
|
188
216
|
return allowed;
|
|
189
217
|
}
|
|
190
218
|
function createCtxGraph(params) {
|
|
191
|
-
const { currentNodePath, graph, projectRoot, touchedFiles, expandedFilesByNode } = params;
|
|
219
|
+
const { currentNodePath, graph, projectRoot, touchedFiles, expandedFilesByNode, recorder, subjectFiles } = params;
|
|
192
220
|
const allowed = computeAllowedNodePaths(currentNodePath, graph);
|
|
193
221
|
function assertAllowed(id) {
|
|
194
222
|
if (!allowed.has(id)) throw new UndeclaredGraphReadError(id);
|
|
195
223
|
}
|
|
224
|
+
function recordGraphNode(m) {
|
|
225
|
+
if (!recorder) return;
|
|
226
|
+
const ygNodePath = path3.join(projectRoot, ".yggdrasil", "model", m.path, "yg-node.yaml");
|
|
227
|
+
let yamlBytes;
|
|
228
|
+
try {
|
|
229
|
+
yamlBytes = fs2.readFileSync(ygNodePath);
|
|
230
|
+
} catch {
|
|
231
|
+
yamlBytes = m.nodeYamlRaw !== void 0 ? Buffer.from(m.nodeYamlRaw, "utf8") : void 0;
|
|
232
|
+
}
|
|
233
|
+
if (yamlBytes === void 0) {
|
|
234
|
+
recorder.recordGraphNodeAbsent(m.path);
|
|
235
|
+
} else {
|
|
236
|
+
recorder.recordGraphNode(m.path, yamlBytes);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
196
239
|
function toPublicNode(m) {
|
|
197
240
|
const files = [];
|
|
198
241
|
const preExpanded = expandedFilesByNode?.get(m.path);
|
|
@@ -203,13 +246,18 @@ function createCtxGraph(params) {
|
|
|
203
246
|
try {
|
|
204
247
|
const stat2 = fs2.statSync(abs);
|
|
205
248
|
if (stat2.isFile()) {
|
|
206
|
-
const
|
|
249
|
+
const bytes = fs2.readFileSync(abs);
|
|
250
|
+
const content = bytes.toString("utf8");
|
|
207
251
|
files.push({ path: p, content });
|
|
208
252
|
touchedFiles.push(p);
|
|
253
|
+
if (recorder && !subjectFiles?.has(p)) {
|
|
254
|
+
recorder.recordRead(p, bytes);
|
|
255
|
+
}
|
|
209
256
|
}
|
|
210
257
|
} catch {
|
|
211
258
|
}
|
|
212
259
|
}
|
|
260
|
+
recordGraphNode(m);
|
|
213
261
|
return {
|
|
214
262
|
id: m.path,
|
|
215
263
|
type: m.meta.type,
|
|
@@ -222,19 +270,29 @@ function createCtxGraph(params) {
|
|
|
222
270
|
node(id) {
|
|
223
271
|
assertAllowed(id);
|
|
224
272
|
const m = graph.nodes.get(id);
|
|
225
|
-
|
|
273
|
+
if (!m) {
|
|
274
|
+
if (recorder) recorder.recordGraphNodeAbsent(id);
|
|
275
|
+
return void 0;
|
|
276
|
+
}
|
|
277
|
+
return toPublicNode(m);
|
|
226
278
|
},
|
|
227
279
|
nodesByType(type) {
|
|
228
280
|
const out = [];
|
|
281
|
+
const matchedIds = [];
|
|
229
282
|
for (const id of allowed) {
|
|
230
283
|
const m = graph.nodes.get(id);
|
|
231
|
-
if (m && m.meta.type === type)
|
|
284
|
+
if (m && m.meta.type === type) {
|
|
285
|
+
matchedIds.push(m.path);
|
|
286
|
+
out.push(toPublicNode(m));
|
|
287
|
+
}
|
|
232
288
|
}
|
|
289
|
+
if (recorder) recorder.recordGraphNodesByType(type, matchedIds);
|
|
233
290
|
return out;
|
|
234
291
|
},
|
|
235
292
|
relationsFrom(node) {
|
|
236
293
|
assertAllowed(node.id);
|
|
237
294
|
const m = graph.nodes.get(node.id);
|
|
295
|
+
if (m) recordGraphNode(m);
|
|
238
296
|
return m?.meta.relations ?? [];
|
|
239
297
|
},
|
|
240
298
|
relationsTo(node) {
|
|
@@ -242,6 +300,7 @@ function createCtxGraph(params) {
|
|
|
242
300
|
for (const id of allowed) {
|
|
243
301
|
const m = graph.nodes.get(id);
|
|
244
302
|
if (!m) continue;
|
|
303
|
+
recordGraphNode(m);
|
|
245
304
|
for (const rel of m.meta.relations ?? []) {
|
|
246
305
|
if (rel.target === node.id) out.push(rel);
|
|
247
306
|
}
|
|
@@ -251,6 +310,8 @@ function createCtxGraph(params) {
|
|
|
251
310
|
children(node) {
|
|
252
311
|
assertAllowed(node.id);
|
|
253
312
|
const m = graph.nodes.get(node.id);
|
|
313
|
+
const childIds = m ? m.children.map((c) => c.path) : [];
|
|
314
|
+
if (recorder) recorder.recordGraphChildren(node.id, childIds);
|
|
254
315
|
return m ? m.children.map(toPublicNode) : [];
|
|
255
316
|
},
|
|
256
317
|
flowParticipants(flowName) {
|
|
@@ -266,6 +327,7 @@ function createCtxGraph(params) {
|
|
|
266
327
|
return false;
|
|
267
328
|
});
|
|
268
329
|
if (!participates) throw new UndeclaredGraphReadError(`flow:${flowName}`);
|
|
330
|
+
if (recorder) recorder.recordFlowParticipants(flow.name, [...flow.nodes]);
|
|
269
331
|
const out = [];
|
|
270
332
|
for (const nodeId of flow.nodes) {
|
|
271
333
|
const m = graph.nodes.get(nodeId);
|
|
@@ -575,7 +637,7 @@ var ParseAstNotPrewarmedError = class extends Error {
|
|
|
575
637
|
}
|
|
576
638
|
};
|
|
577
639
|
function createCtxParsers(params) {
|
|
578
|
-
const { allowedSet, projectRoot, touchedFiles, astCache } = params;
|
|
640
|
+
const { allowedSet, projectRoot, touchedFiles, astCache, recorder, subjectFiles } = params;
|
|
579
641
|
function asFile(input) {
|
|
580
642
|
if (typeof input !== "string") {
|
|
581
643
|
touchedFiles.push(input.path);
|
|
@@ -583,8 +645,18 @@ function createCtxParsers(params) {
|
|
|
583
645
|
}
|
|
584
646
|
const p = resolveAllowedReadPath(input, allowedSet, projectRoot);
|
|
585
647
|
const abs = path5.resolve(projectRoot, p);
|
|
586
|
-
|
|
648
|
+
let bytes;
|
|
649
|
+
try {
|
|
650
|
+
bytes = fs3.readFileSync(abs);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
if (recorder && !subjectFiles?.has(p)) recorder.recordReadAbsent(p);
|
|
653
|
+
throw err;
|
|
654
|
+
}
|
|
655
|
+
const content = bytes.toString("utf8");
|
|
587
656
|
touchedFiles.push(p);
|
|
657
|
+
if (recorder && !subjectFiles?.has(p)) {
|
|
658
|
+
recorder.recordRead(p, bytes);
|
|
659
|
+
}
|
|
588
660
|
return { path: p, content };
|
|
589
661
|
}
|
|
590
662
|
return {
|
|
@@ -707,7 +779,7 @@ import { createRequire as createRequire3 } from "module";
|
|
|
707
779
|
|
|
708
780
|
// src/io/repo-scanner.ts
|
|
709
781
|
import { readFile, readdir } from "fs/promises";
|
|
710
|
-
import { join, relative as relative2, sep } from "path";
|
|
782
|
+
import { join as join2, relative as relative2, sep } from "path";
|
|
711
783
|
import { createRequire as createRequire2 } from "module";
|
|
712
784
|
|
|
713
785
|
// src/utils/debug-log.ts
|
|
@@ -742,7 +814,9 @@ function isIgnoredByStack2(candidatePath, stack) {
|
|
|
742
814
|
function hashString(content) {
|
|
743
815
|
return createHash("sha256").update(content).digest("hex");
|
|
744
816
|
}
|
|
745
|
-
|
|
817
|
+
function hashBytes(bytes) {
|
|
818
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
819
|
+
}
|
|
746
820
|
async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
|
|
747
821
|
let stack = options.gitignoreStack ?? [];
|
|
748
822
|
try {
|
|
@@ -1022,18 +1096,7 @@ function validateCheckModuleExport(mod, opts) {
|
|
|
1022
1096
|
return { ok: true };
|
|
1023
1097
|
}
|
|
1024
1098
|
|
|
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
|
-
};
|
|
1099
|
+
// src/utils/binary-extensions.ts
|
|
1037
1100
|
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1038
1101
|
".gif",
|
|
1039
1102
|
".png",
|
|
@@ -1063,6 +1126,141 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
1063
1126
|
".wasm",
|
|
1064
1127
|
".bin"
|
|
1065
1128
|
]);
|
|
1129
|
+
|
|
1130
|
+
// src/core/pair-hash.ts
|
|
1131
|
+
function observationKey(kind, target) {
|
|
1132
|
+
return `${kind}:${target}`;
|
|
1133
|
+
}
|
|
1134
|
+
var MISSING_OBSERVATION = "missing";
|
|
1135
|
+
function hashNodeSetObservation(nodeIds) {
|
|
1136
|
+
const lines = [...nodeIds].sort((a, b) => a < b ? -1 : a > b ? 1 : 0).join("\n");
|
|
1137
|
+
return hashString(lines);
|
|
1138
|
+
}
|
|
1139
|
+
function hashReadObservation(bytes) {
|
|
1140
|
+
return hashBytes(bytes);
|
|
1141
|
+
}
|
|
1142
|
+
function hashListObservation(entries) {
|
|
1143
|
+
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");
|
|
1144
|
+
return hashString(lines);
|
|
1145
|
+
}
|
|
1146
|
+
function hashExistsObservation(result) {
|
|
1147
|
+
return hashString(result === false ? "false" : result);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/structure/observations.ts
|
|
1151
|
+
var ObservationRecorder = class {
|
|
1152
|
+
_entries = /* @__PURE__ */ new Map();
|
|
1153
|
+
// key → hash (first-wins)
|
|
1154
|
+
_tainted = false;
|
|
1155
|
+
/** Record a file-read observation. `bytes` is the raw content read. */
|
|
1156
|
+
recordRead(repoRelPosixPath, bytes) {
|
|
1157
|
+
this._record(observationKey("read", repoRelPosixPath), hashReadObservation(bytes));
|
|
1158
|
+
}
|
|
1159
|
+
/** Record a directory-listing observation. */
|
|
1160
|
+
recordList(repoRelPosixDir, entries) {
|
|
1161
|
+
this._record(observationKey("list", repoRelPosixDir), hashListObservation(entries));
|
|
1162
|
+
}
|
|
1163
|
+
/** Record an existence-probe observation (including negative probes where result === false). */
|
|
1164
|
+
recordExists(repoRelPosixPath, result) {
|
|
1165
|
+
this._record(observationKey("exists", repoRelPosixPath), hashExistsObservation(result));
|
|
1166
|
+
}
|
|
1167
|
+
/** Record a graph-node observation by hashing its yg-node.yaml bytes. */
|
|
1168
|
+
recordGraphNode(nodePath, ygNodeYamlBytes) {
|
|
1169
|
+
this._record(observationKey("graph", nodePath), hashReadObservation(ygNodeYamlBytes));
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Record an ABSENT file-read observation: the check attempted a read that threw
|
|
1173
|
+
* (the path passed the allow-check but the file was missing/unreadable at read
|
|
1174
|
+
* time). Folds MISSING_OBSERVATION under the same read:<path> key the verifier
|
|
1175
|
+
* re-observes — so if the check swallowed the throw and treated the file as
|
|
1176
|
+
* absent, a later successful read of that path changes the value ⇒ unverified
|
|
1177
|
+
* (spec §3.1, over-record: a throwing access is still an observation).
|
|
1178
|
+
*/
|
|
1179
|
+
recordReadAbsent(repoRelPosixPath) {
|
|
1180
|
+
this._record(observationKey("read", repoRelPosixPath), MISSING_OBSERVATION);
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Record an ABSENT directory-listing observation: the check attempted a list
|
|
1184
|
+
* that threw (path allow-checked but the dir was missing/unreadable at list
|
|
1185
|
+
* time). Folds MISSING_OBSERVATION under the list:<path> key — a later
|
|
1186
|
+
* successful listing changes the value ⇒ unverified (spec §3.1, over-record).
|
|
1187
|
+
*/
|
|
1188
|
+
recordListAbsent(repoRelPosixDir) {
|
|
1189
|
+
this._record(observationKey("list", repoRelPosixDir), MISSING_OBSERVATION);
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Record a NEGATIVE graph-node observation: the check looked up a node that
|
|
1193
|
+
* does not exist. Folds the MISSING_OBSERVATION token so the verifier's
|
|
1194
|
+
* re-observation (which reads that node's yg-node.yaml and also yields
|
|
1195
|
+
* MISSING_OBSERVATION when absent) reproduces it byte-for-byte — and creating
|
|
1196
|
+
* the node later changes the value ⇒ unverified (spec §3.1, over-record).
|
|
1197
|
+
*/
|
|
1198
|
+
recordGraphNodeAbsent(nodePath) {
|
|
1199
|
+
this._record(observationKey("graph", nodePath), MISSING_OBSERVATION);
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Record a child-set observation for `nodePath`: the SET of node ids returned
|
|
1203
|
+
* by ctx.graph.children(node). Folds membership only — adding/removing a child
|
|
1204
|
+
* invalidates; a content edit to an unchanged child rides its own graph:
|
|
1205
|
+
* observation (spec §3.1).
|
|
1206
|
+
*/
|
|
1207
|
+
recordGraphChildren(nodePath, childIds) {
|
|
1208
|
+
this._record(observationKey("graph-children", nodePath), hashNodeSetObservation(childIds));
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Record a by-type-set observation for `type`: the SET of node ids returned by
|
|
1212
|
+
* ctx.graph.nodesByType(type). Folds membership only — adding/removing a node
|
|
1213
|
+
* of that type invalidates (spec §3.1).
|
|
1214
|
+
*/
|
|
1215
|
+
recordGraphNodesByType(type, nodeIds) {
|
|
1216
|
+
this._record(observationKey("graph-bytype", type), hashNodeSetObservation(nodeIds));
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Record a flow-participant-set observation for `flowName`: the SET of declared
|
|
1220
|
+
* participant ids of the flow. Folds the flow's participant list (the flow
|
|
1221
|
+
* DEFINITION's membership) so adding/removing a participant in the flow file
|
|
1222
|
+
* invalidates the verdict, even when every still-present participant node is
|
|
1223
|
+
* unchanged (spec §3.1, flowParticipants minor).
|
|
1224
|
+
*/
|
|
1225
|
+
recordFlowParticipants(flowName, participantIds) {
|
|
1226
|
+
this._record(observationKey("graph-flow", flowName), hashNodeSetObservation(participantIds));
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Returns a sorted, deduplicated array of [observationKey, observationHash] pairs.
|
|
1230
|
+
* Re-observing the same key with a different hash sets `tainted = true` and keeps
|
|
1231
|
+
* the first hash (first-observation-wins).
|
|
1232
|
+
*/
|
|
1233
|
+
snapshot() {
|
|
1234
|
+
const result = [...this._entries.entries()];
|
|
1235
|
+
result.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1236
|
+
return result;
|
|
1237
|
+
}
|
|
1238
|
+
/** True if the same path was observed with different content during this run. */
|
|
1239
|
+
get tainted() {
|
|
1240
|
+
return this._tainted;
|
|
1241
|
+
}
|
|
1242
|
+
_record(key, hash) {
|
|
1243
|
+
const existing = this._entries.get(key);
|
|
1244
|
+
if (existing === void 0) {
|
|
1245
|
+
this._entries.set(key, hash);
|
|
1246
|
+
} else if (existing !== hash) {
|
|
1247
|
+
this._tainted = true;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
// src/structure/runner.ts
|
|
1253
|
+
var StructureRunnerError = class extends Error {
|
|
1254
|
+
constructor(code, data) {
|
|
1255
|
+
super(`${code}: ${data.what}
|
|
1256
|
+
${data.why}
|
|
1257
|
+
${data.next}`);
|
|
1258
|
+
this.code = code;
|
|
1259
|
+
this.messageData = data;
|
|
1260
|
+
this.name = "StructureRunnerError";
|
|
1261
|
+
}
|
|
1262
|
+
messageData;
|
|
1263
|
+
};
|
|
1066
1264
|
async function buildOwnFiles(node, projectRoot, touchedFiles) {
|
|
1067
1265
|
const childMappingEntries = [];
|
|
1068
1266
|
for (const child of node.children) {
|
|
@@ -1078,24 +1276,43 @@ async function buildOwnFiles(node, projectRoot, touchedFiles) {
|
|
|
1078
1276
|
if (childMappingEntries.length > 0 && isPathInMapping(p, childMappingEntries)) continue;
|
|
1079
1277
|
if (BINARY_EXTENSIONS.has(path8.extname(p).toLowerCase())) continue;
|
|
1080
1278
|
const abs = path8.resolve(projectRoot, p);
|
|
1081
|
-
let
|
|
1279
|
+
let bytes;
|
|
1082
1280
|
try {
|
|
1083
|
-
|
|
1281
|
+
bytes = fs4.readFileSync(abs);
|
|
1084
1282
|
} catch {
|
|
1085
1283
|
continue;
|
|
1086
1284
|
}
|
|
1087
|
-
|
|
1285
|
+
const content = bytes.toString("utf8");
|
|
1286
|
+
result.push({ file: { path: p, content }, bytes });
|
|
1088
1287
|
touchedFiles.push(p);
|
|
1089
1288
|
}
|
|
1090
1289
|
return result;
|
|
1091
1290
|
}
|
|
1291
|
+
function wrapNonSubjectFile(f, repoRelPosixPath, bytes, recorder) {
|
|
1292
|
+
if (bytes === void 0) return f;
|
|
1293
|
+
const { content, ...rest } = f;
|
|
1294
|
+
let recorded = false;
|
|
1295
|
+
const wrapped = { ...rest };
|
|
1296
|
+
Object.defineProperty(wrapped, "content", {
|
|
1297
|
+
enumerable: true,
|
|
1298
|
+
configurable: true,
|
|
1299
|
+
get() {
|
|
1300
|
+
if (!recorded) {
|
|
1301
|
+
recorder.recordRead(repoRelPosixPath, bytes);
|
|
1302
|
+
recorded = true;
|
|
1303
|
+
}
|
|
1304
|
+
return content;
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
return wrapped;
|
|
1308
|
+
}
|
|
1092
1309
|
async function enumerateMappedFilesAsync(mappingPaths, projectRoot) {
|
|
1093
1310
|
const normalized = mappingPaths.map(normalizeMappingPath).filter((p) => p !== "");
|
|
1094
1311
|
return expandMappingPaths(projectRoot, normalized);
|
|
1095
1312
|
}
|
|
1096
1313
|
async function runStructureAspect(params) {
|
|
1097
1314
|
ensureLoaderRegistered();
|
|
1098
|
-
const { aspectDir, aspectId, nodePath, graph, projectRoot } = params;
|
|
1315
|
+
const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
|
|
1099
1316
|
const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
|
|
1100
1317
|
const touchedFiles = [];
|
|
1101
1318
|
const node = graph.nodes.get(nodePath);
|
|
@@ -1127,26 +1344,46 @@ async function runStructureAspect(params) {
|
|
|
1127
1344
|
}
|
|
1128
1345
|
const checkFn = mod.check;
|
|
1129
1346
|
const allowedSet = collectAllowedReadsForAspect(nodePath, graph);
|
|
1130
|
-
const
|
|
1347
|
+
const recorder = new ObservationRecorder();
|
|
1348
|
+
const ownFilesRaw = (node.meta.mapping ?? []).map(normalizeMappingPath).filter((p) => p !== "");
|
|
1349
|
+
const ownFilesExpanded = await expandMappingPaths(projectRoot, ownFilesRaw);
|
|
1350
|
+
const subjectFiles = subjectScope !== void 0 ? new Set(subjectScope.map(normalizeMappingPath)) : new Set(ownFilesExpanded);
|
|
1351
|
+
const ctxFs = createCtxFs({ allowedSet, projectRoot, touchedFiles, recorder, subjectFiles });
|
|
1131
1352
|
const expandedFilesByNode = /* @__PURE__ */ new Map();
|
|
1132
1353
|
for (const id of computeAllowedNodePaths(nodePath, graph)) {
|
|
1133
1354
|
const m = graph.nodes.get(id);
|
|
1134
1355
|
if (m) expandedFilesByNode.set(id, await enumerateMappedFilesAsync(m.meta.mapping ?? [], projectRoot));
|
|
1135
1356
|
}
|
|
1136
|
-
const ctxGraph = createCtxGraph({ currentNodePath: nodePath, graph, projectRoot, touchedFiles, expandedFilesByNode });
|
|
1137
|
-
const parsers = createCtxParsers({ allowedSet, projectRoot, touchedFiles, astCache });
|
|
1138
|
-
const
|
|
1357
|
+
const ctxGraph = createCtxGraph({ currentNodePath: nodePath, graph, projectRoot, touchedFiles, expandedFilesByNode, recorder, subjectFiles });
|
|
1358
|
+
const parsers = createCtxParsers({ allowedSet, projectRoot, touchedFiles, astCache, recorder, subjectFiles });
|
|
1359
|
+
const ownFilesWithBytes = await buildOwnFiles(node, projectRoot, touchedFiles);
|
|
1360
|
+
const ownFiles = ownFilesWithBytes.map((x) => x.file);
|
|
1361
|
+
const bytesByPath = /* @__PURE__ */ new Map();
|
|
1362
|
+
for (const x of ownFilesWithBytes) bytesByPath.set(normalizeMappingPath(x.file.path), x.bytes);
|
|
1139
1363
|
await prewarmupAstCache({ astCache, projectRoot, files: ownFiles });
|
|
1140
1364
|
const ownFilesEnriched = enrichFilesWithAst(ownFiles, astCache);
|
|
1365
|
+
let nodeFilesEnriched;
|
|
1366
|
+
let ctxFilesEnriched;
|
|
1367
|
+
if (subjectScope !== void 0) {
|
|
1368
|
+
nodeFilesEnriched = recorder !== void 0 ? ownFilesEnriched.map((f) => {
|
|
1369
|
+
const p = normalizeMappingPath(f.path);
|
|
1370
|
+
if (subjectFiles.has(p)) return f;
|
|
1371
|
+
return wrapNonSubjectFile(f, p, bytesByPath.get(p), recorder);
|
|
1372
|
+
}) : ownFilesEnriched;
|
|
1373
|
+
ctxFilesEnriched = ownFilesEnriched.filter((f) => subjectFiles.has(normalizeMappingPath(f.path)));
|
|
1374
|
+
} else {
|
|
1375
|
+
nodeFilesEnriched = ownFilesEnriched;
|
|
1376
|
+
ctxFilesEnriched = ownFilesEnriched;
|
|
1377
|
+
}
|
|
1141
1378
|
const ctx = {
|
|
1142
1379
|
node: {
|
|
1143
1380
|
id: node.path,
|
|
1144
1381
|
type: node.meta.type,
|
|
1145
1382
|
mapping: node.meta.mapping ?? [],
|
|
1146
|
-
files:
|
|
1383
|
+
files: nodeFilesEnriched,
|
|
1147
1384
|
ports: node.meta.ports ?? {}
|
|
1148
1385
|
},
|
|
1149
|
-
files:
|
|
1386
|
+
files: ctxFilesEnriched,
|
|
1150
1387
|
fs: ctxFs,
|
|
1151
1388
|
graph: ctxGraph,
|
|
1152
1389
|
parseAst: parsers.parseAst,
|
|
@@ -1180,7 +1417,9 @@ async function runStructureAspect(params) {
|
|
|
1180
1417
|
file: `.yggdrasil/aspects/${aspectId}/check.mjs`
|
|
1181
1418
|
}],
|
|
1182
1419
|
touchedFiles: [],
|
|
1183
|
-
succeeded: false
|
|
1420
|
+
succeeded: false,
|
|
1421
|
+
observations: recorder.snapshot(),
|
|
1422
|
+
observationsTainted: recorder.tainted
|
|
1184
1423
|
};
|
|
1185
1424
|
}
|
|
1186
1425
|
if (err instanceof UndeclaredGraphReadError) {
|
|
@@ -1191,7 +1430,9 @@ async function runStructureAspect(params) {
|
|
|
1191
1430
|
file: `.yggdrasil/aspects/${aspectId}/check.mjs`
|
|
1192
1431
|
}],
|
|
1193
1432
|
touchedFiles: [],
|
|
1194
|
-
succeeded: false
|
|
1433
|
+
succeeded: false,
|
|
1434
|
+
observations: recorder.snapshot(),
|
|
1435
|
+
observationsTainted: recorder.tainted
|
|
1195
1436
|
};
|
|
1196
1437
|
}
|
|
1197
1438
|
if (err instanceof ParseAstNotPrewarmedError) {
|
|
@@ -1202,14 +1443,16 @@ async function runStructureAspect(params) {
|
|
|
1202
1443
|
file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
|
|
1203
1444
|
}],
|
|
1204
1445
|
touchedFiles: [],
|
|
1205
|
-
succeeded: false
|
|
1446
|
+
succeeded: false,
|
|
1447
|
+
observations: recorder.snapshot(),
|
|
1448
|
+
observationsTainted: recorder.tainted
|
|
1206
1449
|
};
|
|
1207
1450
|
}
|
|
1208
1451
|
throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
|
|
1209
1452
|
what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
|
|
1210
1453
|
why: `${err.message}
|
|
1211
1454
|
${err.stack ?? ""}`,
|
|
1212
|
-
next: `Fix the bug in check.mjs
|
|
1455
|
+
next: `Fix the bug in check.mjs, then re-run: yg check --approve`
|
|
1213
1456
|
});
|
|
1214
1457
|
}
|
|
1215
1458
|
if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
|
|
@@ -1272,7 +1515,13 @@ ${err.stack ?? ""}`,
|
|
|
1272
1515
|
if (!ranges) return true;
|
|
1273
1516
|
return !isLineSuppressed(ranges, aspectId, v.line);
|
|
1274
1517
|
});
|
|
1275
|
-
return {
|
|
1518
|
+
return {
|
|
1519
|
+
violations: visible,
|
|
1520
|
+
touchedFiles,
|
|
1521
|
+
succeeded: true,
|
|
1522
|
+
observations: recorder.snapshot(),
|
|
1523
|
+
observationsTainted: recorder.tainted
|
|
1524
|
+
};
|
|
1276
1525
|
}
|
|
1277
1526
|
|
|
1278
1527
|
// 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:
|