@unified-product-graph/core 0.27.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/dist/index.js CHANGED
@@ -1540,9 +1540,10 @@ var UPG_EDGE_CATALOG = {
1540
1540
  journey_action_realised_by_feature: { forward_verb: "realised_by", reverse_verb: "realises", classification: "cross-domain", source_type: "journey_action", target_type: "feature" },
1541
1541
  // ── surface (0.27.0): the place inside a screen ─────────────────────────
1542
1542
  // `screen` is route-level; `surface` is the contested place within it. The
1543
- // ten edges below give a surface its spine (nesting), its guest list, its
1543
+ // eleven edges below give a surface its spine (nesting), its guest list, its
1544
1544
  // purpose, its governing rule, its rendering vocabulary, its measurement,
1545
- // and its replacement path.
1545
+ // its replacement path, and (0.28.0) the gap between what it intends and
1546
+ // what it actually does.
1546
1547
  //
1547
1548
  // Classification adjudications, each anchored on the nearest precedent:
1548
1549
  // contains → hierarchy (feature_area_contains_feature_area, planning_cycle_contains_planning_cycle)
@@ -1578,6 +1579,35 @@ var UPG_EDGE_CATALOG = {
1578
1579
  surface_renders_design_component: { forward_verb: "renders", reverse_verb: "rendered_on", classification: "hierarchy", source_type: "surface", target_type: "design_component", cross_product_eligible: true },
1579
1580
  surface_measured_by_metric: { forward_verb: "measured_by", reverse_verb: "measures", classification: "semantic", source_type: "surface", target_type: "metric", cross_product_eligible: true },
1580
1581
  surface_supersedes_surface: { forward_verb: "supersedes", reverse_verb: "superseded_by", classification: "semantic", source_type: "surface", target_type: "surface" },
1582
+ // ── surface, 0.28.0 addition: intent versus reality ─────────────────────────
1583
+ // Every surface property records what the design INTENDS. `capacity: 1` on a
1584
+ // banner region is an assertion, and a real product will eventually be found
1585
+ // rendering four. Without somewhere to put that fact, the only ways to record
1586
+ // it are to edit `capacity` up to 4 (which destroys the intent, and with it
1587
+ // any evidence that a gap exists) or to leave the graph quietly wrong.
1588
+ //
1589
+ // This edge is the third option, and it is what lets `capacity` mean intent
1590
+ // unconditionally: the deviation hangs off a `technical_debt_item`, which is
1591
+ // already a trackable, assignable, prioritisable entity with an owner and a
1592
+ // remediation cost. The gap becomes work rather than a discrepancy.
1593
+ //
1594
+ // classification → cross-domain (ux_design → technical_debt). Crossing INTO
1595
+ // the debt domain from another ring is the established shape:
1596
+ // risk_manifests_as_technical_debt_item is cross-domain on exactly that
1597
+ // reasoning. NOT `causal`: `decision_incurs_technical_debt_item` is causal
1598
+ // because a decision BRINGS the debt into existence, whereas a surface
1599
+ // does not cause its own drift, it exhibits it. NOT `hierarchy`: that
1600
+ // would oblige a surface → technical_debt_item containment pair in
1601
+ // UPG_VALID_CHILDREN (guardrail G2b), and a surface does not own the debt
1602
+ // that afflicts it — service_carries_technical_debt_item can be hierarchy
1603
+ // because a service genuinely owns its backlog.
1604
+ // verbs → `deviates_via` / `causes_deviation_in`. The forward verb names
1605
+ // what the SOURCE does (the surface deviates), qualified by the debt that
1606
+ // accounts for it, so the edge key reads as the sentence it asserts. The
1607
+ // reverse says what the debt does to the place.
1608
+ // not cross_product_eligible → a deviation is a fact about one product's
1609
+ // code, and `surface` is not portfolio_shared in the first place.
1610
+ surface_deviates_via_technical_debt_item: { forward_verb: "deviates_via", reverse_verb: "causes_deviation_in", classification: "cross-domain", source_type: "surface", target_type: "technical_debt_item" },
1581
1611
  // Inbound. `feature_occupies_surface` is the guest list: the edge that makes
1582
1612
  // "what else lives here?" answerable, and the one the contention anti-pattern
1583
1613
  // counts. cross-domain (product_spec → ux_design), like screen_surfaces_feature
@@ -12649,7 +12679,8 @@ var UPG_PROPERTY_SCHEMA = {
12649
12679
  health_status: { type: "string", enum: ["on_track", "at_risk", "off_track"], description: "Overall health" },
12650
12680
  url: { type: "string", description: "Where the product lives. Marketing site, app store URL, etc.", modifier: "volatile" },
12651
12681
  logo_url: { type: "string", description: "Logo or icon URL. Used to render product cards and lists.", modifier: "volatile" },
12652
- launched_at: { type: "string", description: "When the product became generally available (ISO 8601)" }
12682
+ launched_at: { type: "string", description: "When the product became generally available (ISO 8601)" },
12683
+ 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"` }
12653
12684
  },
12654
12685
  // ProductAreaProperties: ProductArea entity.
12655
12686
  product_area: {
@@ -13177,8 +13208,12 @@ var UPG_PROPERTY_SCHEMA = {
13177
13208
  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." },
13178
13209
  persistence: { type: "string", enum: ["always", "conditional", "on_demand", "transient"], description: "How reliably the surface is present." },
13179
13210
  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"' },
13180
- capacity: { type: "number", description: 'How many occupants the surface holds at once, as a non-negative count. 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".' },
13211
+ 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.' },
13212
+ 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." },
13213
+ 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.' },
13214
+ 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." },
13181
13215
  arbitration_rule: { type: "string", description: 'Who wins when more occupants want the surface than `capacity` allows, and why. ABSENCE IS MEANINGFUL: a null or empty `arbitration_rule` on a contested surface means nobody decided, which is exactly what the `contended-surface-without-arbitration` anti-pattern detects. Do not fill this in with a placeholder to silence the check. @example "Highest priority wins; ties break to the most recently updated."' },
13216
+ arbitration_state: { type: "string", enum: ["enforced_documented", "enforced_undocumented", "safe_by_coincidence", "none"], description: 'Whether the arbitration answer is enforced, written down, both, or neither. Separates "enforced in code but never transcribed" (ten minutes of typing) from "never decided" (a design meeting) from "safe only because nothing has collided yet" (the dangerous one).' },
13182
13217
  extensibility: { type: "string", enum: ["closed", "plugin_registerable", "user_configurable"], description: "Who may add occupants to the surface." },
13183
13218
  mutates_content: { type: "boolean", description: "Whether occupying this surface can change the underlying content, as opposed to only selecting or revealing it. The selector-versus-mutator distinction: a gutter that toggles a value is a mutator, a gutter that jumps the cursor is not." },
13184
13219
  dimensional_constraint: { type: "string", description: 'The hard spatial budget the surface imposes on its occupants, in whatever unit the design system speaks. @example "292px wide", "25px per field", "two grid columns"' }
@@ -27015,7 +27050,106 @@ var UPG_ANTI_PATTERNS = [
27015
27050
  id: "contended-surface-without-arbitration",
27016
27051
  since: "0.27.0",
27017
27052
  name: "Contended surface without arbitration",
27018
- description: 'Features occupy surfaces in this graph, but at least one surface carries no arbitration_rule. A surface is a place features compete for; the arbitration rule is the recorded answer to "who wins here, and why". Left blank, that answer lives in whoever remembers the last argument.',
27053
+ 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.',
27054
+ // 0.28.0 (feedback 852a9721, from a 30-surface field audit). Three changes,
27055
+ // each earned by a reported false positive or a reported blind spot:
27056
+ //
27057
+ // (a) rule-absence, EXCEPT chained. `composition_mode: 'chained'` means each
27058
+ // occupant wraps the next rather than competing with it. Many occupants
27059
+ // and no arbitration rule is the designed shape, so the reporter's 14
27060
+ // chained slots were 14 standing false positives — and because
27061
+ // `get_anti_pattern_violations_for` attributes by TYPE, they kept the
27062
+ // whole surface roster lit no matter how diligently the genuinely
27063
+ // contended surfaces were documented. The check could never be silenced
27064
+ // by correct modelling, which is the property a check has to have.
27065
+ // Exemption is DECLARE-TO-EARN: an unset `composition_mode` still fires,
27066
+ // so the default posture stays suspicious and silence must be claimed.
27067
+ // (b) `safe_by_coincidence` fires on its own, with no chained exemption. It
27068
+ // is the one state that a written `arbitration_rule` can mask: the field
27069
+ // audit found surfaces whose prose described disjoint enum values doing
27070
+ // the arbitrating with nothing guarding them. Branch (a) cannot see
27071
+ // those, because the rule text is present.
27072
+ // (c) `none` fires on its own too. A graph that says "nobody decided" while
27073
+ // carrying rule text is contradicting itself, and the admission is the
27074
+ // half to believe.
27075
+ //
27076
+ // `enforced_undocumented` deliberately gets NO branch and NO suppression. It
27077
+ // needs none: a surface enforcing an untranscribed rule has no
27078
+ // `arbitration_rule`, so branch (a) already fires. Nor does it earn a
27079
+ // downgrade — severity is per-anti-pattern, not per-violation, so there is
27080
+ // nowhere to put one; and the harm this pattern names is that the settlement
27081
+ // is unrecorded, which is exactly what `enforced_undocumented` admits. The
27082
+ // graph is the record; code is not. What the state buys is triage, so the
27083
+ // remediation text below names the two costs separately.
27084
+ //
27085
+ // No new anti-pattern was minted for `safe_by_coincidence`. It is a value of
27086
+ // the arbitration question, not a different question, and a second detector
27087
+ // would fire on the same graphs and double-report through type-keyed
27088
+ // attribution — making this family noisier on its first real deployment,
27089
+ // which is the exact failure (a) exists to fix. If field evidence later shows
27090
+ // it needs its own severity, mint it then.
27091
+ //
27092
+ // 0.29.0 (feedback af9ae4c2, the same reporter's measured follow-up: 43
27093
+ // surfaces, 10 flagged, 7 rightly and 3 wrongly). Branch (a) moved from an
27094
+ // aggregate presence count to the per-node `edge_count_vs_property` form.
27095
+ //
27096
+ // (d) CAPACITY IS NOW READ. The old branch was a pure edge count: any
27097
+ // surface with more than one occupant and no rule. That flags a header
27098
+ // row declaring room for four and holding exactly four, which is not
27099
+ // contended but PARTITIONED — everyone fits, by design, so nothing is
27100
+ // displaced and there is nothing to decide. All three of the reporter's
27101
+ // false positives were this, and all three had occupancy at or below a
27102
+ // stated capacity. The rule is now `occupancy > (capacity ?? 1)`.
27103
+ //
27104
+ // ABSENT CAPACITY BEHAVES AS 1, NOT AS INFINITY. Absence means
27105
+ // unbounded, and it is tempting to read unbounded as "never flag". The
27106
+ // opposite is right: a surface that states no limit has stated no
27107
+ // answer, so two or more occupants is precisely the unrecorded decision
27108
+ // this pattern names. That keeps all four of the reporter's unbounded
27109
+ // true positives (7, 7, 10 and 3 occupants) firing.
27110
+ //
27111
+ // The check had to become per-node rather than two ANDed aggregates:
27112
+ // "some surface is over capacity" and "some surface has no rule" are
27113
+ // both true when they are DIFFERENT surfaces, which would have kept
27114
+ // every false positive alive. See `EdgeCountVsPropertyCheck.node_filter`.
27115
+ //
27116
+ // ADJUDICATED: DOES A CAPACITY-SATISFIED `additive` SURFACE STILL NEED AN
27117
+ // ORDERING RULE? The reporter's capacity rule and their own earlier report
27118
+ // pull in opposite directions here: 0.28.0 documented that `additive`
27119
+ // occupants raise a question of ORDER rather than victory and that the order
27120
+ // belongs in `arbitration_rule`, while the capacity rule says a surface
27121
+ // where everyone fits has nothing to settle. Ruling: BOTH ARE RIGHT, because
27122
+ // they are about different failures, and this detector owns only one of them.
27123
+ //
27124
+ // - Contention is about DISPLACEMENT: who is not rendered. Capacity settles
27125
+ // it. If everyone fits, nobody is displaced, and "who wins" is genuinely
27126
+ // moot. That is why reading capacity removes real false positives rather
27127
+ // than merely quieting the check.
27128
+ // - Ordering is about ARRANGEMENT: in what sequence coexisting occupants
27129
+ // appear. Capacity says nothing about it. The reporter's own four-occupant
27130
+ // header row, positioned by runtime width measurement, has a live ordering
27131
+ // question and no displacement question at all.
27132
+ //
27133
+ // So this check reads capacity and stops at displacement. The ordering concern
27134
+ // stays documented on `composition_mode: 'additive'` as guidance, NOT as a
27135
+ // detector: minting `additive-surface-without-ordering` today would repeat the
27136
+ // 0.28.0 mistake of shipping a second detector on no field evidence, and it
27137
+ // would double-report against this one. Mint it when a graph shows unrecorded
27138
+ // ordering causing a real defect, with its own severity and remediation.
27139
+ //
27140
+ // Known consequence, recorded rather than papered over: `arbitration_rule` is
27141
+ // now visibly OVERLOADED. It holds a displacement rule for `exclusive`
27142
+ // surfaces and an ordering rule for `additive` ones, and this check reads it
27143
+ // for only the first. Splitting it (`arbitration_rule` vs `ordering_rule`) is
27144
+ // the natural move if the ordering detector is ever minted; it is not worth a
27145
+ // breaking property change before then.
27146
+ //
27147
+ // STILL OPEN (banked at 0.28.0, deliberately NOT changed here): a `chained`
27148
+ // slot that honestly declares `arbitration_state: 'none'` re-fires through
27149
+ // branch (c), which carries no chained exemption. Current guidance stands:
27150
+ // leave `arbitration_state` unset on chained surfaces. Changing (b)/(c) was
27151
+ // out of scope for this release and still awaits the field confirmation the
27152
+ // reporter will produce populating their 14 chained slots.
27019
27153
  structured_condition: {
27020
27154
  operator: "and",
27021
27155
  checks: [
@@ -27030,17 +27164,45 @@ var UPG_ANTI_PATTERNS = [
27030
27164
  }
27031
27165
  },
27032
27166
  {
27033
- check: {
27034
- type: "entity_count",
27035
- entity_type: "surface",
27036
- filter: { property: "arbitration_rule", present: false },
27037
- comparison: "nonzero"
27038
- }
27167
+ operator: "or",
27168
+ checks: [
27169
+ {
27170
+ check: {
27171
+ type: "edge_count_vs_property",
27172
+ entity_type: "surface",
27173
+ edge_type: "feature_occupies_surface",
27174
+ direction: "inbound",
27175
+ property: "capacity",
27176
+ property_absent_default: 1,
27177
+ node_comparison: "gt",
27178
+ node_filter: { property: "arbitration_rule", present: false },
27179
+ except_property: "composition_mode",
27180
+ except_value: "chained",
27181
+ comparison: "nonzero"
27182
+ }
27183
+ },
27184
+ {
27185
+ check: {
27186
+ type: "entity_count",
27187
+ entity_type: "surface",
27188
+ filter: { property: "arbitration_state", value: "safe_by_coincidence" },
27189
+ comparison: "nonzero"
27190
+ }
27191
+ },
27192
+ {
27193
+ check: {
27194
+ type: "entity_count",
27195
+ entity_type: "surface",
27196
+ filter: { property: "arbitration_state", value: "none" },
27197
+ comparison: "nonzero"
27198
+ }
27199
+ }
27200
+ ]
27039
27201
  }
27040
27202
  ]
27041
27203
  },
27042
- 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.",
27043
- remediation: "For each surface with more than one `feature_occupies_surface` edge, write the rule that settles contention into `arbitration_rule`, and record the constraint it works within (`capacity`, `dimensional_constraint`). Where a design system already answers it, link the source with `surface_governed_by_design_guideline`.",
27204
+ 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.",
27205
+ 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.",
27044
27206
  stages: ["build", "beta", "launch", "growth", "mature", "maintenance"],
27045
27207
  severity: "medium",
27046
27208
  source: { kind: "fundamental" }
@@ -27098,6 +27260,127 @@ var UPG_ANTI_PATTERNS = [
27098
27260
  source: { kind: "fundamental" }
27099
27261
  }
27100
27262
  ];
27263
+ function walkForExceptSpecs(cond, out) {
27264
+ if ("operator" in cond) {
27265
+ for (const child of cond.checks) walkForExceptSpecs(child, out);
27266
+ return;
27267
+ }
27268
+ const check = cond.check;
27269
+ if (check.type !== "entity_count" || !check.filter) return;
27270
+ const f = check.filter;
27271
+ if (typeof f.property !== "string" || typeof f.except_property !== "string" || typeof f.except_value !== "string") {
27272
+ return;
27273
+ }
27274
+ const spec = {
27275
+ entity_type: check.entity_type,
27276
+ property: f.property,
27277
+ except_property: f.except_property,
27278
+ except_value: f.except_value
27279
+ };
27280
+ const seen = out.some(
27281
+ (s) => s.entity_type === spec.entity_type && s.property === spec.property && s.except_property === spec.except_property && s.except_value === spec.except_value
27282
+ );
27283
+ if (!seen) out.push(spec);
27284
+ }
27285
+ var UPG_PRESENCE_EXCEPT_SPECS = (() => {
27286
+ const out = [];
27287
+ for (const ap of UPG_ANTI_PATTERNS) {
27288
+ if (ap.structured_condition) walkForExceptSpecs(ap.structured_condition, out);
27289
+ }
27290
+ return out;
27291
+ })();
27292
+ function presenceExceptKey(property, exceptProperty, exceptValue) {
27293
+ return `${property}!${exceptProperty}=${exceptValue}`;
27294
+ }
27295
+ function edgeCountSpecKey(spec) {
27296
+ const filter = spec.node_filter ? `${spec.node_filter.property}=${spec.node_filter.present ? "present" : "absent"}` : "*";
27297
+ const except = spec.except_property !== void 0 && spec.except_value !== void 0 ? `${spec.except_property}=${spec.except_value}` : "*";
27298
+ return [
27299
+ spec.entity_type,
27300
+ spec.edge_type,
27301
+ spec.direction,
27302
+ spec.property,
27303
+ String(spec.property_absent_default),
27304
+ spec.node_comparison,
27305
+ filter,
27306
+ except
27307
+ ].join("|");
27308
+ }
27309
+ function entityFilterKey(entityType, filter) {
27310
+ const kind = classifyEntityFilter(filter);
27311
+ switch (kind) {
27312
+ case "presence":
27313
+ return `${entityType}|${filter.property}=${filter.present ? "present" : "absent"}`;
27314
+ case "value":
27315
+ return `${entityType}|${filter.property}=${filter.value}`;
27316
+ case "status":
27317
+ return `${entityType}|status=${filter.status}`;
27318
+ case "unrecognized":
27319
+ return `${entityType}|<unrecognized>`;
27320
+ }
27321
+ }
27322
+ function classifyEntityFilter(filter) {
27323
+ const hasProperty = typeof filter.property === "string";
27324
+ if (hasProperty && typeof filter.present === "boolean") return "presence";
27325
+ if (hasProperty && typeof filter.value === "string") return "value";
27326
+ if (typeof filter.status === "string") return "status";
27327
+ return "unrecognized";
27328
+ }
27329
+ function walkForEntityFilterSpecs(cond, out) {
27330
+ if ("operator" in cond) {
27331
+ for (const child of cond.checks) walkForEntityFilterSpecs(child, out);
27332
+ return;
27333
+ }
27334
+ const check = cond.check;
27335
+ if (check.type !== "entity_count" || !check.filter) return;
27336
+ const kind = classifyEntityFilter(check.filter);
27337
+ if (kind === "unrecognized") {
27338
+ throw new Error(
27339
+ `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.`
27340
+ );
27341
+ }
27342
+ const key = entityFilterKey(check.entity_type, check.filter);
27343
+ if (out.some((s) => entityFilterKey(s.entity_type, s.filter) === key)) return;
27344
+ out.push({ entity_type: check.entity_type, filter: check.filter, kind });
27345
+ }
27346
+ var UPG_ENTITY_FILTER_SPECS = (() => {
27347
+ const out = [];
27348
+ for (const ap of UPG_ANTI_PATTERNS) {
27349
+ if (ap.structured_condition) walkForEntityFilterSpecs(ap.structured_condition, out);
27350
+ }
27351
+ return out;
27352
+ })();
27353
+ function checkToEdgeCountSpec(check) {
27354
+ return {
27355
+ entity_type: check.entity_type,
27356
+ edge_type: check.edge_type,
27357
+ direction: check.direction ?? "inbound",
27358
+ property: check.property,
27359
+ property_absent_default: check.property_absent_default,
27360
+ node_comparison: check.node_comparison,
27361
+ node_filter: check.node_filter,
27362
+ except_property: check.except_property,
27363
+ except_value: check.except_value
27364
+ };
27365
+ }
27366
+ function walkForEdgeCountSpecs(cond, out) {
27367
+ if ("operator" in cond) {
27368
+ for (const child of cond.checks) walkForEdgeCountSpecs(child, out);
27369
+ return;
27370
+ }
27371
+ const check = cond.check;
27372
+ if (check.type !== "edge_count_vs_property") return;
27373
+ const spec = checkToEdgeCountSpec(check);
27374
+ const key = edgeCountSpecKey(spec);
27375
+ if (!out.some((s) => edgeCountSpecKey(s) === key)) out.push(spec);
27376
+ }
27377
+ var UPG_EDGE_COUNT_SPECS = (() => {
27378
+ const out = [];
27379
+ for (const ap of UPG_ANTI_PATTERNS) {
27380
+ if (ap.structured_condition) walkForEdgeCountSpecs(ap.structured_condition, out);
27381
+ }
27382
+ return out;
27383
+ })();
27101
27384
  function getAntiPatternById(id) {
27102
27385
  return UPG_ANTI_PATTERNS.find((ap) => ap.id === id);
27103
27386
  }
@@ -27174,7 +27457,11 @@ function evaluateAntiPatterns(inputs, options) {
27174
27457
  const concern = concernFor(ap.id);
27175
27458
  if (!concernEvaluatedFor(inputs.memberKind, concern)) continue;
27176
27459
  if (evaluateCondition(ap.structured_condition, inputs)) {
27177
- fires.push(buildViolation(ap, concern));
27460
+ const ids = /* @__PURE__ */ new Set();
27461
+ collectAttribution(ap.structured_condition, inputs, ids);
27462
+ const violation = buildViolation(ap, concern);
27463
+ if (ids.size > 0) violation.target_node_ids = [...ids].sort();
27464
+ fires.push(violation);
27178
27465
  }
27179
27466
  }
27180
27467
  fires.sort((a, b) => {
@@ -27184,6 +27471,25 @@ function evaluateAntiPatterns(inputs, options) {
27184
27471
  });
27185
27472
  return fires;
27186
27473
  }
27474
+ function collectAttribution(cond, inputs, out) {
27475
+ if ("operator" in cond) {
27476
+ for (const child of cond.checks) {
27477
+ if (cond.operator === "or" && !evaluateCondition(child, inputs)) continue;
27478
+ collectAttribution(child, inputs, out);
27479
+ }
27480
+ return;
27481
+ }
27482
+ const check = cond.check;
27483
+ if (check.type === "edge_count_vs_property") {
27484
+ const key = edgeCountSpecKey(checkToEdgeCountSpec(check));
27485
+ for (const id of inputs.nodesByEdgeCountSpec?.[key] ?? []) out.add(id);
27486
+ return;
27487
+ }
27488
+ if (check.type === "entity_count" && check.filter) {
27489
+ const key = entityFilterKey(check.entity_type, check.filter);
27490
+ for (const id of inputs.nodesByEntityFilter?.[key] ?? []) out.add(id);
27491
+ }
27492
+ }
27187
27493
  function evaluateCondition(cond, inputs) {
27188
27494
  if ("check" in cond) {
27189
27495
  return evaluateLeaf(cond.check, inputs);
@@ -27203,6 +27509,8 @@ function evaluateLeaf(check, inputs) {
27203
27509
  switch (check.type) {
27204
27510
  case "entity_count":
27205
27511
  return evaluateEntityCount(check, inputs);
27512
+ case "edge_count_vs_property":
27513
+ return evaluateEdgeCountVsProperty(check, inputs);
27206
27514
  case "relationship":
27207
27515
  return evaluateRelationship(check, inputs);
27208
27516
  case "benchmark":
@@ -27245,7 +27553,13 @@ function compareNumber(value, comparison, threshold) {
27245
27553
  function evaluateEntityCount(check, inputs) {
27246
27554
  const filter = check.filter;
27247
27555
  let count = 0;
27248
- if (filter && typeof filter.property === "string" && typeof filter.present === "boolean") {
27556
+ if (filter && typeof filter.property === "string" && typeof filter.present === "boolean" && typeof filter.except_property === "string" && typeof filter.except_value === "string") {
27557
+ const key = presenceExceptKey(filter.property, filter.except_property, filter.except_value);
27558
+ const exempt = inputs.countsByTypeAndProperty?.[check.entity_type]?.[filter.except_property]?.[filter.except_value] ?? 0;
27559
+ const eligible = Math.max(0, (inputs.countsByType[check.entity_type] ?? 0) - exempt);
27560
+ const withValue = inputs.countsByTypeAndPropertyPresenceExcept?.[check.entity_type]?.[key] ?? 0;
27561
+ count = filter.present ? Math.min(withValue, eligible) : Math.max(0, eligible - withValue);
27562
+ } else if (filter && typeof filter.property === "string" && typeof filter.present === "boolean") {
27249
27563
  const withValue = inputs.countsByTypeAndPropertyPresence?.[check.entity_type]?.[filter.property] ?? 0;
27250
27564
  count = filter.present ? withValue : Math.max(0, (inputs.countsByType[check.entity_type] ?? 0) - withValue);
27251
27565
  } else if (filter && typeof filter.property === "string" && typeof filter.value === "string") {
@@ -27257,6 +27571,18 @@ function evaluateEntityCount(check, inputs) {
27257
27571
  }
27258
27572
  return compareNumber(count, check.comparison, check.threshold);
27259
27573
  }
27574
+ function evaluateEdgeCountVsProperty(check, inputs) {
27575
+ const key = edgeCountSpecKey(checkToEdgeCountSpec(check));
27576
+ const matched = inputs.nodesByEdgeCountSpec?.[key];
27577
+ if (matched === void 0) {
27578
+ return compareNumber(
27579
+ inputs.countsByType[check.entity_type] ?? 0,
27580
+ check.comparison,
27581
+ check.threshold
27582
+ );
27583
+ }
27584
+ return compareNumber(matched.length, check.comparison, check.threshold);
27585
+ }
27260
27586
  function relationshipKey(source_type, edge_type, target_type) {
27261
27587
  return `${source_type}|${edge_type}|${target_type}`;
27262
27588
  }
@@ -27339,7 +27665,7 @@ function collectTargetEntities(cond) {
27339
27665
  function walk(c) {
27340
27666
  if ("check" in c) {
27341
27667
  const leaf = c.check;
27342
- if (leaf.type === "entity_count" || leaf.type === "benchmark") {
27668
+ if (leaf.type === "entity_count" || leaf.type === "benchmark" || leaf.type === "edge_count_vs_property") {
27343
27669
  types.add(leaf.entity_type);
27344
27670
  } else if (leaf.type === "relationship") {
27345
27671
  types.add(leaf.source_type);
@@ -30366,7 +30692,7 @@ function checkHeaderSealText(text) {
30366
30692
  }
30367
30693
 
30368
30694
  // src/index.ts
30369
- var UPG_VERSION = "0.27.0";
30695
+ var UPG_VERSION = "0.29.0";
30370
30696
  var UPG_FORMAT_VERSION = "0.4.0";
30371
30697
  var MARKDOWN_FORMAT_VERSION = "0.1";
30372
30698
  var UPG_TYPES = getTypes();
@@ -30480,10 +30806,12 @@ export {
30480
30806
  UPG_DOMAIN_RINGS,
30481
30807
  UPG_EDGE_CATALOG,
30482
30808
  UPG_EDGE_COUNT,
30809
+ UPG_EDGE_COUNT_SPECS,
30483
30810
  UPG_EDGE_MIGRATIONS,
30484
30811
  UPG_EDGE_PAIR_MAP,
30485
30812
  UPG_EDGE_TYPES,
30486
30813
  UPG_ENTITY_COUNT,
30814
+ UPG_ENTITY_FILTER_SPECS,
30487
30815
  UPG_ENTITY_META,
30488
30816
  UPG_ENTITY_META_BY_ID,
30489
30817
  UPG_ENTITY_META_BY_NAME,
@@ -30504,6 +30832,7 @@ export {
30504
30832
  UPG_POLYMORPHIC_EDGE_KEYS,
30505
30833
  UPG_PORTFOLIO_KINDS,
30506
30834
  UPG_PORTFOLIO_SHARED_TYPES,
30835
+ UPG_PRESENCE_EXCEPT_SPECS,
30507
30836
  UPG_PRODUCT_STAGES,
30508
30837
  UPG_PRODUCT_STAGE_COERCION_MAP,
30509
30838
  UPG_PROPERTY_MIGRATIONS,
@@ -30537,8 +30866,11 @@ export {
30537
30866
  canBeChildOf,
30538
30867
  checkHeaderSeal,
30539
30868
  checkHeaderSealText,
30869
+ checkToEdgeCountSpec,
30870
+ classifyEntityFilter,
30540
30871
  coerceProductStage,
30541
30872
  collectSlugsForType,
30873
+ collectTargetEntities,
30542
30874
  compareVersions,
30543
30875
  computeBodyChecksum,
30544
30876
  concernEvaluatedFor,
@@ -30550,6 +30882,8 @@ export {
30550
30882
  deriveSingleCounts,
30551
30883
  describeTreePattern,
30552
30884
  edgeCarriesProperties,
30885
+ edgeCountSpecKey,
30886
+ entityFilterKey,
30553
30887
  evaluateAntiPatterns,
30554
30888
  findInvalidStatusMigrationTargets,
30555
30889
  formatUpgText,
@@ -30640,6 +30974,7 @@ export {
30640
30974
  normalizeDocument,
30641
30975
  parseUpg,
30642
30976
  pickCanonicalEdge,
30977
+ presenceExceptKey,
30643
30978
  resolveAllEdges,
30644
30979
  resolveContainmentEdge,
30645
30980
  resolveEntityType,