@unified-product-graph/core 0.28.0 → 0.29.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/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.29.0] - 2026-08-15
11
+
12
+ **The contention check learns to read `capacity`, violations learn to name their nodes, and a batch write learns to un-say a property.** All three came from the same field reporter measuring the check on a real 43-surface graph: 10 surfaces flagged, 7 correctly, 3 falsely — and every false positive had an occupant count at or below its stated capacity. A surface whose occupants all fit is partitioned, not contended. Additive; no breaking change.
13
+
14
+ ### Changed
15
+ - **`contended-surface-without-arbitration` is capacity-aware.** The rule-absence branch now fires per surface only when `occupancy > (capacity ?? 1)` — occupants within a stated capacity are partitioned by design, and an unbounded surface (absent capacity) with several occupants still fires, because no stated limit means no stated answer. Contention is *displacement* (who is not rendered), which capacity settles; *ordering* of coexisting occupants remains guidance on `composition_mode: 'additive'`, deliberately not a second detector.
16
+ - **Violations carry `target_node_ids`.** Attribution rides the same collector aggregates as the verdicts: a fired check names the offending nodes instead of only the entity types, and `get_anti_pattern_violations_for` matches by id exactly for the types the id list covers (`matched_by: 'node' | 'type'`), keeping type-level reach for types never attributed. The payload reserves an empty `configurations` field for per-projection attribution (0.30.0).
17
+
18
+ ### Added
19
+ - **`edge_count_vs_property` check form** — a general per-node comparison of an edge count against a numeric property with a declared absence default. Deliberately not a one-off: the form fits any place the graph states a numeric intent.
20
+ - **`unset_properties` on `batch_update_nodes`** — batch parity with `update_node`'s existing affordance, so returning a property to *absent* no longer requires literal nulls. For `capacity` the distinction is load-bearing: absent (unbounded), `0` (reserved), and `null` are three different states.
21
+ - **`product.described_configuration`** (optional string) — names the configuration a graph describes, the typed home for the one-named-configuration convention published alongside this release. Naming, not configuring: a label for readers, read by no tool.
22
+
23
+ ### Notes
24
+ - Backfilled below: 0.28.0 shipped 2026-08-13 without a changelog entry; it is recorded now so this entry does not sit on a gap.
25
+
26
+ ---
27
+
28
+ ## [0.28.0] - 2026-08-13
29
+
30
+ **Four field-data additions teach `surface` to tell the truth about multiplicity, composition, arbitration, and drift.** All four came from one reporter populating 0.27.0's `surface` against a real 30-surface product (three parallel code audits, 329 file:line citations) before writing a single node. Additive; no breaking change. *(Backfilled 2026-08-15 — this release shipped without an entry.)*
31
+
32
+ ### Added
33
+ - **`cardinality`** (`1 | 0..1 | 1..n | 0..n`) and **`instance_scope`** (`global | per_parent`) — `capacity` counts occupants per instance; these count instances. Without them the graph says "the product has an inspector panel" when the truth is "each document pane has its own."
34
+ - **`composition_mode`** (`exclusive | additive | chained`) — how co-occupants relate. `chained` (each occupant wraps the next) is a trust model, not contention, and is exempt from `contended-surface-without-arbitration` — declare-to-earn: an unset `composition_mode` still fires.
35
+ - **`arbitration_state`** (`enforced_documented | enforced_undocumented | safe_by_coincidence | none`) — routes remediation instead of guessing it. `safe_by_coincidence` and `none` fire the contention check on their own admission; a written rule cannot mask them.
36
+ - **`surface_deviates_via_technical_debt_item`** edge — `capacity` always means *intent*, and drift from it becomes a trackable, assignable debt item instead of a lie or an endorsement.
37
+ - **Except-form on the presence filter** — `filter: { property, present, except_property, except_value }`, needed because the chained exemption is an intersection the marginal forms could not express.
38
+
39
+ ---
40
+
10
41
  ## [0.27.0] - 2026-08-12
11
42
 
12
43
  **A `surface` entity gives the UI a place: what occupies it, and who wins when two things want it.** The catalog could say who OWNS a thing (`feature_area`) and what ARCHITECTURE it belongs to (`bounded_context`), but nothing answered *what else occupies the same place?*. `screen` is route-level (route / viewport / access_level) and too coarse for a shell, a pane, a slot, a field gutter, an action bar, or an overlay. Those places are contested, and the arbitration rule is decided constantly and then rediscovered forever, because prose is where it lands. `surface` records the place, its guest list, and the rule. Additive; no breaking change.
package/dist/index.cjs CHANGED
@@ -80,10 +80,12 @@ __export(index_exports, {
80
80
  UPG_DOMAIN_RINGS: () => UPG_DOMAIN_RINGS,
81
81
  UPG_EDGE_CATALOG: () => UPG_EDGE_CATALOG,
82
82
  UPG_EDGE_COUNT: () => UPG_EDGE_COUNT,
83
+ UPG_EDGE_COUNT_SPECS: () => UPG_EDGE_COUNT_SPECS,
83
84
  UPG_EDGE_MIGRATIONS: () => UPG_EDGE_MIGRATIONS,
84
85
  UPG_EDGE_PAIR_MAP: () => UPG_EDGE_PAIR_MAP,
85
86
  UPG_EDGE_TYPES: () => UPG_EDGE_TYPES,
86
87
  UPG_ENTITY_COUNT: () => UPG_ENTITY_COUNT,
88
+ UPG_ENTITY_FILTER_SPECS: () => UPG_ENTITY_FILTER_SPECS,
87
89
  UPG_ENTITY_META: () => UPG_ENTITY_META,
88
90
  UPG_ENTITY_META_BY_ID: () => UPG_ENTITY_META_BY_ID,
89
91
  UPG_ENTITY_META_BY_NAME: () => UPG_ENTITY_META_BY_NAME,
@@ -138,8 +140,11 @@ __export(index_exports, {
138
140
  canBeChildOf: () => canBeChildOf,
139
141
  checkHeaderSeal: () => checkHeaderSeal,
140
142
  checkHeaderSealText: () => checkHeaderSealText,
143
+ checkToEdgeCountSpec: () => checkToEdgeCountSpec,
144
+ classifyEntityFilter: () => classifyEntityFilter,
141
145
  coerceProductStage: () => coerceProductStage,
142
146
  collectSlugsForType: () => collectSlugsForType,
147
+ collectTargetEntities: () => collectTargetEntities,
143
148
  compareVersions: () => compareVersions,
144
149
  computeBodyChecksum: () => computeBodyChecksum,
145
150
  concernEvaluatedFor: () => concernEvaluatedFor,
@@ -151,6 +156,8 @@ __export(index_exports, {
151
156
  deriveSingleCounts: () => deriveSingleCounts,
152
157
  describeTreePattern: () => describeTreePattern,
153
158
  edgeCarriesProperties: () => edgeCarriesProperties,
159
+ edgeCountSpecKey: () => edgeCountSpecKey,
160
+ entityFilterKey: () => entityFilterKey,
154
161
  evaluateAntiPatterns: () => evaluateAntiPatterns,
155
162
  findInvalidStatusMigrationTargets: () => findInvalidStatusMigrationTargets,
156
163
  formatUpgText: () => formatUpgText,
@@ -12941,7 +12948,8 @@ var UPG_PROPERTY_SCHEMA = {
12941
12948
  health_status: { type: "string", enum: ["on_track", "at_risk", "off_track"], description: "Overall health" },
12942
12949
  url: { type: "string", description: "Where the product lives. Marketing site, app store URL, etc.", modifier: "volatile" },
12943
12950
  logo_url: { type: "string", description: "Logo or icon URL. Used to render product cards and lists.", modifier: "volatile" },
12944
- launched_at: { type: "string", description: "When the product became generally available (ISO 8601)" }
12951
+ launched_at: { type: "string", description: "When the product became generally available (ISO 8601)" },
12952
+ described_configuration: { type: "string", description: `The product configuration this graph describes, named in plain language. A product's composition often differs by feature flag, plan tier, permission level or beta programme: surfaces appear, disappear, or are replaced by different surfaces with different occupants. A graph that models one of those without saying which is qualified by a condition nobody wrote down, and every fact in it inherits that silence. NAMING, NOT CONFIGURING. This is a label for readers. Nothing reads it to gate, filter, or alter how a tool interprets the graph, and it makes no claim about which configuration most customers are on. @example "Enterprise plan, split-navigation flag on" @example "Default: free tier, all flags off"` }
12945
12953
  },
12946
12954
  // ProductAreaProperties: ProductArea entity.
12947
12955
  product_area: {
@@ -13469,7 +13477,7 @@ var UPG_PROPERTY_SCHEMA = {
13469
13477
  surface_kind: { type: "string", enum: ["shell", "tool", "pane", "region", "slot", "gutter", "action_bar", "overlay", "ambient"], description: "Structural kind. Determines what may legally nest inside this surface." },
13470
13478
  persistence: { type: "string", enum: ["always", "conditional", "on_demand", "transient"], description: "How reliably the surface is present." },
13471
13479
  visibility_condition: { type: "string", description: 'When the surface appears, in plain language. Pairs with `persistence: \'conditional\'`, which states *that* it is conditional; this states *what* the condition is. @example "A node is selected", "Only for workspace admins"' },
13472
- capacity: { type: "number", description: 'How many occupants ONE INSTANCE of the surface holds at once, as a non-negative count. Count the instances themselves with `cardinality`. CAPACITY IS ALWAYS INTENT, NEVER OBSERVED BEHAVIOUR. It records the cap the design asserts, so a banner region declared `capacity: 1` keeps saying 1 even after someone finds it rendering four. Reality that has drifted from the declared intent is recorded as a trackable, assignable debt item on `surface_deviates_via_technical_debt_item`, not by quietly editing this number up to match the bug. Editing it to match would destroy the only record that a gap exists. ABSENT MEANS UNBOUNDED. UPG has no union-typed property primitive (`PropertyDefinition.type` is a single scalar kind), so the proposed `integer | unbounded` shape is expressed as an optional number whose absence carries the "no cap" reading, rather than a magic sentinel value that every consumer would have to special-case. `0` is a real cap meaning "nothing may occupy this surface" (a reserved place), not "unbounded".' },
13480
+ capacity: { type: "number", description: 'How many occupants ONE INSTANCE of the surface holds at once, as a non-negative count. Count the instances themselves with `cardinality`. CAPACITY IS ALWAYS INTENT, NEVER OBSERVED BEHAVIOUR. It records the cap the design asserts, so a banner region declared `capacity: 1` keeps saying 1 even after someone finds it rendering four. Reality that has drifted from the declared intent is recorded as a trackable, assignable debt item on `surface_deviates_via_technical_debt_item`, not by quietly editing this number up to match the bug. Editing it to match would destroy the only record that a gap exists. ABSENT MEANS UNBOUNDED. UPG has no union-typed property primitive (`PropertyDefinition.type` is a single scalar kind), so the proposed `integer | unbounded` shape is expressed as an optional number whose absence carries the "no cap" reading, rather than a magic sentinel value that every consumer would have to special-case. `0` is a real cap meaning "nothing may occupy this surface" (a reserved place), not "unbounded". THREE STATES, AND THEY ARE ALL DIFFERENT. Absent = unbounded, no cap stated. `0` = a reserved place nothing may occupy. `null` = neither of those, and nothing this field defines; it is what you get by writing an explicit null rather than removing the key, and no consumer reads it as a cap. To return this property to ABSENT, remove the key with `update_node`\'s or `batch_update_nodes`\' `unset_properties`, since a property merge preserves anything you omit. UNBOUNDED IS NOT AN EXEMPTION FROM SCRUTINY. The contention detector reads an absent capacity as a threshold of 1, on the reasoning that a surface which states no limit has stated no answer either, so several occupants is exactly the unrecorded decision worth naming. Declaring a real capacity is therefore the way to quiet the check honestly, and the only way that also records something true.' },
13473
13481
  cardinality: { type: "string", enum: ["1", "0..1", "1..n", "0..n"], description: "How many instances of this surface exist. `capacity` counts occupants within one instance; this counts the instances." },
13474
13482
  instance_scope: { type: "string", enum: ["global", "per_parent"], description: 'What an instance is scoped to: one shared instance for the product, or one per containing surface. Decides whether "the product has this surface" is a true sentence or a per-parent one.' },
13475
13483
  composition_mode: { type: "string", enum: ["exclusive", "additive", "chained"], description: "How the occupants relate: one wins (`exclusive`), all coexist (`additive`), or each wraps the next (`chained`). Declaring `chained` exempts the surface from `contended-surface-without-arbitration`; leaving this unset does not." },
@@ -27311,7 +27319,7 @@ var UPG_ANTI_PATTERNS = [
27311
27319
  id: "contended-surface-without-arbitration",
27312
27320
  since: "0.27.0",
27313
27321
  name: "Contended surface without arbitration",
27314
- description: 'Features occupy surfaces in this graph, and at least one surface has no recorded answer to "who wins here, and why". Either it carries no arbitration_rule and has not declared itself `chained`, or it admits through arbitration_state that the answer is absent (`none`) or accidental (`safe_by_coincidence`). Left unrecorded, that answer lives in whoever remembers the last argument.',
27322
+ description: 'At least one surface in this graph has no recorded answer to "who wins here, and why". Either it holds more occupants than its stated `capacity` allows (or holds several while stating no limit at all) and carries no arbitration_rule, or it admits through arbitration_state that the answer is absent (`none`) or accidental (`safe_by_coincidence`). Left unrecorded, that answer lives in whoever remembers the last argument.',
27315
27323
  // 0.28.0 (feedback 852a9721, from a 30-surface field audit). Three changes,
27316
27324
  // each earned by a reported false positive or a reported blind spot:
27317
27325
  //
@@ -27349,6 +27357,68 @@ var UPG_ANTI_PATTERNS = [
27349
27357
  // attribution — making this family noisier on its first real deployment,
27350
27358
  // which is the exact failure (a) exists to fix. If field evidence later shows
27351
27359
  // it needs its own severity, mint it then.
27360
+ //
27361
+ // 0.29.0 (feedback af9ae4c2, the same reporter's measured follow-up: 43
27362
+ // surfaces, 10 flagged, 7 rightly and 3 wrongly). Branch (a) moved from an
27363
+ // aggregate presence count to the per-node `edge_count_vs_property` form.
27364
+ //
27365
+ // (d) CAPACITY IS NOW READ. The old branch was a pure edge count: any
27366
+ // surface with more than one occupant and no rule. That flags a header
27367
+ // row declaring room for four and holding exactly four, which is not
27368
+ // contended but PARTITIONED — everyone fits, by design, so nothing is
27369
+ // displaced and there is nothing to decide. All three of the reporter's
27370
+ // false positives were this, and all three had occupancy at or below a
27371
+ // stated capacity. The rule is now `occupancy > (capacity ?? 1)`.
27372
+ //
27373
+ // ABSENT CAPACITY BEHAVES AS 1, NOT AS INFINITY. Absence means
27374
+ // unbounded, and it is tempting to read unbounded as "never flag". The
27375
+ // opposite is right: a surface that states no limit has stated no
27376
+ // answer, so two or more occupants is precisely the unrecorded decision
27377
+ // this pattern names. That keeps all four of the reporter's unbounded
27378
+ // true positives (7, 7, 10 and 3 occupants) firing.
27379
+ //
27380
+ // The check had to become per-node rather than two ANDed aggregates:
27381
+ // "some surface is over capacity" and "some surface has no rule" are
27382
+ // both true when they are DIFFERENT surfaces, which would have kept
27383
+ // every false positive alive. See `EdgeCountVsPropertyCheck.node_filter`.
27384
+ //
27385
+ // ADJUDICATED: DOES A CAPACITY-SATISFIED `additive` SURFACE STILL NEED AN
27386
+ // ORDERING RULE? The reporter's capacity rule and their own earlier report
27387
+ // pull in opposite directions here: 0.28.0 documented that `additive`
27388
+ // occupants raise a question of ORDER rather than victory and that the order
27389
+ // belongs in `arbitration_rule`, while the capacity rule says a surface
27390
+ // where everyone fits has nothing to settle. Ruling: BOTH ARE RIGHT, because
27391
+ // they are about different failures, and this detector owns only one of them.
27392
+ //
27393
+ // - Contention is about DISPLACEMENT: who is not rendered. Capacity settles
27394
+ // it. If everyone fits, nobody is displaced, and "who wins" is genuinely
27395
+ // moot. That is why reading capacity removes real false positives rather
27396
+ // than merely quieting the check.
27397
+ // - Ordering is about ARRANGEMENT: in what sequence coexisting occupants
27398
+ // appear. Capacity says nothing about it. The reporter's own four-occupant
27399
+ // header row, positioned by runtime width measurement, has a live ordering
27400
+ // question and no displacement question at all.
27401
+ //
27402
+ // So this check reads capacity and stops at displacement. The ordering concern
27403
+ // stays documented on `composition_mode: 'additive'` as guidance, NOT as a
27404
+ // detector: minting `additive-surface-without-ordering` today would repeat the
27405
+ // 0.28.0 mistake of shipping a second detector on no field evidence, and it
27406
+ // would double-report against this one. Mint it when a graph shows unrecorded
27407
+ // ordering causing a real defect, with its own severity and remediation.
27408
+ //
27409
+ // Known consequence, recorded rather than papered over: `arbitration_rule` is
27410
+ // now visibly OVERLOADED. It holds a displacement rule for `exclusive`
27411
+ // surfaces and an ordering rule for `additive` ones, and this check reads it
27412
+ // for only the first. Splitting it (`arbitration_rule` vs `ordering_rule`) is
27413
+ // the natural move if the ordering detector is ever minted; it is not worth a
27414
+ // breaking property change before then.
27415
+ //
27416
+ // STILL OPEN (banked at 0.28.0, deliberately NOT changed here): a `chained`
27417
+ // slot that honestly declares `arbitration_state: 'none'` re-fires through
27418
+ // branch (c), which carries no chained exemption. Current guidance stands:
27419
+ // leave `arbitration_state` unset on chained surfaces. Changing (b)/(c) was
27420
+ // out of scope for this release and still awaits the field confirmation the
27421
+ // reporter will produce populating their 14 chained slots.
27352
27422
  structured_condition: {
27353
27423
  operator: "and",
27354
27424
  checks: [
@@ -27367,14 +27437,16 @@ var UPG_ANTI_PATTERNS = [
27367
27437
  checks: [
27368
27438
  {
27369
27439
  check: {
27370
- type: "entity_count",
27440
+ type: "edge_count_vs_property",
27371
27441
  entity_type: "surface",
27372
- filter: {
27373
- property: "arbitration_rule",
27374
- present: false,
27375
- except_property: "composition_mode",
27376
- except_value: "chained"
27377
- },
27442
+ edge_type: "feature_occupies_surface",
27443
+ direction: "inbound",
27444
+ property: "capacity",
27445
+ property_absent_default: 1,
27446
+ node_comparison: "gt",
27447
+ node_filter: { property: "arbitration_rule", present: false },
27448
+ except_property: "composition_mode",
27449
+ except_value: "chained",
27378
27450
  comparison: "nonzero"
27379
27451
  }
27380
27452
  },
@@ -27399,7 +27471,7 @@ var UPG_ANTI_PATTERNS = [
27399
27471
  ]
27400
27472
  },
27401
27473
  why_it_matters: "Contention over a UI place is settled every time it comes up, and an unrecorded settlement is re-argued at the next feature. The cost is paid in repeated decisions, not in a visible defect. A surface that is safe only by coincidence pays it all at once instead, the first time an occupant is added.",
27402
- remediation: "Read `arbitration_state` first: it says what the work actually is. `enforced_undocumented` means the rule already exists in code and needs transcribing into `arbitration_rule`: ten minutes, no meeting. `none` means nobody has decided and someone has to. `safe_by_coincidence` means nothing is enforcing anything and the surface only looks settled; that one wants a guard in code, not just prose. Record the constraint the rule works within (`capacity`, `dimensional_constraint`), and where a design system already answers it, link the source with `surface_governed_by_design_guideline`. Surfaces whose occupants wrap one another rather than compete should declare `composition_mode: 'chained'`, which exempts them: that is a factual claim about how the code composes, not a way to quiet the check.",
27474
+ remediation: "The violation names the offending surfaces in `target_node_ids`; start there rather than re-reading the whole roster. Read `arbitration_state` first: it says what the work actually is. `enforced_undocumented` means the rule already exists in code and needs transcribing into `arbitration_rule`: ten minutes, no meeting. `none` means nobody has decided and someone has to. `safe_by_coincidence` means nothing is enforcing anything and the surface only looks settled; that one wants a guard in code, not just prose. If a flagged surface genuinely holds everything it was designed to hold, the honest fix is to state its `capacity`, not to invent a rule: a surface whose occupancy is at or below a stated capacity is partitioned rather than contended, and it stops firing. Where a design system already answers the question, link the source with `surface_governed_by_design_guideline`. Surfaces whose occupants wrap one another rather than compete should declare `composition_mode: 'chained'`, which exempts them: that is a factual claim about how the code composes, not a way to quiet the check.",
27403
27475
  stages: ["build", "beta", "launch", "growth", "mature", "maintenance"],
27404
27476
  severity: "medium",
27405
27477
  source: { kind: "fundamental" }
@@ -27489,6 +27561,95 @@ var UPG_PRESENCE_EXCEPT_SPECS = (() => {
27489
27561
  function presenceExceptKey(property, exceptProperty, exceptValue) {
27490
27562
  return `${property}!${exceptProperty}=${exceptValue}`;
27491
27563
  }
27564
+ function edgeCountSpecKey(spec) {
27565
+ const filter = spec.node_filter ? `${spec.node_filter.property}=${spec.node_filter.present ? "present" : "absent"}` : "*";
27566
+ const except = spec.except_property !== void 0 && spec.except_value !== void 0 ? `${spec.except_property}=${spec.except_value}` : "*";
27567
+ return [
27568
+ spec.entity_type,
27569
+ spec.edge_type,
27570
+ spec.direction,
27571
+ spec.property,
27572
+ String(spec.property_absent_default),
27573
+ spec.node_comparison,
27574
+ filter,
27575
+ except
27576
+ ].join("|");
27577
+ }
27578
+ function entityFilterKey(entityType, filter) {
27579
+ const kind = classifyEntityFilter(filter);
27580
+ switch (kind) {
27581
+ case "presence":
27582
+ return `${entityType}|${filter.property}=${filter.present ? "present" : "absent"}`;
27583
+ case "value":
27584
+ return `${entityType}|${filter.property}=${filter.value}`;
27585
+ case "status":
27586
+ return `${entityType}|status=${filter.status}`;
27587
+ case "unrecognized":
27588
+ return `${entityType}|<unrecognized>`;
27589
+ }
27590
+ }
27591
+ function classifyEntityFilter(filter) {
27592
+ const hasProperty = typeof filter.property === "string";
27593
+ if (hasProperty && typeof filter.present === "boolean") return "presence";
27594
+ if (hasProperty && typeof filter.value === "string") return "value";
27595
+ if (typeof filter.status === "string") return "status";
27596
+ return "unrecognized";
27597
+ }
27598
+ function walkForEntityFilterSpecs(cond, out) {
27599
+ if ("operator" in cond) {
27600
+ for (const child of cond.checks) walkForEntityFilterSpecs(child, out);
27601
+ return;
27602
+ }
27603
+ const check = cond.check;
27604
+ if (check.type !== "entity_count" || !check.filter) return;
27605
+ const kind = classifyEntityFilter(check.filter);
27606
+ if (kind === "unrecognized") {
27607
+ throw new Error(
27608
+ `Unrecognized entity_count filter shape on "${check.entity_type}": ${JSON.stringify(check.filter)}. Recognised shapes are { property, present }, { property, value } and { status }. Teach classifyEntityFilter + the collector before declaring a new one.`
27609
+ );
27610
+ }
27611
+ const key = entityFilterKey(check.entity_type, check.filter);
27612
+ if (out.some((s) => entityFilterKey(s.entity_type, s.filter) === key)) return;
27613
+ out.push({ entity_type: check.entity_type, filter: check.filter, kind });
27614
+ }
27615
+ var UPG_ENTITY_FILTER_SPECS = (() => {
27616
+ const out = [];
27617
+ for (const ap of UPG_ANTI_PATTERNS) {
27618
+ if (ap.structured_condition) walkForEntityFilterSpecs(ap.structured_condition, out);
27619
+ }
27620
+ return out;
27621
+ })();
27622
+ function checkToEdgeCountSpec(check) {
27623
+ return {
27624
+ entity_type: check.entity_type,
27625
+ edge_type: check.edge_type,
27626
+ direction: check.direction ?? "inbound",
27627
+ property: check.property,
27628
+ property_absent_default: check.property_absent_default,
27629
+ node_comparison: check.node_comparison,
27630
+ node_filter: check.node_filter,
27631
+ except_property: check.except_property,
27632
+ except_value: check.except_value
27633
+ };
27634
+ }
27635
+ function walkForEdgeCountSpecs(cond, out) {
27636
+ if ("operator" in cond) {
27637
+ for (const child of cond.checks) walkForEdgeCountSpecs(child, out);
27638
+ return;
27639
+ }
27640
+ const check = cond.check;
27641
+ if (check.type !== "edge_count_vs_property") return;
27642
+ const spec = checkToEdgeCountSpec(check);
27643
+ const key = edgeCountSpecKey(spec);
27644
+ if (!out.some((s) => edgeCountSpecKey(s) === key)) out.push(spec);
27645
+ }
27646
+ var UPG_EDGE_COUNT_SPECS = (() => {
27647
+ const out = [];
27648
+ for (const ap of UPG_ANTI_PATTERNS) {
27649
+ if (ap.structured_condition) walkForEdgeCountSpecs(ap.structured_condition, out);
27650
+ }
27651
+ return out;
27652
+ })();
27492
27653
  function getAntiPatternById(id) {
27493
27654
  return UPG_ANTI_PATTERNS.find((ap) => ap.id === id);
27494
27655
  }
@@ -27565,7 +27726,11 @@ function evaluateAntiPatterns(inputs, options) {
27565
27726
  const concern = concernFor(ap.id);
27566
27727
  if (!concernEvaluatedFor(inputs.memberKind, concern)) continue;
27567
27728
  if (evaluateCondition(ap.structured_condition, inputs)) {
27568
- fires.push(buildViolation(ap, concern));
27729
+ const ids = /* @__PURE__ */ new Set();
27730
+ collectAttribution(ap.structured_condition, inputs, ids);
27731
+ const violation = buildViolation(ap, concern);
27732
+ if (ids.size > 0) violation.target_node_ids = [...ids].sort();
27733
+ fires.push(violation);
27569
27734
  }
27570
27735
  }
27571
27736
  fires.sort((a, b) => {
@@ -27575,6 +27740,25 @@ function evaluateAntiPatterns(inputs, options) {
27575
27740
  });
27576
27741
  return fires;
27577
27742
  }
27743
+ function collectAttribution(cond, inputs, out) {
27744
+ if ("operator" in cond) {
27745
+ for (const child of cond.checks) {
27746
+ if (cond.operator === "or" && !evaluateCondition(child, inputs)) continue;
27747
+ collectAttribution(child, inputs, out);
27748
+ }
27749
+ return;
27750
+ }
27751
+ const check = cond.check;
27752
+ if (check.type === "edge_count_vs_property") {
27753
+ const key = edgeCountSpecKey(checkToEdgeCountSpec(check));
27754
+ for (const id of inputs.nodesByEdgeCountSpec?.[key] ?? []) out.add(id);
27755
+ return;
27756
+ }
27757
+ if (check.type === "entity_count" && check.filter) {
27758
+ const key = entityFilterKey(check.entity_type, check.filter);
27759
+ for (const id of inputs.nodesByEntityFilter?.[key] ?? []) out.add(id);
27760
+ }
27761
+ }
27578
27762
  function evaluateCondition(cond, inputs) {
27579
27763
  if ("check" in cond) {
27580
27764
  return evaluateLeaf(cond.check, inputs);
@@ -27594,6 +27778,8 @@ function evaluateLeaf(check, inputs) {
27594
27778
  switch (check.type) {
27595
27779
  case "entity_count":
27596
27780
  return evaluateEntityCount(check, inputs);
27781
+ case "edge_count_vs_property":
27782
+ return evaluateEdgeCountVsProperty(check, inputs);
27597
27783
  case "relationship":
27598
27784
  return evaluateRelationship(check, inputs);
27599
27785
  case "benchmark":
@@ -27654,6 +27840,18 @@ function evaluateEntityCount(check, inputs) {
27654
27840
  }
27655
27841
  return compareNumber(count, check.comparison, check.threshold);
27656
27842
  }
27843
+ function evaluateEdgeCountVsProperty(check, inputs) {
27844
+ const key = edgeCountSpecKey(checkToEdgeCountSpec(check));
27845
+ const matched = inputs.nodesByEdgeCountSpec?.[key];
27846
+ if (matched === void 0) {
27847
+ return compareNumber(
27848
+ inputs.countsByType[check.entity_type] ?? 0,
27849
+ check.comparison,
27850
+ check.threshold
27851
+ );
27852
+ }
27853
+ return compareNumber(matched.length, check.comparison, check.threshold);
27854
+ }
27657
27855
  function relationshipKey(source_type, edge_type, target_type) {
27658
27856
  return `${source_type}|${edge_type}|${target_type}`;
27659
27857
  }
@@ -27736,7 +27934,7 @@ function collectTargetEntities(cond) {
27736
27934
  function walk(c) {
27737
27935
  if ("check" in c) {
27738
27936
  const leaf = c.check;
27739
- if (leaf.type === "entity_count" || leaf.type === "benchmark") {
27937
+ if (leaf.type === "entity_count" || leaf.type === "benchmark" || leaf.type === "edge_count_vs_property") {
27740
27938
  types.add(leaf.entity_type);
27741
27939
  } else if (leaf.type === "relationship") {
27742
27940
  types.add(leaf.source_type);
@@ -30763,7 +30961,7 @@ function checkHeaderSealText(text) {
30763
30961
  }
30764
30962
 
30765
30963
  // src/index.ts
30766
- var UPG_VERSION = "0.28.0";
30964
+ var UPG_VERSION = "0.29.0";
30767
30965
  var UPG_FORMAT_VERSION = "0.4.0";
30768
30966
  var MARKDOWN_FORMAT_VERSION = "0.1";
30769
30967
  var UPG_TYPES = getTypes();
@@ -30878,10 +31076,12 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
30878
31076
  UPG_DOMAIN_RINGS,
30879
31077
  UPG_EDGE_CATALOG,
30880
31078
  UPG_EDGE_COUNT,
31079
+ UPG_EDGE_COUNT_SPECS,
30881
31080
  UPG_EDGE_MIGRATIONS,
30882
31081
  UPG_EDGE_PAIR_MAP,
30883
31082
  UPG_EDGE_TYPES,
30884
31083
  UPG_ENTITY_COUNT,
31084
+ UPG_ENTITY_FILTER_SPECS,
30885
31085
  UPG_ENTITY_META,
30886
31086
  UPG_ENTITY_META_BY_ID,
30887
31087
  UPG_ENTITY_META_BY_NAME,
@@ -30936,8 +31136,11 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
30936
31136
  canBeChildOf,
30937
31137
  checkHeaderSeal,
30938
31138
  checkHeaderSealText,
31139
+ checkToEdgeCountSpec,
31140
+ classifyEntityFilter,
30939
31141
  coerceProductStage,
30940
31142
  collectSlugsForType,
31143
+ collectTargetEntities,
30941
31144
  compareVersions,
30942
31145
  computeBodyChecksum,
30943
31146
  concernEvaluatedFor,
@@ -30949,6 +31152,8 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
30949
31152
  deriveSingleCounts,
30950
31153
  describeTreePattern,
30951
31154
  edgeCarriesProperties,
31155
+ edgeCountSpecKey,
31156
+ entityFilterKey,
30952
31157
  evaluateAntiPatterns,
30953
31158
  findInvalidStatusMigrationTargets,
30954
31159
  formatUpgText,