@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.
@@ -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
@@ -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
- return stat2.isDirectory() ? "dir" : stat2.isFile() ? "file" : false;
126
+ result = stat2.isDirectory() ? "dir" : stat2.isFile() ? "file" : false;
122
127
  } catch {
123
- return false;
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
- return fs.readFileSync(abs, "utf8");
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
- const entries = fs.readdirSync(abs, { withFileTypes: true });
135
- return entries.map((e) => ({
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 content = fs2.readFileSync(abs, "utf8");
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
- return m ? toPublicNode(m) : void 0;
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) out.push(toPublicNode(m));
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
- const content = fs3.readFileSync(abs, "utf8");
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
- var EMPTY_IDENTITY = { ownSubset: hashString(""), ports: {}, aspects: {} };
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/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
- };
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 content;
1285
+ let bytes;
1082
1286
  try {
1083
- content = fs4.readFileSync(abs, "utf8");
1287
+ bytes = fs4.readFileSync(abs);
1084
1288
  } catch {
1085
1289
  continue;
1086
1290
  }
1087
- result.push({ path: p, content });
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 ctxFs = createCtxFs({ allowedSet, projectRoot, touchedFiles });
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 ownFiles = await buildOwnFiles(node, projectRoot, touchedFiles);
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: ownFilesEnriched,
1389
+ files: nodeFilesEnriched,
1147
1390
  ports: node.meta.ports ?? {}
1148
1391
  },
1149
- files: ownFilesEnriched,
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 and re-run yg approve.`
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 { violations: visible, touchedFiles, succeeded: true };
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 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: