@chrisdudek/yg 5.0.0-alpha.2 → 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";
@@ -570,9 +584,25 @@ async function collectFiles(dir, projectRoot, stack) {
570
584
  }
571
585
  return results;
572
586
  }
587
+ function excludeNestedGraphSubtrees(relPaths) {
588
+ const seg = `/${YGGDRASIL_DIRNAME}/`;
589
+ const nestedRoots = /* @__PURE__ */ new Set();
590
+ for (const p2 of relPaths) {
591
+ const idx = p2.indexOf(seg);
592
+ if (idx > 0) nestedRoots.add(p2.slice(0, idx));
593
+ }
594
+ if (nestedRoots.size === 0) return relPaths;
595
+ return relPaths.filter((p2) => {
596
+ for (const root of nestedRoots) {
597
+ if (p2 === root || p2.startsWith(root + "/")) return false;
598
+ }
599
+ return true;
600
+ });
601
+ }
573
602
  async function walkRepoFiles(projectRoot) {
574
603
  const stack = await loadRootGitignoreStack(projectRoot);
575
- return collectFiles(projectRoot, projectRoot, stack);
604
+ const files = await collectFiles(projectRoot, projectRoot, stack);
605
+ return excludeNestedGraphSubtrees(files);
576
606
  }
577
607
  var require2, ignoreFactory, YGGDRASIL_DIRNAME;
578
608
  var init_repo_scanner = __esm({
@@ -659,6 +689,11 @@ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, exclu
659
689
  const gitignoreStack = await loadRootGitignoreStack2(projectRoot);
660
690
  const allFiles = [];
661
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
+ }
662
697
  const absPath = path16.join(projectRoot, tf.path);
663
698
  try {
664
699
  const st = await stat5(absPath);
@@ -681,7 +716,7 @@ async function hashTrackedFiles(projectRoot, trackedFiles, storedFileData, exclu
681
716
  continue;
682
717
  }
683
718
  }
684
- 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;
685
720
  const dirty = [];
686
721
  for (const entry of filtered) {
687
722
  const storedMtime = storedFileData?.mtimes[entry.relPath];
@@ -741,26 +776,50 @@ async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, optio
741
776
  result.push(...fileStats);
742
777
  return result;
743
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
+ }
744
798
  async function expandMappingPaths(projectRoot, mappingPaths) {
745
799
  const gitignoreStack = await loadRootGitignoreStack2(projectRoot);
746
800
  const result = [];
747
801
  for (const mp of mappingPaths) {
748
- const absPath = path16.join(projectRoot, mp);
749
- try {
750
- const st = await stat5(absPath);
751
- if (st.isDirectory()) {
752
- const dirEntries = await collectDirectoryFilePaths(absPath, absPath, {
753
- projectRoot,
754
- gitignoreStack
755
- });
756
- for (const entry of dirEntries) {
757
- 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));
758
819
  }
759
- } else {
760
- result.push(toPosixPath(mp));
820
+ } catch {
821
+ continue;
761
822
  }
762
- } catch {
763
- continue;
764
823
  }
765
824
  }
766
825
  return result;
@@ -770,6 +829,7 @@ var init_hash = __esm({
770
829
  "src/io/hash.ts"() {
771
830
  "use strict";
772
831
  init_posix();
832
+ init_mapping_path();
773
833
  init_repo_scanner();
774
834
  require3 = createRequire2(import.meta.url);
775
835
  ignoreFactory2 = require3("ignore");
@@ -1031,10 +1091,10 @@ function computeEffectiveAspectStatuses(node, graph) {
1031
1091
  for (const a of graph.aspects) idToAspect.set(a.id, a);
1032
1092
  let changed = true;
1033
1093
  let iterations = 0;
1034
- const maxIterations = graph.aspects.length + 1;
1094
+ const maxIterations = graph.aspects.length + 2;
1035
1095
  while (changed) {
1036
1096
  if (++iterations > maxIterations) {
1037
- 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)");
1038
1098
  }
1039
1099
  changed = false;
1040
1100
  const currentIds = [...result.keys()];
@@ -1242,8 +1302,7 @@ function collectTrackedFiles(node, graph, baseline) {
1242
1302
  }
1243
1303
  const allAspectIds = computeEffectiveAspects(node, graph);
1244
1304
  const mappingPathsList = normalizeMappingPaths(node.meta.mapping);
1245
- const mappingPathsSet = new Set(mappingPathsList);
1246
- const isOwnedByMapping = (p2) => mappingPathsSet.has(p2) || mappingPathsList.some((m) => p2.startsWith(m + "/"));
1305
+ const isOwnedByMapping = (p2) => mappingPathsList.some((m) => mappingEntryMatchesFile(m, p2));
1247
1306
  for (const aspectId of allAspectIds) {
1248
1307
  const aspect = graph.aspects.find((a) => a.id === aspectId);
1249
1308
  if (!aspect) continue;
@@ -1347,6 +1406,7 @@ var init_files = __esm({
1347
1406
  "src/core/graph/files.ts"() {
1348
1407
  "use strict";
1349
1408
  init_paths();
1409
+ init_mapping_path();
1350
1410
  init_traversal();
1351
1411
  init_aspects();
1352
1412
  init_tier_selection();
@@ -3090,9 +3150,8 @@ function isAllowed(p2, set) {
3090
3150
  if (p2 === "") return false;
3091
3151
  if (set.has(p2)) return true;
3092
3152
  for (const a of set) {
3093
- if (a === p2) return true;
3094
3153
  if (a.startsWith(p2 + "/")) return true;
3095
- if (p2.startsWith(a + "/")) return true;
3154
+ if (mappingEntryMatchesFile(a, p2)) return true;
3096
3155
  }
3097
3156
  return false;
3098
3157
  }
@@ -3184,13 +3243,7 @@ var init_ctx_fs = __esm({
3184
3243
  function isPathInMapping(candidate, mapping) {
3185
3244
  const c = normalizeMappingPath(candidate);
3186
3245
  if (c === "") return false;
3187
- for (const raw of mapping) {
3188
- const n = normalizeMappingPath(raw);
3189
- if (n === "") continue;
3190
- if (c === n) return true;
3191
- if (c.startsWith(n + "/")) return true;
3192
- }
3193
- return false;
3246
+ return mapping.some((raw) => mappingEntryMatchesFile(raw, c));
3194
3247
  }
3195
3248
  var init_expand_mapping_sync = __esm({
3196
3249
  "src/structure/expand-mapping-sync.ts"() {
@@ -3231,15 +3284,16 @@ function computeAllowedNodePaths(currentPath, graph) {
3231
3284
  return allowed;
3232
3285
  }
3233
3286
  function createCtxGraph(params) {
3234
- const { currentNodePath, graph, projectRoot, touchedFiles } = params;
3287
+ const { currentNodePath, graph, projectRoot, touchedFiles, expandedFilesByNode } = params;
3235
3288
  const allowed = computeAllowedNodePaths(currentNodePath, graph);
3236
3289
  function assertAllowed(id) {
3237
3290
  if (!allowed.has(id)) throw new UndeclaredGraphReadError(id);
3238
3291
  }
3239
3292
  function toPublicNode(m) {
3240
3293
  const files = [];
3241
- for (const raw of m.meta.mapping ?? []) {
3242
- 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) {
3243
3297
  if (!p2) continue;
3244
3298
  const abs = path29.resolve(projectRoot, p2);
3245
3299
  try {
@@ -3338,10 +3392,14 @@ import path30 from "path";
3338
3392
  import { fileURLToPath as fileURLToPath4 } from "url";
3339
3393
  import { existsSync as existsSync5 } from "fs";
3340
3394
  import { createRequire as createRequire3 } from "module";
3341
- async function init() {
3342
- if (initialized) return;
3343
- await Parser.init();
3344
- initialized = true;
3395
+ function init() {
3396
+ if (initPromise === null) {
3397
+ initPromise = Parser.init();
3398
+ initPromise.catch(() => {
3399
+ initPromise = null;
3400
+ });
3401
+ }
3402
+ return initPromise;
3345
3403
  }
3346
3404
  function resolveWasm(filename, pkg2) {
3347
3405
  for (const dir of GRAMMAR_DIRS) {
@@ -3364,12 +3422,16 @@ async function getParser(extension) {
3364
3422
  throw new Error(`no parser for extension '${extension}'`);
3365
3423
  }
3366
3424
  const cacheKey = info.wasmFile;
3367
- let lang = langCache.get(cacheKey);
3368
- if (!lang) {
3425
+ let langP = langCache.get(cacheKey);
3426
+ if (langP === void 0) {
3369
3427
  const wasmPath = resolveWasm(info.wasmFile, info.wasmPackage);
3370
- lang = await Language.load(wasmPath);
3371
- 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
+ });
3372
3433
  }
3434
+ const lang = await langP;
3373
3435
  const parser = new Parser();
3374
3436
  parser.setLanguage(lang);
3375
3437
  return parser;
@@ -3383,7 +3445,7 @@ async function parseFile(filePath, content14) {
3383
3445
  }
3384
3446
  return tree;
3385
3447
  }
3386
- var _require, __filename2, __dirname2, GRAMMAR_DIRS, initialized, langCache;
3448
+ var _require, __filename2, __dirname2, GRAMMAR_DIRS, initPromise, langCache;
3387
3449
  var init_parser = __esm({
3388
3450
  "src/ast/parser.ts"() {
3389
3451
  "use strict";
@@ -3395,7 +3457,7 @@ var init_parser = __esm({
3395
3457
  path30.resolve(__dirname2, "grammars"),
3396
3458
  path30.resolve(__dirname2, "..", "grammars")
3397
3459
  ];
3398
- initialized = false;
3460
+ initPromise = null;
3399
3461
  langCache = /* @__PURE__ */ new Map();
3400
3462
  }
3401
3463
  });
@@ -3624,15 +3686,23 @@ function parseMarker(commentText, line, file) {
3624
3686
  if (m) return makeMarker("single", m, line, file);
3625
3687
  return null;
3626
3688
  }
3627
- function collectSuppressions(tree, file, totalLines) {
3628
- if (getLanguageForExtension(extname3(file)) === null) {
3629
- return [];
3630
- }
3631
- const comments = findComments({ path: file, ast: tree });
3689
+ function collectSuppressions(tree, file, totalLines, content14) {
3690
+ const hasGrammar = getLanguageForExtension(extname3(file)) !== null;
3632
3691
  const markers = [];
3633
- for (const c of comments) {
3634
- const m = parseMarker(c.text, c.startPosition.row + 1, file);
3635
- 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 [];
3636
3706
  }
3637
3707
  markers.sort((a, b) => a.line - b.line);
3638
3708
  const ranges = [];
@@ -3730,8 +3800,8 @@ var init_suppress = __esm({
3730
3800
  }
3731
3801
  code = "SUPPRESS_MARKER_MISSING_REASON";
3732
3802
  };
3733
- RE_SINGLE = /\byg-suppress\(\s*([^)]+?)\s*\)\s*(.+)?$/;
3734
- 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;
3735
3805
  RE_ENABLE = /\byg-suppress-enable\(\s*([^)]+?)\s*\)/;
3736
3806
  }
3737
3807
  });
@@ -3862,7 +3932,12 @@ async function runStructureAspect(params) {
3862
3932
  const checkFn = mod.check;
3863
3933
  const allowedSet = collectAllowedReadsForAspect(nodePath, graph);
3864
3934
  const ctxFs = createCtxFs({ allowedSet, projectRoot, touchedFiles });
3865
- 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 });
3866
3941
  const parsers = createCtxParsers({ allowedSet, projectRoot, touchedFiles, astCache });
3867
3942
  const ownFiles = await buildOwnFiles(node, projectRoot, touchedFiles);
3868
3943
  await prewarmupAstCache({ astCache, projectRoot, files: ownFiles });
@@ -3976,12 +4051,22 @@ ${err.stack ?? ""}`,
3976
4051
  }
3977
4052
  violations.push(vv);
3978
4053
  }
4054
+ const contentByPath = /* @__PURE__ */ new Map();
4055
+ for (const f of [...ownFiles, ...astInputSet]) {
4056
+ contentByPath.set(normalizeMappingPath(f.path), f.content);
4057
+ }
3979
4058
  const rangesByFile = /* @__PURE__ */ new Map();
3980
4059
  function rangesFor(filePath) {
3981
4060
  const existing = rangesByFile.get(filePath);
3982
4061
  if (existing !== void 0) return existing;
3983
4062
  const cached = astCache.get(filePath);
3984
- 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
+ }
3985
4070
  rangesByFile.set(filePath, ranges);
3986
4071
  return ranges;
3987
4072
  }
@@ -4575,7 +4660,7 @@ The CLI (\`yg\`) reads and validates \u2014 it never modifies files. You create
4575
4660
  .drift-state/ \u2190 generated by CLI; never edit manually
4576
4661
  \`\`\`
4577
4662
 
4578
- **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).
4579
4664
 
4580
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.
4581
4666
 
@@ -4968,7 +5053,7 @@ context.
4968
5053
 
4969
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.
4970
5055
 
4971
- **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.
4972
5057
 
4973
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\`.
4974
5059
 
@@ -5677,6 +5762,43 @@ var DEFAULT_QUALITY = {
5677
5762
  max_direct_relations: 10,
5678
5763
  max_node_chars: 4e4
5679
5764
  };
5765
+ var DEFAULT_COVERAGE = { required: ["/"], excluded: [] };
5766
+ function parseStringArray(raw, field, filename) {
5767
+ if (raw === void 0) return [];
5768
+ if (!Array.isArray(raw) || raw.some((x) => typeof x !== "string")) {
5769
+ throw new ConfigParseError({
5770
+ what: `${filename}: ${field} must be a list of strings (got ${JSON.stringify(raw)}).`,
5771
+ why: "Coverage roots are repo-relative path prefixes; a non-list value cannot be matched against files.",
5772
+ next: `Set ${field} to a YAML list, e.g.
5773
+ ${field.split(".").pop()}:
5774
+ - services/`
5775
+ }, "config-invalid");
5776
+ }
5777
+ return raw;
5778
+ }
5779
+ function parseCoverage(raw, filename) {
5780
+ if (raw === void 0) return DEFAULT_COVERAGE;
5781
+ if (typeof raw !== "object" || Array.isArray(raw) || raw === null) {
5782
+ throw new ConfigParseError({
5783
+ what: `${filename}: coverage must be a mapping`,
5784
+ why: "coverage holds the required/excluded root lists",
5785
+ next: 'replace with `coverage: { required: ["/"], excluded: [] }`'
5786
+ }, "config-invalid");
5787
+ }
5788
+ const cov = raw;
5789
+ const required = cov.required === void 0 ? ["/"] : parseStringArray(cov.required, "coverage.required", filename);
5790
+ const excluded = parseStringArray(cov.excluded, "coverage.excluded", filename);
5791
+ for (const root of [...required, ...excluded]) {
5792
+ if (root.split("/").includes("..")) {
5793
+ throw new ConfigParseError({
5794
+ what: `${filename}: coverage root '${root}' contains a '..' segment.`,
5795
+ why: "'..' is not a valid repo-relative prefix and will never match any git-tracked path, silently mis-scoping coverage enforcement.",
5796
+ next: 'Use a repo-relative path prefix without any ".." segments (e.g. - services/ instead of - services/../other/).'
5797
+ }, "config-invalid");
5798
+ }
5799
+ }
5800
+ return { required, excluded };
5801
+ }
5680
5802
  function parseMaxNodeChars(raw, filename) {
5681
5803
  if (raw === void 0) return DEFAULT_QUALITY.max_node_chars ?? 4e4;
5682
5804
  if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
@@ -5688,6 +5810,17 @@ function parseMaxNodeChars(raw, filename) {
5688
5810
  }
5689
5811
  return raw;
5690
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
+ }
5691
5824
  var PROVIDER_DEFAULTS = {
5692
5825
  "claude-code": { model: "haiku" },
5693
5826
  "codex": { model: "o4-mini" },
@@ -5715,7 +5848,7 @@ async function parseConfig(filePath) {
5715
5848
  }
5716
5849
  const qualityMap = qualityRaw;
5717
5850
  const quality = qualityMap ? {
5718
- 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),
5719
5852
  max_node_chars: parseMaxNodeChars(qualityMap.max_node_chars, filename)
5720
5853
  } : DEFAULT_QUALITY;
5721
5854
  let reviewer;
@@ -5754,7 +5887,8 @@ async function parseConfig(filePath) {
5754
5887
  quality,
5755
5888
  reviewer,
5756
5889
  parallel,
5757
- debug
5890
+ debug,
5891
+ coverage: parseCoverage(raw.coverage, filename)
5758
5892
  };
5759
5893
  }
5760
5894
  function parseReviewer(raw, filename) {
@@ -5891,6 +6025,13 @@ function parseTier(name, raw, filename) {
5891
6025
  next: "add `model: <model-name>` under config:"
5892
6026
  }, "config-tier-config-missing");
5893
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
+ }
5894
6035
  const allowed = /* @__PURE__ */ new Set(["provider", "consensus", "config", "references"]);
5895
6036
  for (const k of Object.keys(t)) {
5896
6037
  if (!allowed.has(k)) {
@@ -6314,6 +6455,10 @@ function parseRelations(raw, filePath) {
6314
6455
  );
6315
6456
  }
6316
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
+ );
6317
6462
  }
6318
6463
  if (typeof obj.event_name === "string" && obj.event_name.trim()) {
6319
6464
  rel.event_name = obj.event_name.trim();
@@ -6872,13 +7017,13 @@ function parseReviewer2(raw, aspectId, files) {
6872
7017
  next: "add `type: llm` or `type: deterministic` under reviewer:"
6873
7018
  }
6874
7019
  });
6875
- } else if (obj.type !== "llm" && obj.type !== "deterministic") {
7020
+ } else if (obj.type !== "llm" && obj.type !== "deterministic" && obj.type !== "aggregate") {
6876
7021
  errors.push({
6877
7022
  code: "aspect-reviewer-type-invalid",
6878
7023
  messageData: {
6879
7024
  what: `aspect '${aspectId}' has invalid reviewer.type: '${String(obj.type)}'`,
6880
- why: 'only "llm" and "deterministic" are valid',
6881
- 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"
6882
7027
  }
6883
7028
  });
6884
7029
  } else {
@@ -9366,7 +9511,7 @@ init_posix();
9366
9511
  import path21 from "path";
9367
9512
 
9368
9513
  // src/core/file-when-evaluator.ts
9369
- import { minimatch } from "minimatch";
9514
+ init_mapping_path();
9370
9515
  var YGGDRASIL_PREFIX = ".yggdrasil/";
9371
9516
  function safeRegexTest(re, str) {
9372
9517
  const HEAD_LIMIT = 256 * 1024;
@@ -9445,7 +9590,7 @@ async function evaluateAtomic2(predicate, ctx) {
9445
9590
  );
9446
9591
  }
9447
9592
  if (predicate.path !== void 0) {
9448
- const matches = minimatch(ctx.repoRelPath, predicate.path, { dot: true });
9593
+ const matches = globMatch(ctx.repoRelPath, predicate.path);
9449
9594
  return {
9450
9595
  result: matches,
9451
9596
  trace: { kind: "atom-path", pattern: predicate.path, result: matches }
@@ -9488,7 +9633,15 @@ async function evaluateAtomic2(predicate, ctx) {
9488
9633
  }
9489
9634
  };
9490
9635
  }
9491
- 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
+ }
9492
9645
  const { match: matches } = safeRegexTest(regex, fileContent.content);
9493
9646
  return {
9494
9647
  result: matches,
@@ -9549,6 +9702,9 @@ function renderNode(node, indent, lines) {
9549
9702
  }
9550
9703
 
9551
9704
  // src/core/checks/architecture.ts
9705
+ init_hash();
9706
+ init_mapping_path();
9707
+ init_posix();
9552
9708
  function checkTypeUnknownParent(graph) {
9553
9709
  const issues = [];
9554
9710
  const knownTypes = new Set(Object.keys(graph.architecture.node_types));
@@ -9706,7 +9862,15 @@ async function checkTypeWhenMismatch(graph, cache) {
9706
9862
  const typeDef = graph.architecture.node_types[node.meta.type];
9707
9863
  if (typeDef === void 0 || typeDef.when === void 0) continue;
9708
9864
  const mapping = node.meta.mapping ?? [];
9709
- 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) {
9710
9874
  const absPath = path21.join(projectRoot, relPath);
9711
9875
  const result = await evaluateFileWhen(typeDef.when, {
9712
9876
  absPath,
@@ -10205,6 +10369,23 @@ function checkWhenReferences(graph) {
10205
10369
  })
10206
10370
  });
10207
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
+ }
10208
10389
  }
10209
10390
  }
10210
10391
  };
@@ -10596,7 +10777,7 @@ function checkAspectStatusDowngrade(graph) {
10596
10777
  for (const source of sources) {
10597
10778
  if (!sourceIsExplicit(source, node, aspectId, graph)) continue;
10598
10779
  const otherDeclared = sources.filter((s) => s !== source).map((s) => s.declared);
10599
- const anchor = otherDeclared.length === 0 ? aspectDefault : otherDeclared.reduce(
10780
+ const anchor = [...otherDeclared, aspectDefault].reduce(
10600
10781
  (acc, cur) => STATUS_ORDER[cur] > STATUS_ORDER[acc] ? cur : acc,
10601
10782
  "draft"
10602
10783
  );
@@ -10626,6 +10807,7 @@ function checkAspectStatusDowngrade(graph) {
10626
10807
  // src/core/checks/mapping.ts
10627
10808
  init_paths();
10628
10809
  init_hash();
10810
+ init_mapping_path();
10629
10811
  init_graph_fs();
10630
10812
  init_repo_scanner();
10631
10813
  init_aspects();
@@ -10673,12 +10855,6 @@ async function checkStrictBackwardCoverage(graph, cache) {
10673
10855
  );
10674
10856
  if (strictTypes.length === 0) return { issues: [], unreadable: [] };
10675
10857
  const projectRoot = path23.dirname(graph.rootPath);
10676
- const fileToOwner = /* @__PURE__ */ new Map();
10677
- for (const [nodePath, node] of graph.nodes) {
10678
- for (const relPath of node.meta.mapping ?? []) {
10679
- if (!fileToOwner.has(relPath)) fileToOwner.set(relPath, { nodePath, nodeType: node.meta.type });
10680
- }
10681
- }
10682
10858
  const repoFiles = await walkRepoFiles(projectRoot);
10683
10859
  const issues = [];
10684
10860
  const unreadable = [];
@@ -10741,7 +10917,14 @@ Run: yg impact --type ${sorted[j]}`
10741
10917
  }
10742
10918
  if (matchingTypes.length === 0) continue;
10743
10919
  const { typeId, trace } = matchingTypes[0];
10744
- 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
+ }
10745
10928
  if (owner === void 0) {
10746
10929
  issues.push({
10747
10930
  severity: "error",
@@ -10786,7 +10969,7 @@ function arePathsOverlapping(pathA, pathB) {
10786
10969
  function isAncestorNode(possibleAncestor, possibleDescendant) {
10787
10970
  return possibleDescendant.startsWith(possibleAncestor + "/");
10788
10971
  }
10789
- function checkMappingOverlap(graph) {
10972
+ async function checkMappingOverlap(graph) {
10790
10973
  const issues = [];
10791
10974
  const ownership = [];
10792
10975
  for (const [nodePath, node] of graph.nodes) {
@@ -10832,6 +11015,46 @@ function checkMappingOverlap(graph) {
10832
11015
  });
10833
11016
  }
10834
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
+ }
10835
11058
  return issues;
10836
11059
  }
10837
11060
  async function checkMappingPathsExist(graph) {
@@ -10840,21 +11063,38 @@ async function checkMappingPathsExist(graph) {
10840
11063
  for (const [nodePath, node] of graph.nodes) {
10841
11064
  const mappingPaths = normalizeMappingPaths(node.meta.mapping).map(normalizePathForCompare);
10842
11065
  for (const mp of mappingPaths) {
10843
- const absPath = path23.join(projectRoot, mp);
10844
- try {
10845
- await fileAccess(absPath);
10846
- } catch {
10847
- issues.push({
10848
- severity: "error",
10849
- code: "mapping-path-missing",
10850
- rule: "mapping-path-missing",
10851
- ...issueMsg({
10852
- what: `Mapping path '${mp}' does not exist on disk.`,
10853
- why: `Node maps a file that was deleted or moved.`,
10854
- next: `Update mapping in yg-node.yaml: fix the path or remove the entry.`
10855
- }),
10856
- nodePath
10857
- });
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
+ }
10858
11098
  }
10859
11099
  }
10860
11100
  }
@@ -10924,6 +11164,7 @@ async function checkOversizedNodes(graph, cache) {
10924
11164
  refsByAspect.set(aspect.id, aspect.references.map((r) => r.path));
10925
11165
  }
10926
11166
  }
11167
+ const aspectById = new Map(graph.aspects.map((a) => [a.id, a]));
10927
11168
  async function charsOf(repoRelPath) {
10928
11169
  if (BINARY_EXTENSIONS.has(path23.extname(repoRelPath).toLowerCase())) return 0;
10929
11170
  const abs = path23.resolve(projectRoot, repoRelPath);
@@ -10943,13 +11184,24 @@ async function checkOversizedNodes(graph, cache) {
10943
11184
  const mappingPaths = normalizeMappingPaths(node.meta.mapping);
10944
11185
  if (mappingPaths.length === 0) continue;
10945
11186
  const sourceFiles = await expandMappingPaths(projectRoot, mappingPaths);
10946
- const refPaths = /* @__PURE__ */ new Set();
10947
11187
  let effectiveAspects;
10948
11188
  try {
10949
11189
  effectiveAspects = computeEffectiveAspects(node, graph);
10950
11190
  } catch {
10951
11191
  effectiveAspects = /* @__PURE__ */ new Set();
10952
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();
10953
11205
  for (const aspectId of effectiveAspects) {
10954
11206
  for (const rp of refsByAspect.get(aspectId) ?? []) refPaths.add(rp);
10955
11207
  }
@@ -11387,7 +11639,7 @@ async function validate(graph, scope = "all") {
11387
11639
  issues.push(...checkSchemas(graph));
11388
11640
  issues.push(...checkRelationTargets(graph));
11389
11641
  issues.push(...checkNoCycles(graph));
11390
- issues.push(...checkMappingOverlap(graph));
11642
+ issues.push(...await checkMappingOverlap(graph));
11391
11643
  issues.push(...checkMappingEscapesRepo(graph));
11392
11644
  issues.push(...await checkMappingPathsExist(graph));
11393
11645
  issues.push(...checkBrokenFlowRefs(graph));
@@ -11459,6 +11711,7 @@ init_debug_log();
11459
11711
  init_message_builder();
11460
11712
  init_paths();
11461
11713
  init_posix();
11714
+ init_mapping_path();
11462
11715
  function normalizeForMatch(inputPath) {
11463
11716
  return toPosixPath(inputPath.trim());
11464
11717
  }
@@ -11468,17 +11721,25 @@ function findOwner(graph, projectRoot, rawPath) {
11468
11721
  for (const [nodePath, node] of graph.nodes) {
11469
11722
  const mappingPaths = normalizeMappingPaths(node.meta.mapping).map(normalizeForMatch).filter((mappingPath) => mappingPath.length > 0);
11470
11723
  for (const mappingPath of mappingPaths) {
11471
- if (file === mappingPath) {
11472
- return { file, nodePath, mappingPath, direct: true };
11473
- }
11474
- if (file.startsWith(mappingPath + "/")) {
11475
- if (!best || best && mappingPath.length > best.mappingPath.length) {
11476
- 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
+ }
11477
11738
  }
11478
11739
  }
11479
11740
  }
11480
11741
  }
11481
- 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 };
11482
11743
  }
11483
11744
  function registerOwnerCommand(program2) {
11484
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) => {
@@ -11498,11 +11759,21 @@ function registerOwnerCommand(program2) {
11498
11759
  exists = false;
11499
11760
  }
11500
11761
  if (exists) {
11501
- process.stdout.write(`${result.file} -> no graph coverage
11502
- `);
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
+ );
11503
11769
  } else {
11504
- process.stdout.write(`${result.file} -> no graph coverage (file not found)
11505
- `);
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
+ );
11506
11777
  }
11507
11778
  } else {
11508
11779
  process.stdout.write(`${result.file} -> ${result.nodePath}
@@ -11730,6 +12001,13 @@ var STRUCTURAL_CODES = /* @__PURE__ */ new Set([
11730
12001
  "when-unknown-type",
11731
12002
  "when-unknown-node",
11732
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",
11733
12011
  "aspect-unexpected-rule-source",
11734
12012
  "aspect-missing-rule-source",
11735
12013
  "aspect-empty",
@@ -11757,6 +12035,90 @@ var COMPLETENESS_CODES = /* @__PURE__ */ new Set(["description-missing"]);
11757
12035
  // src/core/check.ts
11758
12036
  init_log_format();
11759
12037
  init_posix();
12038
+ init_repo_scanner();
12039
+ init_mapping_path();
12040
+
12041
+ // src/core/check-coverage-tiers.ts
12042
+ init_posix();
12043
+ init_mapping_path();
12044
+ function normalizeRoot(root) {
12045
+ return toPosixPath(root.trim()).replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/");
12046
+ }
12047
+ function matchesRoot(file, normRoot) {
12048
+ return normRoot === "" || mappingEntryMatchesFile(normRoot, file);
12049
+ }
12050
+ function partitionByCoverageTier(uncovered, coverage) {
12051
+ const req = coverage.required.map(normalizeRoot);
12052
+ const exc = coverage.excluded.map(normalizeRoot);
12053
+ const required = [];
12054
+ const middle = [];
12055
+ for (const f of uncovered) {
12056
+ let best = { len: -1, tier: "middle" };
12057
+ for (const r of req) if (matchesRoot(f, r) && r.length > best.len) best = { len: r.length, tier: "required" };
12058
+ for (const r of exc) if (matchesRoot(f, r) && r.length >= best.len) best = { len: r.length, tier: "excluded" };
12059
+ if (best.tier === "required") required.push(f);
12060
+ else if (best.tier === "middle") middle.push(f);
12061
+ }
12062
+ return { required, middle };
12063
+ }
12064
+ function buildCoverageIssue(uncoveredFiles, totalGitFiles) {
12065
+ if (uncoveredFiles.length === 0) return null;
12066
+ const sampleSize = 5;
12067
+ const sample = uncoveredFiles.slice(0, sampleSize);
12068
+ const remaining = uncoveredFiles.length - sample.length;
12069
+ const coveragePct = totalGitFiles > 0 ? (totalGitFiles - uncoveredFiles.length) / totalGitFiles * 100 : 100;
12070
+ let coverageMd;
12071
+ if (uncoveredFiles.length <= sampleSize) {
12072
+ coverageMd = {
12073
+ what: `${uncoveredFiles.length} source file${uncoveredFiles.length === 1 ? "" : "s"} not covered by any node.
12074
+ ${sample.map((f) => " " + f).join("\n")}`,
12075
+ why: "Files without graph coverage cannot be modified under the protocol.",
12076
+ next: `Check ownership candidates: yg context --file <path>
12077
+ Then: add to existing node mapping, or create a new node.`
12078
+ };
12079
+ } else {
12080
+ const guidance = coveragePct < 50 ? "Establish coverage: create nodes for active areas first, expand coverage incrementally." : "Add to an existing node mapping, or create a new node.";
12081
+ coverageMd = {
12082
+ what: `${uncoveredFiles.length} source files have no graph coverage.
12083
+ Examples:
12084
+ ${sample.map((f) => " " + f).join("\n")}
12085
+ ... and ${remaining} more`,
12086
+ why: "Files without graph coverage cannot be modified under the protocol.",
12087
+ next: `${guidance}
12088
+ Check ownership candidates: yg context --file <path>`
12089
+ };
12090
+ }
12091
+ return {
12092
+ severity: "error",
12093
+ code: "unmapped-files",
12094
+ rule: "unmapped-file",
12095
+ messageData: coverageMd,
12096
+ uncoveredFiles,
12097
+ uncoveredCount: uncoveredFiles.length
12098
+ };
12099
+ }
12100
+ function buildCoverageAdvisoryIssue(uncoveredFiles) {
12101
+ if (uncoveredFiles.length === 0) return null;
12102
+ const sample = uncoveredFiles.slice(0, 5);
12103
+ const remaining = uncoveredFiles.length - sample.length;
12104
+ const body = uncoveredFiles.length <= 5 ? sample.map((f) => " " + f).join("\n") : `${sample.map((f) => " " + f).join("\n")}
12105
+ ... and ${remaining} more`;
12106
+ return {
12107
+ severity: "warning",
12108
+ code: "uncovered-advisory",
12109
+ rule: "uncovered-advisory",
12110
+ messageData: {
12111
+ what: `${uncoveredFiles.length} tracked file${uncoveredFiles.length === 1 ? "" : "s"} outside any required coverage root.
12112
+ ${body}`,
12113
+ why: "Not under a coverage.required root \u2014 visible but non-blocking. Bring an area under graph coverage to enforce it.",
12114
+ next: "Map these files to a node, or add their root to coverage.required to make this an error."
12115
+ },
12116
+ uncoveredFiles,
12117
+ uncoveredCount: uncoveredFiles.length
12118
+ };
12119
+ }
12120
+
12121
+ // src/core/check.ts
11760
12122
  async function classifyDrift(graph) {
11761
12123
  const projectRoot = path34.dirname(graph.rootPath);
11762
12124
  const issues = [];
@@ -12012,59 +12374,17 @@ function scanUncoveredFiles(graph, gitTrackedFiles) {
12012
12374
  const projectRoot = path34.dirname(graph.rootPath);
12013
12375
  const yggPrefix = toPosixPath(path34.relative(projectRoot, graph.rootPath));
12014
12376
  const uncovered = [];
12015
- for (const file of gitTrackedFiles) {
12377
+ const tracked = excludeNestedGraphSubtrees(gitTrackedFiles);
12378
+ for (const file of tracked) {
12016
12379
  const normalized = toPosixPath(file.trim());
12017
12380
  if (normalized.startsWith(yggPrefix + "/") || normalized === yggPrefix) continue;
12018
- let covered = false;
12019
- for (const rawMp of allMappings) {
12020
- const mp = toPosixPath(rawMp);
12021
- if (normalized === mp || normalized.startsWith(mp + "/")) {
12022
- covered = true;
12023
- break;
12024
- }
12025
- }
12381
+ const covered = allMappings.some((mp) => mappingEntryMatchesFile(mp, normalized));
12026
12382
  if (!covered) {
12027
12383
  uncovered.push(normalized);
12028
12384
  }
12029
12385
  }
12030
12386
  return uncovered.sort();
12031
12387
  }
12032
- function buildCoverageIssue(uncoveredFiles, totalGitFiles) {
12033
- if (uncoveredFiles.length === 0) return null;
12034
- const sampleSize = 5;
12035
- const sample = uncoveredFiles.slice(0, sampleSize);
12036
- const remaining = uncoveredFiles.length - sample.length;
12037
- const coveragePct = totalGitFiles > 0 ? (totalGitFiles - uncoveredFiles.length) / totalGitFiles * 100 : 100;
12038
- let coverageMd;
12039
- if (uncoveredFiles.length <= sampleSize) {
12040
- coverageMd = {
12041
- what: `${uncoveredFiles.length} source file${uncoveredFiles.length === 1 ? "" : "s"} not covered by any node.
12042
- ${sample.map((f) => " " + f).join("\n")}`,
12043
- why: "Files without graph coverage cannot be modified under the protocol.",
12044
- next: `Check ownership candidates: yg context --file <path>
12045
- Then: add to existing node mapping, or create a new node.`
12046
- };
12047
- } else {
12048
- const guidance = coveragePct < 50 ? "Establish coverage: create nodes for active areas first, expand coverage incrementally." : "Add to an existing node mapping, or create a new node.";
12049
- coverageMd = {
12050
- what: `${uncoveredFiles.length} source files have no graph coverage.
12051
- Examples:
12052
- ${sample.map((f) => " " + f).join("\n")}
12053
- ... and ${remaining} more`,
12054
- why: "Files without graph coverage cannot be modified under the protocol.",
12055
- next: `${guidance}
12056
- Check ownership candidates: yg context --file <path>`
12057
- };
12058
- }
12059
- return {
12060
- severity: "error",
12061
- code: "unmapped-files",
12062
- rule: "unmapped-file",
12063
- messageData: coverageMd,
12064
- uncoveredFiles,
12065
- uncoveredCount: uncoveredFiles.length
12066
- };
12067
- }
12068
12388
  async function detectOrphanedDriftState(graph) {
12069
12389
  const driftState = await readDriftState(graph.rootPath);
12070
12390
  const validNodePaths = new Set(graph.nodes.keys());
@@ -12074,20 +12394,25 @@ async function runCheck(graph, gitTrackedFiles) {
12074
12394
  const validation = await validate(graph);
12075
12395
  const validationIssues = validation.issues.filter((vi) => vi.code).map((vi) => ({ ...vi, code: vi.code }));
12076
12396
  const driftIssues = await classifyDrift(graph);
12077
- let coverageIssue = null;
12397
+ let coverageIssues = [];
12078
12398
  let coveredFiles = 0;
12079
12399
  let totalFiles = 0;
12080
12400
  if (gitTrackedFiles !== null) {
12081
12401
  const projectRoot = path34.dirname(graph.rootPath);
12082
12402
  const yggPrefix = toPosixPath(path34.relative(projectRoot, graph.rootPath));
12083
- const sourceFiles = gitTrackedFiles.filter((f) => {
12403
+ const sourceFiles = excludeNestedGraphSubtrees(gitTrackedFiles).filter((f) => {
12084
12404
  const normalized = toPosixPath(f.trim());
12085
12405
  return !normalized.startsWith(yggPrefix + "/") && normalized !== yggPrefix;
12086
12406
  });
12087
12407
  totalFiles = sourceFiles.length;
12088
12408
  const uncovered = scanUncoveredFiles(graph, gitTrackedFiles);
12089
- coveredFiles = totalFiles - uncovered.length;
12090
- coverageIssue = buildCoverageIssue(uncovered, totalFiles);
12409
+ const coverage = graph.config.coverage ?? DEFAULT_COVERAGE;
12410
+ const tiers = partitionByCoverageTier(uncovered, coverage);
12411
+ coveredFiles = totalFiles - (tiers.required.length + tiers.middle.length);
12412
+ coverageIssues = [
12413
+ buildCoverageIssue(tiers.required, totalFiles),
12414
+ buildCoverageAdvisoryIssue(tiers.middle)
12415
+ ].filter((x) => x !== null);
12091
12416
  }
12092
12417
  const orphanedPaths = await detectOrphanedDriftState(graph);
12093
12418
  await garbageCollectDriftState(
@@ -12116,7 +12441,7 @@ async function runCheck(graph, gitTrackedFiles) {
12116
12441
  const allIssues = [
12117
12442
  ...driftIssues,
12118
12443
  ...validationIssues,
12119
- ...coverageIssue ? [coverageIssue] : [],
12444
+ ...coverageIssues,
12120
12445
  ...orphanWarnings
12121
12446
  ];
12122
12447
  const nodeTypeCounts = /* @__PURE__ */ new Map();
@@ -12219,10 +12544,15 @@ function getChildMappingExclusions2(graph, nodePath) {
12219
12544
  }
12220
12545
  async function allPathsMissing(projectRoot, mappingPaths) {
12221
12546
  for (const mp of mappingPaths) {
12222
- try {
12223
- await fileAccess(path34.join(projectRoot, mp));
12224
- return false;
12225
- } 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
+ }
12226
12556
  }
12227
12557
  }
12228
12558
  return true;
@@ -12331,7 +12661,7 @@ function computeSuggestedNext(issues, graph) {
12331
12661
  addRemaining(coverageErrors.length > 0 ? coverageErrors[0].uncoveredCount ?? 0 : 0, "files need coverage");
12332
12662
  const then = remaining.length > 0 ? `
12333
12663
  Then: ${remaining.join(", ")}` : "";
12334
- return `Fix ${first.code} in ${first.nodePath ?? ".yggdrasil/"}
12664
+ return `Fix ${first.code} in ${first.nodePath ?? ".yggdrasil"}
12335
12665
  1 of ${structuralErrors.length} structural error${structuralErrors.length === 1 ? "" : "s"}${then}`;
12336
12666
  }
12337
12667
  if (coverageErrors.length > 0) {
@@ -14017,10 +14347,16 @@ function renderErrorSection(errors) {
14017
14347
  }
14018
14348
  function renderWarningSection(warnings) {
14019
14349
  const lines = [chalk8.yellow(`Warnings (${warnings.length}):`)];
14020
- for (const issue of sortByNodePath(warnings)) {
14350
+ const coverage = warnings.filter((i) => i.code === "uncovered-advisory");
14351
+ const rest = warnings.filter((i) => i.code !== "uncovered-advisory");
14352
+ for (const issue of sortByNodePath(rest)) {
14021
14353
  lines.push("");
14022
14354
  renderIssueBlock(issue, lines, "warning");
14023
14355
  }
14356
+ for (const issue of coverage) {
14357
+ lines.push("");
14358
+ renderUnmappedBlock(issue, lines, "uncovered");
14359
+ }
14024
14360
  return lines.join("\n");
14025
14361
  }
14026
14362
  function renderIssueBlock(issue, lines, mode) {
@@ -14038,14 +14374,12 @@ function renderIssueBlock(issue, lines, mode) {
14038
14374
  lines.push(` Fix: ${md.next}${fixSuffix}`);
14039
14375
  }
14040
14376
  }
14041
- function renderUnmappedBlock(issue, lines) {
14377
+ function renderUnmappedBlock(issue, lines, label = "unmapped") {
14042
14378
  const md = issue.messageData;
14043
14379
  const files = issue.uncoveredFiles ?? [];
14044
- const whatFirstLine = md.what.split("\n")[0];
14045
- const countMatch = whatFirstLine.match(/^(\d[\d,]*)/);
14046
14380
  const count = issue.uncoveredCount ?? files.length;
14047
- const countLabel = countMatch ? countMatch[1] : String(count);
14048
- lines.push(` unmapped (${countLabel})`);
14381
+ const countLabel = String(count);
14382
+ lines.push(` ${label} (${countLabel})`);
14049
14383
  const shown = files.slice(0, 10);
14050
14384
  for (const f of shown) {
14051
14385
  lines.push(` ${f}`);
@@ -14132,6 +14466,7 @@ init_loader_hook();
14132
14466
  init_parser();
14133
14467
  init_suppress();
14134
14468
  init_validate_check_module();
14469
+ init_language_registry();
14135
14470
  import path37 from "path";
14136
14471
  import { readFile as readFile20 } from "fs/promises";
14137
14472
  import { pathToFileURL as pathToFileURL3 } from "url";
@@ -14179,18 +14514,15 @@ async function runAstAspect(params) {
14179
14514
  continue;
14180
14515
  }
14181
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
+ }
14182
14521
  let ast;
14183
14522
  try {
14184
14523
  ast = await parseFile(f.path, content14);
14185
14524
  } catch (e) {
14186
14525
  const msg = e.message ?? String(e);
14187
- if (msg.startsWith("no parser for extension")) {
14188
- throw new AstRunnerError("AST_NO_PARSER_FOR_EXTENSION", {
14189
- what: msg + ` (file: ${f.path})`,
14190
- why: `v1 supports only .ts/.tsx/.js/.mjs/.cjs/.jsx.`,
14191
- next: `Remove ${f.path} from the node's mapping.`
14192
- });
14193
- }
14194
14526
  throw new AstRunnerError("AST_GRAMMAR_LOAD_FAILED", {
14195
14527
  what: `Failed to load tree-sitter grammar for ${f.path}: ${msg}`,
14196
14528
  why: `The bundled WASM grammar could not be loaded.`,
@@ -14211,7 +14543,7 @@ async function runAstAspect(params) {
14211
14543
  const rangesPerFile = /* @__PURE__ */ new Map();
14212
14544
  for (const f of sourceFiles) {
14213
14545
  const totalLines = f.content.split("\n").length;
14214
- rangesPerFile.set(f.path, collectSuppressions(f.ast, f.path, totalLines));
14546
+ rangesPerFile.set(f.path, collectSuppressions(f.ast, f.path, totalLines, f.content));
14215
14547
  }
14216
14548
  const ctx = { files: sourceFiles };
14217
14549
  let raw;
@@ -15493,6 +15825,20 @@ predicate satisfaction fraction, or edge-case messages for files inside
15493
15825
  Run this whenever you add or modify a type's \`when\` predicate and want
15494
15826
  to verify that existing files are classified as expected.
15495
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
+
15496
15842
  ## Aspect status in architecture default aspects
15497
15843
 
15498
15844
  Architecture-level default aspects (channel 3) may declare \`status:\` to
@@ -15896,7 +16242,11 @@ var content4 = `# Writing deterministic aspects
15896
16242
  A deterministic aspect declares \`reviewer: { type: deterministic }\` and ships
15897
16243
  a \`check.mjs\` file. The check runs locally at zero LLM cost and returns a
15898
16244
  \`Violation[]\`. Deterministic aspects do not use reviewer tiers \u2014
15899
- \`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.
15900
16250
 
15901
16251
  There are two ways to scope a deterministic aspect:
15902
16252
 
@@ -16009,7 +16359,7 @@ arrives in the one \`check.mjs\` invocation.
16009
16359
  | \`walk(node, visitor)\` | \`(node, (n) => boolean|void) => void\` | DFS traversal; visitor returning \`false\` skips descent into that subtree |
16010
16360
  | \`report(file, node, message)\` | \`(file, TreeNode, string) => Violation\` | Build a \`{ file, line, column, message }\` \u2014 \`line\` 1-based, \`column\` 0-based |
16011
16361
  | \`inFile(file, pattern)\` | \`(file, { glob } | { regex } | { contains }) => boolean\` | Path filter (discriminated object form) |
16012
- | \`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) |
16013
16363
  | \`closest(node, types)\` | \`(TreeNode, string[]) => TreeNode | null\` | Nearest ancestor whose \`type\` is in \`types\` |
16014
16364
 
16015
16365
  ## tree-sitter node API
@@ -16268,7 +16618,7 @@ parsed AST trees via \`ctx.parseAst\`:
16268
16618
  | \`closest(node, types)\` | \`(TreeNode, string[]) => TreeNode | null\` | Nearest ancestor of one of the given types |
16269
16619
  | \`report(file, node, message)\` | \`(file, TreeNode, string) => Violation\` | Build \`{ file, line, column, message }\` \u2014 line 1-based, column 0-based |
16270
16620
  | \`inFile(file, pattern)\` | \`(file, { glob } | { regex } | { contains }) => boolean\` | Path filter |
16271
- | \`findComments(target)\` | \`(file | node) => TreeNode[]\` | Returns comment nodes |
16621
+ | \`findComments(target)\` | \`(file) => TreeNode[]\` | Returns comment nodes |
16272
16622
 
16273
16623
  These helpers are optional \u2014 most graph-aware checks work purely with \`ctx.graph\`
16274
16624
  and \`ctx.fs\` without parsing AST trees at all.
@@ -16773,6 +17123,21 @@ For generated files where the entire file is exempt, place the marker at
16773
17123
  the file level (outside any function or class). At file level, the
16774
17124
  contextual scope is the whole file.
16775
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
+
16776
17141
  ## Reason text
16777
17142
 
16778
17143
  The reason text after the aspect-id is permanent. Future maintainers and
@@ -17039,14 +17404,19 @@ reviewer:
17039
17404
  config:
17040
17405
  model: qwen3 # Model identifier for this provider
17041
17406
  temperature: 0 # Sampling temperature (0 = deterministic)
17042
- 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
17043
17408
  # references: # optional caps on aspect reference files
17044
17409
  # max_bytes_per_file: 65536 # default: 64 KiB per reference file
17045
17410
  # max_total_bytes_per_aspect: 262144 # default: 256 KiB total per aspect
17046
17411
 
17412
+ coverage: # Optional \u2014 controls which files must be mapped
17413
+ required: # Unmapped files under these roots are a blocking error
17414
+ - "/" # Default: whole repo (previous always-map-everything behavior)
17415
+ excluded: [] # Files under these roots are silently ignored
17416
+
17047
17417
  quality:
17048
17418
  max_direct_relations: 10 # Max out-edges per node before high-fan-out warning
17049
- 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
17050
17420
 
17051
17421
  parallel: 1 # Concurrent aspect verifications across nodes (default: 1)
17052
17422
 
@@ -17113,7 +17483,7 @@ Provider-specific options passed to the LLM client:
17113
17483
  |---|---|---|
17114
17484
  | \`model\` | string | Required. Provider-specific model identifier. |
17115
17485
  | \`temperature\` | number | Defaults to 0. Higher = more varied responses. |
17116
- | \`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. |
17117
17487
  | \`timeout\` | number | Timeout in seconds. Default 300. Applies to CLI providers only \u2014 non-CLI/API providers ignore it. |
17118
17488
 
17119
17489
  API keys do NOT live here \u2014 they belong in \`yg-secrets.yaml\` (api_key only).
@@ -17179,6 +17549,26 @@ the provider's standard \`*_API_KEY\`) as a fallback when not present in
17179
17549
  \`yg-config.yaml\` itself must never contain credentials. Commit it to the
17180
17550
  repository \u2014 it is safe to share.
17181
17551
 
17552
+ ## Coverage config
17553
+
17554
+ \`\`\`yaml
17555
+ coverage:
17556
+ required:
17557
+ - src/ # unmapped files under src/ are a blocking error
17558
+ excluded:
17559
+ - vendor/ # silently ignored
17560
+ - "**/*.generated.ts" # glob: drop generated files anywhere
17561
+ \`\`\`
17562
+
17563
+ Controls which git-tracked files must be mapped to a node.
17564
+
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.
17568
+ - Files that match neither a required nor an excluded root produce a non-blocking \`uncovered-advisory\` warning.
17569
+ - Subtrees containing their own nested \`.yggdrasil/\` are auto-skipped by all repo-walking checks (they are governed by their own graph).
17570
+ - Longest-match wins (by normalized root/pattern length); on a tie between required and excluded, excluded wins.
17571
+
17182
17572
  ## Quality thresholds
17183
17573
 
17184
17574
  \`\`\`yaml
@@ -17190,6 +17580,9 @@ quality:
17190
17580
  \`max_direct_relations\` fires a warning when exceeded. \`max_node_chars\` is a
17191
17581
  blocking error: a node whose mapped source plus aspect reference files exceed it
17192
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).
17193
17586
  For a node mapping a single unsplittable generated or binary artifact (a lockfile,
17194
17587
  an append-only changelog, an image), opt out per-node with
17195
17588
  \`sizeExempt: { reason: "<why it cannot be split>" }\`.
@@ -17256,8 +17649,9 @@ Unified gate \u2014 runs all validators in sequence.
17256
17649
  yg check
17257
17650
  \`\`\`
17258
17651
 
17259
- Detects: drift (source + cascade), validation errors, coverage gaps,
17260
- \`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.
17261
17655
 
17262
17656
  Exit 0 = clean. Exit 1 = errors found. CI blocks on exit 1.
17263
17657
 
@@ -17668,8 +18062,10 @@ ports: # map keyed by port name (NOT a list)
17668
18062
  \`\`\`
17669
18063
 
17670
18064
  Every aspect id listed in a port's \`aspects\` must be defined under
17671
- \`aspects/\`; a missing one emits a blocking error (code
17672
- \`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\`.
17673
18069
 
17674
18070
  A consumer references the port via the relation's \`consumes\`. In
17675
18071
  \`yg-node.yaml\`, \`relations:\` is a flat list and each entry carries its own
@@ -17714,12 +18110,11 @@ a blocking error on the missing port contract, surfacing the gap.
17714
18110
 
17715
18111
  If a target node declares ports and the consumer's relation does NOT
17716
18112
  declare \`consumes\`, \`yg check\` emits a blocking error (code
17717
- \`port-missing-consumes\`) that fails the architecture gate:
17718
-
17719
- \`\`\`
17720
- Missing port contract: <consumer> \u2192 <target> has ports [<list>],
17721
- consumer must declare consumes: [<port-name>].
17722
- \`\`\`
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.
17723
18118
 
17724
18119
  There is no "accept the gap" mechanism. Resolve it one of two ways:
17725
18120
  declare which port(s) you consume on the relation, or remove the ports