@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.
@@ -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
- /** Optional caps on reference files for aspects resolving to this tier. */
238
- references?: {
239
- /** Max bytes per single reference file. Default 65536 (64 KiB). */
240
- max_bytes_per_file?: number;
241
- /** Max sum of reference bytes per aspect. Default 262144 (256 KiB). */
242
- max_total_bytes_per_aspect?: number;
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
- return stat2.isDirectory() ? "dir" : stat2.isFile() ? "file" : false;
125
+ result = stat2.isDirectory() ? "dir" : stat2.isFile() ? "file" : false;
122
126
  } catch {
123
- return false;
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
- return fs.readFileSync(abs, "utf8");
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
- const entries = fs.readdirSync(abs, { withFileTypes: true });
135
- return entries.map((e) => ({
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 content = fs2.readFileSync(abs, "utf8");
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
- return m ? toPublicNode(m) : void 0;
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) out.push(toPublicNode(m));
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
- const content = fs3.readFileSync(abs, "utf8");
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
- var EMPTY_IDENTITY = { ownSubset: hashString(""), ports: {}, aspects: {} };
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/structure/runner.ts
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 content;
1279
+ let bytes;
1082
1280
  try {
1083
- content = fs4.readFileSync(abs, "utf8");
1281
+ bytes = fs4.readFileSync(abs);
1084
1282
  } catch {
1085
1283
  continue;
1086
1284
  }
1087
- result.push({ path: p, content });
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 ctxFs = createCtxFs({ allowedSet, projectRoot, touchedFiles });
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 ownFiles = await buildOwnFiles(node, projectRoot, touchedFiles);
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: ownFilesEnriched,
1383
+ files: nodeFilesEnriched,
1147
1384
  ports: node.meta.ports ?? {}
1148
1385
  },
1149
- files: ownFilesEnriched,
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 and re-run yg approve.`
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 { violations: visible, touchedFiles, succeeded: true };
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 true. Set false for infrastructure
42
- # or utility types where an approval log adds no value
43
- # (e.g. config, types, constants).
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: