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

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
@@ -570,9 +570,25 @@ async function collectFiles(dir, projectRoot, stack) {
570
570
  }
571
571
  return results;
572
572
  }
573
+ function excludeNestedGraphSubtrees(relPaths) {
574
+ const seg = `/${YGGDRASIL_DIRNAME}/`;
575
+ const nestedRoots = /* @__PURE__ */ new Set();
576
+ for (const p2 of relPaths) {
577
+ const idx = p2.indexOf(seg);
578
+ if (idx > 0) nestedRoots.add(p2.slice(0, idx));
579
+ }
580
+ if (nestedRoots.size === 0) return relPaths;
581
+ return relPaths.filter((p2) => {
582
+ for (const root of nestedRoots) {
583
+ if (p2 === root || p2.startsWith(root + "/")) return false;
584
+ }
585
+ return true;
586
+ });
587
+ }
573
588
  async function walkRepoFiles(projectRoot) {
574
589
  const stack = await loadRootGitignoreStack(projectRoot);
575
- return collectFiles(projectRoot, projectRoot, stack);
590
+ const files = await collectFiles(projectRoot, projectRoot, stack);
591
+ return excludeNestedGraphSubtrees(files);
576
592
  }
577
593
  var require2, ignoreFactory, YGGDRASIL_DIRNAME;
578
594
  var init_repo_scanner = __esm({
@@ -5677,6 +5693,50 @@ var DEFAULT_QUALITY = {
5677
5693
  max_direct_relations: 10,
5678
5694
  max_node_chars: 4e4
5679
5695
  };
5696
+ var DEFAULT_COVERAGE = { required: ["/"], excluded: [] };
5697
+ function parseStringArray(raw, field, filename) {
5698
+ if (raw === void 0) return [];
5699
+ if (!Array.isArray(raw) || raw.some((x) => typeof x !== "string")) {
5700
+ throw new ConfigParseError({
5701
+ what: `${filename}: ${field} must be a list of strings (got ${JSON.stringify(raw)}).`,
5702
+ why: "Coverage roots are repo-relative path prefixes; a non-list value cannot be matched against files.",
5703
+ next: `Set ${field} to a YAML list, e.g.
5704
+ ${field.split(".").pop()}:
5705
+ - services/`
5706
+ }, "config-invalid");
5707
+ }
5708
+ return raw;
5709
+ }
5710
+ function parseCoverage(raw, filename) {
5711
+ if (raw === void 0) return DEFAULT_COVERAGE;
5712
+ if (typeof raw !== "object" || Array.isArray(raw) || raw === null) {
5713
+ throw new ConfigParseError({
5714
+ what: `${filename}: coverage must be a mapping`,
5715
+ why: "coverage holds the required/excluded root lists",
5716
+ next: 'replace with `coverage: { required: ["/"], excluded: [] }`'
5717
+ }, "config-invalid");
5718
+ }
5719
+ const cov = raw;
5720
+ const required = cov.required === void 0 ? ["/"] : parseStringArray(cov.required, "coverage.required", filename);
5721
+ 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
+ for (const root of [...required, ...excluded]) {
5730
+ if (root.split("/").includes("..")) {
5731
+ throw new ConfigParseError({
5732
+ what: `${filename}: coverage root '${root}' contains a '..' segment.`,
5733
+ why: "'..' is not a valid repo-relative prefix and will never match any git-tracked path, silently mis-scoping coverage enforcement.",
5734
+ next: 'Use a repo-relative path prefix without any ".." segments (e.g. - services/ instead of - services/../other/).'
5735
+ }, "config-invalid");
5736
+ }
5737
+ }
5738
+ return { required, excluded };
5739
+ }
5680
5740
  function parseMaxNodeChars(raw, filename) {
5681
5741
  if (raw === void 0) return DEFAULT_QUALITY.max_node_chars ?? 4e4;
5682
5742
  if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
@@ -5754,7 +5814,8 @@ async function parseConfig(filePath) {
5754
5814
  quality,
5755
5815
  reviewer,
5756
5816
  parallel,
5757
- debug
5817
+ debug,
5818
+ coverage: parseCoverage(raw.coverage, filename)
5758
5819
  };
5759
5820
  }
5760
5821
  function parseReviewer(raw, filename) {
@@ -11757,6 +11818,88 @@ var COMPLETENESS_CODES = /* @__PURE__ */ new Set(["description-missing"]);
11757
11818
  // src/core/check.ts
11758
11819
  init_log_format();
11759
11820
  init_posix();
11821
+ init_repo_scanner();
11822
+
11823
+ // src/core/check-coverage-tiers.ts
11824
+ init_posix();
11825
+ function normalizeRoot(root) {
11826
+ return toPosixPath(root.trim()).replace(/^\/+/, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/");
11827
+ }
11828
+ function matchesRoot(file, normRoot) {
11829
+ return normRoot === "" || file === normRoot || file.startsWith(normRoot + "/");
11830
+ }
11831
+ function partitionByCoverageTier(uncovered, coverage) {
11832
+ const req = coverage.required.map(normalizeRoot);
11833
+ const exc = coverage.excluded.map(normalizeRoot);
11834
+ const required = [];
11835
+ const middle = [];
11836
+ for (const f of uncovered) {
11837
+ let best = { len: -1, tier: "middle" };
11838
+ for (const r of req) if (matchesRoot(f, r) && r.length > best.len) best = { len: r.length, tier: "required" };
11839
+ for (const r of exc) if (matchesRoot(f, r) && r.length >= best.len) best = { len: r.length, tier: "excluded" };
11840
+ if (best.tier === "required") required.push(f);
11841
+ else if (best.tier === "middle") middle.push(f);
11842
+ }
11843
+ return { required, middle };
11844
+ }
11845
+ function buildCoverageIssue(uncoveredFiles, totalGitFiles) {
11846
+ if (uncoveredFiles.length === 0) return null;
11847
+ const sampleSize = 5;
11848
+ const sample = uncoveredFiles.slice(0, sampleSize);
11849
+ const remaining = uncoveredFiles.length - sample.length;
11850
+ const coveragePct = totalGitFiles > 0 ? (totalGitFiles - uncoveredFiles.length) / totalGitFiles * 100 : 100;
11851
+ let coverageMd;
11852
+ if (uncoveredFiles.length <= sampleSize) {
11853
+ coverageMd = {
11854
+ what: `${uncoveredFiles.length} source file${uncoveredFiles.length === 1 ? "" : "s"} not covered by any node.
11855
+ ${sample.map((f) => " " + f).join("\n")}`,
11856
+ why: "Files without graph coverage cannot be modified under the protocol.",
11857
+ next: `Check ownership candidates: yg context --file <path>
11858
+ Then: add to existing node mapping, or create a new node.`
11859
+ };
11860
+ } else {
11861
+ 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.";
11862
+ coverageMd = {
11863
+ what: `${uncoveredFiles.length} source files have no graph coverage.
11864
+ Examples:
11865
+ ${sample.map((f) => " " + f).join("\n")}
11866
+ ... and ${remaining} more`,
11867
+ why: "Files without graph coverage cannot be modified under the protocol.",
11868
+ next: `${guidance}
11869
+ Check ownership candidates: yg context --file <path>`
11870
+ };
11871
+ }
11872
+ return {
11873
+ severity: "error",
11874
+ code: "unmapped-files",
11875
+ rule: "unmapped-file",
11876
+ messageData: coverageMd,
11877
+ uncoveredFiles,
11878
+ uncoveredCount: uncoveredFiles.length
11879
+ };
11880
+ }
11881
+ function buildCoverageAdvisoryIssue(uncoveredFiles) {
11882
+ if (uncoveredFiles.length === 0) return null;
11883
+ const sample = uncoveredFiles.slice(0, 5);
11884
+ const remaining = uncoveredFiles.length - sample.length;
11885
+ const body = uncoveredFiles.length <= 5 ? sample.map((f) => " " + f).join("\n") : `${sample.map((f) => " " + f).join("\n")}
11886
+ ... and ${remaining} more`;
11887
+ return {
11888
+ severity: "warning",
11889
+ code: "uncovered-advisory",
11890
+ rule: "uncovered-advisory",
11891
+ messageData: {
11892
+ what: `${uncoveredFiles.length} tracked file${uncoveredFiles.length === 1 ? "" : "s"} outside any required coverage root.
11893
+ ${body}`,
11894
+ why: "Not under a coverage.required root \u2014 visible but non-blocking. Bring an area under graph coverage to enforce it.",
11895
+ next: "Map these files to a node, or add their root to coverage.required to make this an error."
11896
+ },
11897
+ uncoveredFiles,
11898
+ uncoveredCount: uncoveredFiles.length
11899
+ };
11900
+ }
11901
+
11902
+ // src/core/check.ts
11760
11903
  async function classifyDrift(graph) {
11761
11904
  const projectRoot = path34.dirname(graph.rootPath);
11762
11905
  const issues = [];
@@ -12012,7 +12155,8 @@ function scanUncoveredFiles(graph, gitTrackedFiles) {
12012
12155
  const projectRoot = path34.dirname(graph.rootPath);
12013
12156
  const yggPrefix = toPosixPath(path34.relative(projectRoot, graph.rootPath));
12014
12157
  const uncovered = [];
12015
- for (const file of gitTrackedFiles) {
12158
+ const tracked = excludeNestedGraphSubtrees(gitTrackedFiles);
12159
+ for (const file of tracked) {
12016
12160
  const normalized = toPosixPath(file.trim());
12017
12161
  if (normalized.startsWith(yggPrefix + "/") || normalized === yggPrefix) continue;
12018
12162
  let covered = false;
@@ -12029,42 +12173,6 @@ function scanUncoveredFiles(graph, gitTrackedFiles) {
12029
12173
  }
12030
12174
  return uncovered.sort();
12031
12175
  }
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
12176
  async function detectOrphanedDriftState(graph) {
12069
12177
  const driftState = await readDriftState(graph.rootPath);
12070
12178
  const validNodePaths = new Set(graph.nodes.keys());
@@ -12074,20 +12182,25 @@ async function runCheck(graph, gitTrackedFiles) {
12074
12182
  const validation = await validate(graph);
12075
12183
  const validationIssues = validation.issues.filter((vi) => vi.code).map((vi) => ({ ...vi, code: vi.code }));
12076
12184
  const driftIssues = await classifyDrift(graph);
12077
- let coverageIssue = null;
12185
+ let coverageIssues = [];
12078
12186
  let coveredFiles = 0;
12079
12187
  let totalFiles = 0;
12080
12188
  if (gitTrackedFiles !== null) {
12081
12189
  const projectRoot = path34.dirname(graph.rootPath);
12082
12190
  const yggPrefix = toPosixPath(path34.relative(projectRoot, graph.rootPath));
12083
- const sourceFiles = gitTrackedFiles.filter((f) => {
12191
+ const sourceFiles = excludeNestedGraphSubtrees(gitTrackedFiles).filter((f) => {
12084
12192
  const normalized = toPosixPath(f.trim());
12085
12193
  return !normalized.startsWith(yggPrefix + "/") && normalized !== yggPrefix;
12086
12194
  });
12087
12195
  totalFiles = sourceFiles.length;
12088
12196
  const uncovered = scanUncoveredFiles(graph, gitTrackedFiles);
12089
- coveredFiles = totalFiles - uncovered.length;
12090
- coverageIssue = buildCoverageIssue(uncovered, totalFiles);
12197
+ const coverage = graph.config.coverage ?? DEFAULT_COVERAGE;
12198
+ const tiers = partitionByCoverageTier(uncovered, coverage);
12199
+ coveredFiles = totalFiles - (tiers.required.length + tiers.middle.length);
12200
+ coverageIssues = [
12201
+ buildCoverageIssue(tiers.required, totalFiles),
12202
+ buildCoverageAdvisoryIssue(tiers.middle)
12203
+ ].filter((x) => x !== null);
12091
12204
  }
12092
12205
  const orphanedPaths = await detectOrphanedDriftState(graph);
12093
12206
  await garbageCollectDriftState(
@@ -12116,7 +12229,7 @@ async function runCheck(graph, gitTrackedFiles) {
12116
12229
  const allIssues = [
12117
12230
  ...driftIssues,
12118
12231
  ...validationIssues,
12119
- ...coverageIssue ? [coverageIssue] : [],
12232
+ ...coverageIssues,
12120
12233
  ...orphanWarnings
12121
12234
  ];
12122
12235
  const nodeTypeCounts = /* @__PURE__ */ new Map();
@@ -14017,10 +14130,16 @@ function renderErrorSection(errors) {
14017
14130
  }
14018
14131
  function renderWarningSection(warnings) {
14019
14132
  const lines = [chalk8.yellow(`Warnings (${warnings.length}):`)];
14020
- for (const issue of sortByNodePath(warnings)) {
14133
+ const coverage = warnings.filter((i) => i.code === "uncovered-advisory");
14134
+ const rest = warnings.filter((i) => i.code !== "uncovered-advisory");
14135
+ for (const issue of sortByNodePath(rest)) {
14021
14136
  lines.push("");
14022
14137
  renderIssueBlock(issue, lines, "warning");
14023
14138
  }
14139
+ for (const issue of coverage) {
14140
+ lines.push("");
14141
+ renderUnmappedBlock(issue, lines, "uncovered");
14142
+ }
14024
14143
  return lines.join("\n");
14025
14144
  }
14026
14145
  function renderIssueBlock(issue, lines, mode) {
@@ -14038,14 +14157,12 @@ function renderIssueBlock(issue, lines, mode) {
14038
14157
  lines.push(` Fix: ${md.next}${fixSuffix}`);
14039
14158
  }
14040
14159
  }
14041
- function renderUnmappedBlock(issue, lines) {
14160
+ function renderUnmappedBlock(issue, lines, label = "unmapped") {
14042
14161
  const md = issue.messageData;
14043
14162
  const files = issue.uncoveredFiles ?? [];
14044
- const whatFirstLine = md.what.split("\n")[0];
14045
- const countMatch = whatFirstLine.match(/^(\d[\d,]*)/);
14046
14163
  const count = issue.uncoveredCount ?? files.length;
14047
- const countLabel = countMatch ? countMatch[1] : String(count);
14048
- lines.push(` unmapped (${countLabel})`);
14164
+ const countLabel = String(count);
14165
+ lines.push(` ${label} (${countLabel})`);
14049
14166
  const shown = files.slice(0, 10);
14050
14167
  for (const f of shown) {
14051
14168
  lines.push(` ${f}`);
@@ -17044,6 +17161,11 @@ reviewer:
17044
17161
  # max_bytes_per_file: 65536 # default: 64 KiB per reference file
17045
17162
  # max_total_bytes_per_aspect: 262144 # default: 256 KiB total per aspect
17046
17163
 
17164
+ coverage: # Optional \u2014 controls which files must be mapped
17165
+ required: # Unmapped files under these roots are a blocking error
17166
+ - "/" # Default: whole repo (previous always-map-everything behavior)
17167
+ excluded: [] # Files under these roots are silently ignored
17168
+
17047
17169
  quality:
17048
17170
  max_direct_relations: 10 # Max out-edges per node before high-fan-out warning
17049
17171
  max_node_chars: 40000 # Per-node character budget (source + aspect refs) before oversized-node error
@@ -17179,6 +17301,24 @@ the provider's standard \`*_API_KEY\`) as a fallback when not present in
17179
17301
  \`yg-config.yaml\` itself must never contain credentials. Commit it to the
17180
17302
  repository \u2014 it is safe to share.
17181
17303
 
17304
+ ## Coverage config
17305
+
17306
+ \`\`\`yaml
17307
+ coverage:
17308
+ required:
17309
+ - src/ # unmapped files under src/ are a blocking error
17310
+ excluded:
17311
+ - vendor/ # silently ignored
17312
+ \`\`\`
17313
+
17314
+ Controls which git-tracked files must be mapped to a node.
17315
+
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: \`[]\`.
17318
+ - Files that match neither a required nor an excluded root produce a non-blocking \`uncovered-advisory\` warning.
17319
+ - 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.
17321
+
17182
17322
  ## Quality thresholds
17183
17323
 
17184
17324
  \`\`\`yaml
@@ -158,6 +158,12 @@ interface ReviewerConfig {
158
158
  /** At least one entry required; key is the tier name */
159
159
  tiers: Record<string, LlmConfig>;
160
160
  }
161
+ interface CoverageConfig {
162
+ /** Roots (POSIX, from repo root; "/" = whole repo) where an uncovered file is an error. */
163
+ required: string[];
164
+ /** Roots where an uncovered file is silent (no issue). */
165
+ excluded: string[];
166
+ }
161
167
  interface YggConfig {
162
168
  version?: string;
163
169
  quality?: QualityConfig;
@@ -167,6 +173,8 @@ interface YggConfig {
167
173
  reviewer?: ReviewerConfig;
168
174
  parallel?: number;
169
175
  debug?: boolean;
176
+ /** Coverage scope. Absent ⇒ DEFAULT_COVERAGE (whole repo required = today's behavior). */
177
+ coverage?: CoverageConfig;
170
178
  }
171
179
  interface ArchitectureNodeType {
172
180
  description: string;
@@ -14,6 +14,12 @@ parallel: 1 # optional — concurrency limit for batch app
14
14
  debug: false # optional — when true, appends all command output to .yggdrasil/.debug.log
15
15
  # Default: false (off). Log is append-only; rotate or delete manually.
16
16
 
17
+ coverage: # optional — scopes the unmapped-files gate. Absent = whole repo required (today's behavior).
18
+ required: ["/"] # roots where an uncovered tracked file is an ERROR (blocks). "/" = whole repo.
19
+ excluded: [] # roots where an uncovered file is SILENT (no warning).
20
+ # Files outside required and excluded are a non-blocking WARNING.
21
+ # Subtrees containing their own nested .yggdrasil/ are auto-skipped by every check.
22
+
17
23
  reviewer: # required — aspect verification during yg approve
18
24
  default: standard # required when more than one tier is configured; optional with exactly one tier.
19
25
  # Must reference one of the keys under reviewer.tiers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrisdudek/yg",
3
- "version": "5.0.0-alpha.2",
3
+ "version": "5.0.0-alpha.3",
4
4
  "description": "Architecture rules your coding agent can't ignore. Written in Markdown, verified on every change, enforced in the agent's loop — not after on a PR. Works with Claude Code, Cursor, Copilot, Codex, Cline.",
5
5
  "type": "module",
6
6
  "bin": {