@chrisdudek/yg 5.0.4 → 5.1.1

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
@@ -1,31 +1,6 @@
1
- // src/structure/runner.ts
2
- import * as fs4 from "fs";
3
- import * as path8 from "path";
4
- import { pathToFileURL as pathToFileURL2 } from "url";
5
-
6
- // src/ast/loader-hook.ts
7
- import { register } from "module";
8
- import { pathToFileURL } from "url";
9
- import path from "path";
10
- import { fileURLToPath } from "url";
11
- import { existsSync } from "fs";
12
- var __filename = fileURLToPath(import.meta.url);
13
- var __dirname = path.dirname(__filename);
14
- var registered = false;
15
- function ensureLoaderRegistered() {
16
- if (registered) return;
17
- let implPath = path.resolve(__dirname, "./loader-hook-impl.js");
18
- if (!existsSync(implPath)) {
19
- const pkgRoot = path.resolve(__dirname, "../../");
20
- implPath = path.resolve(pkgRoot, "dist/loader-hook-impl.js");
21
- }
22
- register(pathToFileURL(implPath));
23
- registered = true;
24
- }
25
-
26
1
  // src/structure/ctx-fs.ts
27
2
  import * as fs from "fs";
28
- import * as path2 from "path";
3
+ import * as path from "path";
29
4
 
30
5
  // src/utils/mapping-path.ts
31
6
  import { minimatch } from "minimatch";
@@ -81,7 +56,7 @@ function assertRealpathContained(abs, projectRoot, rel) {
81
56
  }
82
57
  let probe = abs;
83
58
  while (!fs.existsSync(probe)) {
84
- const parent = path2.dirname(probe);
59
+ const parent = path.dirname(probe);
85
60
  if (parent === probe) return;
86
61
  probe = parent;
87
62
  }
@@ -91,15 +66,15 @@ function assertRealpathContained(abs, projectRoot, rel) {
91
66
  } catch {
92
67
  return;
93
68
  }
94
- const relReal = toPosix(path2.relative(realRoot, realProbe));
95
- if (relReal === ".." || relReal.startsWith("../") || path2.isAbsolute(relReal)) {
69
+ const relReal = toPosix(path.relative(realRoot, realProbe));
70
+ if (relReal === ".." || relReal.startsWith("../") || path.isAbsolute(relReal)) {
96
71
  throw new UndeclaredFsReadError(rel);
97
72
  }
98
73
  }
99
74
  function resolveAllowedReadPath(raw, allowedSet, projectRoot) {
100
- const abs = path2.resolve(projectRoot, normalizeMappingPath(raw));
101
- const rel = toPosix(path2.relative(projectRoot, abs));
102
- if (rel === "" || rel.startsWith("..") || path2.isAbsolute(rel)) {
75
+ const abs = path.resolve(projectRoot, normalizeMappingPath(raw));
76
+ const rel = toPosix(path.relative(projectRoot, abs));
77
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
103
78
  throw new UndeclaredFsReadError(normalizeMappingPath(raw));
104
79
  }
105
80
  if (!isAllowed(rel, allowedSet)) throw new UndeclaredFsReadError(rel);
@@ -119,7 +94,7 @@ function createCtxFs(params) {
119
94
  return {
120
95
  exists(raw) {
121
96
  const p = assertAllowed(raw);
122
- const abs = path2.resolve(projectRoot, p);
97
+ const abs = path.resolve(projectRoot, p);
123
98
  let result;
124
99
  try {
125
100
  const stat2 = fs.statSync(abs);
@@ -134,7 +109,7 @@ function createCtxFs(params) {
134
109
  },
135
110
  read(raw) {
136
111
  const p = assertAllowed(raw);
137
- const abs = path2.resolve(projectRoot, p);
112
+ const abs = path.resolve(projectRoot, p);
138
113
  let bytes;
139
114
  try {
140
115
  bytes = fs.readFileSync(abs);
@@ -149,7 +124,7 @@ function createCtxFs(params) {
149
124
  },
150
125
  list(raw) {
151
126
  const p = assertAllowed(raw);
152
- const abs = path2.resolve(projectRoot, p);
127
+ const abs = path.resolve(projectRoot, p);
153
128
  let dirents;
154
129
  try {
155
130
  dirents = fs.readdirSync(abs, { withFileTypes: true });
@@ -171,7 +146,7 @@ function createCtxFs(params) {
171
146
 
172
147
  // src/structure/ctx-graph.ts
173
148
  import * as fs2 from "fs";
174
- import * as path3 from "path";
149
+ import * as path2 from "path";
175
150
 
176
151
  // src/structure/expand-mapping-sync.ts
177
152
  function isPathInMapping(candidate, mapping) {
@@ -225,7 +200,7 @@ function createCtxGraph(params) {
225
200
  }
226
201
  function recordGraphNode(m) {
227
202
  if (!recorder) return;
228
- const ygNodePath = path3.join(projectRoot, ".yggdrasil", "model", m.path, "yg-node.yaml");
203
+ const ygNodePath = path2.join(projectRoot, ".yggdrasil", "model", m.path, "yg-node.yaml");
229
204
  let yamlBytes;
230
205
  try {
231
206
  yamlBytes = fs2.readFileSync(ygNodePath);
@@ -244,7 +219,7 @@ function createCtxGraph(params) {
244
219
  const candidatePaths = preExpanded ?? (m.meta.mapping ?? []).map(normalizeMappingPath);
245
220
  for (const p of candidatePaths) {
246
221
  if (!p) continue;
247
- const abs = path3.resolve(projectRoot, p);
222
+ const abs = path2.resolve(projectRoot, p);
248
223
  try {
249
224
  const stat2 = fs2.statSync(abs);
250
225
  if (stat2.isFile()) {
@@ -342,16 +317,16 @@ function createCtxGraph(params) {
342
317
 
343
318
  // src/structure/ctx-parsers.ts
344
319
  import * as fs3 from "fs";
345
- import * as path5 from "path";
320
+ import * as path4 from "path";
346
321
  import { extname } from "path";
347
322
  import { parse as parseYaml } from "yaml";
348
323
  import { parse as parseTomlSmol } from "smol-toml";
349
324
 
350
325
  // src/ast/parser.ts
351
326
  import { Parser, Language } from "web-tree-sitter";
352
- import path4 from "path";
353
- import { fileURLToPath as fileURLToPath2 } from "url";
354
- import { existsSync as existsSync3 } from "fs";
327
+ import path3 from "path";
328
+ import { fileURLToPath } from "url";
329
+ import { existsSync as existsSync2 } from "fs";
355
330
  import { createRequire } from "module";
356
331
 
357
332
  // src/core/graph/language-registry.ts
@@ -566,11 +541,11 @@ function getGrammarForExtension(ext) {
566
541
 
567
542
  // src/ast/parser.ts
568
543
  var _require = createRequire(import.meta.url);
569
- var __filename2 = fileURLToPath2(import.meta.url);
570
- var __dirname2 = path4.dirname(__filename2);
544
+ var __filename = fileURLToPath(import.meta.url);
545
+ var __dirname = path3.dirname(__filename);
571
546
  var GRAMMAR_DIRS = [
572
- path4.resolve(__dirname2, "grammars"),
573
- path4.resolve(__dirname2, "..", "grammars")
547
+ path3.resolve(__dirname, "grammars"),
548
+ path3.resolve(__dirname, "..", "grammars")
574
549
  ];
575
550
  var initPromise = null;
576
551
  var langCache = /* @__PURE__ */ new Map();
@@ -585,13 +560,13 @@ function init() {
585
560
  }
586
561
  function resolveWasm(filename, pkg) {
587
562
  for (const dir of GRAMMAR_DIRS) {
588
- const p = path4.join(dir, filename);
589
- if (existsSync3(p)) return p;
563
+ const p = path3.join(dir, filename);
564
+ if (existsSync2(p)) return p;
590
565
  }
591
566
  try {
592
- const pkgDir = path4.dirname(_require.resolve(`${pkg}/package.json`));
593
- for (const candidate of [path4.join(pkgDir, filename), path4.join(pkgDir, "bindings/node", filename)]) {
594
- if (existsSync3(candidate)) return candidate;
567
+ const pkgDir = path3.dirname(_require.resolve(`${pkg}/package.json`));
568
+ for (const candidate of [path3.join(pkgDir, filename), path3.join(pkgDir, "bindings/node", filename)]) {
569
+ if (existsSync2(candidate)) return candidate;
595
570
  }
596
571
  } catch {
597
572
  }
@@ -619,7 +594,7 @@ async function getParser(extension) {
619
594
  return parser;
620
595
  }
621
596
  async function parseFile(filePath, content) {
622
- const ext = path4.extname(filePath);
597
+ const ext = path3.extname(filePath);
623
598
  const parser = await getParser(ext);
624
599
  const tree = parser.parse(content);
625
600
  if (tree === null) {
@@ -647,7 +622,7 @@ function createCtxParsers(params) {
647
622
  return input;
648
623
  }
649
624
  const p = resolveAllowedReadPath(input, allowedSet, projectRoot);
650
- const abs = path5.resolve(projectRoot, p);
625
+ const abs = path4.resolve(projectRoot, p);
651
626
  let bytes;
652
627
  try {
653
628
  bytes = fs3.readFileSync(abs);
@@ -703,6 +678,226 @@ function enrichFilesWithAst(files, astCache) {
703
678
  });
704
679
  }
705
680
 
681
+ // src/ast/suppress.ts
682
+ import { extname as extname3 } from "path";
683
+
684
+ // src/ast/find-comments.ts
685
+ import { extname as extname2 } from "path";
686
+ function findComments(target) {
687
+ const hasAst = "ast" in target;
688
+ const hasRootNode = "rootNode" in target;
689
+ if (hasAst && hasRootNode) {
690
+ throw new Error("AST_FINDCOMMENTS_AMBIGUOUS_TARGET: pass either ast or rootNode, not both");
691
+ }
692
+ let language = "language" in target ? target.language : void 0;
693
+ if (language === void 0 && "path" in target) {
694
+ language = getLanguageForExtension(extname2(target.path)) ?? void 0;
695
+ }
696
+ if (language === void 0) {
697
+ throw new Error(
698
+ "AST_FINDCOMMENTS_NO_LANGUAGE: pass a SourceFile whose path has a known extension, or an explicit { language }"
699
+ );
700
+ }
701
+ const def = LANGUAGES[language];
702
+ if (def === void 0) {
703
+ throw new Error(`AST_FINDCOMMENTS_UNKNOWN_LANGUAGE: '${language}' not in registry`);
704
+ }
705
+ const root = hasAst ? target.ast.rootNode : target.rootNode;
706
+ const out = [];
707
+ for (const type of def.commentTypes) {
708
+ out.push(...root.descendantsOfType(type));
709
+ }
710
+ return out;
711
+ }
712
+
713
+ // src/ast/suppress.ts
714
+ var SuppressMarkerError = class extends Error {
715
+ constructor(message, file, line) {
716
+ super(message);
717
+ this.file = file;
718
+ this.line = line;
719
+ this.name = "SuppressMarkerError";
720
+ }
721
+ file;
722
+ line;
723
+ code = "SUPPRESS_MARKER_MISSING_REASON";
724
+ };
725
+ var RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
726
+ var RE_DISABLE = /\byg-suppress-disable\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
727
+ var RE_ENABLE = /\byg-suppress-enable\(\s*([^)]+?)\s*\)/;
728
+ function commentBody(text) {
729
+ if (text.startsWith("//")) return text.slice(2).trim();
730
+ if (text.startsWith("/*")) return text.replace(/^\/\*+/, "").replace(/\*+\/$/, "").trim();
731
+ return text.trim();
732
+ }
733
+ function splitAspectList(raw) {
734
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
735
+ }
736
+ function makeMarker(kind, match, line, file) {
737
+ const aspectIds = splitAspectList(match[1]);
738
+ const reason = (match[2] ?? "").trim();
739
+ if (reason === "") {
740
+ throw new SuppressMarkerError(
741
+ `yg-suppress${kind === "disable" ? "-disable" : ""}(${match[1]}) missing reason at ${file}:${line}`,
742
+ file,
743
+ line
744
+ );
745
+ }
746
+ return { kind, aspectIds, reason, line };
747
+ }
748
+ function parseMarker(commentText, line, file) {
749
+ const body = commentBody(commentText);
750
+ let m = body.match(RE_DISABLE);
751
+ if (m) return makeMarker("disable", m, line, file);
752
+ m = body.match(RE_ENABLE);
753
+ if (m) {
754
+ return { kind: "enable", aspectIds: splitAspectList(m[1]), reason: "", line };
755
+ }
756
+ m = body.match(RE_SINGLE);
757
+ if (m) return makeMarker("single", m, line, file);
758
+ return null;
759
+ }
760
+ function collectSuppressions(tree, file, totalLines, content) {
761
+ const hasGrammar = getLanguageForExtension(extname3(file)) !== null;
762
+ const markers = [];
763
+ if (hasGrammar && tree) {
764
+ const comments = findComments({ path: file, ast: tree });
765
+ for (const c of comments) {
766
+ const m = parseMarker(c.text, c.startPosition.row + 1, file);
767
+ if (m) markers.push(m);
768
+ }
769
+ } else if (content !== void 0) {
770
+ const lines = content.split("\n");
771
+ for (let i = 0; i < lines.length; i++) {
772
+ const m = parseMarker(lines[i], i + 1, file);
773
+ if (m) markers.push(m);
774
+ }
775
+ } else {
776
+ return [];
777
+ }
778
+ markers.sort((a, b) => a.line - b.line);
779
+ const ranges = [];
780
+ const openSpecific = /* @__PURE__ */ new Map();
781
+ let openWildcard = null;
782
+ for (const m of markers) {
783
+ if (m.kind === "single") {
784
+ const isWildcard = m.aspectIds.includes("*");
785
+ ranges.push({ aspectIds: new Set(m.aspectIds), startLine: m.line + 1, endLine: m.line + 1, isWildcard });
786
+ } else if (m.kind === "disable") {
787
+ for (const id of m.aspectIds) {
788
+ if (id === "*") {
789
+ if (openWildcard === null) openWildcard = m.line + 1;
790
+ } else {
791
+ if (!openSpecific.has(id)) openSpecific.set(id, m.line + 1);
792
+ }
793
+ }
794
+ } else {
795
+ for (const id of m.aspectIds) {
796
+ if (id === "*") {
797
+ if (openWildcard !== null) {
798
+ ranges.push({ aspectIds: /* @__PURE__ */ new Set(["*"]), startLine: openWildcard, endLine: m.line - 1, isWildcard: true });
799
+ openWildcard = null;
800
+ }
801
+ } else {
802
+ const start = openSpecific.get(id);
803
+ if (start !== void 0) {
804
+ ranges.push({ aspectIds: /* @__PURE__ */ new Set([id]), startLine: start, endLine: m.line - 1, isWildcard: false });
805
+ openSpecific.delete(id);
806
+ }
807
+ }
808
+ }
809
+ }
810
+ }
811
+ if (openWildcard !== null) ranges.push({ aspectIds: /* @__PURE__ */ new Set(["*"]), startLine: openWildcard, endLine: totalLines, isWildcard: true });
812
+ for (const [id, start] of openSpecific) {
813
+ ranges.push({ aspectIds: /* @__PURE__ */ new Set([id]), startLine: start, endLine: totalLines, isWildcard: false });
814
+ }
815
+ return ranges;
816
+ }
817
+ function isLineSuppressed(ranges, aspectId, line) {
818
+ return ranges.some((r) => {
819
+ if (line < r.startLine || line > r.endLine) return false;
820
+ return r.isWildcard || r.aspectIds.has(aspectId);
821
+ });
822
+ }
823
+
824
+ // src/utils/validate-check-module.ts
825
+ function validateCheckModuleExport(mod, opts) {
826
+ const { codePrefix, runnerLabel } = opts;
827
+ if (mod.check === void 0) {
828
+ const defaultExport = mod.default;
829
+ if (typeof defaultExport === "function" && defaultExport.name === "check") {
830
+ return {
831
+ ok: false,
832
+ code: `${codePrefix}_CHECK_DEFAULT_EXPORT`,
833
+ message: {
834
+ what: `check.mjs exports 'check' as default, but a NAMED export is required (${runnerLabel}).`,
835
+ why: `The runner imports the named export. A default export is invisible to it.`,
836
+ next: `Change 'export default function check(...)' to 'export function check(...)'.`
837
+ }
838
+ };
839
+ }
840
+ return {
841
+ ok: false,
842
+ code: `${codePrefix}_CHECK_NOT_EXPORTED`,
843
+ message: {
844
+ what: `check.mjs does not export a function named 'check' (${runnerLabel}).`,
845
+ why: `The runner expects 'export function check(ctx) { ... }'.`,
846
+ next: `Add a named export 'check' in check.mjs.`
847
+ }
848
+ };
849
+ }
850
+ if (typeof mod.check !== "function") {
851
+ return {
852
+ ok: false,
853
+ code: `${codePrefix}_CHECK_NOT_FUNCTION`,
854
+ message: {
855
+ what: `'check' is exported but is not a function (got ${typeof mod.check}).`,
856
+ why: `The runner calls check(ctx).`,
857
+ next: `Re-export check as a function.`
858
+ }
859
+ };
860
+ }
861
+ const checkFn = mod.check;
862
+ if (checkFn.length !== 1) {
863
+ return {
864
+ ok: false,
865
+ code: `${codePrefix}_CHECK_WRONG_ARITY`,
866
+ message: {
867
+ what: `'check' must accept exactly 1 parameter (ctx); declared arity is ${checkFn.length}.`,
868
+ why: `The runner invokes check(ctx).`,
869
+ next: `Change the signature to function check(ctx).`
870
+ }
871
+ };
872
+ }
873
+ return { ok: true };
874
+ }
875
+
876
+ // src/structure/hook-loader.ts
877
+ import * as fs4 from "fs";
878
+ import * as path8 from "path";
879
+ import { pathToFileURL as pathToFileURL2 } from "url";
880
+
881
+ // src/ast/loader-hook.ts
882
+ import { register } from "module";
883
+ import { pathToFileURL } from "url";
884
+ import path5 from "path";
885
+ import { fileURLToPath as fileURLToPath2 } from "url";
886
+ import { existsSync as existsSync3 } from "fs";
887
+ var __filename2 = fileURLToPath2(import.meta.url);
888
+ var __dirname2 = path5.dirname(__filename2);
889
+ var registered = false;
890
+ function ensureLoaderRegistered() {
891
+ if (registered) return;
892
+ let implPath = path5.resolve(__dirname2, "./loader-hook-impl.js");
893
+ if (!existsSync3(implPath)) {
894
+ const pkgRoot = path5.resolve(__dirname2, "../../");
895
+ implPath = path5.resolve(pkgRoot, "dist/loader-hook-impl.js");
896
+ }
897
+ register(pathToFileURL(implPath));
898
+ registered = true;
899
+ }
900
+
706
901
  // src/structure/allowed-reads.ts
707
902
  function collectAllowedReadsForAspect(nodePath, graph) {
708
903
  const allowed = /* @__PURE__ */ new Set();
@@ -922,201 +1117,6 @@ async function expandMappingPaths(projectRoot, mappingPaths) {
922
1117
  return result;
923
1118
  }
924
1119
 
925
- // src/ast/suppress.ts
926
- import { extname as extname3 } from "path";
927
-
928
- // src/ast/find-comments.ts
929
- import { extname as extname2 } from "path";
930
- function findComments(target) {
931
- const hasAst = "ast" in target;
932
- const hasRootNode = "rootNode" in target;
933
- if (hasAst && hasRootNode) {
934
- throw new Error("AST_FINDCOMMENTS_AMBIGUOUS_TARGET: pass either ast or rootNode, not both");
935
- }
936
- let language = "language" in target ? target.language : void 0;
937
- if (language === void 0 && "path" in target) {
938
- language = getLanguageForExtension(extname2(target.path)) ?? void 0;
939
- }
940
- if (language === void 0) {
941
- throw new Error(
942
- "AST_FINDCOMMENTS_NO_LANGUAGE: pass a SourceFile whose path has a known extension, or an explicit { language }"
943
- );
944
- }
945
- const def = LANGUAGES[language];
946
- if (def === void 0) {
947
- throw new Error(`AST_FINDCOMMENTS_UNKNOWN_LANGUAGE: '${language}' not in registry`);
948
- }
949
- const root = hasAst ? target.ast.rootNode : target.rootNode;
950
- const out = [];
951
- for (const type of def.commentTypes) {
952
- out.push(...root.descendantsOfType(type));
953
- }
954
- return out;
955
- }
956
-
957
- // src/ast/suppress.ts
958
- var SuppressMarkerError = class extends Error {
959
- constructor(message, file, line) {
960
- super(message);
961
- this.file = file;
962
- this.line = line;
963
- this.name = "SuppressMarkerError";
964
- }
965
- file;
966
- line;
967
- code = "SUPPRESS_MARKER_MISSING_REASON";
968
- };
969
- var RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
970
- var RE_DISABLE = /\byg-suppress-disable\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
971
- var RE_ENABLE = /\byg-suppress-enable\(\s*([^)]+?)\s*\)/;
972
- function commentBody(text) {
973
- if (text.startsWith("//")) return text.slice(2).trim();
974
- if (text.startsWith("/*")) return text.replace(/^\/\*+/, "").replace(/\*+\/$/, "").trim();
975
- return text.trim();
976
- }
977
- function splitAspectList(raw) {
978
- return raw.split(",").map((s) => s.trim()).filter(Boolean);
979
- }
980
- function makeMarker(kind, match, line, file) {
981
- const aspectIds = splitAspectList(match[1]);
982
- const reason = (match[2] ?? "").trim();
983
- if (reason === "") {
984
- throw new SuppressMarkerError(
985
- `yg-suppress${kind === "disable" ? "-disable" : ""}(${match[1]}) missing reason at ${file}:${line}`,
986
- file,
987
- line
988
- );
989
- }
990
- return { kind, aspectIds, reason, line };
991
- }
992
- function parseMarker(commentText, line, file) {
993
- const body = commentBody(commentText);
994
- let m = body.match(RE_DISABLE);
995
- if (m) return makeMarker("disable", m, line, file);
996
- m = body.match(RE_ENABLE);
997
- if (m) {
998
- return { kind: "enable", aspectIds: splitAspectList(m[1]), reason: "", line };
999
- }
1000
- m = body.match(RE_SINGLE);
1001
- if (m) return makeMarker("single", m, line, file);
1002
- return null;
1003
- }
1004
- function collectSuppressions(tree, file, totalLines, content) {
1005
- const hasGrammar = getLanguageForExtension(extname3(file)) !== null;
1006
- const markers = [];
1007
- if (hasGrammar && tree) {
1008
- const comments = findComments({ path: file, ast: tree });
1009
- for (const c of comments) {
1010
- const m = parseMarker(c.text, c.startPosition.row + 1, file);
1011
- if (m) markers.push(m);
1012
- }
1013
- } else if (content !== void 0) {
1014
- const lines = content.split("\n");
1015
- for (let i = 0; i < lines.length; i++) {
1016
- const m = parseMarker(lines[i], i + 1, file);
1017
- if (m) markers.push(m);
1018
- }
1019
- } else {
1020
- return [];
1021
- }
1022
- markers.sort((a, b) => a.line - b.line);
1023
- const ranges = [];
1024
- const openSpecific = /* @__PURE__ */ new Map();
1025
- let openWildcard = null;
1026
- for (const m of markers) {
1027
- if (m.kind === "single") {
1028
- const isWildcard = m.aspectIds.includes("*");
1029
- ranges.push({ aspectIds: new Set(m.aspectIds), startLine: m.line + 1, endLine: m.line + 1, isWildcard });
1030
- } else if (m.kind === "disable") {
1031
- for (const id of m.aspectIds) {
1032
- if (id === "*") {
1033
- if (openWildcard === null) openWildcard = m.line + 1;
1034
- } else {
1035
- if (!openSpecific.has(id)) openSpecific.set(id, m.line + 1);
1036
- }
1037
- }
1038
- } else {
1039
- for (const id of m.aspectIds) {
1040
- if (id === "*") {
1041
- if (openWildcard !== null) {
1042
- ranges.push({ aspectIds: /* @__PURE__ */ new Set(["*"]), startLine: openWildcard, endLine: m.line - 1, isWildcard: true });
1043
- openWildcard = null;
1044
- }
1045
- } else {
1046
- const start = openSpecific.get(id);
1047
- if (start !== void 0) {
1048
- ranges.push({ aspectIds: /* @__PURE__ */ new Set([id]), startLine: start, endLine: m.line - 1, isWildcard: false });
1049
- openSpecific.delete(id);
1050
- }
1051
- }
1052
- }
1053
- }
1054
- }
1055
- if (openWildcard !== null) ranges.push({ aspectIds: /* @__PURE__ */ new Set(["*"]), startLine: openWildcard, endLine: totalLines, isWildcard: true });
1056
- for (const [id, start] of openSpecific) {
1057
- ranges.push({ aspectIds: /* @__PURE__ */ new Set([id]), startLine: start, endLine: totalLines, isWildcard: false });
1058
- }
1059
- return ranges;
1060
- }
1061
- function isLineSuppressed(ranges, aspectId, line) {
1062
- return ranges.some((r) => {
1063
- if (line < r.startLine || line > r.endLine) return false;
1064
- return r.isWildcard || r.aspectIds.has(aspectId);
1065
- });
1066
- }
1067
-
1068
- // src/utils/validate-check-module.ts
1069
- function validateCheckModuleExport(mod, opts) {
1070
- const { codePrefix, runnerLabel } = opts;
1071
- if (mod.check === void 0) {
1072
- const defaultExport = mod.default;
1073
- if (typeof defaultExport === "function" && defaultExport.name === "check") {
1074
- return {
1075
- ok: false,
1076
- code: `${codePrefix}_CHECK_DEFAULT_EXPORT`,
1077
- message: {
1078
- what: `check.mjs exports 'check' as default, but a NAMED export is required (${runnerLabel}).`,
1079
- why: `The runner imports the named export. A default export is invisible to it.`,
1080
- next: `Change 'export default function check(...)' to 'export function check(...)'.`
1081
- }
1082
- };
1083
- }
1084
- return {
1085
- ok: false,
1086
- code: `${codePrefix}_CHECK_NOT_EXPORTED`,
1087
- message: {
1088
- what: `check.mjs does not export a function named 'check' (${runnerLabel}).`,
1089
- why: `The runner expects 'export function check(ctx) { ... }'.`,
1090
- next: `Add a named export 'check' in check.mjs.`
1091
- }
1092
- };
1093
- }
1094
- if (typeof mod.check !== "function") {
1095
- return {
1096
- ok: false,
1097
- code: `${codePrefix}_CHECK_NOT_FUNCTION`,
1098
- message: {
1099
- what: `'check' is exported but is not a function (got ${typeof mod.check}).`,
1100
- why: `The runner calls check(ctx).`,
1101
- next: `Re-export check as a function.`
1102
- }
1103
- };
1104
- }
1105
- const checkFn = mod.check;
1106
- if (checkFn.length !== 1) {
1107
- return {
1108
- ok: false,
1109
- code: `${codePrefix}_CHECK_WRONG_ARITY`,
1110
- message: {
1111
- what: `'check' must accept exactly 1 parameter (ctx); declared arity is ${checkFn.length}.`,
1112
- why: `The runner invokes check(ctx).`,
1113
- next: `Change the signature to function check(ctx).`
1114
- }
1115
- };
1116
- }
1117
- return { ok: true };
1118
- }
1119
-
1120
1120
  // src/utils/binary-extensions.ts
1121
1121
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
1122
1122
  ".gif",
@@ -1270,7 +1270,7 @@ var ObservationRecorder = class {
1270
1270
  }
1271
1271
  };
1272
1272
 
1273
- // src/structure/runner.ts
1273
+ // src/structure/hook-loader.ts
1274
1274
  var StructureRunnerError = class extends Error {
1275
1275
  constructor(code, data) {
1276
1276
  super(`${code}: ${data.what}
@@ -1332,11 +1332,24 @@ async function enumerateMappedFilesAsync(mappingPaths, projectRoot) {
1332
1332
  const normalized = mappingPaths.map(normalizeMappingPath).filter((p) => p !== "");
1333
1333
  return expandMappingPaths(projectRoot, normalized);
1334
1334
  }
1335
- async function runStructureAspect(params) {
1335
+ async function loadHookModule(params) {
1336
1336
  ensureLoaderRegistered();
1337
- const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
1338
- const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
1339
- const touchedFiles = [];
1337
+ const { aspectDir, projectRoot, filename } = params;
1338
+ const resolveFailedCode = params.resolveFailedCode ?? "STRUCTURE_LOADER_RESOLVE_FAILED";
1339
+ const aspectDirAbs = path8.isAbsolute(aspectDir) ? aspectDir : path8.resolve(projectRoot, aspectDir);
1340
+ const modulePath = path8.join(aspectDirAbs, filename);
1341
+ try {
1342
+ return await import(pathToFileURL2(modulePath).href);
1343
+ } catch (err) {
1344
+ throw new StructureRunnerError(resolveFailedCode, {
1345
+ what: `Failed to load ${filename} at ${modulePath}: ${err.message}`,
1346
+ why: `The runner dynamically imports the aspect's ${filename} before invoking it.`,
1347
+ next: `Ensure ${filename} exists at the aspect directory and has no unresolved imports.`
1348
+ });
1349
+ }
1350
+ }
1351
+ async function buildUnitCtx(params) {
1352
+ const { aspectId, nodePath, graph, projectRoot, astCache, touchedFiles, subjectScope } = params;
1340
1353
  const node = graph.nodes.get(nodePath);
1341
1354
  if (!node) {
1342
1355
  throw new StructureRunnerError("STRUCTURE_NODE_MISSING", {
@@ -1345,26 +1358,7 @@ async function runStructureAspect(params) {
1345
1358
  next: `Pass an existing node path, or add the node to the graph.`
1346
1359
  });
1347
1360
  }
1348
- const aspectDirAbs = path8.isAbsolute(aspectDir) ? aspectDir : path8.resolve(projectRoot, aspectDir);
1349
- const checkPath = path8.join(aspectDirAbs, "check.mjs");
1350
- let mod;
1351
- try {
1352
- mod = await import(pathToFileURL2(checkPath).href);
1353
- } catch (err) {
1354
- throw new StructureRunnerError("STRUCTURE_LOADER_RESOLVE_FAILED", {
1355
- what: `Failed to load check.mjs at ${checkPath}: ${err.message}`,
1356
- why: `The runner dynamically imports the aspect's check.mjs before invoking it.`,
1357
- next: `Ensure check.mjs exists at the aspect directory and has no unresolved imports.`
1358
- });
1359
- }
1360
- const exportCheck = validateCheckModuleExport(mod, {
1361
- codePrefix: "STRUCTURE",
1362
- runnerLabel: `aspect '${aspectId}'`
1363
- });
1364
- if (!exportCheck.ok) {
1365
- throw new StructureRunnerError(exportCheck.code, exportCheck.message);
1366
- }
1367
- const checkFn = mod.check;
1361
+ void aspectId;
1368
1362
  const allowedSet = collectAllowedReadsForAspect(nodePath, graph);
1369
1363
  const recorder = new ObservationRecorder();
1370
1364
  const ownFilesRaw = (node.meta.mapping ?? []).map(normalizeMappingPath).filter((p) => p !== "");
@@ -1406,6 +1400,11 @@ async function runStructureAspect(params) {
1406
1400
  ports: node.meta.ports ?? {}
1407
1401
  },
1408
1402
  files: ctxFilesEnriched,
1403
+ // ctx.subject is the unit's subject file(s): for the deterministic whole-node
1404
+ // case it is the SAME array reference as ctx.files; for a per:file unit it is
1405
+ // exactly the narrowed subject view (also ctx.files here). Identical reference
1406
+ // in both branches keeps the alias contract.
1407
+ subject: ctxFilesEnriched,
1409
1408
  fs: ctxFs,
1410
1409
  graph: ctxGraph,
1411
1410
  parseAst: parsers.parseAst,
@@ -1427,6 +1426,32 @@ async function runStructureAspect(params) {
1427
1426
  }
1428
1427
  }
1429
1428
  await prewarmupAstCache({ astCache, projectRoot, files: astInputSet });
1429
+ return { ctx, recorder, node, subjectFiles, ownFiles, astInputSet };
1430
+ }
1431
+
1432
+ // src/structure/runner.ts
1433
+ async function runStructureAspect(params) {
1434
+ const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
1435
+ const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
1436
+ const touchedFiles = [];
1437
+ const mod = await loadHookModule({ aspectDir, projectRoot, filename: "check.mjs" });
1438
+ const exportCheck = validateCheckModuleExport(mod, {
1439
+ codePrefix: "STRUCTURE",
1440
+ runnerLabel: `aspect '${aspectId}'`
1441
+ });
1442
+ if (!exportCheck.ok) {
1443
+ throw new StructureRunnerError(exportCheck.code, exportCheck.message);
1444
+ }
1445
+ const checkFn = mod.check;
1446
+ const { ctx, recorder, ownFiles, astInputSet } = await buildUnitCtx({
1447
+ aspectId,
1448
+ nodePath,
1449
+ graph,
1450
+ projectRoot,
1451
+ astCache,
1452
+ touchedFiles,
1453
+ subjectScope
1454
+ });
1430
1455
  let raw;
1431
1456
  try {
1432
1457
  raw = checkFn(ctx);