@chrisdudek/yg 4.1.0 → 4.2.0

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
@@ -125,6 +125,7 @@ import { Command } from "commander";
125
125
 
126
126
  // src/cli/init.ts
127
127
  import chalk from "chalk";
128
+ import { existsSync } from "fs";
128
129
  import { mkdir as mkdir2, writeFile as writeFile4, readdir as readdir2, readFile as readFile4, stat as stat2 } from "fs/promises";
129
130
  import path5 from "path";
130
131
  import { fileURLToPath } from "url";
@@ -191,7 +192,7 @@ The CLI (\`yg\`) reads and validates \u2014 it never modifies files. You create
191
192
 
192
193
  **Architecture** \u2014 \`yg-architecture.yaml\` defines the vocabulary: node types, default aspects per type, allowed parent types, allowed relation targets per type. This is the foundation \u2014 read it when starting work on a new repo. Changes require user confirmation. Structure details in \`schemas/yg-architecture.yaml\`.
193
194
 
194
- ### How Aspects Reach a Node \u2014 7 Channels
195
+ ### How Aspects Reach a Node \u2014 7 Channels + Applicability Filter
195
196
 
196
197
  Aspects accumulate from multiple sources simultaneously. The reviewer checks ALL of them \u2014 the node must satisfy every aspect regardless of origin.
197
198
 
@@ -221,6 +222,36 @@ Consequences of this cascade:
221
222
  - Architecture default aspects apply to every node of that type automatically.
222
223
  - Implies chains expand recursively. Cycles are forbidden \u2014 CLI detects them.
223
224
 
225
+ ### Applicability Filter (\`when\`) \u2014 Evaluated on Every Channel
226
+
227
+ Seven channels propagate aspects onto nodes. A separate mechanism \u2014 the \`when\`
228
+ predicate \u2014 filters applicability. Every channel's attachment passes through
229
+ \`when\` before the aspect becomes effective. The predicate is evaluated by the
230
+ CLI against the graph (deterministic, no LLM call); if false, the aspect is
231
+ silently skipped on that node.
232
+
233
+ \`when\` can be declared globally on the aspect (applies across channels) or
234
+ per attach site (per channel instance). Global and attach-site \`when\` combine
235
+ via AND on each channel path. Multiple channels deliver independently \u2014 the
236
+ aspect is effective if any channel's path passes both its global and
237
+ attach-site filter.
238
+
239
+ Use \`when\` when an aspect is meaningful for only a subset of nodes under a
240
+ common attach channel. Example: \`external-api-error-mapping\` attached to
241
+ type \`command\` but only applicable when the node calls \`service-client\` \u2014
242
+ declare on the aspect:
243
+
244
+ when:
245
+ relations:
246
+ calls:
247
+ target_type: service-client
248
+
249
+ Domain-neutral examples: a \`pii-encryption\` aspect attached to all
250
+ repositories, but only applicable when the repository stores a field of
251
+ type \`user-profile\`; an \`idempotency-key\` aspect required only for commands
252
+ that emit events; a \`database-migration-review\` aspect only for nodes with
253
+ \`has_mapping: true\` pointing at \`db/migrations/\`.
254
+
224
255
  ### Reviewer
225
256
 
226
257
  The reviewer is an LLM invoked by \`yg approve\`. It receives: the aspect's content.md + all source files of the node. It checks every rule from content.md against the code.
@@ -303,6 +334,13 @@ var DECISIONS = `## DECISIONS
303
334
 
304
335
  **Architecture change** \u2014 when existing types don't fit the project structure. Always confirm with the user. Never silently modify \`yg-architecture.yaml\`. If a relation between types is forbidden, present the constraint and let the user decide: use an allowed relation type, change the node type, or update the architecture.
305
336
 
337
+ **\`when\` predicate on an aspect or attach site** \u2014 when the aspect applies to
338
+ only a subset of nodes under a common attach channel. Prefer \`when\` over
339
+ splitting node types (proliferation of types). Prefer \`when\` over leaving
340
+ the decision to the reviewer textually inside \`content.md\`; \`when\` is
341
+ deterministic, has zero LLM cost, and keeps the graph as the source of
342
+ truth for applicability.
343
+
306
344
  ### Aspect Discovery
307
345
 
308
346
  Aspects emerge from patterns \u2014 greenfield and brownfield:
@@ -1133,10 +1171,23 @@ var MIGRATIONS = [
1133
1171
  }
1134
1172
  ];
1135
1173
 
1174
+ // src/formatters/message-builder.ts
1175
+ function buildIssueMessage(msg) {
1176
+ return `${msg.what}
1177
+ ${msg.why}
1178
+ ${msg.next}`;
1179
+ }
1180
+
1136
1181
  // src/cli/init.ts
1137
1182
  function getPackageRoot() {
1138
- const currentDir = path5.dirname(fileURLToPath(import.meta.url));
1139
- return path5.join(currentDir, "..");
1183
+ let dir = path5.dirname(fileURLToPath(import.meta.url));
1184
+ while (dir !== path5.dirname(dir)) {
1185
+ if (existsSync(path5.join(dir, "package.json"))) {
1186
+ return dir;
1187
+ }
1188
+ dir = path5.dirname(dir);
1189
+ }
1190
+ throw new Error("Could not locate package root (no package.json found walking up from init module).");
1140
1191
  }
1141
1192
  function getGraphSchemasDir() {
1142
1193
  return path5.join(getPackageRoot(), "graph-schemas");
@@ -1438,6 +1489,25 @@ async function createYggdrasilStructure(projectRoot, yggRoot, platform) {
1438
1489
  await writeFile4(path5.join(yggRoot, ".gitignore"), GITIGNORE_CONTENT, "utf-8");
1439
1490
  await installRulesForPlatform(projectRoot, platform);
1440
1491
  }
1492
+ async function runVersionUpgrade(projectRoot, yggRoot, fromVersion, toVersion, platform) {
1493
+ const migrationResults = await runMigrations(fromVersion, MIGRATIONS, yggRoot);
1494
+ await updateConfigVersion(yggRoot, toVersion);
1495
+ await refreshSchemas(yggRoot);
1496
+ const architecturePath = path5.join(yggRoot, "yg-architecture.yaml");
1497
+ try {
1498
+ await stat2(architecturePath);
1499
+ } catch {
1500
+ await writeFile4(architecturePath, DEFAULT_ARCHITECTURE, "utf-8");
1501
+ }
1502
+ const rulesPath = await installRulesForPlatform(projectRoot, platform);
1503
+ const migrationActions = [];
1504
+ const migrationWarnings = [];
1505
+ for (const r of migrationResults) {
1506
+ migrationActions.push(...r.actions);
1507
+ migrationWarnings.push(...r.warnings);
1508
+ }
1509
+ return { rulesPath, migrationActions, migrationWarnings };
1510
+ }
1441
1511
  async function existingInit(projectRoot) {
1442
1512
  const yggRoot = path5.join(projectRoot, ".yggdrasil");
1443
1513
  if (!isTTY()) {
@@ -1448,33 +1518,28 @@ async function existingInit(projectRoot) {
1448
1518
  const currentVersion = await detectVersion(yggRoot);
1449
1519
  const cliVersion = await getCliVersion();
1450
1520
  if (currentVersion && currentVersion !== cliVersion) {
1451
- const migrate = await p.confirm({
1452
- message: `Graph version ${currentVersion} detected \u2014 CLI is ${cliVersion}. Run migration?`,
1453
- initialValue: true
1454
- });
1455
- assertNotCancelled(migrate);
1456
- if (migrate) {
1457
- const s = p.spinner();
1458
- s.start("Running migrations...");
1459
- const results = await runMigrations(currentVersion, MIGRATIONS, yggRoot);
1460
- await updateConfigVersion(yggRoot, cliVersion);
1461
- await refreshSchemas(yggRoot);
1462
- s.stop("Migration complete.");
1463
- for (const result of results) {
1464
- for (const action2 of result.actions) {
1465
- p.log.info(action2);
1466
- }
1467
- for (const warning of result.warnings) {
1468
- p.log.warning(warning);
1469
- }
1470
- }
1471
- p.log.step("Next steps:");
1472
- p.log.info("1. Run yg init again to configure reviewer (if not set)");
1473
- p.log.info("2. Run yg check to verify graph integrity");
1474
- p.log.info("3. Run yg approve on all nodes to establish baselines");
1475
- p.outro(chalk.green(`Migrated from ${currentVersion} to ${cliVersion}.`));
1476
- return;
1477
- }
1521
+ p.log.step(`Graph version ${currentVersion} detected \u2014 CLI is ${cliVersion}. Upgrade required.`);
1522
+ p.log.info("Select the agent platform so rules and schemas advance together.");
1523
+ const platform = await promptPlatform();
1524
+ const s = p.spinner();
1525
+ s.start("Running migrations and installing rules...");
1526
+ const result = await runVersionUpgrade(projectRoot, yggRoot, currentVersion, cliVersion, platform);
1527
+ s.stop("Upgrade complete.");
1528
+ for (const action2 of result.migrationActions) {
1529
+ p.log.info(action2);
1530
+ }
1531
+ for (const warning of result.migrationWarnings) {
1532
+ p.log.warning(warning);
1533
+ }
1534
+ p.log.step("Next steps:");
1535
+ p.log.info("1. Run yg check to verify graph integrity");
1536
+ p.log.info("2. Run yg approve on all nodes to establish baselines");
1537
+ p.outro(
1538
+ chalk.green(
1539
+ `Migrated from ${currentVersion} to ${cliVersion}. Rules installed: ${path5.relative(projectRoot, result.rulesPath)}`
1540
+ )
1541
+ );
1542
+ return;
1478
1543
  }
1479
1544
  const action = await p.select({
1480
1545
  message: "What would you like to do?",
@@ -1488,15 +1553,9 @@ async function existingInit(projectRoot) {
1488
1553
  switch (action) {
1489
1554
  case "upgrade": {
1490
1555
  const platform = await promptPlatform();
1491
- await refreshSchemas(yggRoot);
1492
- const architecturePath = path5.join(yggRoot, "yg-architecture.yaml");
1493
- try {
1494
- await stat2(architecturePath);
1495
- } catch {
1496
- await writeFile4(architecturePath, DEFAULT_ARCHITECTURE, "utf-8");
1497
- }
1498
- const rulesPath = await installRulesForPlatform(projectRoot, platform);
1499
- p.outro(chalk.green(`Rules and schemas refreshed: ${path5.relative(projectRoot, rulesPath)}`));
1556
+ const fromVersion = currentVersion ?? cliVersion;
1557
+ const result = await runVersionUpgrade(projectRoot, yggRoot, fromVersion, cliVersion, platform);
1558
+ p.outro(chalk.green(`Rules and schemas refreshed: ${path5.relative(projectRoot, result.rulesPath)}`));
1500
1559
  break;
1501
1560
  }
1502
1561
  case "reviewer": {
@@ -1537,17 +1596,27 @@ function registerInitCommand(program2) {
1537
1596
  process.stderr.write(chalk.red("Error: No .yggdrasil/ directory found. Run 'yg init' first.\n"));
1538
1597
  process.exit(1);
1539
1598
  }
1540
- await refreshSchemas(yggRoot);
1541
- const architecturePath = path5.join(yggRoot, "yg-architecture.yaml");
1542
- try {
1543
- await stat2(architecturePath);
1544
- } catch {
1545
- await writeFile4(architecturePath, DEFAULT_ARCHITECTURE, "utf-8");
1599
+ const toVersion = await getCliVersion();
1600
+ const fromVersion = await detectVersion(yggRoot);
1601
+ if (fromVersion === null) {
1602
+ process.stderr.write(chalk.red(buildIssueMessage({
1603
+ what: "No graph version detected.",
1604
+ why: ".yggdrasil/yg-config.yaml is missing a 'version:' field, so --upgrade cannot determine which migrations to run.",
1605
+ next: "Run 'yg init' interactively once to record the current version, then retry 'yg init --upgrade --platform <name>'."
1606
+ }) + "\n"));
1607
+ process.exit(1);
1546
1608
  }
1547
- await updateConfigVersion(yggRoot, await getCliVersion());
1548
- const rulesPath = await installRulesForPlatform(projectRoot, options.platform);
1549
- process.stdout.write(`Rules and schemas refreshed: ${path5.relative(projectRoot, rulesPath)}
1550
- `);
1609
+ const result = await runVersionUpgrade(
1610
+ projectRoot,
1611
+ yggRoot,
1612
+ fromVersion,
1613
+ toVersion,
1614
+ options.platform
1615
+ );
1616
+ process.stdout.write(
1617
+ `Rules and schemas refreshed: ${path5.relative(projectRoot, result.rulesPath)}
1618
+ `
1619
+ );
1551
1620
  return;
1552
1621
  }
1553
1622
  let exists = false;
@@ -1704,7 +1773,219 @@ function normalizeProviderConfig(providerName, pc, generalConfig, filename) {
1704
1773
  // src/io/node-parser.ts
1705
1774
  import { readFile as readFile6 } from "fs/promises";
1706
1775
  import { parse as parseYaml4 } from "yaml";
1707
- var RELATION_TYPES = [
1776
+
1777
+ // src/io/when-parser.ts
1778
+ var RELATION_TYPES = /* @__PURE__ */ new Set([
1779
+ "calls",
1780
+ "uses",
1781
+ "extends",
1782
+ "implements",
1783
+ "emits",
1784
+ "listens"
1785
+ ]);
1786
+ var ATOMIC_KEYS = /* @__PURE__ */ new Set(["relations", "descendants", "node"]);
1787
+ var BOOLEAN_KEYS = /* @__PURE__ */ new Set(["all_of", "any_of", "not"]);
1788
+ function parseWhen(raw, ctx) {
1789
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1790
+ throw new Error(`${ctx}: when must be a YAML mapping`);
1791
+ }
1792
+ const keys = Object.keys(raw);
1793
+ if (keys.length === 0) {
1794
+ throw new Error(`${ctx}: when mapping must not be empty`);
1795
+ }
1796
+ const booleanKeys = keys.filter((k) => BOOLEAN_KEYS.has(k));
1797
+ const atomicKeys = keys.filter((k) => ATOMIC_KEYS.has(k));
1798
+ const unknownKeys = keys.filter((k) => !BOOLEAN_KEYS.has(k) && !ATOMIC_KEYS.has(k));
1799
+ if (unknownKeys.length > 0) {
1800
+ throw new Error(`${ctx}: unknown when operator '${unknownKeys[0]}' (expected one of: all_of, any_of, not, relations, descendants, node)`);
1801
+ }
1802
+ if (booleanKeys.length > 0 && atomicKeys.length > 0) {
1803
+ throw new Error(`${ctx}: when cannot mix boolean operators with atomic clauses at the same level`);
1804
+ }
1805
+ if (booleanKeys.length > 1) {
1806
+ throw new Error(`${ctx}: when can have at most one boolean operator at a level (got: ${booleanKeys.join(", ")})`);
1807
+ }
1808
+ if (booleanKeys.length === 1) {
1809
+ return parseBoolean(raw, booleanKeys[0], ctx);
1810
+ }
1811
+ return parseAtomic(raw, ctx);
1812
+ }
1813
+ function parseBoolean(raw, key, ctx) {
1814
+ const val = raw[key];
1815
+ if (key === "not") {
1816
+ return { not: parseWhen(val, `${ctx}/not`) };
1817
+ }
1818
+ if (!Array.isArray(val)) {
1819
+ throw new Error(`${ctx}: '${key}' must be an array`);
1820
+ }
1821
+ if (val.length === 0) {
1822
+ throw new Error(`${ctx}: '${key}' array must not be empty`);
1823
+ }
1824
+ const items = val.map((v, i) => parseWhen(v, `${ctx}/${key}[${i}]`));
1825
+ return key === "all_of" ? { all_of: items } : { any_of: items };
1826
+ }
1827
+ function parseAtomic(raw, ctx) {
1828
+ const result = {};
1829
+ if ("relations" in raw) {
1830
+ result.relations = parseRelationClause(raw.relations, `${ctx}/relations`);
1831
+ }
1832
+ if ("descendants" in raw) {
1833
+ result.descendants = parseDescendantsClause(raw.descendants, `${ctx}/descendants`);
1834
+ }
1835
+ if ("node" in raw) {
1836
+ result.node = parseNodeClause(raw.node, `${ctx}/node`);
1837
+ }
1838
+ return result;
1839
+ }
1840
+ function parseRelationClause(raw, ctx) {
1841
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1842
+ throw new Error(`${ctx}: relations must be a YAML mapping keyed by relation type`);
1843
+ }
1844
+ const entries = Object.entries(raw);
1845
+ if (entries.length === 0) {
1846
+ throw new Error(`${ctx}: relations mapping must not be empty`);
1847
+ }
1848
+ const out = {};
1849
+ for (const [relType, match] of entries) {
1850
+ if (!RELATION_TYPES.has(relType)) {
1851
+ throw new Error(`${ctx}: unknown relation type '${relType}' (valid: ${Array.from(RELATION_TYPES).join(", ")})`);
1852
+ }
1853
+ out[relType] = parseRelationMatch(match, `${ctx}/${relType}`);
1854
+ }
1855
+ return out;
1856
+ }
1857
+ function parseRelationMatch(raw, ctx) {
1858
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1859
+ throw new Error(`${ctx}: must be a YAML mapping`);
1860
+ }
1861
+ const obj = raw;
1862
+ const allowed = /* @__PURE__ */ new Set(["target_type", "target", "consumes_port"]);
1863
+ for (const k of Object.keys(obj)) {
1864
+ if (!allowed.has(k)) {
1865
+ throw new Error(`${ctx}: unknown field '${k}' (allowed: target_type, target, consumes_port)`);
1866
+ }
1867
+ }
1868
+ const out = {};
1869
+ if ("target_type" in obj) {
1870
+ if (typeof obj.target_type !== "string" || obj.target_type.trim() === "") {
1871
+ throw new Error(`${ctx}: target_type must be a non-empty string`);
1872
+ }
1873
+ out.target_type = obj.target_type.trim();
1874
+ }
1875
+ if ("target" in obj) {
1876
+ if (typeof obj.target !== "string" || obj.target.trim() === "") {
1877
+ throw new Error(`${ctx}: target must be a non-empty string (node path relative to model/)`);
1878
+ }
1879
+ out.target = obj.target.trim();
1880
+ }
1881
+ if ("consumes_port" in obj) {
1882
+ if (typeof obj.consumes_port !== "string" || obj.consumes_port.trim() === "") {
1883
+ throw new Error(`${ctx}: consumes_port must be a non-empty string`);
1884
+ }
1885
+ out.consumes_port = obj.consumes_port.trim();
1886
+ }
1887
+ if (Object.keys(out).length === 0) {
1888
+ throw new Error(`${ctx}: at least one of target_type, target, consumes_port must be present`);
1889
+ }
1890
+ return out;
1891
+ }
1892
+ function parseDescendantsClause(raw, ctx) {
1893
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1894
+ throw new Error(`${ctx}: descendants must be a YAML mapping`);
1895
+ }
1896
+ const obj = raw;
1897
+ const allowed = /* @__PURE__ */ new Set(["relations", "type", "has_port"]);
1898
+ for (const k of Object.keys(obj)) {
1899
+ if (!allowed.has(k)) {
1900
+ throw new Error(`${ctx}: unknown field '${k}' (allowed: relations, type, has_port)`);
1901
+ }
1902
+ }
1903
+ const out = {};
1904
+ if ("relations" in obj) {
1905
+ out.relations = parseRelationClause(obj.relations, `${ctx}/relations`);
1906
+ }
1907
+ if ("type" in obj) {
1908
+ if (typeof obj.type !== "string" || obj.type.trim() === "") {
1909
+ throw new Error(`${ctx}: type must be a non-empty string`);
1910
+ }
1911
+ out.type = obj.type.trim();
1912
+ }
1913
+ if ("has_port" in obj) {
1914
+ if (typeof obj.has_port !== "string" || obj.has_port.trim() === "") {
1915
+ throw new Error(`${ctx}: has_port must be a non-empty string`);
1916
+ }
1917
+ out.has_port = obj.has_port.trim();
1918
+ }
1919
+ if (Object.keys(out).length === 0) {
1920
+ throw new Error(`${ctx}: at least one of relations, type, has_port must be present`);
1921
+ }
1922
+ return out;
1923
+ }
1924
+ function parseNodeClause(raw, ctx) {
1925
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1926
+ throw new Error(`${ctx}: node must be a YAML mapping`);
1927
+ }
1928
+ const obj = raw;
1929
+ const allowed = /* @__PURE__ */ new Set(["type", "has_port", "has_mapping"]);
1930
+ for (const k of Object.keys(obj)) {
1931
+ if (!allowed.has(k)) {
1932
+ throw new Error(`${ctx}: unknown field '${k}' (allowed: type, has_port, has_mapping)`);
1933
+ }
1934
+ }
1935
+ const out = {};
1936
+ if ("type" in obj) {
1937
+ if (typeof obj.type !== "string" || obj.type.trim() === "") {
1938
+ throw new Error(`${ctx}: type must be a non-empty string`);
1939
+ }
1940
+ out.type = obj.type.trim();
1941
+ }
1942
+ if ("has_port" in obj) {
1943
+ if (typeof obj.has_port !== "string" || obj.has_port.trim() === "") {
1944
+ throw new Error(`${ctx}: has_port must be a non-empty string`);
1945
+ }
1946
+ out.has_port = obj.has_port.trim();
1947
+ }
1948
+ if ("has_mapping" in obj) {
1949
+ if (typeof obj.has_mapping !== "boolean") {
1950
+ throw new Error(`${ctx}: has_mapping must be a boolean`);
1951
+ }
1952
+ out.has_mapping = obj.has_mapping;
1953
+ }
1954
+ if (Object.keys(out).length === 0) {
1955
+ throw new Error(`${ctx}: at least one of type, has_port, has_mapping must be present`);
1956
+ }
1957
+ return out;
1958
+ }
1959
+ function parseAspectAttachment(raw, ctx) {
1960
+ if (typeof raw === "string") {
1961
+ const id = raw.trim();
1962
+ if (id === "") {
1963
+ throw new Error(`${ctx}: aspect id must be a non-empty string`);
1964
+ }
1965
+ return { id };
1966
+ }
1967
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1968
+ const obj = raw;
1969
+ if (typeof obj.id !== "string" || obj.id.trim() === "") {
1970
+ throw new Error(`${ctx}: object form requires 'id' as a non-empty string`);
1971
+ }
1972
+ const result = { id: obj.id.trim() };
1973
+ const allowed = /* @__PURE__ */ new Set(["id", "when"]);
1974
+ for (const k of Object.keys(obj)) {
1975
+ if (!allowed.has(k)) {
1976
+ throw new Error(`${ctx}: unknown field '${k}' in aspect attachment (allowed: id, when)`);
1977
+ }
1978
+ }
1979
+ if ("when" in obj) {
1980
+ result.when = parseWhen(obj.when, `${ctx}/when`);
1981
+ }
1982
+ return result;
1983
+ }
1984
+ throw new Error(`${ctx}: aspect attachment must be a string or an object with 'id' (and optional 'when')`);
1985
+ }
1986
+
1987
+ // src/io/node-parser.ts
1988
+ var RELATION_TYPES2 = [
1708
1989
  "uses",
1709
1990
  "calls",
1710
1991
  "extends",
@@ -1713,7 +1994,7 @@ var RELATION_TYPES = [
1713
1994
  "listens"
1714
1995
  ];
1715
1996
  function isValidRelationType(t) {
1716
- return typeof t === "string" && RELATION_TYPES.includes(t);
1997
+ return typeof t === "string" && RELATION_TYPES2.includes(t);
1717
1998
  }
1718
1999
  async function parseNodeYaml(filePath) {
1719
2000
  const content = await readFile6(filePath, "utf-8");
@@ -1730,52 +2011,43 @@ async function parseNodeYaml(filePath) {
1730
2011
  const description = typeof raw.description === "string" ? raw.description.trim() : void 0;
1731
2012
  const relations = parseRelations(raw.relations, filePath);
1732
2013
  const mapping = parseMapping(raw.mapping, filePath);
1733
- const aspects = parseAspects(raw.aspects, filePath);
2014
+ const aspectsResult = parseAspects(raw.aspects, filePath);
1734
2015
  const ports = parsePorts(raw.ports, filePath);
1735
2016
  return {
1736
2017
  name: raw.name.trim(),
1737
2018
  type: raw.type.trim(),
1738
2019
  description,
1739
- aspects,
2020
+ aspects: aspectsResult.aspects,
2021
+ ...aspectsResult.aspectWhens && { aspectWhens: aspectsResult.aspectWhens },
1740
2022
  relations: relations.length > 0 ? relations : void 0,
1741
2023
  mapping,
1742
2024
  ports
1743
2025
  };
1744
2026
  }
1745
2027
  function parseAspects(raw, filePath) {
1746
- if (raw === void 0 || raw === null) return void 0;
2028
+ if (raw === void 0 || raw === null) return {};
1747
2029
  if (!Array.isArray(raw)) {
1748
2030
  throw new Error(`yg-node.yaml at ${filePath}: 'aspects' must be an array`);
1749
2031
  }
1750
- if (raw.length === 0) return void 0;
1751
- const result = [];
1752
- const seenAspects = /* @__PURE__ */ new Set();
2032
+ if (raw.length === 0) return {};
2033
+ const aspects = [];
2034
+ let aspectWhens;
2035
+ const seen = /* @__PURE__ */ new Set();
1753
2036
  for (let i = 0; i < raw.length; i++) {
1754
- const item = raw[i];
1755
- let aspectId;
1756
- if (typeof item === "string") {
1757
- aspectId = item.trim();
1758
- if (aspectId === "") {
1759
- throw new Error(
1760
- `yg-node.yaml at ${filePath}: aspects[${i}] must be a non-empty string`
1761
- );
1762
- }
1763
- } else if (typeof item === "object" && item !== null) {
1764
- throw new Error(
1765
- `yg-node.yaml at ${filePath}: aspects must be an array of strings.`
1766
- );
1767
- } else {
1768
- throw new Error(`yg-node.yaml at ${filePath}: aspects[${i}] must be a string`);
2037
+ const parsed = parseAspectAttachment(
2038
+ raw[i],
2039
+ `yg-node.yaml at ${filePath}: aspects[${i}]`
2040
+ );
2041
+ if (seen.has(parsed.id)) {
2042
+ throw new Error(`yg-node.yaml at ${filePath}: duplicate aspect '${parsed.id}' in aspects list`);
1769
2043
  }
1770
- if (seenAspects.has(aspectId)) {
1771
- throw new Error(
1772
- `yg-node.yaml at ${filePath}: duplicate aspect '${aspectId}' in aspects list`
1773
- );
2044
+ seen.add(parsed.id);
2045
+ aspects.push(parsed.id);
2046
+ if (parsed.when) {
2047
+ (aspectWhens ??= {})[parsed.id] = parsed.when;
1774
2048
  }
1775
- seenAspects.add(aspectId);
1776
- result.push(aspectId);
1777
2049
  }
1778
- return result.length > 0 ? result : void 0;
2050
+ return { aspects: aspects.length > 0 ? aspects : void 0, aspectWhens };
1779
2051
  }
1780
2052
  function parseRelations(raw, filePath) {
1781
2053
  if (raw === void 0) return [];
@@ -1867,13 +2139,28 @@ function parsePorts(rawPorts, filePath) {
1867
2139
  if (!Array.isArray(obj.aspects)) {
1868
2140
  throw new Error(`yg-node.yaml at ${filePath}: ports.${name}.aspects must be an array`);
1869
2141
  }
1870
- const aspects = obj.aspects.map((a, i) => {
1871
- if (typeof a !== "string" || a.trim() === "") {
1872
- throw new Error(`yg-node.yaml at ${filePath}: ports.${name}.aspects[${i}] must be a non-empty string`);
2142
+ const portAspects = [];
2143
+ let portAspectWhens;
2144
+ const seenPortAspects = /* @__PURE__ */ new Set();
2145
+ for (let i = 0; i < obj.aspects.length; i++) {
2146
+ const parsed = parseAspectAttachment(
2147
+ obj.aspects[i],
2148
+ `yg-node.yaml at ${filePath}: ports.${name}.aspects[${i}]`
2149
+ );
2150
+ if (seenPortAspects.has(parsed.id)) {
2151
+ throw new Error(`yg-node.yaml at ${filePath}: ports.${name}.aspects has duplicate '${parsed.id}'`);
1873
2152
  }
1874
- return a.trim();
1875
- });
1876
- ports[name] = { description: obj.description.trim(), aspects };
2153
+ seenPortAspects.add(parsed.id);
2154
+ portAspects.push(parsed.id);
2155
+ if (parsed.when) {
2156
+ (portAspectWhens ??= {})[parsed.id] = parsed.when;
2157
+ }
2158
+ }
2159
+ ports[name] = {
2160
+ description: obj.description.trim(),
2161
+ aspects: portAspects,
2162
+ ...portAspectWhens && { aspectWhens: portAspectWhens }
2163
+ };
1877
2164
  }
1878
2165
  return Object.keys(ports).length > 0 ? ports : void 0;
1879
2166
  }
@@ -1924,17 +2211,34 @@ async function parseAspect(aspectDir, aspectYamlPath, id) {
1924
2211
  const description = typeof raw.description === "string" ? raw.description.trim() : void 0;
1925
2212
  const artifacts = await readArtifacts(aspectDir, ["yg-aspect.yaml"]);
1926
2213
  let implies;
2214
+ let impliesWhens;
1927
2215
  if (raw.implies !== void 0) {
1928
2216
  if (!Array.isArray(raw.implies)) {
1929
- throw new Error(`yg-aspect.yaml at ${aspectYamlPath}: 'implies' must be an array of strings`);
2217
+ throw new Error(`yg-aspect.yaml at ${aspectYamlPath}: 'implies' must be an array`);
2218
+ }
2219
+ implies = [];
2220
+ for (let i = 0; i < raw.implies.length; i++) {
2221
+ const parsed = parseAspectAttachment(
2222
+ raw.implies[i],
2223
+ `yg-aspect.yaml at ${aspectYamlPath}: implies[${i}]`
2224
+ );
2225
+ implies.push(parsed.id);
2226
+ if (parsed.when) {
2227
+ (impliesWhens ??= {})[parsed.id] = parsed.when;
2228
+ }
1930
2229
  }
1931
- implies = raw.implies.filter((t) => typeof t === "string");
2230
+ }
2231
+ let when;
2232
+ if (raw.when !== void 0) {
2233
+ when = parseWhen(raw.when, `yg-aspect.yaml at ${aspectYamlPath}: when`);
1932
2234
  }
1933
2235
  return {
1934
2236
  name: raw.name.trim(),
1935
2237
  id: idTrimmed,
1936
2238
  description,
1937
2239
  implies,
2240
+ ...impliesWhens && { impliesWhens },
2241
+ ...when && { when },
1938
2242
  artifacts
1939
2243
  };
1940
2244
  }
@@ -1966,19 +2270,30 @@ async function parseFlow(flowDir, flowYamlPath) {
1966
2270
  );
1967
2271
  }
1968
2272
  let aspects;
2273
+ let aspectWhens;
1969
2274
  if (raw.aspects !== void 0) {
1970
2275
  if (!Array.isArray(raw.aspects)) {
1971
2276
  throw new Error(`yg-flow.yaml at ${flowYamlPath}: 'aspects' must be an array of strings`);
1972
2277
  }
1973
- const aspectTags = raw.aspects.filter((a) => typeof a === "string");
1974
- aspects = aspectTags.length > 0 ? aspectTags : [];
2278
+ aspects = [];
2279
+ for (let i = 0; i < raw.aspects.length; i++) {
2280
+ const parsed = parseAspectAttachment(
2281
+ raw.aspects[i],
2282
+ `yg-flow.yaml at ${flowYamlPath}: aspects[${i}]`
2283
+ );
2284
+ aspects.push(parsed.id);
2285
+ if (parsed.when) {
2286
+ (aspectWhens ??= {})[parsed.id] = parsed.when;
2287
+ }
2288
+ }
1975
2289
  }
1976
2290
  return {
1977
2291
  path: path8.basename(flowDir),
1978
2292
  name: raw.name.trim(),
1979
2293
  description,
1980
2294
  nodes: nodePaths,
1981
- ...aspects !== void 0 && { aspects }
2295
+ ...aspects !== void 0 && { aspects },
2296
+ ...aspectWhens && { aspectWhens }
1982
2297
  };
1983
2298
  }
1984
2299
 
@@ -2025,12 +2340,28 @@ async function parseArchitecture(filePath) {
2025
2340
  `yg-architecture.yaml: node type '${typeName}' has unknown field 'integration_aspects'. Use ports on the target node instead.`
2026
2341
  );
2027
2342
  }
2028
- const aspects = Array.isArray(entry.aspects) ? entry.aspects.filter((t) => typeof t === "string") : void 0;
2343
+ let aspects;
2344
+ let aspectWhens;
2345
+ if (Array.isArray(entry.aspects)) {
2346
+ aspects = [];
2347
+ for (let i = 0; i < entry.aspects.length; i++) {
2348
+ const parsed = parseAspectAttachment(
2349
+ entry.aspects[i],
2350
+ `yg-architecture.yaml: node_types.${typeName}.aspects[${i}]`
2351
+ );
2352
+ aspects.push(parsed.id);
2353
+ if (parsed.when) {
2354
+ (aspectWhens ??= {})[parsed.id] = parsed.when;
2355
+ }
2356
+ }
2357
+ if (aspects.length === 0) aspects = void 0;
2358
+ }
2029
2359
  const parents = Array.isArray(entry.parents) ? entry.parents.filter((t) => typeof t === "string") : void 0;
2030
2360
  const relations = parseRelations2(entry.relations, typeName);
2031
2361
  nodeTypes[typeName] = {
2032
2362
  description: entry.description,
2033
- aspects: aspects && aspects.length > 0 ? aspects : void 0,
2363
+ aspects,
2364
+ ...aspectWhens && { aspectWhens },
2034
2365
  parents: parents && parents.length > 0 ? parents : void 0,
2035
2366
  relations
2036
2367
  };
@@ -2307,57 +2638,191 @@ async function loadSchemas(schemasDir) {
2307
2638
  import { readFile as readFile13 } from "fs/promises";
2308
2639
  import path12 from "path";
2309
2640
 
2641
+ // src/core/when-evaluator.ts
2642
+ function evaluateWhen(predicate, node, graph) {
2643
+ if ("all_of" in predicate) {
2644
+ return predicate.all_of.every((p2) => evaluateWhen(p2, node, graph));
2645
+ }
2646
+ if ("any_of" in predicate) {
2647
+ return predicate.any_of.some((p2) => evaluateWhen(p2, node, graph));
2648
+ }
2649
+ if ("not" in predicate) {
2650
+ return !evaluateWhen(predicate.not, node, graph);
2651
+ }
2652
+ return evaluateAtomic(predicate, node, graph);
2653
+ }
2654
+ function evaluateAtomic(clause, node, graph) {
2655
+ if (clause.relations) {
2656
+ if (!evaluateRelationClause(clause.relations, node.meta.relations ?? [], graph)) return false;
2657
+ }
2658
+ if (clause.descendants) {
2659
+ if (!evaluateDescendantsClause(clause.descendants, node, graph)) return false;
2660
+ }
2661
+ if (clause.node) {
2662
+ if (!evaluateNodeClause(clause.node, node)) return false;
2663
+ }
2664
+ return true;
2665
+ }
2666
+ function evaluateRelationClause(rc, relations, graph) {
2667
+ for (const [relType, match] of Object.entries(rc)) {
2668
+ if (!match) continue;
2669
+ const candidates = relations.filter((r) => r.type === relType);
2670
+ if (!candidates.some((r) => matchesRelation(r, match, graph))) {
2671
+ return false;
2672
+ }
2673
+ }
2674
+ return true;
2675
+ }
2676
+ function matchesRelation(r, match, graph) {
2677
+ if (match.target !== void 0 && r.target !== match.target) return false;
2678
+ if (match.target_type !== void 0) {
2679
+ const tgt = graph.nodes.get(r.target);
2680
+ if (!tgt || tgt.meta.type !== match.target_type) return false;
2681
+ }
2682
+ if (match.consumes_port !== void 0) {
2683
+ if (!r.consumes || !r.consumes.includes(match.consumes_port)) return false;
2684
+ }
2685
+ return true;
2686
+ }
2687
+ function evaluateDescendantsClause(dc, node, graph) {
2688
+ const descendants = collectDescendants(node);
2689
+ if (descendants.length === 0) return false;
2690
+ if (dc.type !== void 0) {
2691
+ if (!descendants.some((d) => d.meta.type === dc.type)) return false;
2692
+ }
2693
+ if (dc.has_port !== void 0) {
2694
+ if (!descendants.some((d) => d.meta.ports && Object.prototype.hasOwnProperty.call(d.meta.ports, dc.has_port))) {
2695
+ return false;
2696
+ }
2697
+ }
2698
+ if (dc.relations) {
2699
+ if (!descendants.some((d) => evaluateRelationClause(dc.relations, d.meta.relations ?? [], graph))) {
2700
+ return false;
2701
+ }
2702
+ }
2703
+ return true;
2704
+ }
2705
+ function evaluateNodeClause(nc, node) {
2706
+ if (nc.type !== void 0 && node.meta.type !== nc.type) return false;
2707
+ if (nc.has_port !== void 0) {
2708
+ if (!node.meta.ports || !Object.prototype.hasOwnProperty.call(node.meta.ports, nc.has_port)) return false;
2709
+ }
2710
+ if (nc.has_mapping !== void 0) {
2711
+ const has = (node.meta.mapping?.length ?? 0) > 0;
2712
+ if (has !== nc.has_mapping) return false;
2713
+ }
2714
+ return true;
2715
+ }
2716
+ function collectDescendants(node) {
2717
+ const out = [];
2718
+ const queue = [...node.children];
2719
+ while (queue.length > 0) {
2720
+ const curr = queue.shift();
2721
+ out.push(curr);
2722
+ for (const c of curr.children) queue.push(c);
2723
+ }
2724
+ return out;
2725
+ }
2726
+
2310
2727
  // src/core/effective-aspects.ts
2311
2728
  function computeEffectiveAspects(node, graph) {
2312
- const raw = /* @__PURE__ */ new Set();
2729
+ const direct = /* @__PURE__ */ new Set();
2730
+ const ancestors = collectAncestors(node);
2731
+ const tryAdd = (aspectId, attachWhen) => {
2732
+ const aspectDef = graph.aspects.find((a) => a.id === aspectId);
2733
+ const globalWhen = aspectDef?.when;
2734
+ if (globalWhen && !evaluateWhen(globalWhen, node, graph)) {
2735
+ debugWrite(`[effective-aspects] node '${node.path}' aspect '${aspectId}' filtered: global when=false`);
2736
+ return;
2737
+ }
2738
+ if (attachWhen && !evaluateWhen(attachWhen, node, graph)) {
2739
+ debugWrite(`[effective-aspects] node '${node.path}' aspect '${aspectId}' filtered: attach-site when=false`);
2740
+ return;
2741
+ }
2742
+ direct.add(aspectId);
2743
+ };
2313
2744
  for (const id of node.meta.aspects ?? []) {
2314
- raw.add(id);
2745
+ tryAdd(id, node.meta.aspectWhens?.[id]);
2315
2746
  }
2316
- const ancestors = collectAncestors(node);
2317
2747
  for (const ancestor of ancestors) {
2318
2748
  for (const id of ancestor.meta.aspects ?? []) {
2319
- raw.add(id);
2749
+ tryAdd(id, ancestor.meta.aspectWhens?.[id]);
2320
2750
  }
2321
2751
  }
2322
2752
  if (graph.architecture) {
2323
2753
  const typeDef = graph.architecture.node_types[node.meta.type];
2324
2754
  for (const id of typeDef?.aspects ?? []) {
2325
- raw.add(id);
2755
+ tryAdd(id, typeDef?.aspectWhens?.[id]);
2326
2756
  }
2327
2757
  }
2328
2758
  if (graph.architecture) {
2329
2759
  for (const ancestor of ancestors) {
2330
2760
  const typeDef = graph.architecture.node_types[ancestor.meta.type];
2331
2761
  for (const id of typeDef?.aspects ?? []) {
2332
- raw.add(id);
2762
+ tryAdd(id, typeDef?.aspectWhens?.[id]);
2333
2763
  }
2334
2764
  }
2335
2765
  }
2336
2766
  const allPaths = /* @__PURE__ */ new Set([node.path, ...ancestors.map((a) => a.path)]);
2337
2767
  for (const flow of graph.flows) {
2338
- if (flow.nodes.some((n) => allPaths.has(n))) {
2339
- for (const id of flow.aspects ?? []) {
2340
- raw.add(id);
2341
- }
2768
+ if (!flow.nodes.some((n) => allPaths.has(n))) continue;
2769
+ for (const id of flow.aspects ?? []) {
2770
+ tryAdd(id, flow.aspectWhens?.[id]);
2342
2771
  }
2343
2772
  }
2344
2773
  if (node.meta.relations) {
2345
2774
  for (const relation of node.meta.relations) {
2346
2775
  const targetNode = graph.nodes.get(relation.target);
2347
2776
  if (!targetNode) continue;
2348
- if (relation.consumes && targetNode.meta.ports) {
2349
- for (const portName of relation.consumes) {
2350
- const port = targetNode.meta.ports[portName];
2351
- if (port?.aspects) {
2352
- for (const id of port.aspects) {
2353
- raw.add(id);
2354
- }
2355
- }
2777
+ if (!relation.consumes || !targetNode.meta.ports) continue;
2778
+ for (const portName of relation.consumes) {
2779
+ const port = targetNode.meta.ports[portName];
2780
+ if (!port?.aspects) continue;
2781
+ for (const id of port.aspects) {
2782
+ tryAdd(id, port.aspectWhens?.[id]);
2356
2783
  }
2357
2784
  }
2358
2785
  }
2359
2786
  }
2360
- return expandImplies(raw, graph);
2787
+ return expandImpliesFiltered(direct, node, graph);
2788
+ }
2789
+ function expandImpliesFiltered(directIds, node, graph) {
2790
+ const idToAspect = /* @__PURE__ */ new Map();
2791
+ for (const a of graph.aspects) idToAspect.set(a.id, a);
2792
+ const result = /* @__PURE__ */ new Set();
2793
+ const visited = /* @__PURE__ */ new Set();
2794
+ const stack = /* @__PURE__ */ new Set();
2795
+ const visit = (id, implierId) => {
2796
+ if (stack.has(id)) {
2797
+ throw new Error(`Aspect implies cycle detected involving aspect '${id}'`);
2798
+ }
2799
+ if (visited.has(id)) return;
2800
+ const aspectDef = idToAspect.get(id);
2801
+ if (aspectDef?.when && !evaluateWhen(aspectDef.when, node, graph)) {
2802
+ debugWrite(`[effective-aspects] node '${node.path}' aspect '${id}' filtered: global when=false (implies path)`);
2803
+ return;
2804
+ }
2805
+ if (implierId) {
2806
+ const implierDef = idToAspect.get(implierId);
2807
+ const perImplies = implierDef?.impliesWhens?.[id];
2808
+ if (perImplies && !evaluateWhen(perImplies, node, graph)) {
2809
+ debugWrite(`[effective-aspects] node '${node.path}' aspect '${id}' filtered: impliesWhens from '${implierId}' is false`);
2810
+ return;
2811
+ }
2812
+ }
2813
+ stack.add(id);
2814
+ visited.add(id);
2815
+ result.add(id);
2816
+ const implies = aspectDef?.implies;
2817
+ if (implies) {
2818
+ for (const implied of implies) {
2819
+ visit(implied, id);
2820
+ }
2821
+ }
2822
+ stack.delete(id);
2823
+ };
2824
+ for (const id of directIds) visit(id, null);
2825
+ return result;
2361
2826
  }
2362
2827
  function getAspectSource(aspectId, node, graph) {
2363
2828
  if (node.meta.aspects?.includes(aspectId)) {
@@ -2424,37 +2889,6 @@ function collectAncestors(node) {
2424
2889
  }
2425
2890
  return ancestors;
2426
2891
  }
2427
- function expandImplies(aspectIds, graph) {
2428
- const idToImplies = /* @__PURE__ */ new Map();
2429
- for (const aspect of graph.aspects) {
2430
- if (aspect.implies) {
2431
- idToImplies.set(aspect.id, aspect.implies);
2432
- }
2433
- }
2434
- const result = /* @__PURE__ */ new Set();
2435
- const visited = /* @__PURE__ */ new Set();
2436
- const stack = /* @__PURE__ */ new Set();
2437
- function collect(id) {
2438
- if (stack.has(id)) {
2439
- throw new Error(`Aspect implies cycle detected involving aspect '${id}'`);
2440
- }
2441
- if (visited.has(id)) return;
2442
- stack.add(id);
2443
- visited.add(id);
2444
- result.add(id);
2445
- const implies = idToImplies.get(id);
2446
- if (implies) {
2447
- for (const implied of implies) {
2448
- collect(implied);
2449
- }
2450
- }
2451
- stack.delete(id);
2452
- }
2453
- for (const id of aspectIds) {
2454
- collect(id);
2455
- }
2456
- return result;
2457
- }
2458
2892
 
2459
2893
  // src/core/context-builder.ts
2460
2894
  var STRUCTURAL_RELATION_TYPES = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements"]);
@@ -2847,13 +3281,6 @@ async function expandMappingPaths(projectRoot, mappingPaths) {
2847
3281
  return result;
2848
3282
  }
2849
3283
 
2850
- // src/formatters/message-builder.ts
2851
- function buildIssueMessage(msg) {
2852
- return `${msg.what}
2853
- ${msg.why}
2854
- ${msg.next}`;
2855
- }
2856
-
2857
3284
  // src/core/validator.ts
2858
3285
  async function validate(graph, scope = "all") {
2859
3286
  const issues = [];
@@ -2904,6 +3331,7 @@ async function validate(graph, scope = "all") {
2904
3331
  issues.push(...checkPortAspectsDefined(graph));
2905
3332
  issues.push(...checkPortConsumes(graph));
2906
3333
  issues.push(...checkOrphanedAspects(graph));
3334
+ issues.push(...checkWhenReferences(graph));
2907
3335
  let filtered = issues;
2908
3336
  let nodesScanned = graph.nodes.size;
2909
3337
  const normalizedScope = scope.trim().replace(/\\/g, "/").replace(/\/+$/, "");
@@ -3721,6 +4149,141 @@ function checkOrphanedAspects(graph) {
3721
4149
  }
3722
4150
  return issues;
3723
4151
  }
4152
+ function checkWhenReferences(graph) {
4153
+ const issues = [];
4154
+ const knownTypes = new Set(Object.keys(graph.architecture?.node_types ?? {}));
4155
+ const visitPredicate = (p2, ctx) => {
4156
+ if ("all_of" in p2) {
4157
+ p2.all_of.forEach((c, i) => visitPredicate(c, `${ctx}/all_of[${i}]`));
4158
+ return;
4159
+ }
4160
+ if ("any_of" in p2) {
4161
+ p2.any_of.forEach((c, i) => visitPredicate(c, `${ctx}/any_of[${i}]`));
4162
+ return;
4163
+ }
4164
+ if ("not" in p2) {
4165
+ visitPredicate(p2.not, `${ctx}/not`);
4166
+ return;
4167
+ }
4168
+ visitAtomic(p2, ctx);
4169
+ };
4170
+ const visitAtomic = (a, ctx) => {
4171
+ if (a.relations) visitRelationClause(a.relations, `${ctx}/relations`);
4172
+ if (a.descendants) visitDescendantsClause(a.descendants, `${ctx}/descendants`);
4173
+ if (a.node) visitNodeClause(a.node, `${ctx}/node`);
4174
+ };
4175
+ const visitRelationClause = (rc, ctx) => {
4176
+ for (const [relType, match] of Object.entries(rc)) {
4177
+ if (!match) continue;
4178
+ if (match.target_type !== void 0 && !knownTypes.has(match.target_type)) {
4179
+ issues.push({
4180
+ severity: "error",
4181
+ code: "when-unknown-type",
4182
+ rule: "when-unknown-type",
4183
+ message: buildIssueMessage({
4184
+ what: `Unknown node type '${match.target_type}' in when at ${ctx}/${relType}.target_type.`,
4185
+ why: "The predicate references a type that is not defined in yg-architecture.yaml; it will never evaluate.",
4186
+ next: `Fix the type name or define it in yg-architecture.yaml. Known types: ${Array.from(knownTypes).join(", ")}.`
4187
+ })
4188
+ });
4189
+ }
4190
+ if (match.target !== void 0 && !graph.nodes.has(match.target)) {
4191
+ issues.push({
4192
+ severity: "error",
4193
+ code: "when-unknown-node",
4194
+ rule: "when-unknown-node",
4195
+ message: buildIssueMessage({
4196
+ what: `Referenced node '${match.target}' in when at ${ctx}/${relType}.target does not exist.`,
4197
+ why: "The predicate targets a node that is not in the graph.",
4198
+ next: `Fix the node path or add the node under .yggdrasil/model/.`
4199
+ })
4200
+ });
4201
+ }
4202
+ if (match.consumes_port !== void 0 && match.target !== void 0) {
4203
+ const tgt = graph.nodes.get(match.target);
4204
+ if (tgt && !(tgt.meta.ports && match.consumes_port in tgt.meta.ports)) {
4205
+ issues.push({
4206
+ severity: "error",
4207
+ code: "when-unknown-port",
4208
+ rule: "when-unknown-port",
4209
+ message: buildIssueMessage({
4210
+ what: `Port '${match.consumes_port}' is not declared on node '${match.target}' in when at ${ctx}/${relType}.consumes_port.`,
4211
+ why: "The predicate references a port that does not exist on the target node.",
4212
+ next: `Fix the port name or add it to .yggdrasil/model/${match.target}/yg-node.yaml.`
4213
+ })
4214
+ });
4215
+ }
4216
+ }
4217
+ }
4218
+ };
4219
+ const visitDescendantsClause = (dc, ctx) => {
4220
+ if (dc.relations) visitRelationClause(dc.relations, `${ctx}/relations`);
4221
+ if (dc.type !== void 0 && !knownTypes.has(dc.type)) {
4222
+ issues.push({
4223
+ severity: "error",
4224
+ code: "when-unknown-type",
4225
+ rule: "when-unknown-type",
4226
+ message: buildIssueMessage({
4227
+ what: `Unknown node type '${dc.type}' in when at ${ctx}/type.`,
4228
+ why: "The predicate references a type that is not defined in yg-architecture.yaml.",
4229
+ next: `Fix the type name or define it in yg-architecture.yaml. Known types: ${Array.from(knownTypes).join(", ")}.`
4230
+ })
4231
+ });
4232
+ }
4233
+ };
4234
+ const visitNodeClause = (nc, ctx) => {
4235
+ if (nc.type !== void 0 && !knownTypes.has(nc.type)) {
4236
+ issues.push({
4237
+ severity: "error",
4238
+ code: "when-unknown-type",
4239
+ rule: "when-unknown-type",
4240
+ message: buildIssueMessage({
4241
+ what: `Unknown node type '${nc.type}' in when at ${ctx}/type.`,
4242
+ why: "The predicate references a type that is not defined in yg-architecture.yaml.",
4243
+ next: `Fix the type name or define it in yg-architecture.yaml. Known types: ${Array.from(knownTypes).join(", ")}.`
4244
+ })
4245
+ });
4246
+ }
4247
+ };
4248
+ for (const aspect of graph.aspects) {
4249
+ if (aspect.when) visitPredicate(aspect.when, `aspect '${aspect.id}' when`);
4250
+ if (aspect.impliesWhens) {
4251
+ for (const [targetId, pred] of Object.entries(aspect.impliesWhens)) {
4252
+ visitPredicate(pred, `aspect '${aspect.id}' implies[${targetId}] when`);
4253
+ }
4254
+ }
4255
+ }
4256
+ if (graph.architecture) {
4257
+ for (const [typeName, typeDef] of Object.entries(graph.architecture.node_types)) {
4258
+ if (!typeDef.aspectWhens) continue;
4259
+ for (const [aspectId, pred] of Object.entries(typeDef.aspectWhens)) {
4260
+ visitPredicate(pred, `architecture node_types.${typeName} aspectWhens[${aspectId}]`);
4261
+ }
4262
+ }
4263
+ }
4264
+ for (const [nodePath, node] of graph.nodes) {
4265
+ if (node.meta.aspectWhens) {
4266
+ for (const [aspectId, pred] of Object.entries(node.meta.aspectWhens)) {
4267
+ visitPredicate(pred, `node '${nodePath}' aspectWhens[${aspectId}]`);
4268
+ }
4269
+ }
4270
+ if (node.meta.ports) {
4271
+ for (const [portName, portDef] of Object.entries(node.meta.ports)) {
4272
+ if (!portDef.aspectWhens) continue;
4273
+ for (const [aspectId, pred] of Object.entries(portDef.aspectWhens)) {
4274
+ visitPredicate(pred, `node '${nodePath}' ports.${portName} aspectWhens[${aspectId}]`);
4275
+ }
4276
+ }
4277
+ }
4278
+ }
4279
+ for (const flow of graph.flows) {
4280
+ if (!flow.aspectWhens) continue;
4281
+ for (const [aspectId, pred] of Object.entries(flow.aspectWhens)) {
4282
+ visitPredicate(pred, `flow '${flow.path}' aspectWhens[${aspectId}]`);
4283
+ }
4284
+ }
4285
+ return issues;
4286
+ }
3724
4287
 
3725
4288
  // src/cli/owner.ts
3726
4289
  import path15 from "path";
@@ -4018,12 +4581,14 @@ async function writeNodeDriftState(yggRoot, nodePath, nodeState) {
4018
4581
  const content = JSON.stringify(nodeState, null, 2) + "\n";
4019
4582
  await writeFile5(filePath, content, "utf-8");
4020
4583
  }
4021
- async function garbageCollectDriftState(yggRoot, validNodePaths) {
4584
+ async function garbageCollectDriftState(yggRoot, validNodePaths, shouldKeep) {
4022
4585
  const driftDir = path16.join(yggRoot, DRIFT_STATE_DIR);
4023
4586
  const allNodePaths = await scanJsonFiles(driftDir, driftDir);
4024
4587
  const removed = [];
4025
4588
  for (const nodePath of allNodePaths) {
4026
- if (!validNodePaths.has(nodePath)) {
4589
+ const inGraph = validNodePaths.has(nodePath);
4590
+ const keep = inGraph && (shouldKeep ? shouldKeep(nodePath) : true);
4591
+ if (!keep) {
4027
4592
  const filePath = nodeStatePath(yggRoot, nodePath);
4028
4593
  await rm2(filePath);
4029
4594
  await removeEmptyParents(filePath, driftDir);
@@ -4302,7 +4867,16 @@ function getChildMappingExclusions(graph, nodePath) {
4302
4867
  }
4303
4868
  async function runGC(graph) {
4304
4869
  const validPaths = new Set(graph.nodes.keys());
4305
- return garbageCollectDriftState(graph.rootPath, validPaths);
4870
+ return garbageCollectDriftState(
4871
+ graph.rootPath,
4872
+ validPaths,
4873
+ (nodePath) => {
4874
+ const node = graph.nodes.get(nodePath);
4875
+ if (!node) return false;
4876
+ const effective = computeEffectiveAspects(node, graph);
4877
+ return effective.size > 0;
4878
+ }
4879
+ );
4306
4880
  }
4307
4881
  async function commitApproval(yggRoot, result) {
4308
4882
  if (result.pendingDriftState) {
@@ -4602,6 +5176,15 @@ async function runCheck(graph, gitTrackedFiles) {
4602
5176
  coverageIssue = buildCoverageIssue(uncovered, totalFiles);
4603
5177
  }
4604
5178
  const orphanedPaths = await detectOrphanedDriftState(graph);
5179
+ await garbageCollectDriftState(
5180
+ graph.rootPath,
5181
+ new Set(graph.nodes.keys()),
5182
+ (nodePath) => {
5183
+ const node = graph.nodes.get(nodePath);
5184
+ if (!node) return false;
5185
+ return computeEffectiveAspects(node, graph).size > 0;
5186
+ }
5187
+ );
4605
5188
  const yggRelative = path19.relative(path19.dirname(graph.rootPath), graph.rootPath).replace(/\\/g, "/").replace(/\/+$/, "");
4606
5189
  const orphanWarnings = orphanedPaths.map((p2) => ({
4607
5190
  severity: "warning",
@@ -5250,26 +5833,57 @@ async function loadSecrets(rootPath, providerName) {
5250
5833
  return void 0;
5251
5834
  }
5252
5835
  const raw = parseYaml9(content);
5253
- if (!raw) return void 0;
5254
- if (raw.reviewer && typeof raw.reviewer === "object") {
5255
- const reviewerRaw = raw.reviewer;
5256
- if (!providerName) return void 0;
5257
- const providerKey = providerName;
5258
- const providerSection = reviewerRaw[providerKey];
5259
- if (!providerSection || typeof providerSection !== "object") return void 0;
5260
- return extractSecretFields(providerSection);
5261
- }
5262
- return void 0;
5263
- }
5264
- function extractSecretFields(raw) {
5836
+ if (raw === null || raw === void 0) return void 0;
5837
+ if (typeof raw !== "object" || Array.isArray(raw)) {
5838
+ throw new Error(`yg-secrets.yaml: top level must be a YAML mapping`);
5839
+ }
5840
+ const rawObj = raw;
5841
+ if (rawObj.reviewer === void 0) return void 0;
5842
+ if (typeof rawObj.reviewer !== "object" || rawObj.reviewer === null || Array.isArray(rawObj.reviewer)) {
5843
+ throw new Error(`yg-secrets.yaml: 'reviewer' must be a YAML mapping`);
5844
+ }
5845
+ if (!providerName) return void 0;
5846
+ const reviewerRaw = rawObj.reviewer;
5847
+ const providerSection = reviewerRaw[providerName];
5848
+ if (providerSection === void 0) return void 0;
5849
+ if (typeof providerSection !== "object" || providerSection === null || Array.isArray(providerSection)) {
5850
+ throw new Error(`yg-secrets.yaml: 'reviewer.${providerName}' must be a YAML mapping`);
5851
+ }
5852
+ return extractSecretFields(providerSection, providerName);
5853
+ }
5854
+ function extractSecretFields(raw, providerName) {
5855
+ const ctx = (field) => `yg-secrets.yaml at reviewer.${providerName}.${field}`;
5265
5856
  const partial = {};
5266
- if (typeof raw.api_key === "string") partial.api_key = raw.api_key;
5267
- if (typeof raw.provider === "string") partial.provider = raw.provider;
5268
- if (typeof raw.model === "string") partial.model = raw.model;
5269
- if (typeof raw.endpoint === "string") partial.endpoint = raw.endpoint;
5270
- if (typeof raw.temperature === "number") partial.temperature = raw.temperature;
5271
- if (typeof raw.consensus === "number") partial.consensus = raw.consensus;
5272
- if (raw.max_tokens !== void 0) partial.max_tokens = raw.max_tokens;
5857
+ if (raw.api_key !== void 0) {
5858
+ if (typeof raw.api_key !== "string") throw new Error(`${ctx("api_key")}: must be a string`);
5859
+ partial.api_key = raw.api_key;
5860
+ }
5861
+ if (raw.provider !== void 0) {
5862
+ if (typeof raw.provider !== "string") throw new Error(`${ctx("provider")}: must be a string`);
5863
+ partial.provider = raw.provider;
5864
+ }
5865
+ if (raw.model !== void 0) {
5866
+ if (typeof raw.model !== "string") throw new Error(`${ctx("model")}: must be a string`);
5867
+ partial.model = raw.model;
5868
+ }
5869
+ if (raw.endpoint !== void 0) {
5870
+ if (typeof raw.endpoint !== "string") throw new Error(`${ctx("endpoint")}: must be a string`);
5871
+ partial.endpoint = raw.endpoint;
5872
+ }
5873
+ if (raw.temperature !== void 0) {
5874
+ if (typeof raw.temperature !== "number") throw new Error(`${ctx("temperature")}: must be a number`);
5875
+ partial.temperature = raw.temperature;
5876
+ }
5877
+ if (raw.consensus !== void 0) {
5878
+ if (typeof raw.consensus !== "number") throw new Error(`${ctx("consensus")}: must be a number`);
5879
+ partial.consensus = raw.consensus;
5880
+ }
5881
+ if (raw.max_tokens !== void 0) {
5882
+ if (typeof raw.max_tokens !== "number" && raw.max_tokens !== "auto") {
5883
+ throw new Error(`${ctx("max_tokens")}: must be a number or 'auto'`);
5884
+ }
5885
+ partial.max_tokens = raw.max_tokens;
5886
+ }
5273
5887
  return Object.keys(partial).length > 0 ? partial : void 0;
5274
5888
  }
5275
5889
  function mergeLlmConfig(base, secrets) {
@@ -5766,7 +6380,7 @@ function buildTransitiveChains(targetNode, direct, allDependents, reverse) {
5766
6380
  }
5767
6381
  return chains.sort();
5768
6382
  }
5769
- function collectDescendants(graph, nodePath) {
6383
+ function collectDescendants2(graph, nodePath) {
5770
6384
  const node = graph.nodes.get(nodePath);
5771
6385
  if (!node) return [];
5772
6386
  const result = [];
@@ -5920,7 +6534,7 @@ async function handleFlowImpact(graph, flowName) {
5920
6534
  for (const nodePath of flow.nodes) {
5921
6535
  if (graph.nodes.has(nodePath)) {
5922
6536
  participants.add(nodePath);
5923
- for (const desc of collectDescendants(graph, nodePath)) {
6537
+ for (const desc of collectDescendants2(graph, nodePath)) {
5924
6538
  participants.add(desc);
5925
6539
  }
5926
6540
  }
@@ -6096,7 +6710,7 @@ function registerImpactCommand(program2) {
6096
6710
  `);
6097
6711
  }
6098
6712
  }
6099
- const descendants = collectDescendants(graph, nodePath);
6713
+ const descendants = collectDescendants2(graph, nodePath);
6100
6714
  if (descendants.length > 0) {
6101
6715
  process.stdout.write("\nDescendants (hierarchy impact):\n");
6102
6716
  for (const desc of descendants) {