@chrisdudek/yg 5.0.0-alpha.3 → 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/structure.js CHANGED
@@ -28,9 +28,23 @@ import * as fs from "fs";
28
28
  import * as path2 from "path";
29
29
 
30
30
  // src/utils/mapping-path.ts
31
+ import { minimatch } from "minimatch";
31
32
  function normalizeMappingPath(p) {
32
33
  return p.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
33
34
  }
35
+ function isGlobPattern(entry) {
36
+ return entry.includes("*");
37
+ }
38
+ function globMatch(file, pattern) {
39
+ return minimatch(file, pattern, { dot: true });
40
+ }
41
+ function mappingEntryMatchesFile(entry, file) {
42
+ const e = normalizeMappingPath(entry);
43
+ const f = normalizeMappingPath(file);
44
+ if (e === "") return false;
45
+ if (isGlobPattern(e)) return globMatch(f, e);
46
+ return f === e || f.startsWith(e + "/");
47
+ }
34
48
 
35
49
  // src/utils/posix.ts
36
50
  function toPosix(p) {
@@ -52,9 +66,8 @@ function isAllowed(p, set) {
52
66
  if (p === "") return false;
53
67
  if (set.has(p)) return true;
54
68
  for (const a of set) {
55
- if (a === p) return true;
56
69
  if (a.startsWith(p + "/")) return true;
57
- if (p.startsWith(a + "/")) return true;
70
+ if (mappingEntryMatchesFile(a, p)) return true;
58
71
  }
59
72
  return false;
60
73
  }
@@ -93,36 +106,64 @@ function resolveAllowedReadPath(raw, allowedSet, projectRoot) {
93
106
  return rel;
94
107
  }
95
108
  function createCtxFs(params) {
96
- const { allowedSet, projectRoot, touchedFiles } = params;
109
+ const { allowedSet, projectRoot, touchedFiles, recorder, subjectFiles } = params;
97
110
  function assertAllowed(raw) {
98
111
  const p = resolveAllowedReadPath(raw, allowedSet, projectRoot);
99
112
  touchedFiles.push(p);
100
113
  return p;
101
114
  }
115
+ function isSubjectFile(p) {
116
+ return subjectFiles !== void 0 && subjectFiles.has(p);
117
+ }
102
118
  return {
103
119
  exists(raw) {
104
120
  const p = assertAllowed(raw);
105
121
  const abs = path2.resolve(projectRoot, p);
122
+ let result;
106
123
  try {
107
124
  const stat2 = fs.statSync(abs);
108
- return stat2.isDirectory() ? "dir" : stat2.isFile() ? "file" : false;
125
+ result = stat2.isDirectory() ? "dir" : stat2.isFile() ? "file" : false;
109
126
  } catch {
110
- return false;
127
+ result = false;
128
+ }
129
+ if (recorder && !isSubjectFile(p)) {
130
+ recorder.recordExists(p, result);
111
131
  }
132
+ return result;
112
133
  },
113
134
  read(raw) {
114
135
  const p = assertAllowed(raw);
115
136
  const abs = path2.resolve(projectRoot, p);
116
- 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");
117
148
  },
118
149
  list(raw) {
119
150
  const p = assertAllowed(raw);
120
151
  const abs = path2.resolve(projectRoot, p);
121
- const entries = fs.readdirSync(abs, { withFileTypes: true });
122
- 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) => ({
123
160
  name: e.name,
124
161
  kind: e.isDirectory() ? "dir" : "file"
125
162
  }));
163
+ if (recorder && !isSubjectFile(p)) {
164
+ recorder.recordList(p, entries);
165
+ }
166
+ return entries;
126
167
  }
127
168
  };
128
169
  }
@@ -135,13 +176,7 @@ import * as path3 from "path";
135
176
  function isPathInMapping(candidate, mapping) {
136
177
  const c = normalizeMappingPath(candidate);
137
178
  if (c === "") return false;
138
- for (const raw of mapping) {
139
- const n = normalizeMappingPath(raw);
140
- if (n === "") continue;
141
- if (c === n) return true;
142
- if (c.startsWith(n + "/")) return true;
143
- }
144
- return false;
179
+ return mapping.some((raw) => mappingEntryMatchesFile(raw, c));
145
180
  }
146
181
 
147
182
  // src/structure/ctx-graph.ts
@@ -181,27 +216,48 @@ function computeAllowedNodePaths(currentPath, graph) {
181
216
  return allowed;
182
217
  }
183
218
  function createCtxGraph(params) {
184
- const { currentNodePath, graph, projectRoot, touchedFiles } = params;
219
+ const { currentNodePath, graph, projectRoot, touchedFiles, expandedFilesByNode, recorder, subjectFiles } = params;
185
220
  const allowed = computeAllowedNodePaths(currentNodePath, graph);
186
221
  function assertAllowed(id) {
187
222
  if (!allowed.has(id)) throw new UndeclaredGraphReadError(id);
188
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
+ }
189
239
  function toPublicNode(m) {
190
240
  const files = [];
191
- for (const raw of m.meta.mapping ?? []) {
192
- const p = normalizeMappingPath(raw);
241
+ const preExpanded = expandedFilesByNode?.get(m.path);
242
+ const candidatePaths = preExpanded ?? (m.meta.mapping ?? []).map(normalizeMappingPath);
243
+ for (const p of candidatePaths) {
193
244
  if (!p) continue;
194
245
  const abs = path3.resolve(projectRoot, p);
195
246
  try {
196
247
  const stat2 = fs2.statSync(abs);
197
248
  if (stat2.isFile()) {
198
- const content = fs2.readFileSync(abs, "utf8");
249
+ const bytes = fs2.readFileSync(abs);
250
+ const content = bytes.toString("utf8");
199
251
  files.push({ path: p, content });
200
252
  touchedFiles.push(p);
253
+ if (recorder && !subjectFiles?.has(p)) {
254
+ recorder.recordRead(p, bytes);
255
+ }
201
256
  }
202
257
  } catch {
203
258
  }
204
259
  }
260
+ recordGraphNode(m);
205
261
  return {
206
262
  id: m.path,
207
263
  type: m.meta.type,
@@ -214,19 +270,29 @@ function createCtxGraph(params) {
214
270
  node(id) {
215
271
  assertAllowed(id);
216
272
  const m = graph.nodes.get(id);
217
- return m ? toPublicNode(m) : void 0;
273
+ if (!m) {
274
+ if (recorder) recorder.recordGraphNodeAbsent(id);
275
+ return void 0;
276
+ }
277
+ return toPublicNode(m);
218
278
  },
219
279
  nodesByType(type) {
220
280
  const out = [];
281
+ const matchedIds = [];
221
282
  for (const id of allowed) {
222
283
  const m = graph.nodes.get(id);
223
- 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
+ }
224
288
  }
289
+ if (recorder) recorder.recordGraphNodesByType(type, matchedIds);
225
290
  return out;
226
291
  },
227
292
  relationsFrom(node) {
228
293
  assertAllowed(node.id);
229
294
  const m = graph.nodes.get(node.id);
295
+ if (m) recordGraphNode(m);
230
296
  return m?.meta.relations ?? [];
231
297
  },
232
298
  relationsTo(node) {
@@ -234,6 +300,7 @@ function createCtxGraph(params) {
234
300
  for (const id of allowed) {
235
301
  const m = graph.nodes.get(id);
236
302
  if (!m) continue;
303
+ recordGraphNode(m);
237
304
  for (const rel of m.meta.relations ?? []) {
238
305
  if (rel.target === node.id) out.push(rel);
239
306
  }
@@ -243,6 +310,8 @@ function createCtxGraph(params) {
243
310
  children(node) {
244
311
  assertAllowed(node.id);
245
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);
246
315
  return m ? m.children.map(toPublicNode) : [];
247
316
  },
248
317
  flowParticipants(flowName) {
@@ -258,6 +327,7 @@ function createCtxGraph(params) {
258
327
  return false;
259
328
  });
260
329
  if (!participates) throw new UndeclaredGraphReadError(`flow:${flowName}`);
330
+ if (recorder) recorder.recordFlowParticipants(flow.name, [...flow.nodes]);
261
331
  const out = [];
262
332
  for (const nodeId of flow.nodes) {
263
333
  const m = graph.nodes.get(nodeId);
@@ -500,12 +570,16 @@ var GRAMMAR_DIRS = [
500
570
  path4.resolve(__dirname2, "grammars"),
501
571
  path4.resolve(__dirname2, "..", "grammars")
502
572
  ];
503
- var initialized = false;
573
+ var initPromise = null;
504
574
  var langCache = /* @__PURE__ */ new Map();
505
- async function init() {
506
- if (initialized) return;
507
- await Parser.init();
508
- initialized = true;
575
+ function init() {
576
+ if (initPromise === null) {
577
+ initPromise = Parser.init();
578
+ initPromise.catch(() => {
579
+ initPromise = null;
580
+ });
581
+ }
582
+ return initPromise;
509
583
  }
510
584
  function resolveWasm(filename, pkg) {
511
585
  for (const dir of GRAMMAR_DIRS) {
@@ -528,12 +602,16 @@ async function getParser(extension) {
528
602
  throw new Error(`no parser for extension '${extension}'`);
529
603
  }
530
604
  const cacheKey = info.wasmFile;
531
- let lang = langCache.get(cacheKey);
532
- if (!lang) {
605
+ let langP = langCache.get(cacheKey);
606
+ if (langP === void 0) {
533
607
  const wasmPath = resolveWasm(info.wasmFile, info.wasmPackage);
534
- lang = await Language.load(wasmPath);
535
- langCache.set(cacheKey, lang);
608
+ langP = Language.load(wasmPath);
609
+ langCache.set(cacheKey, langP);
610
+ langP.catch(() => {
611
+ if (langCache.get(cacheKey) === langP) langCache.delete(cacheKey);
612
+ });
536
613
  }
614
+ const lang = await langP;
537
615
  const parser = new Parser();
538
616
  parser.setLanguage(lang);
539
617
  return parser;
@@ -559,7 +637,7 @@ var ParseAstNotPrewarmedError = class extends Error {
559
637
  }
560
638
  };
561
639
  function createCtxParsers(params) {
562
- const { allowedSet, projectRoot, touchedFiles, astCache } = params;
640
+ const { allowedSet, projectRoot, touchedFiles, astCache, recorder, subjectFiles } = params;
563
641
  function asFile(input) {
564
642
  if (typeof input !== "string") {
565
643
  touchedFiles.push(input.path);
@@ -567,8 +645,18 @@ function createCtxParsers(params) {
567
645
  }
568
646
  const p = resolveAllowedReadPath(input, allowedSet, projectRoot);
569
647
  const abs = path5.resolve(projectRoot, p);
570
- 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");
571
656
  touchedFiles.push(p);
657
+ if (recorder && !subjectFiles?.has(p)) {
658
+ recorder.recordRead(p, bytes);
659
+ }
572
660
  return { path: p, content };
573
661
  }
574
662
  return {
@@ -691,7 +779,7 @@ import { createRequire as createRequire3 } from "module";
691
779
 
692
780
  // src/io/repo-scanner.ts
693
781
  import { readFile, readdir } from "fs/promises";
694
- import { join, relative as relative2, sep } from "path";
782
+ import { join as join2, relative as relative2, sep } from "path";
695
783
  import { createRequire as createRequire2 } from "module";
696
784
 
697
785
  // src/utils/debug-log.ts
@@ -726,7 +814,9 @@ function isIgnoredByStack2(candidatePath, stack) {
726
814
  function hashString(content) {
727
815
  return createHash("sha256").update(content).digest("hex");
728
816
  }
729
- var EMPTY_IDENTITY = { ownSubset: hashString(""), ports: {}, aspects: {} };
817
+ function hashBytes(bytes) {
818
+ return createHash("sha256").update(bytes).digest("hex");
819
+ }
730
820
  async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
731
821
  let stack = options.gitignoreStack ?? [];
732
822
  try {
@@ -764,26 +854,50 @@ async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, optio
764
854
  result.push(...fileStats);
765
855
  return result;
766
856
  }
857
+ async function expandGlobEntry(projectRoot, glob, gitignoreStack) {
858
+ const segments = glob.split("/");
859
+ const firstGlobIdx = segments.findIndex((s) => isGlobPattern(s));
860
+ const baseSegments = firstGlobIdx > 0 ? segments.slice(0, firstGlobIdx) : [];
861
+ const baseDir = baseSegments.length > 0 ? path7.join(projectRoot, ...baseSegments) : projectRoot;
862
+ try {
863
+ const dirEntries = await collectDirectoryFilePaths(baseDir, projectRoot, {
864
+ projectRoot,
865
+ gitignoreStack
866
+ });
867
+ return dirEntries.filter((entry) => globMatch(entry.relPath, glob)).map((entry) => ({
868
+ relPath: toPosixPath(entry.relPath),
869
+ absPath: entry.absPath,
870
+ mtimeMs: entry.mtimeMs
871
+ }));
872
+ } catch {
873
+ return [];
874
+ }
875
+ }
767
876
  async function expandMappingPaths(projectRoot, mappingPaths) {
768
877
  const gitignoreStack = await loadRootGitignoreStack2(projectRoot);
769
878
  const result = [];
770
879
  for (const mp of mappingPaths) {
771
- const absPath = path7.join(projectRoot, mp);
772
- try {
773
- const st = await stat(absPath);
774
- if (st.isDirectory()) {
775
- const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
776
- projectRoot,
777
- gitignoreStack
778
- });
779
- for (const entry of dirEntries) {
780
- result.push(toPosixPath(path7.join(mp, entry.relPath)));
880
+ if (isGlobPattern(mp)) {
881
+ const entries = await expandGlobEntry(projectRoot, mp, gitignoreStack);
882
+ for (const entry of entries) result.push(entry.relPath);
883
+ } else {
884
+ const absPath = path7.join(projectRoot, mp);
885
+ try {
886
+ const st = await stat(absPath);
887
+ if (st.isDirectory()) {
888
+ const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
889
+ projectRoot,
890
+ gitignoreStack
891
+ });
892
+ for (const entry of dirEntries) {
893
+ result.push(toPosixPath(path7.join(mp, entry.relPath)));
894
+ }
895
+ } else {
896
+ result.push(toPosixPath(mp));
781
897
  }
782
- } else {
783
- result.push(toPosixPath(mp));
898
+ } catch {
899
+ continue;
784
900
  }
785
- } catch {
786
- continue;
787
901
  }
788
902
  }
789
903
  return result;
@@ -831,8 +945,8 @@ var SuppressMarkerError = class extends Error {
831
945
  }
832
946
  code = "SUPPRESS_MARKER_MISSING_REASON";
833
947
  };
834
- var RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/;
835
- var RE_DISABLE = /\byg-suppress-disable\(\s*([^)]+?)\s*\)\s*(.+)?$/;
948
+ var RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
949
+ var RE_DISABLE = /\byg-suppress-disable\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
836
950
  var RE_ENABLE = /\byg-suppress-enable\(\s*([^)]+?)\s*\)/;
837
951
  function commentBody(text) {
838
952
  if (text.startsWith("//")) return text.slice(2).trim();
@@ -866,15 +980,23 @@ function parseMarker(commentText, line, file) {
866
980
  if (m) return makeMarker("single", m, line, file);
867
981
  return null;
868
982
  }
869
- function collectSuppressions(tree, file, totalLines) {
870
- if (getLanguageForExtension(extname3(file)) === null) {
871
- return [];
872
- }
873
- const comments = findComments({ path: file, ast: tree });
983
+ function collectSuppressions(tree, file, totalLines, content) {
984
+ const hasGrammar = getLanguageForExtension(extname3(file)) !== null;
874
985
  const markers = [];
875
- for (const c of comments) {
876
- const m = parseMarker(c.text, c.startPosition.row + 1, file);
877
- if (m) markers.push(m);
986
+ if (hasGrammar && tree) {
987
+ const comments = findComments({ path: file, ast: tree });
988
+ for (const c of comments) {
989
+ const m = parseMarker(c.text, c.startPosition.row + 1, file);
990
+ if (m) markers.push(m);
991
+ }
992
+ } else if (content !== void 0) {
993
+ const lines = content.split("\n");
994
+ for (let i = 0; i < lines.length; i++) {
995
+ const m = parseMarker(lines[i], i + 1, file);
996
+ if (m) markers.push(m);
997
+ }
998
+ } else {
999
+ return [];
878
1000
  }
879
1001
  markers.sort((a, b) => a.line - b.line);
880
1002
  const ranges = [];
@@ -974,18 +1096,7 @@ function validateCheckModuleExport(mod, opts) {
974
1096
  return { ok: true };
975
1097
  }
976
1098
 
977
- // src/structure/runner.ts
978
- var StructureRunnerError = class extends Error {
979
- constructor(code, data) {
980
- super(`${code}: ${data.what}
981
- ${data.why}
982
- ${data.next}`);
983
- this.code = code;
984
- this.messageData = data;
985
- this.name = "StructureRunnerError";
986
- }
987
- messageData;
988
- };
1099
+ // src/utils/binary-extensions.ts
989
1100
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
990
1101
  ".gif",
991
1102
  ".png",
@@ -1015,6 +1126,141 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
1015
1126
  ".wasm",
1016
1127
  ".bin"
1017
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
+ };
1018
1264
  async function buildOwnFiles(node, projectRoot, touchedFiles) {
1019
1265
  const childMappingEntries = [];
1020
1266
  for (const child of node.children) {
@@ -1030,24 +1276,43 @@ async function buildOwnFiles(node, projectRoot, touchedFiles) {
1030
1276
  if (childMappingEntries.length > 0 && isPathInMapping(p, childMappingEntries)) continue;
1031
1277
  if (BINARY_EXTENSIONS.has(path8.extname(p).toLowerCase())) continue;
1032
1278
  const abs = path8.resolve(projectRoot, p);
1033
- let content;
1279
+ let bytes;
1034
1280
  try {
1035
- content = fs4.readFileSync(abs, "utf8");
1281
+ bytes = fs4.readFileSync(abs);
1036
1282
  } catch {
1037
1283
  continue;
1038
1284
  }
1039
- result.push({ path: p, content });
1285
+ const content = bytes.toString("utf8");
1286
+ result.push({ file: { path: p, content }, bytes });
1040
1287
  touchedFiles.push(p);
1041
1288
  }
1042
1289
  return result;
1043
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
+ }
1044
1309
  async function enumerateMappedFilesAsync(mappingPaths, projectRoot) {
1045
1310
  const normalized = mappingPaths.map(normalizeMappingPath).filter((p) => p !== "");
1046
1311
  return expandMappingPaths(projectRoot, normalized);
1047
1312
  }
1048
1313
  async function runStructureAspect(params) {
1049
1314
  ensureLoaderRegistered();
1050
- const { aspectDir, aspectId, nodePath, graph, projectRoot } = params;
1315
+ const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
1051
1316
  const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
1052
1317
  const touchedFiles = [];
1053
1318
  const node = graph.nodes.get(nodePath);
@@ -1079,21 +1344,46 @@ async function runStructureAspect(params) {
1079
1344
  }
1080
1345
  const checkFn = mod.check;
1081
1346
  const allowedSet = collectAllowedReadsForAspect(nodePath, graph);
1082
- const ctxFs = createCtxFs({ allowedSet, projectRoot, touchedFiles });
1083
- const ctxGraph = createCtxGraph({ currentNodePath: nodePath, graph, projectRoot, touchedFiles });
1084
- const parsers = createCtxParsers({ allowedSet, projectRoot, touchedFiles, astCache });
1085
- const ownFiles = await buildOwnFiles(node, 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 });
1352
+ const expandedFilesByNode = /* @__PURE__ */ new Map();
1353
+ for (const id of computeAllowedNodePaths(nodePath, graph)) {
1354
+ const m = graph.nodes.get(id);
1355
+ if (m) expandedFilesByNode.set(id, await enumerateMappedFilesAsync(m.meta.mapping ?? [], projectRoot));
1356
+ }
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);
1086
1363
  await prewarmupAstCache({ astCache, projectRoot, files: ownFiles });
1087
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
+ }
1088
1378
  const ctx = {
1089
1379
  node: {
1090
1380
  id: node.path,
1091
1381
  type: node.meta.type,
1092
1382
  mapping: node.meta.mapping ?? [],
1093
- files: ownFilesEnriched,
1383
+ files: nodeFilesEnriched,
1094
1384
  ports: node.meta.ports ?? {}
1095
1385
  },
1096
- files: ownFilesEnriched,
1386
+ files: ctxFilesEnriched,
1097
1387
  fs: ctxFs,
1098
1388
  graph: ctxGraph,
1099
1389
  parseAst: parsers.parseAst,
@@ -1127,7 +1417,9 @@ async function runStructureAspect(params) {
1127
1417
  file: `.yggdrasil/aspects/${aspectId}/check.mjs`
1128
1418
  }],
1129
1419
  touchedFiles: [],
1130
- succeeded: false
1420
+ succeeded: false,
1421
+ observations: recorder.snapshot(),
1422
+ observationsTainted: recorder.tainted
1131
1423
  };
1132
1424
  }
1133
1425
  if (err instanceof UndeclaredGraphReadError) {
@@ -1138,7 +1430,9 @@ async function runStructureAspect(params) {
1138
1430
  file: `.yggdrasil/aspects/${aspectId}/check.mjs`
1139
1431
  }],
1140
1432
  touchedFiles: [],
1141
- succeeded: false
1433
+ succeeded: false,
1434
+ observations: recorder.snapshot(),
1435
+ observationsTainted: recorder.tainted
1142
1436
  };
1143
1437
  }
1144
1438
  if (err instanceof ParseAstNotPrewarmedError) {
@@ -1149,14 +1443,16 @@ async function runStructureAspect(params) {
1149
1443
  file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
1150
1444
  }],
1151
1445
  touchedFiles: [],
1152
- succeeded: false
1446
+ succeeded: false,
1447
+ observations: recorder.snapshot(),
1448
+ observationsTainted: recorder.tainted
1153
1449
  };
1154
1450
  }
1155
1451
  throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
1156
1452
  what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
1157
1453
  why: `${err.message}
1158
1454
  ${err.stack ?? ""}`,
1159
- 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`
1160
1456
  });
1161
1457
  }
1162
1458
  if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
@@ -1194,12 +1490,22 @@ ${err.stack ?? ""}`,
1194
1490
  }
1195
1491
  violations.push(vv);
1196
1492
  }
1493
+ const contentByPath = /* @__PURE__ */ new Map();
1494
+ for (const f of [...ownFiles, ...astInputSet]) {
1495
+ contentByPath.set(normalizeMappingPath(f.path), f.content);
1496
+ }
1197
1497
  const rangesByFile = /* @__PURE__ */ new Map();
1198
1498
  function rangesFor(filePath) {
1199
1499
  const existing = rangesByFile.get(filePath);
1200
1500
  if (existing !== void 0) return existing;
1201
1501
  const cached = astCache.get(filePath);
1202
- const ranges = cached ? collectSuppressions(cached.ast, filePath, cached.content.split("\n").length) : null;
1502
+ let ranges;
1503
+ if (cached) {
1504
+ ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
1505
+ } else {
1506
+ const content = contentByPath.get(filePath);
1507
+ ranges = content !== void 0 ? collectSuppressions(void 0, filePath, content.split("\n").length, content) : null;
1508
+ }
1203
1509
  rangesByFile.set(filePath, ranges);
1204
1510
  return ranges;
1205
1511
  }
@@ -1209,7 +1515,13 @@ ${err.stack ?? ""}`,
1209
1515
  if (!ranges) return true;
1210
1516
  return !isLineSuppressed(ranges, aspectId, v.line);
1211
1517
  });
1212
- 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
+ };
1213
1525
  }
1214
1526
 
1215
1527
  // src/ast/walk.ts
@@ -1242,9 +1554,8 @@ function report(file, node, message) {
1242
1554
  }
1243
1555
 
1244
1556
  // src/ast/file-path.ts
1245
- import { minimatch } from "minimatch";
1246
1557
  function inFile(file, pattern) {
1247
- if ("glob" in pattern) return minimatch(file.path, pattern.glob);
1558
+ if ("glob" in pattern) return globMatch(file.path, pattern.glob);
1248
1559
  if ("regex" in pattern) return pattern.regex.test(file.path);
1249
1560
  if ("contains" in pattern) return file.path.includes(pattern.contains);
1250
1561
  return false;