@chrisdudek/yg 5.0.0-alpha.3 → 5.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -162,9 +162,23 @@ var init_graph = __esm({
162
162
  });
163
163
 
164
164
  // src/utils/mapping-path.ts
165
+ import { minimatch } from "minimatch";
165
166
  function normalizeMappingPath(p2) {
166
167
  return p2.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
167
168
  }
169
+ function isGlobPattern(entry) {
170
+ return entry.includes("*");
171
+ }
172
+ function globMatch(file, pattern) {
173
+ return minimatch(file, pattern, { dot: true });
174
+ }
175
+ function mappingEntryMatchesFile(entry, file) {
176
+ const e = normalizeMappingPath(entry);
177
+ const f = normalizeMappingPath(file);
178
+ if (e === "") return false;
179
+ if (isGlobPattern(e)) return globMatch(f, e);
180
+ return f === e || f.startsWith(e + "/");
181
+ }
168
182
  var init_mapping_path = __esm({
169
183
  "src/utils/mapping-path.ts"() {
170
184
  "use strict";
@@ -675,6 +689,11 @@ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, exclu
675
689
  const gitignoreStack = await loadRootGitignoreStack2(projectRoot);
676
690
  const allFiles = [];
677
691
  for (const tf of trackedFiles) {
692
+ if (isGlobPattern(tf.path)) {
693
+ const entries = await expandGlobEntry(projectRoot, tf.path, gitignoreStack);
694
+ for (const entry of entries) allFiles.push(entry);
695
+ continue;
696
+ }
678
697
  const absPath = path16.join(projectRoot, tf.path);
679
698
  try {
680
699
  const st = await stat5(absPath);
@@ -697,7 +716,7 @@ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, exclu
697
716
  continue;
698
717
  }
699
718
  }
700
- const filtered = excludePrefixes?.length ? allFiles.filter((entry) => !excludePrefixes.some((prefix) => entry.relPath === prefix || entry.relPath.startsWith(prefix + "/"))) : allFiles;
719
+ const filtered = excludePrefixes?.length ? allFiles.filter((entry) => !excludePrefixes.some((prefix) => mappingEntryMatchesFile(prefix, entry.relPath))) : allFiles;
701
720
  const dirty = [];
702
721
  for (const entry of filtered) {
703
722
  const storedMtime = storedFileData?.mtimes[entry.relPath];
@@ -757,26 +776,50 @@ async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, optio
757
776
  result.push(...fileStats);
758
777
  return result;
759
778
  }
779
+ async function expandGlobEntry(projectRoot, glob, gitignoreStack) {
780
+ const segments = glob.split("/");
781
+ const firstGlobIdx = segments.findIndex((s) => isGlobPattern(s));
782
+ const baseSegments = firstGlobIdx > 0 ? segments.slice(0, firstGlobIdx) : [];
783
+ const baseDir = baseSegments.length > 0 ? path16.join(projectRoot, ...baseSegments) : projectRoot;
784
+ try {
785
+ const dirEntries = await collectDirectoryFilePaths(baseDir, projectRoot, {
786
+ projectRoot,
787
+ gitignoreStack
788
+ });
789
+ return dirEntries.filter((entry) => globMatch(entry.relPath, glob)).map((entry) => ({
790
+ relPath: toPosixPath(entry.relPath),
791
+ absPath: entry.absPath,
792
+ mtimeMs: entry.mtimeMs
793
+ }));
794
+ } catch {
795
+ return [];
796
+ }
797
+ }
760
798
  async function expandMappingPaths(projectRoot, mappingPaths) {
761
799
  const gitignoreStack = await loadRootGitignoreStack2(projectRoot);
762
800
  const result = [];
763
801
  for (const mp of mappingPaths) {
764
- const absPath = path16.join(projectRoot, mp);
765
- try {
766
- const st = await stat5(absPath);
767
- if (st.isDirectory()) {
768
- const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
769
- projectRoot,
770
- gitignoreStack
771
- });
772
- for (const entry of dirEntries) {
773
- result.push(toPosixPath(path16.join(mp, entry.relPath)));
802
+ if (isGlobPattern(mp)) {
803
+ const entries = await expandGlobEntry(projectRoot, mp, gitignoreStack);
804
+ for (const entry of entries) result.push(entry.relPath);
805
+ } else {
806
+ const absPath = path16.join(projectRoot, mp);
807
+ try {
808
+ const st = await stat5(absPath);
809
+ if (st.isDirectory()) {
810
+ const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
811
+ projectRoot,
812
+ gitignoreStack
813
+ });
814
+ for (const entry of dirEntries) {
815
+ result.push(toPosixPath(path16.join(mp, entry.relPath)));
816
+ }
817
+ } else {
818
+ result.push(toPosixPath(mp));
774
819
  }
775
- } else {
776
- result.push(toPosixPath(mp));
820
+ } catch {
821
+ continue;
777
822
  }
778
- } catch {
779
- continue;
780
823
  }
781
824
  }
782
825
  return result;
@@ -786,6 +829,7 @@ var init_hash = __esm({
786
829
  "src/io/hash.ts"() {
787
830
  "use strict";
788
831
  init_posix();
832
+ init_mapping_path();
789
833
  init_repo_scanner();
790
834
  require3 = createRequire2(import.meta.url);
791
835
  ignoreFactory2 = require3("ignore");
@@ -1047,10 +1091,10 @@ function computeEffectiveAspectStatuses(node, graph) {
1047
1091
  for (const a of graph.aspects) idToAspect.set(a.id, a);
1048
1092
  let changed = true;
1049
1093
  let iterations = 0;
1050
- const maxIterations = graph.aspects.length + 1;
1094
+ const maxIterations = graph.aspects.length + 2;
1051
1095
  while (changed) {
1052
1096
  if (++iterations > maxIterations) {
1053
- throw new ImpliesCycleError(`implies fix-point did not converge after ${maxIterations} iterations (cycle suspected)`);
1097
+ throw new ImpliesCycleError("implies fix-point exceeded its iteration bound (internal invariant violated)");
1054
1098
  }
1055
1099
  changed = false;
1056
1100
  const currentIds = [...result.keys()];
@@ -1258,8 +1302,7 @@ function collectTrackedFiles(node, graph, baseline) {
1258
1302
  }
1259
1303
  const allAspectIds = computeEffectiveAspects(node, graph);
1260
1304
  const mappingPathsList = normalizeMappingPaths(node.meta.mapping);
1261
- const mappingPathsSet = new Set(mappingPathsList);
1262
- const isOwnedByMapping = (p2) => mappingPathsSet.has(p2) || mappingPathsList.some((m) => p2.startsWith(m + "/"));
1305
+ const isOwnedByMapping = (p2) => mappingPathsList.some((m) => mappingEntryMatchesFile(m, p2));
1263
1306
  for (const aspectId of allAspectIds) {
1264
1307
  const aspect = graph.aspects.find((a) => a.id === aspectId);
1265
1308
  if (!aspect) continue;
@@ -1363,6 +1406,7 @@ var init_files = __esm({
1363
1406
  "src/core/graph/files.ts"() {
1364
1407
  "use strict";
1365
1408
  init_paths();
1409
+ init_mapping_path();
1366
1410
  init_traversal();
1367
1411
  init_aspects();
1368
1412
  init_tier_selection();
@@ -3106,9 +3150,8 @@ function isAllowed(p2, set) {
3106
3150
  if (p2 === "") return false;
3107
3151
  if (set.has(p2)) return true;
3108
3152
  for (const a of set) {
3109
- if (a === p2) return true;
3110
3153
  if (a.startsWith(p2 + "/")) return true;
3111
- if (p2.startsWith(a + "/")) return true;
3154
+ if (mappingEntryMatchesFile(a, p2)) return true;
3112
3155
  }
3113
3156
  return false;
3114
3157
  }
@@ -3200,13 +3243,7 @@ var init_ctx_fs = __esm({
3200
3243
  function isPathInMapping(candidate, mapping) {
3201
3244
  const c = normalizeMappingPath(candidate);
3202
3245
  if (c === "") return false;
3203
- for (const raw of mapping) {
3204
- const n = normalizeMappingPath(raw);
3205
- if (n === "") continue;
3206
- if (c === n) return true;
3207
- if (c.startsWith(n + "/")) return true;
3208
- }
3209
- return false;
3246
+ return mapping.some((raw) => mappingEntryMatchesFile(raw, c));
3210
3247
  }
3211
3248
  var init_expand_mapping_sync = __esm({
3212
3249
  "src/structure/expand-mapping-sync.ts"() {
@@ -3247,15 +3284,16 @@ function computeAllowedNodePaths(currentPath, graph) {
3247
3284
  return allowed;
3248
3285
  }
3249
3286
  function createCtxGraph(params) {
3250
- const { currentNodePath, graph, projectRoot, touchedFiles } = params;
3287
+ const { currentNodePath, graph, projectRoot, touchedFiles, expandedFilesByNode } = params;
3251
3288
  const allowed = computeAllowedNodePaths(currentNodePath, graph);
3252
3289
  function assertAllowed(id) {
3253
3290
  if (!allowed.has(id)) throw new UndeclaredGraphReadError(id);
3254
3291
  }
3255
3292
  function toPublicNode(m) {
3256
3293
  const files = [];
3257
- for (const raw of m.meta.mapping ?? []) {
3258
- const p2 = normalizeMappingPath(raw);
3294
+ const preExpanded = expandedFilesByNode?.get(m.path);
3295
+ const candidatePaths = preExpanded ?? (m.meta.mapping ?? []).map(normalizeMappingPath);
3296
+ for (const p2 of candidatePaths) {
3259
3297
  if (!p2) continue;
3260
3298
  const abs = path29.resolve(projectRoot, p2);
3261
3299
  try {
@@ -3354,10 +3392,14 @@ import path30 from "path";
3354
3392
  import { fileURLToPath as fileURLToPath4 } from "url";
3355
3393
  import { existsSync as existsSync5 } from "fs";
3356
3394
  import { createRequire as createRequire3 } from "module";
3357
- async function init() {
3358
- if (initialized) return;
3359
- await Parser.init();
3360
- initialized = true;
3395
+ function init() {
3396
+ if (initPromise === null) {
3397
+ initPromise = Parser.init();
3398
+ initPromise.catch(() => {
3399
+ initPromise = null;
3400
+ });
3401
+ }
3402
+ return initPromise;
3361
3403
  }
3362
3404
  function resolveWasm(filename, pkg2) {
3363
3405
  for (const dir of GRAMMAR_DIRS) {
@@ -3380,12 +3422,16 @@ async function getParser(extension) {
3380
3422
  throw new Error(`no parser for extension '${extension}'`);
3381
3423
  }
3382
3424
  const cacheKey = info.wasmFile;
3383
- let lang = langCache.get(cacheKey);
3384
- if (!lang) {
3425
+ let langP = langCache.get(cacheKey);
3426
+ if (langP === void 0) {
3385
3427
  const wasmPath = resolveWasm(info.wasmFile, info.wasmPackage);
3386
- lang = await Language.load(wasmPath);
3387
- langCache.set(cacheKey, lang);
3428
+ langP = Language.load(wasmPath);
3429
+ langCache.set(cacheKey, langP);
3430
+ langP.catch(() => {
3431
+ if (langCache.get(cacheKey) === langP) langCache.delete(cacheKey);
3432
+ });
3388
3433
  }
3434
+ const lang = await langP;
3389
3435
  const parser = new Parser();
3390
3436
  parser.setLanguage(lang);
3391
3437
  return parser;
@@ -3399,7 +3445,7 @@ async function parseFile(filePath, content14) {
3399
3445
  }
3400
3446
  return tree;
3401
3447
  }
3402
- var _require, __filename2, __dirname2, GRAMMAR_DIRS, initialized, langCache;
3448
+ var _require, __filename2, __dirname2, GRAMMAR_DIRS, initPromise, langCache;
3403
3449
  var init_parser = __esm({
3404
3450
  "src/ast/parser.ts"() {
3405
3451
  "use strict";
@@ -3411,7 +3457,7 @@ var init_parser = __esm({
3411
3457
  path30.resolve(__dirname2, "grammars"),
3412
3458
  path30.resolve(__dirname2, "..", "grammars")
3413
3459
  ];
3414
- initialized = false;
3460
+ initPromise = null;
3415
3461
  langCache = /* @__PURE__ */ new Map();
3416
3462
  }
3417
3463
  });
@@ -3640,15 +3686,23 @@ function parseMarker(commentText, line, file) {
3640
3686
  if (m) return makeMarker("single", m, line, file);
3641
3687
  return null;
3642
3688
  }
3643
- function collectSuppressions(tree, file, totalLines) {
3644
- if (getLanguageForExtension(extname3(file)) === null) {
3645
- return [];
3646
- }
3647
- const comments = findComments({ path: file, ast: tree });
3689
+ function collectSuppressions(tree, file, totalLines, content14) {
3690
+ const hasGrammar = getLanguageForExtension(extname3(file)) !== null;
3648
3691
  const markers = [];
3649
- for (const c of comments) {
3650
- const m = parseMarker(c.text, c.startPosition.row + 1, file);
3651
- if (m) markers.push(m);
3692
+ if (hasGrammar && tree) {
3693
+ const comments = findComments({ path: file, ast: tree });
3694
+ for (const c of comments) {
3695
+ const m = parseMarker(c.text, c.startPosition.row + 1, file);
3696
+ if (m) markers.push(m);
3697
+ }
3698
+ } else if (content14 !== void 0) {
3699
+ const lines = content14.split("\n");
3700
+ for (let i = 0; i < lines.length; i++) {
3701
+ const m = parseMarker(lines[i], i + 1, file);
3702
+ if (m) markers.push(m);
3703
+ }
3704
+ } else {
3705
+ return [];
3652
3706
  }
3653
3707
  markers.sort((a, b) => a.line - b.line);
3654
3708
  const ranges = [];
@@ -3746,8 +3800,8 @@ var init_suppress = __esm({
3746
3800
  }
3747
3801
  code = "SUPPRESS_MARKER_MISSING_REASON";
3748
3802
  };
3749
- RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/;
3750
- RE_DISABLE = /\byg-suppress-disable\(\s*([^)]+?)\s*\)\s*(.+)?$/;
3803
+ RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
3804
+ RE_DISABLE = /\byg-suppress-disable\(\s*([^)]+?)\s*\)\s*(.+)?$/m;
3751
3805
  RE_ENABLE = /\byg-suppress-enable\(\s*([^)]+?)\s*\)/;
3752
3806
  }
3753
3807
  });
@@ -3878,7 +3932,12 @@ async function runStructureAspect(params) {
3878
3932
  const checkFn = mod.check;
3879
3933
  const allowedSet = collectAllowedReadsForAspect(nodePath, graph);
3880
3934
  const ctxFs = createCtxFs({ allowedSet, projectRoot, touchedFiles });
3881
- const ctxGraph = createCtxGraph({ currentNodePath: nodePath, graph, projectRoot, touchedFiles });
3935
+ const expandedFilesByNode = /* @__PURE__ */ new Map();
3936
+ for (const id of computeAllowedNodePaths(nodePath, graph)) {
3937
+ const m = graph.nodes.get(id);
3938
+ if (m) expandedFilesByNode.set(id, await enumerateMappedFilesAsync(m.meta.mapping ?? [], projectRoot));
3939
+ }
3940
+ const ctxGraph = createCtxGraph({ currentNodePath: nodePath, graph, projectRoot, touchedFiles, expandedFilesByNode });
3882
3941
  const parsers = createCtxParsers({ allowedSet, projectRoot, touchedFiles, astCache });
3883
3942
  const ownFiles = await buildOwnFiles(node, projectRoot, touchedFiles);
3884
3943
  await prewarmupAstCache({ astCache, projectRoot, files: ownFiles });
@@ -3992,12 +4051,22 @@ ${err.stack ?? ""}`,
3992
4051
  }
3993
4052
  violations.push(vv);
3994
4053
  }
4054
+ const contentByPath = /* @__PURE__ */ new Map();
4055
+ for (const f of [...ownFiles, ...astInputSet]) {
4056
+ contentByPath.set(normalizeMappingPath(f.path), f.content);
4057
+ }
3995
4058
  const rangesByFile = /* @__PURE__ */ new Map();
3996
4059
  function rangesFor(filePath) {
3997
4060
  const existing = rangesByFile.get(filePath);
3998
4061
  if (existing !== void 0) return existing;
3999
4062
  const cached = astCache.get(filePath);
4000
- const ranges = cached ? collectSuppressions(cached.ast, filePath, cached.content.split("\n").length) : null;
4063
+ let ranges;
4064
+ if (cached) {
4065
+ ranges = collectSuppressions(cached.ast, filePath, cached.content.split("\n").length, cached.content);
4066
+ } else {
4067
+ const content14 = contentByPath.get(filePath);
4068
+ ranges = content14 !== void 0 ? collectSuppressions(void 0, filePath, content14.split("\n").length, content14) : null;
4069
+ }
4001
4070
  rangesByFile.set(filePath, ranges);
4002
4071
  return ranges;
4003
4072
  }
@@ -4591,7 +4660,7 @@ The CLI (\`yg\`) reads and validates \u2014 it never modifies files. You create
4591
4660
  .drift-state/ \u2190 generated by CLI; never edit manually
4592
4661
  \`\`\`
4593
4662
 
4594
- **Nodes** \u2014 components. \`model/<path>/yg-node.yaml\`. Nodes nest by directory \u2014 children inherit parent aspects. Schema: \`schemas/yg-node.yaml\`.
4663
+ **Nodes** \u2014 components. \`model/<path>/yg-node.yaml\`. Nodes nest by directory \u2014 children inherit parent aspects. Schema: \`schemas/yg-node.yaml\`. Node \`mapping:\` entries and architecture \`when.path\` both accept minimatch glob patterns \u2014 \`*\` matches within a single path segment, \`**\` matches across segments (e.g. \`src/db/*Repository.cs\` maps only repository files in that directory; \`src/**/*.ts\` maps all TypeScript files under src).
4595
4664
 
4596
4665
  **Aspects** \u2014 enforceable rules. \`aspects/<id>/yg-aspect.yaml\` + zero or one rule source files. The reviewer kind is inferred from which rule source is present: \`content.md\` \u2192 LLM reviewer; \`check.mjs\` \u2192 deterministic reviewer; neither file but \`implies:\` declared \u2192 aggregating aspect (a named bundle with no own reviewer). The \`reviewer:\` block in \`yg-aspect.yaml\` is optional \u2014 kind is inferred automatically. If present, an explicit \`reviewer.type\` must agree with the inferred kind. LLM aspects may set \`reviewer.tier:\` to pick a named tier from \`yg-config.yaml\` (otherwise the configured default tier is used). An aspect can declare \`implies: [other-aspect]\` \u2014 implied aspects are included recursively (must be acyclic). LLM aspects may declare \`references:\` \u2014 supporting files (lookup tables, catalogues) included in the reviewer prompt and exposed to the agent under \`read:\`. Schema: \`schemas/yg-aspect.yaml\`. Aspects also carry a \`status:\` field (default \`enforced\`) \u2014 three levels \`draft / advisory / enforced\` control whether the reviewer runs and whether violations block.
4597
4666
 
@@ -4984,7 +5053,7 @@ context.
4984
5053
 
4985
5054
  **Flow** \u2014 when you see a sequence of steps toward a business goal. Not code call sequences \u2014 real-world processes. "User places an order" = flow. "Handler calls service" = relation between nodes. Read \`schemas/yg-flow.yaml\` and \`yg knowledge read flows\` before creating.
4986
5055
 
4987
- **Node** \u2014 one per cohesive feature area. Not per directory, not per file. If a node's mapped source (plus any aspect reference files) exceeds the per-node character budget (\`quality.max_node_chars\`, default 40000) or it covers >3 distinct workflows, split into children \u2014 \`yg check\` enforces the budget as an \`oversized-node\` error. Why: the reviewer sees ALL files in a node at once; an oversized context dilutes focus and risks window truncation that falsely rejects unchanged code. Keep each node well under the budget. Binary files (images, fonts, archives, etc.) count 0 toward the budget automatically. A node mapping a single unsplittable generated **text** artifact (e.g. a large lockfile) can opt out with \`sizeExempt: { reason: "..." }\`. Read \`schemas/yg-node.yaml\` before creating.
5056
+ **Node** \u2014 one per cohesive feature area. Not per directory, not per file. If a node's mapped source (plus any aspect reference files) exceeds the per-node character budget (\`quality.max_node_chars\`, default 40000) or it covers >3 distinct workflows, split into children \u2014 \`yg check\` enforces the budget as an \`oversized-node\` error. Why: the LLM reviewer sends ALL of a node's files in one prompt, so an oversized context dilutes focus and risks window truncation that falsely rejects unchanged code. The budget therefore applies ONLY to nodes an LLM reviewer actually reads \u2014 those with at least one non-draft LLM aspect; deterministic-only and aspect-less nodes carry no budget (a \`check.mjs\` reads files programmatically, with no context window). Keep LLM-reviewed nodes well under the budget. Binary files (images, fonts, archives, etc.) count 0 toward the budget automatically. A node mapping a single unsplittable generated **text** artifact (e.g. a large lockfile) can opt out with \`sizeExempt: { reason: "..." }\`. Read \`schemas/yg-node.yaml\` before creating.
4988
5057
 
4989
5058
  **Port / relation** \u2014 when a critical aspect must cross a node boundary, or when a new typed dependency is needed. Bare relations do NOT propagate aspects; ports do. Six relation types exist (\`calls\`, \`uses\`, \`extends\`, \`implements\`, \`emits\`, \`listens\`); event relations must be paired. Deep dive: \`yg knowledge read ports-and-relations\`.
4990
5059
 
@@ -5719,13 +5788,6 @@ function parseCoverage(raw, filename) {
5719
5788
  const cov = raw;
5720
5789
  const required = cov.required === void 0 ? ["/"] : parseStringArray(cov.required, "coverage.required", filename);
5721
5790
  const excluded = parseStringArray(cov.excluded, "coverage.excluded", filename);
5722
- if (required.length === 0) {
5723
- throw new ConfigParseError({
5724
- what: `${filename}: coverage.required must list at least one root.`,
5725
- why: "An empty required list silently turns every unmapped file into a non-blocking warning, disabling coverage enforcement.",
5726
- next: "Omit the coverage block to require the whole repo, or list real roots (e.g. - services/)."
5727
- }, "config-invalid");
5728
- }
5729
5791
  for (const root of [...required, ...excluded]) {
5730
5792
  if (root.split("/").includes("..")) {
5731
5793
  throw new ConfigParseError({
@@ -5748,6 +5810,17 @@ function parseMaxNodeChars(raw, filename) {
5748
5810
  }
5749
5811
  return raw;
5750
5812
  }
5813
+ function parseMaxDirectRelations(raw, filename) {
5814
+ if (raw === void 0) return DEFAULT_QUALITY.max_direct_relations ?? 10;
5815
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
5816
+ throw new ConfigParseError({
5817
+ what: `${filename}: quality.max_direct_relations must be a positive integer (got ${JSON.stringify(raw)}).`,
5818
+ why: "It is the per-node relation-count budget; a zero, negative, or fractional value makes the threshold nonsensical.",
5819
+ next: "Set quality.max_direct_relations to a positive integer (default 10), or remove it to use the default."
5820
+ }, "config-invalid");
5821
+ }
5822
+ return raw;
5823
+ }
5751
5824
  var PROVIDER_DEFAULTS = {
5752
5825
  "claude-code": { model: "haiku" },
5753
5826
  "codex": { model: "o4-mini" },
@@ -5775,7 +5848,7 @@ async function parseConfig(filePath) {
5775
5848
  }
5776
5849
  const qualityMap = qualityRaw;
5777
5850
  const quality = qualityMap ? {
5778
- max_direct_relations: typeof qualityMap.max_direct_relations === "number" ? qualityMap.max_direct_relations : DEFAULT_QUALITY.max_direct_relations,
5851
+ max_direct_relations: parseMaxDirectRelations(qualityMap.max_direct_relations, filename),
5779
5852
  max_node_chars: parseMaxNodeChars(qualityMap.max_node_chars, filename)
5780
5853
  } : DEFAULT_QUALITY;
5781
5854
  let reviewer;
@@ -5952,6 +6025,13 @@ function parseTier(name, raw, filename) {
5952
6025
  next: "add `model: <model-name>` under config:"
5953
6026
  }, "config-tier-config-missing");
5954
6027
  }
6028
+ if (t.provider === "openai-compatible" && (typeof c.endpoint !== "string" || !c.endpoint.trim())) {
6029
+ throw new ConfigParseError({
6030
+ what: `${filename}: tier '${name}' (provider 'openai-compatible') is missing config.endpoint`,
6031
+ why: `'openai-compatible' has no default host \u2014 without an explicit endpoint it silently falls back to the public OpenAI API (api.openai.com).`,
6032
+ next: "add `endpoint: <url>` under config: pointing at your compatible server."
6033
+ }, "config-tier-endpoint-missing");
6034
+ }
5955
6035
  const allowed = /* @__PURE__ */ new Set(["provider", "consensus", "config", "references"]);
5956
6036
  for (const k of Object.keys(t)) {
5957
6037
  if (!allowed.has(k)) {
@@ -6375,6 +6455,10 @@ function parseRelations(raw, filePath) {
6375
6455
  );
6376
6456
  }
6377
6457
  rel.consumes = consumesArr;
6458
+ } else if (obj.consumes !== void 0) {
6459
+ throw new Error(
6460
+ `yg-node.yaml at ${filePath}: relations[${index}].consumes must be an array of string port names (got ${Array.isArray(obj.consumes) ? "array" : typeof obj.consumes}). A scalar value is silently ignored, so the consumed port's required aspects would not be enforced. Use consumes: [<port-name>].`
6461
+ );
6378
6462
  }
6379
6463
  if (typeof obj.event_name === "string" && obj.event_name.trim()) {
6380
6464
  rel.event_name = obj.event_name.trim();
@@ -6933,13 +7017,13 @@ function parseReviewer2(raw, aspectId, files) {
6933
7017
  next: "add `type: llm` or `type: deterministic` under reviewer:"
6934
7018
  }
6935
7019
  });
6936
- } else if (obj.type !== "llm" && obj.type !== "deterministic") {
7020
+ } else if (obj.type !== "llm" && obj.type !== "deterministic" && obj.type !== "aggregate") {
6937
7021
  errors.push({
6938
7022
  code: "aspect-reviewer-type-invalid",
6939
7023
  messageData: {
6940
7024
  what: `aspect '${aspectId}' has invalid reviewer.type: '${String(obj.type)}'`,
6941
- why: 'only "llm" and "deterministic" are valid',
6942
- next: "change to type: llm or type: deterministic"
7025
+ why: 'only "llm", "deterministic", or "aggregate" are valid',
7026
+ next: "change to type: llm, type: deterministic, or type: aggregate"
6943
7027
  }
6944
7028
  });
6945
7029
  } else {
@@ -9427,7 +9511,7 @@ init_posix();
9427
9511
  import path21 from "path";
9428
9512
 
9429
9513
  // src/core/file-when-evaluator.ts
9430
- import { minimatch } from "minimatch";
9514
+ init_mapping_path();
9431
9515
  var YGGDRASIL_PREFIX = ".yggdrasil/";
9432
9516
  function safeRegexTest(re, str) {
9433
9517
  const HEAD_LIMIT = 256 * 1024;
@@ -9506,7 +9590,7 @@ async function evaluateAtomic2(predicate, ctx) {
9506
9590
  );
9507
9591
  }
9508
9592
  if (predicate.path !== void 0) {
9509
- const matches = minimatch(ctx.repoRelPath, predicate.path, { dot: true });
9593
+ const matches = globMatch(ctx.repoRelPath, predicate.path);
9510
9594
  return {
9511
9595
  result: matches,
9512
9596
  trace: { kind: "atom-path", pattern: predicate.path, result: matches }
@@ -9549,7 +9633,15 @@ async function evaluateAtomic2(predicate, ctx) {
9549
9633
  }
9550
9634
  };
9551
9635
  }
9552
- const regex = new RegExp(predicate.content);
9636
+ let regex;
9637
+ try {
9638
+ regex = new RegExp(predicate.content);
9639
+ } catch {
9640
+ return {
9641
+ result: false,
9642
+ trace: { kind: "atom-content", pattern: predicate.content, result: false, detail: "invalid content regex" }
9643
+ };
9644
+ }
9553
9645
  const { match: matches } = safeRegexTest(regex, fileContent.content);
9554
9646
  return {
9555
9647
  result: matches,
@@ -9610,6 +9702,9 @@ function renderNode(node, indent, lines) {
9610
9702
  }
9611
9703
 
9612
9704
  // src/core/checks/architecture.ts
9705
+ init_hash();
9706
+ init_mapping_path();
9707
+ init_posix();
9613
9708
  function checkTypeUnknownParent(graph) {
9614
9709
  const issues = [];
9615
9710
  const knownTypes = new Set(Object.keys(graph.architecture.node_types));
@@ -9767,7 +9862,15 @@ async function checkTypeWhenMismatch(graph, cache) {
9767
9862
  const typeDef = graph.architecture.node_types[node.meta.type];
9768
9863
  if (typeDef === void 0 || typeDef.when === void 0) continue;
9769
9864
  const mapping = node.meta.mapping ?? [];
9770
- for (const relPath of mapping) {
9865
+ const pathsToCheck = [];
9866
+ for (const entry of mapping) {
9867
+ if (isGlobPattern(entry)) {
9868
+ pathsToCheck.push(...(await expandMappingPaths(projectRoot, [entry])).map(toPosixPath));
9869
+ } else {
9870
+ pathsToCheck.push(entry);
9871
+ }
9872
+ }
9873
+ for (const relPath of pathsToCheck) {
9771
9874
  const absPath = path21.join(projectRoot, relPath);
9772
9875
  const result = await evaluateFileWhen(typeDef.when, {
9773
9876
  absPath,
@@ -10266,6 +10369,23 @@ function checkWhenReferences(graph) {
10266
10369
  })
10267
10370
  });
10268
10371
  }
10372
+ } else if (match.consumes_port !== void 0) {
10373
+ const port = match.consumes_port;
10374
+ const known = [...graph.nodes.values()].some(
10375
+ (n) => n.meta.ports !== void 0 && port in n.meta.ports
10376
+ );
10377
+ if (!known) {
10378
+ issues.push({
10379
+ severity: "error",
10380
+ code: "when-unknown-port",
10381
+ rule: "when-unknown-port",
10382
+ ...issueMsg({
10383
+ what: `Port '${port}' in when at ${ctx}/${relType}.consumes_port is not declared on any node.`,
10384
+ why: "The predicate references a port that no node defines, so it can never match.",
10385
+ next: `Fix the port name, or declare it under ports: on the node(s) this relation targets.`
10386
+ })
10387
+ });
10388
+ }
10269
10389
  }
10270
10390
  }
10271
10391
  };
@@ -10657,7 +10777,7 @@ function checkAspectStatusDowngrade(graph) {
10657
10777
  for (const source of sources) {
10658
10778
  if (!sourceIsExplicit(source, node, aspectId, graph)) continue;
10659
10779
  const otherDeclared = sources.filter((s) => s !== source).map((s) => s.declared);
10660
- const anchor = otherDeclared.length === 0 ? aspectDefault : otherDeclared.reduce(
10780
+ const anchor = [...otherDeclared, aspectDefault].reduce(
10661
10781
  (acc, cur) => STATUS_ORDER[cur] > STATUS_ORDER[acc] ? cur : acc,
10662
10782
  "draft"
10663
10783
  );
@@ -10687,6 +10807,7 @@ function checkAspectStatusDowngrade(graph) {
10687
10807
  // src/core/checks/mapping.ts
10688
10808
  init_paths();
10689
10809
  init_hash();
10810
+ init_mapping_path();
10690
10811
  init_graph_fs();
10691
10812
  init_repo_scanner();
10692
10813
  init_aspects();
@@ -10734,12 +10855,6 @@ async function checkStrictBackwardCoverage(graph, cache) {
10734
10855
  );
10735
10856
  if (strictTypes.length === 0) return { issues: [], unreadable: [] };
10736
10857
  const projectRoot = path23.dirname(graph.rootPath);
10737
- const fileToOwner = /* @__PURE__ */ new Map();
10738
- for (const [nodePath, node] of graph.nodes) {
10739
- for (const relPath of node.meta.mapping ?? []) {
10740
- if (!fileToOwner.has(relPath)) fileToOwner.set(relPath, { nodePath, nodeType: node.meta.type });
10741
- }
10742
- }
10743
10858
  const repoFiles = await walkRepoFiles(projectRoot);
10744
10859
  const issues = [];
10745
10860
  const unreadable = [];
@@ -10802,7 +10917,14 @@ Run: yg impact --type ${sorted[j]}`
10802
10917
  }
10803
10918
  if (matchingTypes.length === 0) continue;
10804
10919
  const { typeId, trace } = matchingTypes[0];
10805
- const owner = fileToOwner.get(relPath);
10920
+ let owner;
10921
+ for (const [nodePath, node] of graph.nodes) {
10922
+ const entries = node.meta.mapping ?? [];
10923
+ if (entries.some((entry) => mappingEntryMatchesFile(entry, relPath))) {
10924
+ owner = { nodePath, nodeType: node.meta.type };
10925
+ break;
10926
+ }
10927
+ }
10806
10928
  if (owner === void 0) {
10807
10929
  issues.push({
10808
10930
  severity: "error",
@@ -10847,7 +10969,7 @@ function arePathsOverlapping(pathA, pathB) {
10847
10969
  function isAncestorNode(possibleAncestor, possibleDescendant) {
10848
10970
  return possibleDescendant.startsWith(possibleAncestor + "/");
10849
10971
  }
10850
- function checkMappingOverlap(graph) {
10972
+ async function checkMappingOverlap(graph) {
10851
10973
  const issues = [];
10852
10974
  const ownership = [];
10853
10975
  for (const [nodePath, node] of graph.nodes) {
@@ -10893,6 +11015,46 @@ function checkMappingOverlap(graph) {
10893
11015
  });
10894
11016
  }
10895
11017
  }
11018
+ const anyGlob = [...graph.nodes.values()].some(
11019
+ (n) => (n.meta.mapping ?? []).some((e) => isGlobPattern(e))
11020
+ );
11021
+ if (anyGlob) {
11022
+ const projectRoot = path23.dirname(graph.rootPath);
11023
+ const repoFiles = await walkRepoFiles(projectRoot);
11024
+ const reported = /* @__PURE__ */ new Set();
11025
+ for (const rawRel of repoFiles) {
11026
+ const relPath = normalizePathForCompare(rawRel);
11027
+ const owners = [];
11028
+ let viaGlob = false;
11029
+ for (const [nodePath, node] of graph.nodes) {
11030
+ let matched = false;
11031
+ for (const entry of node.meta.mapping ?? []) {
11032
+ if (!mappingEntryMatchesFile(entry, relPath)) continue;
11033
+ matched = true;
11034
+ if (isGlobPattern(entry)) viaGlob = true;
11035
+ }
11036
+ if (matched) owners.push(nodePath);
11037
+ }
11038
+ if (owners.length < 2 || !viaGlob || reported.has(relPath)) continue;
11039
+ const leaves = owners.filter(
11040
+ (o) => !owners.some((other) => other !== o && isAncestorNode(o, other))
11041
+ );
11042
+ if (leaves.length < 2) continue;
11043
+ reported.add(relPath);
11044
+ issues.push({
11045
+ severity: "error",
11046
+ code: "overlapping-mapping",
11047
+ rule: "overlapping-mapping",
11048
+ ...issueMsg({
11049
+ what: `File '${relPath}' is owned by multiple non-hierarchical nodes:
11050
+ ${leaves.map((n) => " " + n).join("\n")}`,
11051
+ why: `Each source file must have exactly one owner node. A glob mapping in one node resolves to a file also claimed by another node.`,
11052
+ next: `Narrow the glob, or remove the file from one node's mapping and model the dependency via a relation.`
11053
+ }),
11054
+ nodePath: leaves[0]
11055
+ });
11056
+ }
11057
+ }
10896
11058
  return issues;
10897
11059
  }
10898
11060
  async function checkMappingPathsExist(graph) {
@@ -10901,21 +11063,38 @@ async function checkMappingPathsExist(graph) {
10901
11063
  for (const [nodePath, node] of graph.nodes) {
10902
11064
  const mappingPaths = normalizeMappingPaths(node.meta.mapping).map(normalizePathForCompare);
10903
11065
  for (const mp of mappingPaths) {
10904
- const absPath = path23.join(projectRoot, mp);
10905
- try {
10906
- await fileAccess(absPath);
10907
- } catch {
10908
- issues.push({
10909
- severity: "error",
10910
- code: "mapping-path-missing",
10911
- rule: "mapping-path-missing",
10912
- ...issueMsg({
10913
- what: `Mapping path '${mp}' does not exist on disk.`,
10914
- why: `Node maps a file that was deleted or moved.`,
10915
- next: `Update mapping in yg-node.yaml: fix the path or remove the entry.`
10916
- }),
10917
- nodePath
10918
- });
11066
+ if (isGlobPattern(mp)) {
11067
+ const matched = await expandMappingPaths(projectRoot, [mp]);
11068
+ if (matched.length === 0) {
11069
+ issues.push({
11070
+ severity: "error",
11071
+ code: "mapping-path-missing",
11072
+ rule: "mapping-path-missing",
11073
+ ...issueMsg({
11074
+ what: `Glob '${mp}' matches no files on disk.`,
11075
+ why: `Node maps a glob pattern that currently resolves to no files \u2014 possibly all matching files were deleted or the pattern is wrong.`,
11076
+ next: `Update mapping in yg-node.yaml: fix the glob or remove the entry.`
11077
+ }),
11078
+ nodePath
11079
+ });
11080
+ }
11081
+ } else {
11082
+ const absPath = path23.join(projectRoot, mp);
11083
+ try {
11084
+ await fileAccess(absPath);
11085
+ } catch {
11086
+ issues.push({
11087
+ severity: "error",
11088
+ code: "mapping-path-missing",
11089
+ rule: "mapping-path-missing",
11090
+ ...issueMsg({
11091
+ what: `Mapping path '${mp}' does not exist on disk.`,
11092
+ why: `Node maps a file that was deleted or moved.`,
11093
+ next: `Update mapping in yg-node.yaml: fix the path or remove the entry.`
11094
+ }),
11095
+ nodePath
11096
+ });
11097
+ }
10919
11098
  }
10920
11099
  }
10921
11100
  }
@@ -10985,6 +11164,7 @@ async function checkOversizedNodes(graph, cache) {
10985
11164
  refsByAspect.set(aspect.id, aspect.references.map((r) => r.path));
10986
11165
  }
10987
11166
  }
11167
+ const aspectById = new Map(graph.aspects.map((a) => [a.id, a]));
10988
11168
  async function charsOf(repoRelPath) {
10989
11169
  if (BINARY_EXTENSIONS.has(path23.extname(repoRelPath).toLowerCase())) return 0;
10990
11170
  const abs = path23.resolve(projectRoot, repoRelPath);
@@ -11004,13 +11184,24 @@ async function checkOversizedNodes(graph, cache) {
11004
11184
  const mappingPaths = normalizeMappingPaths(node.meta.mapping);
11005
11185
  if (mappingPaths.length === 0) continue;
11006
11186
  const sourceFiles = await expandMappingPaths(projectRoot, mappingPaths);
11007
- const refPaths = /* @__PURE__ */ new Set();
11008
11187
  let effectiveAspects;
11009
11188
  try {
11010
11189
  effectiveAspects = computeEffectiveAspects(node, graph);
11011
11190
  } catch {
11012
11191
  effectiveAspects = /* @__PURE__ */ new Set();
11013
11192
  }
11193
+ let statuses;
11194
+ try {
11195
+ statuses = computeEffectiveAspectStatuses(node, graph);
11196
+ } catch {
11197
+ statuses = /* @__PURE__ */ new Map();
11198
+ }
11199
+ const isLlmReviewed = [...effectiveAspects].some((id) => {
11200
+ const def = aspectById.get(id);
11201
+ return def?.reviewer.type === "llm" && (statuses.get(id) ?? "enforced") !== "draft";
11202
+ });
11203
+ if (!isLlmReviewed) continue;
11204
+ const refPaths = /* @__PURE__ */ new Set();
11014
11205
  for (const aspectId of effectiveAspects) {
11015
11206
  for (const rp of refsByAspect.get(aspectId) ?? []) refPaths.add(rp);
11016
11207
  }
@@ -11448,7 +11639,7 @@ async function validate(graph, scope = "all") {
11448
11639
  issues.push(...checkSchemas(graph));
11449
11640
  issues.push(...checkRelationTargets(graph));
11450
11641
  issues.push(...checkNoCycles(graph));
11451
- issues.push(...checkMappingOverlap(graph));
11642
+ issues.push(...await checkMappingOverlap(graph));
11452
11643
  issues.push(...checkMappingEscapesRepo(graph));
11453
11644
  issues.push(...await checkMappingPathsExist(graph));
11454
11645
  issues.push(...checkBrokenFlowRefs(graph));
@@ -11520,6 +11711,7 @@ init_debug_log();
11520
11711
  init_message_builder();
11521
11712
  init_paths();
11522
11713
  init_posix();
11714
+ init_mapping_path();
11523
11715
  function normalizeForMatch(inputPath) {
11524
11716
  return toPosixPath(inputPath.trim());
11525
11717
  }
@@ -11529,17 +11721,25 @@ function findOwner(graph, projectRoot, rawPath) {
11529
11721
  for (const [nodePath, node] of graph.nodes) {
11530
11722
  const mappingPaths = normalizeMappingPaths(node.meta.mapping).map(normalizeForMatch).filter((mappingPath) => mappingPath.length > 0);
11531
11723
  for (const mappingPath of mappingPaths) {
11532
- if (file === mappingPath) {
11533
- return { file, nodePath, mappingPath, direct: true };
11534
- }
11535
- if (file.startsWith(mappingPath + "/")) {
11536
- if (!best || best && mappingPath.length > best.mappingPath.length) {
11537
- best = { nodePath, mappingPath, exact: false };
11724
+ if (isGlobPattern(mappingPath)) {
11725
+ if (mappingEntryMatchesFile(mappingPath, file)) {
11726
+ if (!best || mappingPath.length > best.mappingPath.length) {
11727
+ best = { nodePath, mappingPath, exact: true };
11728
+ }
11729
+ }
11730
+ } else {
11731
+ if (file === mappingPath) {
11732
+ return { file, nodePath, mappingPath, direct: true };
11733
+ }
11734
+ if (file.startsWith(mappingPath + "/")) {
11735
+ if (!best || mappingPath.length > best.mappingPath.length) {
11736
+ best = { nodePath, mappingPath, exact: false };
11737
+ }
11538
11738
  }
11539
11739
  }
11540
11740
  }
11541
11741
  }
11542
- return best ? { file, nodePath: best.nodePath, mappingPath: best.mappingPath, direct: false } : { file, nodePath: null };
11742
+ return best ? { file, nodePath: best.nodePath, mappingPath: best.mappingPath, direct: best.exact } : { file, nodePath: null };
11543
11743
  }
11544
11744
  function registerOwnerCommand(program2) {
11545
11745
  program2.command("owner").description("Find which graph node owns a source file").requiredOption("--file <path>", "File path (relative to repository root)").action(async (options) => {
@@ -11559,11 +11759,21 @@ function registerOwnerCommand(program2) {
11559
11759
  exists = false;
11560
11760
  }
11561
11761
  if (exists) {
11562
- process.stdout.write(`${result.file} -> no graph coverage
11563
- `);
11762
+ process.stdout.write(
11763
+ buildIssueMessage({
11764
+ what: `${result.file} -> no graph coverage`,
11765
+ why: "This file exists but no graph node maps it, so its code is not verified against any aspect.",
11766
+ next: `Add '${result.file}' to a node's mapping in yg-node.yaml, or create a node for it.`
11767
+ }) + "\n"
11768
+ );
11564
11769
  } else {
11565
- process.stdout.write(`${result.file} -> no graph coverage (file not found)
11566
- `);
11770
+ process.stdout.write(
11771
+ buildIssueMessage({
11772
+ what: `${result.file} -> no graph coverage (file not found)`,
11773
+ why: "This path does not exist on disk and is not mapped by any graph node.",
11774
+ next: `Check the path for typos; once the file exists, add it to a node's mapping in yg-node.yaml.`
11775
+ }) + "\n"
11776
+ );
11567
11777
  }
11568
11778
  } else {
11569
11779
  process.stdout.write(`${result.file} -> ${result.nodePath}
@@ -11791,6 +12001,13 @@ var STRUCTURAL_CODES = /* @__PURE__ */ new Set([
11791
12001
  "when-unknown-type",
11792
12002
  "when-unknown-node",
11793
12003
  "when-unknown-port",
12004
+ // Port-contract codes — blocking architecture-gate errors (documented in the
12005
+ // ports-and-relations knowledge topic); belong in the single-source structural set.
12006
+ "port-missing-consumes",
12007
+ "port-undefined",
12008
+ "port-missing-aspect",
12009
+ "consumes-without-ports",
12010
+ "relation-target-forbidden",
11794
12011
  "aspect-unexpected-rule-source",
11795
12012
  "aspect-missing-rule-source",
11796
12013
  "aspect-empty",
@@ -11819,14 +12036,16 @@ var COMPLETENESS_CODES = /* @__PURE__ */ new Set(["description-missing"]);
11819
12036
  init_log_format();
11820
12037
  init_posix();
11821
12038
  init_repo_scanner();
12039
+ init_mapping_path();
11822
12040
 
11823
12041
  // src/core/check-coverage-tiers.ts
11824
12042
  init_posix();
12043
+ init_mapping_path();
11825
12044
  function normalizeRoot(root) {
11826
12045
  return toPosixPath(root.trim()).replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/");
11827
12046
  }
11828
12047
  function matchesRoot(file, normRoot) {
11829
- return normRoot === "" || file === normRoot || file.startsWith(normRoot + "/");
12048
+ return normRoot === "" || mappingEntryMatchesFile(normRoot, file);
11830
12049
  }
11831
12050
  function partitionByCoverageTier(uncovered, coverage) {
11832
12051
  const req = coverage.required.map(normalizeRoot);
@@ -12159,14 +12378,7 @@ function scanUncoveredFiles(graph, gitTrackedFiles) {
12159
12378
  for (const file of tracked) {
12160
12379
  const normalized = toPosixPath(file.trim());
12161
12380
  if (normalized.startsWith(yggPrefix + "/") || normalized === yggPrefix) continue;
12162
- let covered = false;
12163
- for (const rawMp of allMappings) {
12164
- const mp = toPosixPath(rawMp);
12165
- if (normalized === mp || normalized.startsWith(mp + "/")) {
12166
- covered = true;
12167
- break;
12168
- }
12169
- }
12381
+ const covered = allMappings.some((mp) => mappingEntryMatchesFile(mp, normalized));
12170
12382
  if (!covered) {
12171
12383
  uncovered.push(normalized);
12172
12384
  }
@@ -12332,10 +12544,15 @@ function getChildMappingExclusions2(graph, nodePath) {
12332
12544
  }
12333
12545
  async function allPathsMissing(projectRoot, mappingPaths) {
12334
12546
  for (const mp of mappingPaths) {
12335
- try {
12336
- await fileAccess(path34.join(projectRoot, mp));
12337
- return false;
12338
- } catch {
12547
+ if (isGlobPattern(mp)) {
12548
+ const matched = await expandMappingPaths(projectRoot, [mp]);
12549
+ if (matched.length > 0) return false;
12550
+ } else {
12551
+ try {
12552
+ await fileAccess(path34.join(projectRoot, mp));
12553
+ return false;
12554
+ } catch {
12555
+ }
12339
12556
  }
12340
12557
  }
12341
12558
  return true;
@@ -12444,7 +12661,7 @@ function computeSuggestedNext(issues, graph) {
12444
12661
  addRemaining(coverageErrors.length > 0 ? coverageErrors[0].uncoveredCount ?? 0 : 0, "files need coverage");
12445
12662
  const then = remaining.length > 0 ? `
12446
12663
  Then: ${remaining.join(", ")}` : "";
12447
- return `Fix ${first.code} in ${first.nodePath ?? ".yggdrasil/"}
12664
+ return `Fix ${first.code} in ${first.nodePath ?? ".yggdrasil"}
12448
12665
  1 of ${structuralErrors.length} structural error${structuralErrors.length === 1 ? "" : "s"}${then}`;
12449
12666
  }
12450
12667
  if (coverageErrors.length > 0) {
@@ -14249,6 +14466,7 @@ init_loader_hook();
14249
14466
  init_parser();
14250
14467
  init_suppress();
14251
14468
  init_validate_check_module();
14469
+ init_language_registry();
14252
14470
  import path37 from "path";
14253
14471
  import { readFile as readFile20 } from "fs/promises";
14254
14472
  import { pathToFileURL as pathToFileURL3 } from "url";
@@ -14296,18 +14514,15 @@ async function runAstAspect(params) {
14296
14514
  continue;
14297
14515
  }
14298
14516
  const content14 = await readFile20(path37.resolve(params.projectRoot, f.path), "utf-8");
14517
+ if (getLanguageForExtension(path37.extname(f.path).toLowerCase()) === null) {
14518
+ sourceFiles.push({ path: f.path, content: content14, ast: void 0 });
14519
+ continue;
14520
+ }
14299
14521
  let ast;
14300
14522
  try {
14301
14523
  ast = await parseFile(f.path, content14);
14302
14524
  } catch (e) {
14303
14525
  const msg = e.message ?? String(e);
14304
- if (msg.startsWith("no parser for extension")) {
14305
- throw new AstRunnerError("AST_NO_PARSER_FOR_EXTENSION", {
14306
- what: msg + ` (file: ${f.path})`,
14307
- why: `v1 supports only .ts/.tsx/.js/.mjs/.cjs/.jsx.`,
14308
- next: `Remove ${f.path} from the node's mapping.`
14309
- });
14310
- }
14311
14526
  throw new AstRunnerError("AST_GRAMMAR_LOAD_FAILED", {
14312
14527
  what: `Failed to load tree-sitter grammar for ${f.path}: ${msg}`,
14313
14528
  why: `The bundled WASM grammar could not be loaded.`,
@@ -14328,7 +14543,7 @@ async function runAstAspect(params) {
14328
14543
  const rangesPerFile = /* @__PURE__ */ new Map();
14329
14544
  for (const f of sourceFiles) {
14330
14545
  const totalLines = f.content.split("\n").length;
14331
- rangesPerFile.set(f.path, collectSuppressions(f.ast, f.path, totalLines));
14546
+ rangesPerFile.set(f.path, collectSuppressions(f.ast, f.path, totalLines, f.content));
14332
14547
  }
14333
14548
  const ctx = { files: sourceFiles };
14334
14549
  let raw;
@@ -15610,6 +15825,20 @@ predicate satisfaction fraction, or edge-case messages for files inside
15610
15825
  Run this whenever you add or modify a type's \`when\` predicate and want
15611
15826
  to verify that existing files are classified as expected.
15612
15827
 
15828
+ ## Glob patterns in mapping and when.path
15829
+
15830
+ Both node \`mapping:\` entries and architecture \`when.path\` predicates accept
15831
+ minimatch glob patterns. \`*\` matches any characters within a single path
15832
+ segment (does not cross \`/\`); \`**\` matches across path segments.
15833
+
15834
+ Examples:
15835
+ - \`src/db/*Repository.cs\` \u2014 owns only files matching \`*Repository.cs\` directly
15836
+ inside \`src/db/\`, not subdirectory files or non-matching files like \`Helper.cs\`.
15837
+ - \`src/**/*.ts\` \u2014 owns all \`.ts\` files anywhere under \`src/\` at any depth.
15838
+
15839
+ Plain (non-glob) entries remain unchanged: an exact file path or a directory
15840
+ prefix (e.g. \`src/handlers\`) covers that file or all files beneath it.
15841
+
15613
15842
  ## Aspect status in architecture default aspects
15614
15843
 
15615
15844
  Architecture-level default aspects (channel 3) may declare \`status:\` to
@@ -16013,7 +16242,11 @@ var content4 = `# Writing deterministic aspects
16013
16242
  A deterministic aspect declares \`reviewer: { type: deterministic }\` and ships
16014
16243
  a \`check.mjs\` file. The check runs locally at zero LLM cost and returns a
16015
16244
  \`Violation[]\`. Deterministic aspects do not use reviewer tiers \u2014
16016
- \`reviewer.tier:\` is rejected together with \`type: deterministic\`.
16245
+ \`reviewer.tier:\` is rejected together with \`type: deterministic\`. Because the
16246
+ check reads files programmatically (no LLM prompt), the per-node character budget
16247
+ (\`quality.max_node_chars\` / \`oversized-node\`) does NOT apply to a node whose
16248
+ only effective aspects are deterministic \u2014 such a node may map an arbitrarily
16249
+ large area.
16017
16250
 
16018
16251
  There are two ways to scope a deterministic aspect:
16019
16252
 
@@ -16126,7 +16359,7 @@ arrives in the one \`check.mjs\` invocation.
16126
16359
  | \`walk(node, visitor)\` | \`(node, (n) => boolean|void) => void\` | DFS traversal; visitor returning \`false\` skips descent into that subtree |
16127
16360
  | \`report(file, node, message)\` | \`(file, TreeNode, string) => Violation\` | Build a \`{ file, line, column, message }\` \u2014 \`line\` 1-based, \`column\` 0-based |
16128
16361
  | \`inFile(file, pattern)\` | \`(file, { glob } | { regex } | { contains }) => boolean\` | Path filter (discriminated object form) |
16129
- | \`findComments(target)\` | \`(file | node) => TreeNode[]\` | Returns comment nodes within the file or subtree |
16362
+ | \`findComments(target)\` | \`(file) => TreeNode[]\` | Returns comment nodes within a file (language derived from its path) |
16130
16363
  | \`closest(node, types)\` | \`(TreeNode, string[]) => TreeNode | null\` | Nearest ancestor whose \`type\` is in \`types\` |
16131
16364
 
16132
16365
  ## tree-sitter node API
@@ -16385,7 +16618,7 @@ parsed AST trees via \`ctx.parseAst\`:
16385
16618
  | \`closest(node, types)\` | \`(TreeNode, string[]) => TreeNode | null\` | Nearest ancestor of one of the given types |
16386
16619
  | \`report(file, node, message)\` | \`(file, TreeNode, string) => Violation\` | Build \`{ file, line, column, message }\` \u2014 line 1-based, column 0-based |
16387
16620
  | \`inFile(file, pattern)\` | \`(file, { glob } | { regex } | { contains }) => boolean\` | Path filter |
16388
- | \`findComments(target)\` | \`(file | node) => TreeNode[]\` | Returns comment nodes |
16621
+ | \`findComments(target)\` | \`(file) => TreeNode[]\` | Returns comment nodes |
16389
16622
 
16390
16623
  These helpers are optional \u2014 most graph-aware checks work purely with \`ctx.graph\`
16391
16624
  and \`ctx.fs\` without parsing AST trees at all.
@@ -16890,6 +17123,21 @@ For generated files where the entire file is exempt, place the marker at
16890
17123
  the file level (outside any function or class). At file level, the
16891
17124
  contextual scope is the whole file.
16892
17125
 
17126
+ ## Language support
17127
+
17128
+ Markers are recognized in any source language, using whichever comment syntax
17129
+ the language provides \u2014 \`//\` and \`/* */\` (C-family), \`#\` (shell, Python),
17130
+ \`--\` (SQL), and so on. The marker token \`yg-suppress(...)\` is what is matched,
17131
+ not a specific comment style.
17132
+
17133
+ For a file whose extension has a registered grammar, markers are read from the
17134
+ file's comments, so a \`yg-suppress(...)\` that merely appears inside a string
17135
+ literal is NOT treated as a marker. For a file whose extension has no registered
17136
+ grammar (e.g. \`.sql\`, \`.md\`, \`.sh\`), there is no parse tree, so markers are
17137
+ found by scanning the raw lines \u2014 which is what lets a content-only deterministic
17138
+ check waive a violation in such a file. (In that raw-scan mode a marker token
17139
+ sitting inside a string literal would also match, so keep markers in comments.)
17140
+
16893
17141
  ## Reason text
16894
17142
 
16895
17143
  The reason text after the aspect-id is permanent. Future maintainers and
@@ -17156,7 +17404,7 @@ reviewer:
17156
17404
  config:
17157
17405
  model: qwen3 # Model identifier for this provider
17158
17406
  temperature: 0 # Sampling temperature (0 = deterministic)
17159
- endpoint: http://localhost:11434 # Required for ollama and openai-compatible
17407
+ endpoint: http://localhost:11434 # Required for openai-compatible (no default host); ollama defaults to localhost:11434
17160
17408
  # references: # optional caps on aspect reference files
17161
17409
  # max_bytes_per_file: 65536 # default: 64 KiB per reference file
17162
17410
  # max_total_bytes_per_aspect: 262144 # default: 256 KiB total per aspect
@@ -17168,7 +17416,7 @@ coverage: # Optional \u2014 controls which files must
17168
17416
 
17169
17417
  quality:
17170
17418
  max_direct_relations: 10 # Max out-edges per node before high-fan-out warning
17171
- max_node_chars: 40000 # Per-node character budget (source + aspect refs) before oversized-node error
17419
+ max_node_chars: 40000 # Per-node char budget (source + aspect refs); oversized-node error \u2014 LLM-reviewed nodes only
17172
17420
 
17173
17421
  parallel: 1 # Concurrent aspect verifications across nodes (default: 1)
17174
17422
 
@@ -17235,7 +17483,7 @@ Provider-specific options passed to the LLM client:
17235
17483
  |---|---|---|
17236
17484
  | \`model\` | string | Required. Provider-specific model identifier. |
17237
17485
  | \`temperature\` | number | Defaults to 0. Higher = more varied responses. |
17238
- | \`endpoint\` | string | Required for \`ollama\` and \`openai-compatible\`. |
17486
+ | \`endpoint\` | string | Required for \`openai-compatible\` (no default host \u2014 else falls back to api.openai.com); \`ollama\` defaults to http://localhost:11434. |
17239
17487
  | \`timeout\` | number | Timeout in seconds. Default 300. Applies to CLI providers only \u2014 non-CLI/API providers ignore it. |
17240
17488
 
17241
17489
  API keys do NOT live here \u2014 they belong in \`yg-secrets.yaml\` (api_key only).
@@ -17306,18 +17554,20 @@ repository \u2014 it is safe to share.
17306
17554
  \`\`\`yaml
17307
17555
  coverage:
17308
17556
  required:
17309
- - src/ # unmapped files under src/ are a blocking error
17557
+ - src/ # unmapped files under src/ are a blocking error
17310
17558
  excluded:
17311
- - vendor/ # silently ignored
17559
+ - vendor/ # silently ignored
17560
+ - "**/*.generated.ts" # glob: drop generated files anywhere
17312
17561
  \`\`\`
17313
17562
 
17314
17563
  Controls which git-tracked files must be mapped to a node.
17315
17564
 
17316
- - \`required\` \u2014 path roots where unmapped files are a blocking \`unmapped-files\` error. Default: \`["/"]\` (whole repo \u2014 the previous always-map-everything behavior).
17317
- - \`excluded\` \u2014 path roots that are silently ignored. Default: \`[]\`.
17565
+ - \`required\` \u2014 roots where unmapped files are a blocking \`unmapped-files\` error. Default: \`["/"]\` (whole repo \u2014 the previous always-map-everything behavior). An explicit empty list \`[]\` means require nothing: every uncovered file (outside \`excluded\`/nested) becomes a non-blocking \`uncovered-advisory\` warning and nothing blocks (pure-advisory adoption). Empty only counts when written explicitly; omitting the \`coverage\` block keeps the \`["/"]\` default.
17566
+ - \`excluded\` \u2014 roots that are silently ignored. Default: \`[]\`.
17567
+ - Roots accept the same forms as a node \`mapping:\` entry: an exact file, a directory prefix (e.g. \`src/\` covers everything beneath it), or a glob (\`*\` within a segment, \`**\` across) \u2014 so \`excluded: ["**/*.generated.ts"]\` drops generated files anywhere and \`required: ["services/*/api/**"]\` scopes the blocking tier to a pattern. \`/\` still means the whole repo.
17318
17568
  - Files that match neither a required nor an excluded root produce a non-blocking \`uncovered-advisory\` warning.
17319
17569
  - Subtrees containing their own nested \`.yggdrasil/\` are auto-skipped by all repo-walking checks (they are governed by their own graph).
17320
- - Longest-match wins; on a tie between required and excluded, excluded wins.
17570
+ - Longest-match wins (by normalized root/pattern length); on a tie between required and excluded, excluded wins.
17321
17571
 
17322
17572
  ## Quality thresholds
17323
17573
 
@@ -17330,6 +17580,9 @@ quality:
17330
17580
  \`max_direct_relations\` fires a warning when exceeded. \`max_node_chars\` is a
17331
17581
  blocking error: a node whose mapped source plus aspect reference files exceed it
17332
17582
  (binary files do not count) must be split into children to stay under the budget.
17583
+ The budget applies only to nodes an LLM reviewer actually reads \u2014 those with at
17584
+ least one non-draft LLM aspect; deterministic-only and aspect-less nodes are not
17585
+ bounded (a check.mjs reads files programmatically, with no context window).
17333
17586
  For a node mapping a single unsplittable generated or binary artifact (a lockfile,
17334
17587
  an append-only changelog, an image), opt out per-node with
17335
17588
  \`sizeExempt: { reason: "<why it cannot be split>" }\`.
@@ -17396,8 +17649,9 @@ Unified gate \u2014 runs all validators in sequence.
17396
17649
  yg check
17397
17650
  \`\`\`
17398
17651
 
17399
- Detects: drift (source + cascade), validation errors, coverage gaps,
17400
- \`unmapped-files\`, type-when mismatches, strict orphans/misplaced files.
17652
+ Detects: drift (source + cascade), validation errors, coverage gaps
17653
+ (\`unmapped-files\` errors under required roots, \`uncovered-advisory\` warnings
17654
+ outside them), type-when mismatches, strict orphans/misplaced files.
17401
17655
 
17402
17656
  Exit 0 = clean. Exit 1 = errors found. CI blocks on exit 1.
17403
17657
 
@@ -17808,8 +18062,10 @@ ports: # map keyed by port name (NOT a list)
17808
18062
  \`\`\`
17809
18063
 
17810
18064
  Every aspect id listed in a port's \`aspects\` must be defined under
17811
- \`aspects/\`; a missing one emits a blocking error (code
17812
- \`port-missing-aspect\`).
18065
+ \`aspects/\`. An undefined id is caught unconditionally by the
18066
+ reference-integrity check (code \`aspect-undefined\`); when the port is
18067
+ actually consumed, the missing aspect additionally surfaces as
18068
+ \`port-missing-aspect\`.
17813
18069
 
17814
18070
  A consumer references the port via the relation's \`consumes\`. In
17815
18071
  \`yg-node.yaml\`, \`relations:\` is a flat list and each entry carries its own
@@ -17854,12 +18110,11 @@ a blocking error on the missing port contract, surfacing the gap.
17854
18110
 
17855
18111
  If a target node declares ports and the consumer's relation does NOT
17856
18112
  declare \`consumes\`, \`yg check\` emits a blocking error (code
17857
- \`port-missing-consumes\`) that fails the architecture gate:
17858
-
17859
- \`\`\`
17860
- Missing port contract: <consumer> \u2192 <target> has ports [<list>],
17861
- consumer must declare consumes: [<port-name>].
17862
- \`\`\`
18113
+ \`port-missing-consumes\`) that fails the architecture gate. Like every
18114
+ diagnostic, it is rendered in the what/why/next form: it names the
18115
+ relation, explains that the target's port-required aspects won't be
18116
+ verified without a \`consumes\` declaration, and tells you to add
18117
+ \`consumes: [<port-names>]\` to the relation.
17863
18118
 
17864
18119
  There is no "accept the gap" mechanism. Resolve it one of two ways:
17865
18120
  declare which port(s) you consume on the relation, or remove the ports