@chrisdudek/yg 5.2.4 → 5.2.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.
Files changed (3) hide show
  1. package/dist/bin.js +227 -194
  2. package/dist/structure.js +145 -126
  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) {
@@ -10404,6 +10408,14 @@ function validateCheckModuleExport(mod, opts) {
10404
10408
  return { ok: true };
10405
10409
  }
10406
10410
 
10411
+ // src/ast/parse-cache.ts
10412
+ function destroyParseCache(cache) {
10413
+ for (const { ast } of cache.values()) {
10414
+ ast.delete();
10415
+ }
10416
+ cache.clear();
10417
+ }
10418
+
10407
10419
  // src/structure/hook-loader.ts
10408
10420
  import * as fs4 from "fs";
10409
10421
  import * as path29 from "path";
@@ -10787,143 +10799,149 @@ async function runCompanionHook(params) {
10787
10799
  // src/structure/runner.ts
10788
10800
  async function runStructureAspect(params) {
10789
10801
  const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
10802
+ const ownCache = !params.parseCache;
10790
10803
  const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
10791
10804
  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
10805
  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
- };
10806
+ let rangesFor2 = function(filePath) {
10807
+ const existing = rangesByFile.get(filePath);
10808
+ if (existing !== void 0) return existing;
10809
+ const cached = astCache.get(filePath);
10810
+ let ranges;
10811
+ if (cached) {
10812
+ ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
10813
+ } else {
10814
+ const content20 = contentByPath.get(filePath);
10815
+ ranges = content20 !== void 0 ? collectSuppressions(void 0, filePath, content20.split("\n").length, content20) : null;
10816
+ }
10817
+ rangesByFile.set(filePath, ranges);
10818
+ return ranges;
10819
+ };
10820
+ var rangesFor = rangesFor2;
10821
+ const mod = await loadHookModule({ aspectDir, projectRoot, filename: "check.mjs" });
10822
+ const exportCheck = validateCheckModuleExport(mod, {
10823
+ codePrefix: "STRUCTURE",
10824
+ runnerLabel: `aspect '${aspectId}'`
10825
+ });
10826
+ if (!exportCheck.ok) {
10827
+ throw new StructureRunnerError(exportCheck.code, exportCheck.message);
10826
10828
  }
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
- };
10829
+ const checkFn = mod.check;
10830
+ const { ctx, recorder, ownFiles, astInputSet } = await buildUnitCtx({
10831
+ aspectId,
10832
+ nodePath,
10833
+ graph,
10834
+ projectRoot,
10835
+ astCache,
10836
+ touchedFiles,
10837
+ subjectScope
10838
+ });
10839
+ let raw;
10840
+ try {
10841
+ raw = checkFn(ctx);
10842
+ } catch (err) {
10843
+ if (err instanceof UndeclaredFsReadError) {
10844
+ return {
10845
+ violations: [{
10846
+ message: `Aspect tried to read undeclared path '${err.path}'. Add a relation in yg-node.yaml to the node owning this path.`,
10847
+ kind: "structure-aspect-undeclared-fs-read",
10848
+ file: `.yggdrasil/aspects/${aspectId}/check.mjs`
10849
+ }],
10850
+ touchedFiles: [],
10851
+ succeeded: false,
10852
+ observations: recorder.snapshot(),
10853
+ observationsTainted: recorder.tainted
10854
+ };
10855
+ }
10856
+ if (err instanceof UndeclaredGraphReadError) {
10857
+ return {
10858
+ violations: [{
10859
+ message: `Aspect tried to read undeclared graph node '${err.nodePath}'. Add a relation in yg-node.yaml.`,
10860
+ kind: "structure-aspect-undeclared-graph-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 ParseAstNotPrewarmedError) {
10870
+ return {
10871
+ violations: [{
10872
+ 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.`,
10873
+ kind: "structure-aspect-parseast-not-prewarmed",
10874
+ file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
10875
+ }],
10876
+ touchedFiles: [],
10877
+ succeeded: false,
10878
+ observations: recorder.snapshot(),
10879
+ observationsTainted: recorder.tainted
10880
+ };
10881
+ }
10882
+ throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
10883
+ what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
10884
+ why: `${err.message}
10885
+ ${err.stack ?? ""}`,
10886
+ next: `Fix the bug in check.mjs, then re-run: yg check --approve`
10887
+ });
10839
10888
  }
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
- };
10889
+ if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
10890
+ throw new StructureRunnerError("STRUCTURE_CHECK_ASYNC", {
10891
+ what: `check.mjs returned a Promise; only synchronous returns are supported.`,
10892
+ why: `The runner does not await check's return value.`,
10893
+ next: `Refactor check to be synchronous.`
10894
+ });
10852
10895
  }
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") {
10896
+ if (!Array.isArray(raw)) {
10879
10897
  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.`
10898
+ what: `check.mjs returned ${typeof raw}, expected Violation[].`,
10899
+ why: `The runner reports violations from the array returned by check.`,
10900
+ next: `Return [] or Violation[] from check.`
10883
10901
  });
10884
10902
  }
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
- });
10903
+ const contextFiles = new Set(ownFiles.map((f) => f.path));
10904
+ for (const t of touchedFiles) contextFiles.add(t);
10905
+ const violations = [];
10906
+ for (const v of raw) {
10907
+ if (typeof v !== "object" || v === null || typeof v.message !== "string") {
10908
+ throw new StructureRunnerError("STRUCTURE_CHECK_RETURN_SHAPE", {
10909
+ what: `Violation entry must be an object with a string 'message' field.`,
10910
+ why: `The runner renders each violation from its message and optional file/line.`,
10911
+ next: `Return objects shaped { message: string, file?: string, line?: number } from check.`
10912
+ });
10913
+ }
10914
+ const vv = v;
10915
+ if (typeof vv.file === "string" && !contextFiles.has(normalizeMappingPath(vv.file))) {
10916
+ throw new StructureRunnerError("STRUCTURE_CHECK_FILE_NOT_IN_CONTEXT", {
10917
+ what: `Violation references file '${vv.file}' not in ctx (own mapping or touched via ctx.fs/ctx.graph).`,
10918
+ why: `Author cannot synthesize violations against files they were not given.`,
10919
+ next: `Return only violations for files in ctx, or declare a relation to the node owning '${vv.file}'.`
10920
+ });
10921
+ }
10922
+ violations.push(vv);
10892
10923
  }
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;
10924
+ const contentByPath = /* @__PURE__ */ new Map();
10925
+ for (const f of [...ownFiles, ...astInputSet]) {
10926
+ contentByPath.set(normalizeMappingPath(f.path), f.content);
10910
10927
  }
10911
- rangesByFile.set(filePath, ranges);
10912
- return ranges;
10928
+ const rangesByFile = /* @__PURE__ */ new Map();
10929
+ const visible = violations.filter((v) => {
10930
+ if (typeof v.file !== "string" || typeof v.line !== "number") return true;
10931
+ const ranges = rangesFor2(normalizeMappingPath(v.file));
10932
+ if (!ranges) return true;
10933
+ return !isLineSuppressed(ranges, aspectId, v.line);
10934
+ });
10935
+ return {
10936
+ violations: visible,
10937
+ touchedFiles,
10938
+ succeeded: true,
10939
+ observations: recorder.snapshot(),
10940
+ observationsTainted: recorder.tainted
10941
+ };
10942
+ } finally {
10943
+ if (ownCache) destroyParseCache(astCache);
10913
10944
  }
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
10945
  }
10928
10946
 
10929
10947
  // src/structure/suppress-ranges.ts
@@ -10939,6 +10957,7 @@ async function resolveSuppressedRangesForPrompt(subjects, aspectId) {
10939
10957
  }
10940
10958
  const totalLines = content20.split("\n").length;
10941
10959
  const all = collectSuppressions(tree, subject.path, totalLines, content20);
10960
+ tree?.delete();
10942
10961
  const ranges = formatSuppressedRangesForAspect(all, aspectId);
10943
10962
  if (ranges.length > 0) {
10944
10963
  byFile.push({ path: subject.path, ranges });
@@ -12237,6 +12256,9 @@ async function runRelationPass(graph, projectRoot, deps) {
12237
12256
  violationsByNode.set(nodeId, { verdict: "approved", violations: [] });
12238
12257
  }
12239
12258
  }
12259
+ for (const entry of parseCache.values()) {
12260
+ if (entry) entry.tree.delete();
12261
+ }
12240
12262
  return { violationsByNode };
12241
12263
  }
12242
12264
 
@@ -16212,86 +16234,95 @@ async function runAstAspect(params) {
16212
16234
  throw new AstRunnerError(exportCheck.code, exportCheck.message);
16213
16235
  }
16214
16236
  const checkFn = mod.check;
16237
+ const localTrees = [];
16215
16238
  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;
16239
+ try {
16240
+ for (const f of params.files) {
16241
+ const cached = params.parseCache?.get(f.path);
16242
+ if (cached !== void 0) {
16243
+ sourceFiles.push({ path: f.path, content: cached.content, ast: cached.ast });
16244
+ continue;
16245
+ }
16246
+ const content20 = await readFile16(path49.resolve(params.projectRoot, f.path), "utf-8");
16247
+ if (getLanguageForExtension(path49.extname(f.path).toLowerCase()) === null) {
16248
+ sourceFiles.push({ path: f.path, content: content20, ast: void 0 });
16249
+ continue;
16250
+ }
16251
+ let ast;
16252
+ try {
16253
+ ast = await parseFile(f.path, content20);
16254
+ } catch (e) {
16255
+ const msg = e.message ?? String(e);
16256
+ throw new AstRunnerError("AST_GRAMMAR_LOAD_FAILED", {
16257
+ what: `Failed to load tree-sitter grammar for ${f.path}: ${msg}`,
16258
+ why: `The bundled WASM grammar could not be loaded.`,
16259
+ next: `Reinstall the CLI.`
16260
+ });
16261
+ }
16262
+ if (ast.rootNode.hasError) {
16263
+ const err = findFirstErrorNode(ast.rootNode);
16264
+ throw new AstRunnerError("AST_SOURCE_PARSE_ERROR", {
16265
+ what: `Source file ${f.path} has a syntax error at line ${(err?.startPosition.row ?? 0) + 1}.`,
16266
+ why: `Tree-sitter could not parse the file cleanly.`,
16267
+ next: `Fix the syntax error in ${f.path}.`
16268
+ });
16269
+ }
16270
+ if (params.parseCache) {
16271
+ params.parseCache.set(f.path, { content: content20, ast });
16272
+ } else {
16273
+ localTrees.push(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;
@@ -21822,7 +21853,9 @@ async function scanMarkersForFile(relFile, text2) {
21822
21853
  }
21823
21854
  try {
21824
21855
  const tree = await parseFile(relFile, text2);
21825
- return scanSuppressionMarkersInComments(tree, relFile);
21856
+ const result = scanSuppressionMarkersInComments(tree, relFile);
21857
+ tree.delete();
21858
+ return result;
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);
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) {
@@ -876,6 +880,14 @@ function validateCheckModuleExport(mod, opts) {
876
880
  return { ok: true };
877
881
  }
878
882
 
883
+ // src/ast/parse-cache.ts
884
+ function destroyParseCache(cache) {
885
+ for (const { ast } of cache.values()) {
886
+ ast.delete();
887
+ }
888
+ cache.clear();
889
+ }
890
+
879
891
  // src/structure/hook-loader.ts
880
892
  import * as fs4 from "fs";
881
893
  import * as path8 from "path";
@@ -1435,143 +1447,149 @@ async function buildUnitCtx(params) {
1435
1447
  // src/structure/runner.ts
1436
1448
  async function runStructureAspect(params) {
1437
1449
  const { aspectDir, aspectId, nodePath, graph, projectRoot, subjectScope } = params;
1450
+ const ownCache = !params.parseCache;
1438
1451
  const astCache = params.parseCache ?? /* @__PURE__ */ new Map();
1439
1452
  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
1453
  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
- };
1454
+ let rangesFor2 = function(filePath) {
1455
+ const existing = rangesByFile.get(filePath);
1456
+ if (existing !== void 0) return existing;
1457
+ const cached = astCache.get(filePath);
1458
+ let ranges;
1459
+ if (cached) {
1460
+ ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
1461
+ } else {
1462
+ const content = contentByPath.get(filePath);
1463
+ ranges = content !== void 0 ? collectSuppressions(void 0, filePath, content.split("\n").length, content) : null;
1464
+ }
1465
+ rangesByFile.set(filePath, ranges);
1466
+ return ranges;
1467
+ };
1468
+ var rangesFor = rangesFor2;
1469
+ const mod = await loadHookModule({ aspectDir, projectRoot, filename: "check.mjs" });
1470
+ const exportCheck = validateCheckModuleExport(mod, {
1471
+ codePrefix: "STRUCTURE",
1472
+ runnerLabel: `aspect '${aspectId}'`
1473
+ });
1474
+ if (!exportCheck.ok) {
1475
+ throw new StructureRunnerError(exportCheck.code, exportCheck.message);
1474
1476
  }
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
- };
1477
+ const checkFn = mod.check;
1478
+ const { ctx, recorder, ownFiles, astInputSet } = await buildUnitCtx({
1479
+ aspectId,
1480
+ nodePath,
1481
+ graph,
1482
+ projectRoot,
1483
+ astCache,
1484
+ touchedFiles,
1485
+ subjectScope
1486
+ });
1487
+ let raw;
1488
+ try {
1489
+ raw = checkFn(ctx);
1490
+ } catch (err) {
1491
+ if (err instanceof UndeclaredFsReadError) {
1492
+ return {
1493
+ violations: [{
1494
+ message: `Aspect tried to read undeclared path '${err.path}'. Add a relation in yg-node.yaml to the node owning this path.`,
1495
+ kind: "structure-aspect-undeclared-fs-read",
1496
+ file: `.yggdrasil/aspects/${aspectId}/check.mjs`
1497
+ }],
1498
+ touchedFiles: [],
1499
+ succeeded: false,
1500
+ observations: recorder.snapshot(),
1501
+ observationsTainted: recorder.tainted
1502
+ };
1503
+ }
1504
+ if (err instanceof UndeclaredGraphReadError) {
1505
+ return {
1506
+ violations: [{
1507
+ message: `Aspect tried to read undeclared graph node '${err.nodePath}'. Add a relation in yg-node.yaml.`,
1508
+ kind: "structure-aspect-undeclared-graph-read",
1509
+ file: `.yggdrasil/aspects/${aspectId}/check.mjs`
1510
+ }],
1511
+ touchedFiles: [],
1512
+ succeeded: false,
1513
+ observations: recorder.snapshot(),
1514
+ observationsTainted: recorder.tainted
1515
+ };
1516
+ }
1517
+ if (err instanceof ParseAstNotPrewarmedError) {
1518
+ return {
1519
+ violations: [{
1520
+ 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.`,
1521
+ kind: "structure-aspect-parseast-not-prewarmed",
1522
+ file: `.yggdrasil/model/${nodePath}/yg-node.yaml`
1523
+ }],
1524
+ touchedFiles: [],
1525
+ succeeded: false,
1526
+ observations: recorder.snapshot(),
1527
+ observationsTainted: recorder.tainted
1528
+ };
1529
+ }
1530
+ throw new StructureRunnerError("STRUCTURE_CHECK_THROWN", {
1531
+ what: `check.mjs threw an exception while running (aspect '${aspectId}').`,
1532
+ why: `${err.message}
1533
+ ${err.stack ?? ""}`,
1534
+ next: `Fix the bug in check.mjs, then re-run: yg check --approve`
1535
+ });
1487
1536
  }
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
- };
1537
+ if (raw !== null && typeof raw === "object" && typeof raw.then === "function") {
1538
+ throw new StructureRunnerError("STRUCTURE_CHECK_ASYNC", {
1539
+ what: `check.mjs returned a Promise; only synchronous returns are supported.`,
1540
+ why: `The runner does not await check's return value.`,
1541
+ next: `Refactor check to be synchronous.`
1542
+ });
1500
1543
  }
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") {
1544
+ if (!Array.isArray(raw)) {
1527
1545
  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.`
1546
+ what: `check.mjs returned ${typeof raw}, expected Violation[].`,
1547
+ why: `The runner reports violations from the array returned by check.`,
1548
+ next: `Return [] or Violation[] from check.`
1531
1549
  });
1532
1550
  }
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
- });
1551
+ const contextFiles = new Set(ownFiles.map((f) => f.path));
1552
+ for (const t of touchedFiles) contextFiles.add(t);
1553
+ const violations = [];
1554
+ for (const v of raw) {
1555
+ if (typeof v !== "object" || v === null || typeof v.message !== "string") {
1556
+ throw new StructureRunnerError("STRUCTURE_CHECK_RETURN_SHAPE", {
1557
+ what: `Violation entry must be an object with a string 'message' field.`,
1558
+ why: `The runner renders each violation from its message and optional file/line.`,
1559
+ next: `Return objects shaped { message: string, file?: string, line?: number } from check.`
1560
+ });
1561
+ }
1562
+ const vv = v;
1563
+ if (typeof vv.file === "string" && !contextFiles.has(normalizeMappingPath(vv.file))) {
1564
+ throw new StructureRunnerError("STRUCTURE_CHECK_FILE_NOT_IN_CONTEXT", {
1565
+ what: `Violation references file '${vv.file}' not in ctx (own mapping or touched via ctx.fs/ctx.graph).`,
1566
+ why: `Author cannot synthesize violations against files they were not given.`,
1567
+ next: `Return only violations for files in ctx, or declare a relation to the node owning '${vv.file}'.`
1568
+ });
1569
+ }
1570
+ violations.push(vv);
1540
1571
  }
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;
1572
+ const contentByPath = /* @__PURE__ */ new Map();
1573
+ for (const f of [...ownFiles, ...astInputSet]) {
1574
+ contentByPath.set(normalizeMappingPath(f.path), f.content);
1558
1575
  }
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
- };
1576
+ const rangesByFile = /* @__PURE__ */ new Map();
1577
+ const visible = violations.filter((v) => {
1578
+ if (typeof v.file !== "string" || typeof v.line !== "number") return true;
1579
+ const ranges = rangesFor2(normalizeMappingPath(v.file));
1580
+ if (!ranges) return true;
1581
+ return !isLineSuppressed(ranges, aspectId, v.line);
1582
+ });
1583
+ return {
1584
+ violations: visible,
1585
+ touchedFiles,
1586
+ succeeded: true,
1587
+ observations: recorder.snapshot(),
1588
+ observationsTainted: recorder.tainted
1589
+ };
1590
+ } finally {
1591
+ if (ownCache) destroyParseCache(astCache);
1592
+ }
1575
1593
  }
1576
1594
 
1577
1595
  // src/structure/suppress-ranges.ts
@@ -1587,6 +1605,7 @@ async function resolveSuppressedRangesForPrompt(subjects, aspectId) {
1587
1605
  }
1588
1606
  const totalLines = content.split("\n").length;
1589
1607
  const all = collectSuppressions(tree, subject.path, totalLines, content);
1608
+ tree?.delete();
1590
1609
  const ranges = formatSuppressedRangesForAspect(all, aspectId);
1591
1610
  if (ranges.length > 0) {
1592
1611
  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.5",
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": {