@chrisdudek/yg 5.2.4 → 5.2.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.
Files changed (3) hide show
  1. package/dist/bin.js +352 -329
  2. package/dist/structure.js +157 -131
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -10027,6 +10027,7 @@ var GRAMMAR_DIRS = [
10027
10027
  ];
10028
10028
  var initPromise = null;
10029
10029
  var langCache = /* @__PURE__ */ new Map();
10030
+ var parserCache = /* @__PURE__ */ new Map();
10030
10031
  function init() {
10031
10032
  if (initPromise === null) {
10032
10033
  initPromise = Parser.init();
@@ -10067,8 +10068,11 @@ async function getParser(extension) {
10067
10068
  });
10068
10069
  }
10069
10070
  const lang = await langP;
10071
+ const existing = parserCache.get(cacheKey);
10072
+ if (existing) return existing;
10070
10073
  const parser = new Parser();
10071
10074
  parser.setLanguage(lang);
10075
+ parserCache.set(cacheKey, parser);
10072
10076
  return parser;
10073
10077
  }
10074
10078
  async function parseFile(filePath, content20) {
@@ -10080,6 +10084,14 @@ async function parseFile(filePath, content20) {
10080
10084
  }
10081
10085
  return tree;
10082
10086
  }
10087
+ async function withParsedFile(filePath, content20, fn) {
10088
+ const tree = await parseFile(filePath, content20);
10089
+ try {
10090
+ return await fn(tree);
10091
+ } finally {
10092
+ tree.delete();
10093
+ }
10094
+ }
10083
10095
 
10084
10096
  // src/structure/ctx-parsers.ts
10085
10097
  var ParseAstNotPrewarmedError = class extends Error {
@@ -10404,6 +10416,14 @@ function validateCheckModuleExport(mod, opts) {
10404
10416
  return { ok: true };
10405
10417
  }
10406
10418
 
10419
+ // src/ast/parse-cache.ts
10420
+ function destroyParseCache(cache) {
10421
+ for (const { ast } of cache.values()) {
10422
+ ast.delete();
10423
+ }
10424
+ cache.clear();
10425
+ }
10426
+
10407
10427
  // src/structure/hook-loader.ts
10408
10428
  import * as fs4 from "fs";
10409
10429
  import * as path29 from "path";
@@ -10694,236 +10714,247 @@ function companionInfra(what, why, next) {
10694
10714
  }
10695
10715
  async function runCompanionHook(params) {
10696
10716
  const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
10717
+ const ownCache = !params.parseCache;
10697
10718
  const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
10698
10719
  const touchedFiles = [];
10699
- let mod;
10700
- try {
10701
- mod = await loadHookModule({
10702
- aspectDir,
10703
- projectRoot,
10704
- filename: "companion.mjs",
10705
- resolveFailedCode: "COMPANION_LOADER_RESOLVE_FAILED"
10706
- });
10707
- } catch (err) {
10708
- if (err instanceof StructureRunnerError) {
10709
- return { kind: "infra", messageData: err.messageData };
10710
- }
10711
- return companionInfra(
10712
- `Failed to load companion.mjs for aspect '${aspectId}': ${err.message}`,
10713
- `The runner dynamically imports the aspect's companion.mjs before resolving companions.`,
10714
- `Ensure companion.mjs exists at the aspect directory and has no unresolved imports.`
10715
- );
10716
- }
10717
- const fn = mod.companion;
10718
- if (typeof fn !== "function") {
10719
- return companionInfra(
10720
- `companion.mjs does not export a function named 'companion' (aspect '${aspectId}'; got ${typeof fn}).`,
10721
- `The runner imports the named export 'companion' and calls companion(ctx).`,
10722
- `Add 'export function companion(ctx) { ... }' to companion.mjs (it may be async).`
10723
- );
10724
- }
10725
- let built;
10726
10720
  try {
10727
- built = await buildUnitCtx({ aspectId, nodePath, graph, projectRoot, astCache, touchedFiles, subjectScope });
10728
- } catch (err) {
10729
- if (err instanceof StructureRunnerError) {
10730
- return { kind: "infra", messageData: err.messageData };
10721
+ let mod;
10722
+ try {
10723
+ mod = await loadHookModule({
10724
+ aspectDir,
10725
+ projectRoot,
10726
+ filename: "companion.mjs",
10727
+ resolveFailedCode: "COMPANION_LOADER_RESOLVE_FAILED"
10728
+ });
10729
+ } catch (err) {
10730
+ if (err instanceof StructureRunnerError) {
10731
+ return { kind: "infra", messageData: err.messageData };
10732
+ }
10733
+ return companionInfra(
10734
+ `Failed to load companion.mjs for aspect '${aspectId}': ${err.message}`,
10735
+ `The runner dynamically imports the aspect's companion.mjs before resolving companions.`,
10736
+ `Ensure companion.mjs exists at the aspect directory and has no unresolved imports.`
10737
+ );
10731
10738
  }
10732
- throw err;
10733
- }
10734
- const { ctx, recorder } = built;
10735
- let out;
10736
- try {
10737
- out = await fn(ctx);
10738
- } catch (err) {
10739
- if (err instanceof UndeclaredFsReadError || err instanceof UndeclaredGraphReadError || err instanceof ParseAstNotPrewarmedError) {
10739
+ const fn = mod.companion;
10740
+ if (typeof fn !== "function") {
10740
10741
  return companionInfra(
10741
- `companion.mjs for aspect '${aspectId}' read an undeclared path or node: ${err.message}`,
10742
- `A companion resolves the prompt only \u2014 it cannot judge code, so an undeclared read is an infrastructure fault, not a code violation.`,
10743
- `Declare a relation in yg-node.yaml to the node owning that path, or read only relation-reachable files.`
10742
+ `companion.mjs does not export a function named 'companion' (aspect '${aspectId}'; got ${typeof fn}).`,
10743
+ `The runner imports the named export 'companion' and calls companion(ctx).`,
10744
+ `Add 'export function companion(ctx) { ... }' to companion.mjs (it may be async).`
10744
10745
  );
10745
10746
  }
10746
- return companionInfra(
10747
- `companion hook threw while resolving companions (aspect '${aspectId}'): ${err.message}`,
10748
- `${err.stack ?? ""}`,
10749
- `Fix the bug in companion.mjs, then re-run: yg check --approve`
10750
- );
10751
- }
10752
- if (!Array.isArray(out)) {
10753
- return companionInfra(
10754
- `companion.mjs returned ${typeof out}, expected an array of { path: string, label?: string } (aspect '${aspectId}').`,
10755
- `The runner attaches each returned path to the reviewer prompt; a non-array return cannot be interpreted.`,
10756
- `Return [] or { path, label? }[] from companion.`
10757
- );
10758
- }
10759
- const descriptors = [];
10760
- for (const d of out) {
10761
- if (typeof d !== "object" || d === null || typeof d.path !== "string") {
10747
+ let built;
10748
+ try {
10749
+ built = await buildUnitCtx({ aspectId, nodePath, graph, projectRoot, astCache, touchedFiles, subjectScope });
10750
+ } catch (err) {
10751
+ if (err instanceof StructureRunnerError) {
10752
+ return { kind: "infra", messageData: err.messageData };
10753
+ }
10754
+ throw err;
10755
+ }
10756
+ const { ctx, recorder } = built;
10757
+ let out;
10758
+ try {
10759
+ out = await fn(ctx);
10760
+ } catch (err) {
10761
+ if (err instanceof UndeclaredFsReadError || err instanceof UndeclaredGraphReadError || err instanceof ParseAstNotPrewarmedError) {
10762
+ return companionInfra(
10763
+ `companion.mjs for aspect '${aspectId}' read an undeclared path or node: ${err.message}`,
10764
+ `A companion resolves the prompt only \u2014 it cannot judge code, so an undeclared read is an infrastructure fault, not a code violation.`,
10765
+ `Declare a relation in yg-node.yaml to the node owning that path, or read only relation-reachable files.`
10766
+ );
10767
+ }
10762
10768
  return companionInfra(
10763
- `companion.mjs returned an entry that is not { path: string, label?: string } (aspect '${aspectId}').`,
10764
- `Each companion descriptor must carry a string 'path' (and an optional string 'label').`,
10765
- `Return objects shaped { path: string, label?: string } from companion.`
10769
+ `companion hook threw while resolving companions (aspect '${aspectId}'): ${err.message}`,
10770
+ `${err.stack ?? ""}`,
10771
+ `Fix the bug in companion.mjs, then re-run: yg check --approve`
10766
10772
  );
10767
10773
  }
10768
- const dd = d;
10769
- if (dd.label !== void 0 && typeof dd.label !== "string") {
10774
+ if (!Array.isArray(out)) {
10770
10775
  return companionInfra(
10771
- `companion.mjs returned an entry whose 'label' is not a string (aspect '${aspectId}'; got ${typeof dd.label}).`,
10772
- `A companion descriptor's optional 'label' is a human tag and must be a string when present.`,
10773
- `Omit 'label' or set it to a string in companion.`
10776
+ `companion.mjs returned ${typeof out}, expected an array of { path: string, label?: string } (aspect '${aspectId}').`,
10777
+ `The runner attaches each returned path to the reviewer prompt; a non-array return cannot be interpreted.`,
10778
+ `Return [] or { path, label? }[] from companion.`
10774
10779
  );
10775
10780
  }
10776
- descriptors.push({ path: dd.path, ...dd.label !== void 0 ? { label: dd.label } : {} });
10781
+ const descriptors = [];
10782
+ for (const d of out) {
10783
+ if (typeof d !== "object" || d === null || typeof d.path !== "string") {
10784
+ return companionInfra(
10785
+ `companion.mjs returned an entry that is not { path: string, label?: string } (aspect '${aspectId}').`,
10786
+ `Each companion descriptor must carry a string 'path' (and an optional string 'label').`,
10787
+ `Return objects shaped { path: string, label?: string } from companion.`
10788
+ );
10789
+ }
10790
+ const dd = d;
10791
+ if (dd.label !== void 0 && typeof dd.label !== "string") {
10792
+ return companionInfra(
10793
+ `companion.mjs returned an entry whose 'label' is not a string (aspect '${aspectId}'; got ${typeof dd.label}).`,
10794
+ `A companion descriptor's optional 'label' is a human tag and must be a string when present.`,
10795
+ `Omit 'label' or set it to a string in companion.`
10796
+ );
10797
+ }
10798
+ descriptors.push({ path: dd.path, ...dd.label !== void 0 ? { label: dd.label } : {} });
10799
+ }
10800
+ return {
10801
+ kind: "ok",
10802
+ descriptors,
10803
+ touchedFiles,
10804
+ observations: recorder.snapshot(),
10805
+ observationsTainted: recorder.tainted
10806
+ };
10807
+ } finally {
10808
+ if (ownCache) destroyParseCache(astCache);
10777
10809
  }
10778
- return {
10779
- kind: "ok",
10780
- descriptors,
10781
- touchedFiles,
10782
- observations: recorder.snapshot(),
10783
- observationsTainted: recorder.tainted
10784
- };
10785
10810
  }
10786
10811
 
10787
10812
  // src/structure/runner.ts
10788
10813
  async function runStructureAspect(params) {
10789
10814
  const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
10815
+ const ownCache = !params.parseCache;
10790
10816
  const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
10791
10817
  const touchedFiles = [];
10792
- const mod = await loadHookModule({ aspectDir, projectRoot, filename: "check.mjs" });
10793
- const exportCheck = validateCheckModuleExport(mod, {
10794
- codePrefix: "STRUCTURE",
10795
- runnerLabel: `aspect '${aspectId}'`
10796
- });
10797
- if (!exportCheck.ok) {
10798
- throw new StructureRunnerError(exportCheck.code, exportCheck.message);
10799
- }
10800
- const checkFn = mod.check;
10801
- const { ctx, recorder, ownFiles, astInputSet } = await buildUnitCtx({
10802
- aspectId,
10803
- nodePath,
10804
- graph,
10805
- projectRoot,
10806
- astCache,
10807
- touchedFiles,
10808
- subjectScope
10809
- });
10810
- let raw;
10811
10818
  try {
10812
- raw = checkFn(ctx);
10813
- } catch (err) {
10814
- if (err instanceof UndeclaredFsReadError) {
10815
- return {
10816
- violations: [{
10817
- message: `Aspect tried to read undeclared path '${err.path}'. Add a relation in yg-node.yaml to the node owning this path.`,
10818
- kind: "structure-aspect-undeclared-fs-read",
10819
- file: `.yggdrasil/aspects/${aspectId}/check.mjs`
10820
- }],
10821
- touchedFiles: [],
10822
- succeeded: false,
10823
- observations: recorder.snapshot(),
10824
- observationsTainted: recorder.tainted
10825
- };
10819
+ let rangesFor2 = function(filePath) {
10820
+ const existing = rangesByFile.get(filePath);
10821
+ if (existing !== void 0) return existing;
10822
+ const cached = astCache.get(filePath);
10823
+ let ranges;
10824
+ if (cached) {
10825
+ ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
10826
+ } else {
10827
+ const content20 = contentByPath.get(filePath);
10828
+ ranges = content20 !== void 0 ? collectSuppressions(void 0, filePath, content20.split("\n").length, content20) : null;
10829
+ }
10830
+ rangesByFile.set(filePath, ranges);
10831
+ return ranges;
10832
+ };
10833
+ var rangesFor = rangesFor2;
10834
+ const mod = await loadHookModule({ aspectDir, projectRoot, filename: "check.mjs" });
10835
+ const exportCheck = validateCheckModuleExport(mod, {
10836
+ codePrefix: "STRUCTURE",
10837
+ runnerLabel: `aspect '${aspectId}'`
10838
+ });
10839
+ if (!exportCheck.ok) {
10840
+ throw new StructureRunnerError(exportCheck.code, exportCheck.message);
10826
10841
  }
10827
- if (err instanceof UndeclaredGraphReadError) {
10828
- return {
10829
- violations: [{
10830
- message: `Aspect tried to read undeclared graph node '${err.nodePath}'. Add a relation in yg-node.yaml.`,
10831
- kind: "structure-aspect-undeclared-graph-read",
10832
- file: `.yggdrasil/aspects/${aspectId}/check.mjs`
10833
- }],
10834
- touchedFiles: [],
10835
- succeeded: false,
10836
- observations: recorder.snapshot(),
10837
- observationsTainted: recorder.tainted
10838
- };
10842
+ const checkFn = mod.check;
10843
+ const { ctx, recorder, ownFiles, astInputSet } = await buildUnitCtx({
10844
+ aspectId,
10845
+ nodePath,
10846
+ graph,
10847
+ projectRoot,
10848
+ astCache,
10849
+ touchedFiles,
10850
+ subjectScope
10851
+ });
10852
+ let raw;
10853
+ try {
10854
+ raw = checkFn(ctx);
10855
+ } catch (err) {
10856
+ if (err instanceof UndeclaredFsReadError) {
10857
+ return {
10858
+ violations: [{
10859
+ message: `Aspect tried to read undeclared path '${err.path}'. Add a relation in yg-node.yaml to the node owning this path.`,
10860
+ kind: "structure-aspect-undeclared-fs-read",
10861
+ file: `.yggdrasil/aspects/${aspectId}/check.mjs`
10862
+ }],
10863
+ touchedFiles: [],
10864
+ succeeded: false,
10865
+ observations: recorder.snapshot(),
10866
+ observationsTainted: recorder.tainted
10867
+ };
10868
+ }
10869
+ if (err instanceof UndeclaredGraphReadError) {
10870
+ return {
10871
+ violations: [{
10872
+ message: `Aspect tried to read undeclared graph node '${err.nodePath}'. Add a relation in yg-node.yaml.`,
10873
+ kind: "structure-aspect-undeclared-graph-read",
10874
+ file: `.yggdrasil/aspects/${aspectId}/check.mjs`
10875
+ }],
10876
+ touchedFiles: [],
10877
+ succeeded: false,
10878
+ observations: recorder.snapshot(),
10879
+ observationsTainted: recorder.tainted
10880
+ };
10881
+ }
10882
+ if (err instanceof ParseAstNotPrewarmedError) {
10883
+ return {
10884
+ violations: [{
10885
+ message: `Aspect called ctx.parseAst on '${err.filePath}', which was not pre-warmed by the dispatcher. Add a declared relation to the node owning this file, or use ctx.parseYaml/Json/Toml if AST is not required.`,
10886
+ kind: "structure-aspect-parseast-not-prewarmed",
10887
+ file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
10888
+ }],
10889
+ touchedFiles: [],
10890
+ succeeded: false,
10891
+ observations: recorder.snapshot(),
10892
+ observationsTainted: recorder.tainted
10893
+ };
10894
+ }
10895
+ throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
10896
+ what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
10897
+ why: `${err.message}
10898
+ ${err.stack ?? ""}`,
10899
+ next: `Fix the bug in check.mjs, then re-run: yg check --approve`
10900
+ });
10839
10901
  }
10840
- if (err instanceof ParseAstNotPrewarmedError) {
10841
- return {
10842
- violations: [{
10843
- message: `Aspect called ctx.parseAst on '${err.filePath}', which was not pre-warmed by the dispatcher. Add a declared relation to the node owning this file, or use ctx.parseYaml/Json/Toml if AST is not required.`,
10844
- kind: "structure-aspect-parseast-not-prewarmed",
10845
- file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
10846
- }],
10847
- touchedFiles: [],
10848
- succeeded: false,
10849
- observations: recorder.snapshot(),
10850
- observationsTainted: recorder.tainted
10851
- };
10902
+ if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
10903
+ throw new StructureRunnerError("STRUCTURE_CHECK_ASYNC", {
10904
+ what: `check.mjs returned a Promise; only synchronous returns are supported.`,
10905
+ why: `The runner does not await check's return value.`,
10906
+ next: `Refactor check to be synchronous.`
10907
+ });
10852
10908
  }
10853
- throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
10854
- what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
10855
- why: `${err.message}
10856
- ${err.stack ?? ""}`,
10857
- next: `Fix the bug in check.mjs, then re-run: yg check --approve`
10858
- });
10859
- }
10860
- if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
10861
- throw new StructureRunnerError("STRUCTURE_CHECK_ASYNC", {
10862
- what: `check.mjs returned a Promise; only synchronous returns are supported.`,
10863
- why: `The runner does not await check's return value.`,
10864
- next: `Refactor check to be synchronous.`
10865
- });
10866
- }
10867
- if (!Array.isArray(raw)) {
10868
- throw new StructureRunnerError("STRUCTURE_CHECK_RETURN_SHAPE", {
10869
- what: `check.mjs returned ${typeof raw}, expected Violation[].`,
10870
- why: `The runner reports violations from the array returned by check.`,
10871
- next: `Return [] or Violation[] from check.`
10872
- });
10873
- }
10874
- const contextFiles = new Set(ownFiles.map((f) => f.path));
10875
- for (const t of touchedFiles) contextFiles.add(t);
10876
- const violations = [];
10877
- for (const v of raw) {
10878
- if (typeof v !== "object" || v === null || typeof v.message !== "string") {
10909
+ if (!Array.isArray(raw)) {
10879
10910
  throw new StructureRunnerError("STRUCTURE_CHECK_RETURN_SHAPE", {
10880
- what: `Violation entry must be an object with a string 'message' field.`,
10881
- why: `The runner renders each violation from its message and optional file/line.`,
10882
- next: `Return objects shaped { message: string, file?: string, line?: number } from check.`
10911
+ what: `check.mjs returned ${typeof raw}, expected Violation[].`,
10912
+ why: `The runner reports violations from the array returned by check.`,
10913
+ next: `Return [] or Violation[] from check.`
10883
10914
  });
10884
10915
  }
10885
- const vv = v;
10886
- if (typeof vv.file === "string" && !contextFiles.has(normalizeMappingPath(vv.file))) {
10887
- throw new StructureRunnerError("STRUCTURE_CHECK_FILE_NOT_IN_CONTEXT", {
10888
- what: `Violation references file '${vv.file}' not in ctx (own mapping or touched via ctx.fs/ctx.graph).`,
10889
- why: `Author cannot synthesize violations against files they were not given.`,
10890
- next: `Return only violations for files in ctx, or declare a relation to the node owning '${vv.file}'.`
10891
- });
10916
+ const contextFiles = new Set(ownFiles.map((f) => f.path));
10917
+ for (const t of touchedFiles) contextFiles.add(t);
10918
+ const violations = [];
10919
+ for (const v of raw) {
10920
+ if (typeof v !== "object" || v === null || typeof v.message !== "string") {
10921
+ throw new StructureRunnerError("STRUCTURE_CHECK_RETURN_SHAPE", {
10922
+ what: `Violation entry must be an object with a string 'message' field.`,
10923
+ why: `The runner renders each violation from its message and optional file/line.`,
10924
+ next: `Return objects shaped { message: string, file?: string, line?: number } from check.`
10925
+ });
10926
+ }
10927
+ const vv = v;
10928
+ if (typeof vv.file === "string" && !contextFiles.has(normalizeMappingPath(vv.file))) {
10929
+ throw new StructureRunnerError("STRUCTURE_CHECK_FILE_NOT_IN_CONTEXT", {
10930
+ what: `Violation references file '${vv.file}' not in ctx (own mapping or touched via ctx.fs/ctx.graph).`,
10931
+ why: `Author cannot synthesize violations against files they were not given.`,
10932
+ next: `Return only violations for files in ctx, or declare a relation to the node owning '${vv.file}'.`
10933
+ });
10934
+ }
10935
+ violations.push(vv);
10892
10936
  }
10893
- violations.push(vv);
10894
- }
10895
- const contentByPath = /* @__PURE__ */ new Map();
10896
- for (const f of [...ownFiles, ...astInputSet]) {
10897
- contentByPath.set(normalizeMappingPath(f.path), f.content);
10898
- }
10899
- const rangesByFile = /* @__PURE__ */ new Map();
10900
- function rangesFor(filePath) {
10901
- const existing = rangesByFile.get(filePath);
10902
- if (existing !== void 0) return existing;
10903
- const cached = astCache.get(filePath);
10904
- let ranges;
10905
- if (cached) {
10906
- ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
10907
- } else {
10908
- const content20 = contentByPath.get(filePath);
10909
- ranges = content20 !== void 0 ? collectSuppressions(void 0, filePath, content20.split("\n").length, content20) : null;
10937
+ const contentByPath = /* @__PURE__ */ new Map();
10938
+ for (const f of [...ownFiles, ...astInputSet]) {
10939
+ contentByPath.set(normalizeMappingPath(f.path), f.content);
10910
10940
  }
10911
- rangesByFile.set(filePath, ranges);
10912
- return ranges;
10941
+ const rangesByFile = /* @__PURE__ */ new Map();
10942
+ const visible = violations.filter((v) => {
10943
+ if (typeof v.file !== "string" || typeof v.line !== "number") return true;
10944
+ const ranges = rangesFor2(normalizeMappingPath(v.file));
10945
+ if (!ranges) return true;
10946
+ return !isLineSuppressed(ranges, aspectId, v.line);
10947
+ });
10948
+ return {
10949
+ violations: visible,
10950
+ touchedFiles,
10951
+ succeeded: true,
10952
+ observations: recorder.snapshot(),
10953
+ observationsTainted: recorder.tainted
10954
+ };
10955
+ } finally {
10956
+ if (ownCache) destroyParseCache(astCache);
10913
10957
  }
10914
- const visible = violations.filter((v) => {
10915
- if (typeof v.file !== "string" || typeof v.line !== "number") return true;
10916
- const ranges = rangesFor(normalizeMappingPath(v.file));
10917
- if (!ranges) return true;
10918
- return !isLineSuppressed(ranges, aspectId, v.line);
10919
- });
10920
- return {
10921
- violations: visible,
10922
- touchedFiles,
10923
- succeeded: true,
10924
- observations: recorder.snapshot(),
10925
- observationsTainted: recorder.tainted
10926
- };
10927
10958
  }
10928
10959
 
10929
10960
  // src/structure/suppress-ranges.ts
@@ -10933,12 +10964,12 @@ async function resolveSuppressedRangesForPrompt(subjects, aspectId) {
10933
10964
  for (const subject of subjects) {
10934
10965
  const content20 = subject.bytes.toString("utf8");
10935
10966
  const hasGrammar = getLanguageForExtension(extname5(subject.path).toLowerCase()) !== null;
10936
- let tree;
10937
- if (hasGrammar) {
10938
- tree = await parseFile(subject.path, content20);
10939
- }
10940
10967
  const totalLines = content20.split("\n").length;
10941
- const all = collectSuppressions(tree, subject.path, totalLines, content20);
10968
+ const all = hasGrammar ? await withParsedFile(
10969
+ subject.path,
10970
+ content20,
10971
+ (tree) => collectSuppressions(tree, subject.path, totalLines, content20)
10972
+ ) : collectSuppressions(void 0, subject.path, totalLines, content20);
10942
10973
  const ranges = formatSuppressedRangesForAspect(all, aspectId);
10943
10974
  if (ranges.length > 0) {
10944
10975
  byFile.push({ path: subject.path, ranges });
@@ -12125,22 +12156,14 @@ async function runRelationPass(graph, projectRoot, deps) {
12125
12156
  }
12126
12157
  }
12127
12158
  const ownerIndex = buildOwnerIndex(graph.nodes);
12128
- const parseCache = /* @__PURE__ */ new Map();
12129
- async function getParsed(record) {
12130
- if (parseCache.has(record.path)) return parseCache.get(record.path) ?? null;
12131
- if (!record.language) {
12132
- parseCache.set(record.path, null);
12133
- return null;
12134
- }
12135
- let parsed;
12159
+ async function parseSingle(record) {
12160
+ if (!record.language) return null;
12136
12161
  try {
12137
12162
  const tree = await parseFile(record.path, record.content);
12138
- parsed = { path: record.path, content: record.content, tree, language: record.language };
12163
+ return { path: record.path, content: record.content, tree, language: record.language };
12139
12164
  } catch {
12140
- parsed = null;
12165
+ return null;
12141
12166
  }
12142
- parseCache.set(record.path, parsed);
12143
- return parsed;
12144
12167
  }
12145
12168
  const symbolTable = new SymbolTable();
12146
12169
  const recordsByLanguage = /* @__PURE__ */ new Map();
@@ -12164,11 +12187,15 @@ async function runRelationPass(graph, projectRoot, deps) {
12164
12187
  }
12165
12188
  const symbols = [];
12166
12189
  for (const record of records) {
12167
- const parsed = await getParsed(record);
12190
+ const parsed = await parseSingle(record);
12168
12191
  if (!parsed) continue;
12169
- for (const decl of extractor.declarations(parsed)) {
12170
- symbols.push([decl.symbolKey, record.path]);
12171
- symbolTable.declare(language, decl.symbolKey, record.path);
12192
+ try {
12193
+ for (const decl of extractor.declarations(parsed)) {
12194
+ symbols.push([decl.symbolKey, record.path]);
12195
+ symbolTable.declare(language, decl.symbolKey, record.path);
12196
+ }
12197
+ } finally {
12198
+ parsed.tree.delete();
12172
12199
  }
12173
12200
  }
12174
12201
  const toPersist = { builtFrom, symbols };
@@ -12178,10 +12205,14 @@ async function runRelationPass(graph, projectRoot, deps) {
12178
12205
  const projectGlobalUsings = /* @__PURE__ */ new Set();
12179
12206
  const projectGlobalUsingAliases = /* @__PURE__ */ new Map();
12180
12207
  for (const record of csharpRecords) {
12181
- const parsed = await getParsed(record);
12208
+ const parsed = await parseSingle(record);
12182
12209
  if (!parsed) continue;
12183
- for (const prefix of collectGlobalUsings(parsed)) projectGlobalUsings.add(prefix);
12184
- for (const [name, fqn] of collectGlobalUsingAliases(parsed)) projectGlobalUsingAliases.set(name, fqn);
12210
+ try {
12211
+ for (const prefix of collectGlobalUsings(parsed)) projectGlobalUsings.add(prefix);
12212
+ for (const [name, fqn] of collectGlobalUsingAliases(parsed)) projectGlobalUsingAliases.set(name, fqn);
12213
+ } finally {
12214
+ parsed.tree.delete();
12215
+ }
12185
12216
  }
12186
12217
  const csharpGlobalUsings = [...projectGlobalUsings];
12187
12218
  const csharpGlobalUsingAliases = [...projectGlobalUsingAliases.entries()];
@@ -12216,17 +12247,21 @@ async function runRelationPass(graph, projectRoot, deps) {
12216
12247
  if (!record.language) continue;
12217
12248
  const extractor = deps.extractorFor(record.language);
12218
12249
  if (!extractor) continue;
12219
- const parsed = await getParsed(record);
12250
+ const parsed = await parseSingle(record);
12220
12251
  if (!parsed) continue;
12221
- const detected = record.language === "csharp" ? csharpUses(parsed, {
12222
- projectGlobalUsings: csharpGlobalUsings,
12223
- projectGlobalUsingAliases: csharpGlobalUsingAliases
12224
- }) : extractor.uses(parsed);
12225
- for (const dep of detected) {
12226
- const ownerNode = resolveCandidateGroup(dep.candidates, resolver, record.path, record.language);
12227
- if (ownerNode !== void 0) {
12228
- resolvedDeps.push({ fromFile: record.path, line: dep.line, ownerNode });
12252
+ try {
12253
+ const detected = record.language === "csharp" ? csharpUses(parsed, {
12254
+ projectGlobalUsings: csharpGlobalUsings,
12255
+ projectGlobalUsingAliases: csharpGlobalUsingAliases
12256
+ }) : extractor.uses(parsed);
12257
+ for (const dep of detected) {
12258
+ const ownerNode = resolveCandidateGroup(dep.candidates, resolver, record.path, record.language);
12259
+ if (ownerNode !== void 0) {
12260
+ resolvedDeps.push({ fromFile: record.path, line: dep.line, ownerNode });
12261
+ }
12229
12262
  }
12263
+ } finally {
12264
+ parsed.tree.delete();
12230
12265
  }
12231
12266
  }
12232
12267
  const violations = verifyNodeDeps(nodeId, resolvedDeps, graphView);
@@ -15796,21 +15831,7 @@ async function runFill(graph, opts) {
15796
15831
  }
15797
15832
 
15798
15833
  // src/cli/check.ts
15799
- import { execFileSync } from "child_process";
15800
15834
  import path48 from "path";
15801
- function collectGitFiles(projectRoot) {
15802
- try {
15803
- const output = execFileSync("git", ["ls-files", "."], {
15804
- cwd: projectRoot,
15805
- encoding: "utf-8",
15806
- stdio: ["pipe", "pipe", "pipe"]
15807
- });
15808
- return output.trim().split("\n").filter((f) => f.length > 0);
15809
- } catch (e) {
15810
- debugWrite(`[check] git ls-files failed: ${e instanceof Error ? e.message : String(e)}`);
15811
- return null;
15812
- }
15813
- }
15814
15835
  function registerCheckCommand(program2) {
15815
15836
  program2.command("check").description("Unified graph gate \u2014 verification, coverage, completeness").option("--approve", "Fill every unverified pair (deterministic first, then LLM), then report").option("--only-deterministic", "With --approve: fill ONLY deterministic pairs (keyless, free); committed locks stay untouched. For CI and pre-commit.").option("--dry-run", "With --approve: free cost preview \u2014 print the budget + per-node/per-aspect breakdown, then exit 0 WITHOUT writing anything or calling the reviewer.").option("--top [n]", "Read-only triage: print only the N highest-priority issue blocks (bare --top = just the single suggestedNext block). Header counts + exit code stay TRUE.").option("--summary", "Read-only triage: print per-node counts only (no per-issue blocks). Header counts + exit code stay TRUE.").action(async (opts) => {
15816
15837
  try {
@@ -15818,7 +15839,7 @@ function registerCheckCommand(program2) {
15818
15839
  const graph = await loadGraphOrAbort(cwd, { tolerateInvalidConfig: true });
15819
15840
  initDebugLog(graph.rootPath, graph.config.debug ?? false, appendToDebugLog);
15820
15841
  const projectRoot = path48.dirname(graph.rootPath);
15821
- const gitFiles = collectGitFiles(projectRoot);
15842
+ const gitFiles = await walkRepoFiles(projectRoot);
15822
15843
  const wantsTop = opts.top !== void 0;
15823
15844
  if (wantsTop && opts.summary) {
15824
15845
  process.stderr.write(chalk8.red(buildIssueMessage({
@@ -16212,86 +16233,96 @@ async function runAstAspect(params) {
16212
16233
  throw new AstRunnerError(exportCheck.code, exportCheck.message);
16213
16234
  }
16214
16235
  const checkFn = mod.check;
16236
+ const localTrees = [];
16215
16237
  const sourceFiles = [];
16216
- for (const f of params.files) {
16217
- const cached = params.parseCache?.get(f.path);
16218
- if (cached !== void 0) {
16219
- sourceFiles.push({ path: f.path, content: cached.content, ast: cached.ast });
16220
- continue;
16238
+ try {
16239
+ for (const f of params.files) {
16240
+ const cached = params.parseCache?.get(f.path);
16241
+ if (cached !== void 0) {
16242
+ sourceFiles.push({ path: f.path, content: cached.content, ast: cached.ast });
16243
+ continue;
16244
+ }
16245
+ const content20 = await readFile16(path49.resolve(params.projectRoot, f.path), "utf-8");
16246
+ if (getLanguageForExtension(path49.extname(f.path).toLowerCase()) === null) {
16247
+ sourceFiles.push({ path: f.path, content: content20, ast: void 0 });
16248
+ continue;
16249
+ }
16250
+ let ast;
16251
+ try {
16252
+ ast = await parseFile(f.path, content20);
16253
+ } catch (e) {
16254
+ const msg = e.message ?? String(e);
16255
+ throw new AstRunnerError("AST_GRAMMAR_LOAD_FAILED", {
16256
+ what: `Failed to load tree-sitter grammar for ${f.path}: ${msg}`,
16257
+ why: `The bundled WASM grammar could not be loaded.`,
16258
+ next: `Reinstall the CLI.`
16259
+ });
16260
+ }
16261
+ if (!params.parseCache) {
16262
+ localTrees.push(ast);
16263
+ }
16264
+ if (ast.rootNode.hasError) {
16265
+ const err = findFirstErrorNode(ast.rootNode);
16266
+ throw new AstRunnerError("AST_SOURCE_PARSE_ERROR", {
16267
+ what: `Source file ${f.path} has a syntax error at line ${(err?.startPosition.row ?? 0) + 1}.`,
16268
+ why: `Tree-sitter could not parse the file cleanly.`,
16269
+ next: `Fix the syntax error in ${f.path}.`
16270
+ });
16271
+ }
16272
+ if (params.parseCache) {
16273
+ params.parseCache.set(f.path, { content: content20, ast });
16274
+ }
16275
+ sourceFiles.push({ path: f.path, content: content20, ast });
16221
16276
  }
16222
- const content20 = await readFile16(path49.resolve(params.projectRoot, f.path), "utf-8");
16223
- if (getLanguageForExtension(path49.extname(f.path).toLowerCase()) === null) {
16224
- sourceFiles.push({ path: f.path, content: content20, ast: void 0 });
16225
- continue;
16277
+ const rangesPerFile = /* @__PURE__ */ new Map();
16278
+ for (const f of sourceFiles) {
16279
+ const totalLines = f.content.split("\n").length;
16280
+ rangesPerFile.set(f.path, collectSuppressions(f.ast, f.path, totalLines, f.content));
16226
16281
  }
16227
- let ast;
16282
+ const ctx = { files: sourceFiles };
16283
+ let raw;
16228
16284
  try {
16229
- ast = await parseFile(f.path, content20);
16285
+ raw = checkFn(ctx);
16230
16286
  } catch (e) {
16231
- const msg = e.message ?? String(e);
16232
- throw new AstRunnerError("AST_GRAMMAR_LOAD_FAILED", {
16233
- what: `Failed to load tree-sitter grammar for ${f.path}: ${msg}`,
16234
- why: `The bundled WASM grammar could not be loaded.`,
16235
- next: `Reinstall the CLI.`
16287
+ throw new AstRunnerError("AST_CHECK_THROWN", {
16288
+ what: `check.mjs threw an exception while running (aspect '${params.aspectId}').`,
16289
+ why: (e instanceof Error ? e.stack : void 0) ?? String(e),
16290
+ next: `Fix the bug in check.mjs, then re-run: yg check --approve`
16236
16291
  });
16237
16292
  }
16238
- if (ast.rootNode.hasError) {
16239
- const err = findFirstErrorNode(ast.rootNode);
16240
- throw new AstRunnerError("AST_SOURCE_PARSE_ERROR", {
16241
- what: `Source file ${f.path} has a syntax error at line ${(err?.startPosition.row ?? 0) + 1}.`,
16242
- why: `Tree-sitter could not parse the file cleanly.`,
16243
- next: `Fix the syntax error in ${f.path}.`
16293
+ if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
16294
+ throw new AstRunnerError("AST_CHECK_ASYNC", {
16295
+ what: `check.mjs returned a Promise; only synchronous returns are supported in v1.`,
16296
+ why: `The runner does not await check's return value.`,
16297
+ next: `Refactor check to be synchronous.`
16244
16298
  });
16245
16299
  }
16246
- params.parseCache?.set(f.path, { content: content20, ast });
16247
- sourceFiles.push({ path: f.path, content: content20, ast });
16248
- }
16249
- const rangesPerFile = /* @__PURE__ */ new Map();
16250
- for (const f of sourceFiles) {
16251
- const totalLines = f.content.split("\n").length;
16252
- rangesPerFile.set(f.path, collectSuppressions(f.ast, f.path, totalLines, f.content));
16253
- }
16254
- const ctx = { files: sourceFiles };
16255
- let raw;
16256
- try {
16257
- raw = checkFn(ctx);
16258
- } catch (e) {
16259
- throw new AstRunnerError("AST_CHECK_THROWN", {
16260
- what: `check.mjs threw an exception while running (aspect '${params.aspectId}').`,
16261
- why: (e instanceof Error ? e.stack : void 0) ?? String(e),
16262
- next: `Fix the bug in check.mjs, then re-run: yg check --approve`
16263
- });
16264
- }
16265
- if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
16266
- throw new AstRunnerError("AST_CHECK_ASYNC", {
16267
- what: `check.mjs returned a Promise; only synchronous returns are supported in v1.`,
16268
- why: `The runner does not await check's return value.`,
16269
- next: `Refactor check to be synchronous.`
16270
- });
16271
- }
16272
- if (!Array.isArray(raw)) {
16273
- throw new AstRunnerError("AST_CHECK_RETURN_SHAPE", {
16274
- what: `check.mjs returned ${typeof raw}, expected Violation[].`,
16275
- why: `The runner reports violations from the array returned by check.`,
16276
- next: `Return [] or Violation[] from check.`
16277
- });
16278
- }
16279
- const contextPaths = new Set(sourceFiles.map((f) => f.path));
16280
- for (const v of raw) {
16281
- if (!contextPaths.has(v.file)) {
16282
- throw new AstRunnerError("AST_CHECK_FILE_NOT_IN_CONTEXT", {
16283
- what: `check.mjs returned a Violation referencing file '${v.file}' which is not in ctx.files (aspect '${params.aspectId}').`,
16284
- why: `Author cannot synthesize violations against files they were not given. Suppress markers cannot reach unknown files.`,
16285
- next: `Return only violations for files in ctx.files (the array passed to check).`
16300
+ if (!Array.isArray(raw)) {
16301
+ throw new AstRunnerError("AST_CHECK_RETURN_SHAPE", {
16302
+ what: `check.mjs returned ${typeof raw}, expected Violation[].`,
16303
+ why: `The runner reports violations from the array returned by check.`,
16304
+ next: `Return [] or Violation[] from check.`
16286
16305
  });
16287
16306
  }
16307
+ const contextPaths = new Set(sourceFiles.map((f) => f.path));
16308
+ for (const v of raw) {
16309
+ if (!contextPaths.has(v.file)) {
16310
+ throw new AstRunnerError("AST_CHECK_FILE_NOT_IN_CONTEXT", {
16311
+ what: `check.mjs returned a Violation referencing file '${v.file}' which is not in ctx.files (aspect '${params.aspectId}').`,
16312
+ why: `Author cannot synthesize violations against files they were not given. Suppress markers cannot reach unknown files.`,
16313
+ next: `Return only violations for files in ctx.files (the array passed to check).`
16314
+ });
16315
+ }
16316
+ }
16317
+ const filtered = raw.filter((v) => {
16318
+ const ranges = rangesPerFile.get(v.file);
16319
+ if (!ranges) return true;
16320
+ return !isLineSuppressed(ranges, params.aspectId, v.line);
16321
+ });
16322
+ return { violations: filtered };
16323
+ } finally {
16324
+ for (const t of localTrees) t.delete();
16288
16325
  }
16289
- const filtered = raw.filter((v) => {
16290
- const ranges = rangesPerFile.get(v.file);
16291
- if (!ranges) return true;
16292
- return !isLineSuppressed(ranges, params.aspectId, v.line);
16293
- });
16294
- return { violations: filtered };
16295
16326
  }
16296
16327
  function findFirstErrorNode(node) {
16297
16328
  if (node.isError) return node;
@@ -21792,7 +21823,6 @@ function registerSchemasCommand(program2) {
21792
21823
  // src/cli/suppressions.ts
21793
21824
  import chalk14 from "chalk";
21794
21825
  import { readFileSync as readFileSync8, existsSync as existsSync9 } from "fs";
21795
- import { execFileSync as execFileSync2 } from "child_process";
21796
21826
  import path57 from "path";
21797
21827
  function isBinaryContent(buf) {
21798
21828
  const checkLen = Math.min(buf.length, 8192);
@@ -21821,8 +21851,11 @@ async function scanMarkersForFile(relFile, text2) {
21821
21851
  return scanSuppressionMarkers(text2);
21822
21852
  }
21823
21853
  try {
21824
- const tree = await parseFile(relFile, text2);
21825
- return scanSuppressionMarkersInComments(tree, relFile);
21854
+ return await withParsedFile(
21855
+ relFile,
21856
+ text2,
21857
+ (tree) => scanSuppressionMarkersInComments(tree, relFile)
21858
+ );
21826
21859
  } catch (error) {
21827
21860
  debugWrite(`[suppressions] parse fallback (raw scan): ${relFile}: ${error instanceof Error ? error.message : String(error)}`);
21828
21861
  return scanSuppressionMarkers(text2);
@@ -21941,17 +21974,7 @@ function registerSuppressionsCommand(program2) {
21941
21974
  const graph = await loadGraphOrAbort(cwd);
21942
21975
  initDebugLog(graph.rootPath, graph.config.debug ?? false, appendToDebugLog);
21943
21976
  const projectRoot = path57.dirname(graph.rootPath);
21944
- let gitFiles = [];
21945
- try {
21946
- const output = execFileSync2("git", ["ls-files", "."], {
21947
- cwd: projectRoot,
21948
- encoding: "utf-8",
21949
- stdio: ["pipe", "pipe", "pipe"]
21950
- });
21951
- gitFiles = output.trim().split("\n").filter((f) => f.length > 0);
21952
- } catch (error) {
21953
- debugWrite(`[suppressions] git ls-files fallback: ${error instanceof Error ? error.message : String(error)}`);
21954
- }
21977
+ const gitFiles = await walkRepoFiles(projectRoot);
21955
21978
  const knownAspectIds = new Set(graph.aspects.map((a) => a.id));
21956
21979
  const report2 = await runSuppressionsScan(projectRoot, gitFiles, knownAspectIds);
21957
21980
  process.stdout.write(formatSuppressionsOutput(report2));
package/dist/structure.js CHANGED
@@ -549,6 +549,7 @@ var GRAMMAR_DIRS = [
549
549
  ];
550
550
  var initPromise = null;
551
551
  var langCache = /* @__PURE__ */ new Map();
552
+ var parserCache = /* @__PURE__ */ new Map();
552
553
  function init() {
553
554
  if (initPromise === null) {
554
555
  initPromise = Parser.init();
@@ -589,8 +590,11 @@ async function getParser(extension) {
589
590
  });
590
591
  }
591
592
  const lang = await langP;
593
+ const existing = parserCache.get(cacheKey);
594
+ if (existing) return existing;
592
595
  const parser = new Parser();
593
596
  parser.setLanguage(lang);
597
+ parserCache.set(cacheKey, parser);
594
598
  return parser;
595
599
  }
596
600
  async function parseFile(filePath, content) {
@@ -602,6 +606,14 @@ async function parseFile(filePath, content) {
602
606
  }
603
607
  return tree;
604
608
  }
609
+ async function withParsedFile(filePath, content, fn) {
610
+ const tree = await parseFile(filePath, content);
611
+ try {
612
+ return await fn(tree);
613
+ } finally {
614
+ tree.delete();
615
+ }
616
+ }
605
617
 
606
618
  // src/structure/ctx-parsers.ts
607
619
  var ParseAstNotPrewarmedError = class extends Error {
@@ -876,6 +888,14 @@ function validateCheckModuleExport(mod, opts) {
876
888
  return { ok: true };
877
889
  }
878
890
 
891
+ // src/ast/parse-cache.ts
892
+ function destroyParseCache(cache) {
893
+ for (const { ast } of cache.values()) {
894
+ ast.delete();
895
+ }
896
+ cache.clear();
897
+ }
898
+
879
899
  // src/structure/hook-loader.ts
880
900
  import * as fs4 from "fs";
881
901
  import * as path8 from "path";
@@ -1435,143 +1455,149 @@ async function buildUnitCtx(params) {
1435
1455
  // src/structure/runner.ts
1436
1456
  async function runStructureAspect(params) {
1437
1457
  const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
1458
+ const ownCache = !params.parseCache;
1438
1459
  const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
1439
1460
  const touchedFiles = [];
1440
- const mod = await loadHookModule({ aspectDir, projectRoot, filename: "check.mjs" });
1441
- const exportCheck = validateCheckModuleExport(mod, {
1442
- codePrefix: "STRUCTURE",
1443
- runnerLabel: `aspect '${aspectId}'`
1444
- });
1445
- if (!exportCheck.ok) {
1446
- throw new StructureRunnerError(exportCheck.code, exportCheck.message);
1447
- }
1448
- const checkFn = mod.check;
1449
- const { ctx, recorder, ownFiles, astInputSet } = await buildUnitCtx({
1450
- aspectId,
1451
- nodePath,
1452
- graph,
1453
- projectRoot,
1454
- astCache,
1455
- touchedFiles,
1456
- subjectScope
1457
- });
1458
- let raw;
1459
1461
  try {
1460
- raw = checkFn(ctx);
1461
- } catch (err) {
1462
- if (err instanceof UndeclaredFsReadError) {
1463
- return {
1464
- violations: [{
1465
- message: `Aspect tried to read undeclared path '${err.path}'. Add a relation in yg-node.yaml to the node owning this path.`,
1466
- kind: "structure-aspect-undeclared-fs-read",
1467
- file: `.yggdrasil/aspects/${aspectId}/check.mjs`
1468
- }],
1469
- touchedFiles: [],
1470
- succeeded: false,
1471
- observations: recorder.snapshot(),
1472
- observationsTainted: recorder.tainted
1473
- };
1462
+ let rangesFor2 = function(filePath) {
1463
+ const existing = rangesByFile.get(filePath);
1464
+ if (existing !== void 0) return existing;
1465
+ const cached = astCache.get(filePath);
1466
+ let ranges;
1467
+ if (cached) {
1468
+ ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
1469
+ } else {
1470
+ const content = contentByPath.get(filePath);
1471
+ ranges = content !== void 0 ? collectSuppressions(void 0, filePath, content.split("\n").length, content) : null;
1472
+ }
1473
+ rangesByFile.set(filePath, ranges);
1474
+ return ranges;
1475
+ };
1476
+ var rangesFor = rangesFor2;
1477
+ const mod = await loadHookModule({ aspectDir, projectRoot, filename: "check.mjs" });
1478
+ const exportCheck = validateCheckModuleExport(mod, {
1479
+ codePrefix: "STRUCTURE",
1480
+ runnerLabel: `aspect '${aspectId}'`
1481
+ });
1482
+ if (!exportCheck.ok) {
1483
+ throw new StructureRunnerError(exportCheck.code, exportCheck.message);
1474
1484
  }
1475
- if (err instanceof UndeclaredGraphReadError) {
1476
- return {
1477
- violations: [{
1478
- message: `Aspect tried to read undeclared graph node '${err.nodePath}'. Add a relation in yg-node.yaml.`,
1479
- kind: "structure-aspect-undeclared-graph-read",
1480
- file: `.yggdrasil/aspects/${aspectId}/check.mjs`
1481
- }],
1482
- touchedFiles: [],
1483
- succeeded: false,
1484
- observations: recorder.snapshot(),
1485
- observationsTainted: recorder.tainted
1486
- };
1485
+ const checkFn = mod.check;
1486
+ const { ctx, recorder, ownFiles, astInputSet } = await buildUnitCtx({
1487
+ aspectId,
1488
+ nodePath,
1489
+ graph,
1490
+ projectRoot,
1491
+ astCache,
1492
+ touchedFiles,
1493
+ subjectScope
1494
+ });
1495
+ let raw;
1496
+ try {
1497
+ raw = checkFn(ctx);
1498
+ } catch (err) {
1499
+ if (err instanceof UndeclaredFsReadError) {
1500
+ return {
1501
+ violations: [{
1502
+ message: `Aspect tried to read undeclared path '${err.path}'. Add a relation in yg-node.yaml to the node owning this path.`,
1503
+ kind: "structure-aspect-undeclared-fs-read",
1504
+ file: `.yggdrasil/aspects/${aspectId}/check.mjs`
1505
+ }],
1506
+ touchedFiles: [],
1507
+ succeeded: false,
1508
+ observations: recorder.snapshot(),
1509
+ observationsTainted: recorder.tainted
1510
+ };
1511
+ }
1512
+ if (err instanceof UndeclaredGraphReadError) {
1513
+ return {
1514
+ violations: [{
1515
+ message: `Aspect tried to read undeclared graph node '${err.nodePath}'. Add a relation in yg-node.yaml.`,
1516
+ kind: "structure-aspect-undeclared-graph-read",
1517
+ file: `.yggdrasil/aspects/${aspectId}/check.mjs`
1518
+ }],
1519
+ touchedFiles: [],
1520
+ succeeded: false,
1521
+ observations: recorder.snapshot(),
1522
+ observationsTainted: recorder.tainted
1523
+ };
1524
+ }
1525
+ if (err instanceof ParseAstNotPrewarmedError) {
1526
+ return {
1527
+ violations: [{
1528
+ message: `Aspect called ctx.parseAst on '${err.filePath}', which was not pre-warmed by the dispatcher. Add a declared relation to the node owning this file, or use ctx.parseYaml/Json/Toml if AST is not required.`,
1529
+ kind: "structure-aspect-parseast-not-prewarmed",
1530
+ file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
1531
+ }],
1532
+ touchedFiles: [],
1533
+ succeeded: false,
1534
+ observations: recorder.snapshot(),
1535
+ observationsTainted: recorder.tainted
1536
+ };
1537
+ }
1538
+ throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
1539
+ what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
1540
+ why: `${err.message}
1541
+ ${err.stack ?? ""}`,
1542
+ next: `Fix the bug in check.mjs, then re-run: yg check --approve`
1543
+ });
1487
1544
  }
1488
- if (err instanceof ParseAstNotPrewarmedError) {
1489
- return {
1490
- violations: [{
1491
- message: `Aspect called ctx.parseAst on '${err.filePath}', which was not pre-warmed by the dispatcher. Add a declared relation to the node owning this file, or use ctx.parseYaml/Json/Toml if AST is not required.`,
1492
- kind: "structure-aspect-parseast-not-prewarmed",
1493
- file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
1494
- }],
1495
- touchedFiles: [],
1496
- succeeded: false,
1497
- observations: recorder.snapshot(),
1498
- observationsTainted: recorder.tainted
1499
- };
1545
+ if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
1546
+ throw new StructureRunnerError("STRUCTURE_CHECK_ASYNC", {
1547
+ what: `check.mjs returned a Promise; only synchronous returns are supported.`,
1548
+ why: `The runner does not await check's return value.`,
1549
+ next: `Refactor check to be synchronous.`
1550
+ });
1500
1551
  }
1501
- throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
1502
- what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
1503
- why: `${err.message}
1504
- ${err.stack ?? ""}`,
1505
- next: `Fix the bug in check.mjs, then re-run: yg check --approve`
1506
- });
1507
- }
1508
- if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
1509
- throw new StructureRunnerError("STRUCTURE_CHECK_ASYNC", {
1510
- what: `check.mjs returned a Promise; only synchronous returns are supported.`,
1511
- why: `The runner does not await check's return value.`,
1512
- next: `Refactor check to be synchronous.`
1513
- });
1514
- }
1515
- if (!Array.isArray(raw)) {
1516
- throw new StructureRunnerError("STRUCTURE_CHECK_RETURN_SHAPE", {
1517
- what: `check.mjs returned ${typeof raw}, expected Violation[].`,
1518
- why: `The runner reports violations from the array returned by check.`,
1519
- next: `Return [] or Violation[] from check.`
1520
- });
1521
- }
1522
- const contextFiles = new Set(ownFiles.map((f) => f.path));
1523
- for (const t of touchedFiles) contextFiles.add(t);
1524
- const violations = [];
1525
- for (const v of raw) {
1526
- if (typeof v !== "object" || v === null || typeof v.message !== "string") {
1552
+ if (!Array.isArray(raw)) {
1527
1553
  throw new StructureRunnerError("STRUCTURE_CHECK_RETURN_SHAPE", {
1528
- what: `Violation entry must be an object with a string 'message' field.`,
1529
- why: `The runner renders each violation from its message and optional file/line.`,
1530
- next: `Return objects shaped { message: string, file?: string, line?: number } from check.`
1554
+ what: `check.mjs returned ${typeof raw}, expected Violation[].`,
1555
+ why: `The runner reports violations from the array returned by check.`,
1556
+ next: `Return [] or Violation[] from check.`
1531
1557
  });
1532
1558
  }
1533
- const vv = v;
1534
- if (typeof vv.file === "string" && !contextFiles.has(normalizeMappingPath(vv.file))) {
1535
- throw new StructureRunnerError("STRUCTURE_CHECK_FILE_NOT_IN_CONTEXT", {
1536
- what: `Violation references file '${vv.file}' not in ctx (own mapping or touched via ctx.fs/ctx.graph).`,
1537
- why: `Author cannot synthesize violations against files they were not given.`,
1538
- next: `Return only violations for files in ctx, or declare a relation to the node owning '${vv.file}'.`
1539
- });
1559
+ const contextFiles = new Set(ownFiles.map((f) => f.path));
1560
+ for (const t of touchedFiles) contextFiles.add(t);
1561
+ const violations = [];
1562
+ for (const v of raw) {
1563
+ if (typeof v !== "object" || v === null || typeof v.message !== "string") {
1564
+ throw new StructureRunnerError("STRUCTURE_CHECK_RETURN_SHAPE", {
1565
+ what: `Violation entry must be an object with a string 'message' field.`,
1566
+ why: `The runner renders each violation from its message and optional file/line.`,
1567
+ next: `Return objects shaped { message: string, file?: string, line?: number } from check.`
1568
+ });
1569
+ }
1570
+ const vv = v;
1571
+ if (typeof vv.file === "string" && !contextFiles.has(normalizeMappingPath(vv.file))) {
1572
+ throw new StructureRunnerError("STRUCTURE_CHECK_FILE_NOT_IN_CONTEXT", {
1573
+ what: `Violation references file '${vv.file}' not in ctx (own mapping or touched via ctx.fs/ctx.graph).`,
1574
+ why: `Author cannot synthesize violations against files they were not given.`,
1575
+ next: `Return only violations for files in ctx, or declare a relation to the node owning '${vv.file}'.`
1576
+ });
1577
+ }
1578
+ violations.push(vv);
1540
1579
  }
1541
- violations.push(vv);
1542
- }
1543
- const contentByPath = /* @__PURE__ */ new Map();
1544
- for (const f of [...ownFiles, ...astInputSet]) {
1545
- contentByPath.set(normalizeMappingPath(f.path), f.content);
1546
- }
1547
- const rangesByFile = /* @__PURE__ */ new Map();
1548
- function rangesFor(filePath) {
1549
- const existing = rangesByFile.get(filePath);
1550
- if (existing !== void 0) return existing;
1551
- const cached = astCache.get(filePath);
1552
- let ranges;
1553
- if (cached) {
1554
- ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
1555
- } else {
1556
- const content = contentByPath.get(filePath);
1557
- ranges = content !== void 0 ? collectSuppressions(void 0, filePath, content.split("\n").length, content) : null;
1580
+ const contentByPath = /* @__PURE__ */ new Map();
1581
+ for (const f of [...ownFiles, ...astInputSet]) {
1582
+ contentByPath.set(normalizeMappingPath(f.path), f.content);
1558
1583
  }
1559
- rangesByFile.set(filePath, ranges);
1560
- return ranges;
1561
- }
1562
- const visible = violations.filter((v) => {
1563
- if (typeof v.file !== "string" || typeof v.line !== "number") return true;
1564
- const ranges = rangesFor(normalizeMappingPath(v.file));
1565
- if (!ranges) return true;
1566
- return !isLineSuppressed(ranges, aspectId, v.line);
1567
- });
1568
- return {
1569
- violations: visible,
1570
- touchedFiles,
1571
- succeeded: true,
1572
- observations: recorder.snapshot(),
1573
- observationsTainted: recorder.tainted
1574
- };
1584
+ const rangesByFile = /* @__PURE__ */ new Map();
1585
+ const visible = violations.filter((v) => {
1586
+ if (typeof v.file !== "string" || typeof v.line !== "number") return true;
1587
+ const ranges = rangesFor2(normalizeMappingPath(v.file));
1588
+ if (!ranges) return true;
1589
+ return !isLineSuppressed(ranges, aspectId, v.line);
1590
+ });
1591
+ return {
1592
+ violations: visible,
1593
+ touchedFiles,
1594
+ succeeded: true,
1595
+ observations: recorder.snapshot(),
1596
+ observationsTainted: recorder.tainted
1597
+ };
1598
+ } finally {
1599
+ if (ownCache) destroyParseCache(astCache);
1600
+ }
1575
1601
  }
1576
1602
 
1577
1603
  // src/structure/suppress-ranges.ts
@@ -1581,12 +1607,12 @@ async function resolveSuppressedRangesForPrompt(subjects, aspectId) {
1581
1607
  for (const subject of subjects) {
1582
1608
  const content = subject.bytes.toString("utf8");
1583
1609
  const hasGrammar = getLanguageForExtension(extname5(subject.path).toLowerCase()) !== null;
1584
- let tree;
1585
- if (hasGrammar) {
1586
- tree = await parseFile(subject.path, content);
1587
- }
1588
1610
  const totalLines = content.split("\n").length;
1589
- const all = collectSuppressions(tree, subject.path, totalLines, content);
1611
+ const all = hasGrammar ? await withParsedFile(
1612
+ subject.path,
1613
+ content,
1614
+ (tree) => collectSuppressions(tree, subject.path, totalLines, content)
1615
+ ) : collectSuppressions(void 0, subject.path, totalLines, content);
1590
1616
  const ranges = formatSuppressedRangesForAspect(all, aspectId);
1591
1617
  if (ranges.length > 0) {
1592
1618
  byFile.push({ path: subject.path, ranges });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrisdudek/yg",
3
- "version": "5.2.4",
3
+ "version": "5.2.6",
4
4
  "description": "Architecture rules your AI coding agent can't ignore. It gets the rules for a file before it edits, and every change is checked — by a free local script or an LLM reviewer — before it moves on. Works with Claude Code, Cursor, Copilot, Codex, Cline.",
5
5
  "type": "module",
6
6
  "bin": {