@unified-product-graph/core 0.29.0 → 0.30.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.cjs CHANGED
@@ -30,11 +30,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ACTIVE_WHEN_PROPERTY: () => ACTIVE_WHEN_PROPERTY,
33
34
  ANALYTICS_DATA_PLAYBOOK: () => ANALYTICS_DATA_PLAYBOOK,
34
35
  BUSINESS_GROWTH_METRIC_DRIVEN_PLAYBOOK: () => BUSINESS_GROWTH_METRIC_DRIVEN_PLAYBOOK,
35
36
  BUSINESS_GTM_GROWTH_PLAYBOOK: () => BUSINESS_GTM_GROWTH_PLAYBOOK,
36
37
  BUSINESS_MARKETING_AUDIENCE_FIRST_PLAYBOOK: () => BUSINESS_MARKETING_AUDIENCE_FIRST_PLAYBOOK,
37
38
  CLASSIFICATION_EDGE_PROPERTY_SCHEMA: () => CLASSIFICATION_EDGE_PROPERTY_SCHEMA,
39
+ CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA: () => CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA,
40
+ CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA: () => CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA,
38
41
  CONTENT_DEPTH_WARNING_RULES: () => CONTENT_DEPTH_WARNING_RULES,
39
42
  COVERAGE_ANTI_PATTERNS: () => COVERAGE_ANTI_PATTERNS,
40
43
  DEFAULT_ENTITY_EMOJI: () => DEFAULT_ENTITY_EMOJI,
@@ -49,10 +52,12 @@ __export(index_exports, {
49
52
  MARKDOWN_FORMAT_VERSION: () => MARKDOWN_FORMAT_VERSION,
50
53
  MARKET_COMPETITIVE_PLAYBOOK: () => MARKET_COMPETITIVE_PLAYBOOK,
51
54
  OPERATIONS_QUALITY_PLAYBOOK: () => OPERATIONS_QUALITY_PLAYBOOK,
55
+ PRESENT_UNDER_PROPERTY: () => PRESENT_UNDER_PROPERTY,
52
56
  PRODUCT_DELIVERY_PLAYBOOK: () => PRODUCT_DELIVERY_PLAYBOOK,
53
57
  PROPERTY_MODIFIERS: () => PROPERTY_MODIFIERS,
54
58
  PROPERTY_MODIFIER_SEMANTICS: () => PROPERTY_MODIFIER_SEMANTICS,
55
59
  PROPERTY_SCALE_MAP: () => PROPERTY_SCALE_MAP,
60
+ QUALIFIABLE_EDGE_TYPES: () => QUALIFIABLE_EDGE_TYPES,
56
61
  RECORD_ENTITY_TYPES: () => RECORD_ENTITY_TYPES,
57
62
  REFLECT_MODES: () => REFLECT_MODES,
58
63
  REGISTRY_PRODUCT_ID: () => REGISTRY_PRODUCT_ID,
@@ -135,9 +140,11 @@ __export(index_exports, {
135
140
  UPG_WILDCARD_ENDPOINT: () => UPG_WILDCARD_ENDPOINT,
136
141
  USERS_NEEDS_PLAYBOOK: () => USERS_NEEDS_PLAYBOOK,
137
142
  UnknownEntityTypeError: () => UnknownEntityTypeError,
143
+ VARIES_BY_EDGE: () => VARIES_BY_EDGE,
138
144
  backfillSlug: () => backfillSlug,
139
145
  buildTypeAliases: () => buildTypeAliases,
140
146
  canBeChildOf: () => canBeChildOf,
147
+ checkConfigurationDrift: () => checkConfigurationDrift,
141
148
  checkHeaderSeal: () => checkHeaderSeal,
142
149
  checkHeaderSealText: () => checkHeaderSealText,
143
150
  checkToEdgeCountSpec: () => checkToEdgeCountSpec,
@@ -158,6 +165,7 @@ __export(index_exports, {
158
165
  edgeCarriesProperties: () => edgeCarriesProperties,
159
166
  edgeCountSpecKey: () => edgeCountSpecKey,
160
167
  entityFilterKey: () => entityFilterKey,
168
+ enumerateProjections: () => enumerateProjections,
161
169
  evaluateAntiPatterns: () => evaluateAntiPatterns,
162
170
  findInvalidStatusMigrationTargets: () => findInvalidStatusMigrationTargets,
163
171
  formatUpgText: () => formatUpgText,
@@ -249,6 +257,9 @@ __export(index_exports, {
249
257
  parseUpg: () => parseUpg,
250
258
  pickCanonicalEdge: () => pickCanonicalEdge,
251
259
  presenceExceptKey: () => presenceExceptKey,
260
+ projectGraph: () => projectGraph,
261
+ readActiveWhen: () => readActiveWhen,
262
+ readStringArray: () => readStringArray,
252
263
  resolveAllEdges: () => resolveAllEdges,
253
264
  resolveContainmentEdge: () => resolveContainmentEdge,
254
265
  resolveEntityType: () => resolveEntityType,
@@ -375,7 +386,8 @@ var UPG_DOMAINS = [
375
386
  "roadmap_item",
376
387
  "roadmap_theme",
377
388
  "changelog",
378
- "planning_cycle"
389
+ "planning_cycle",
390
+ "configuration_axis"
379
391
  ]
380
392
  },
381
393
  {
@@ -915,6 +927,7 @@ var UPG_ENTITY_META = [
915
927
  // it, carrying its occupants and the rule that arbitrates between them.
916
928
  // Enters `proposed` per the maturity rubric (precedent: planning_cycle 0.20.0).
917
929
  { name: "surface", type_id: "ent_359", maturity: "proposed", since: "0.27.0" },
930
+ { name: "configuration_axis", type_id: "ent_360", maturity: "proposed", since: "0.30.0" },
918
931
  { name: "journey_phase", type_id: "ent_330", maturity: "proposed", since: "0.2.0" },
919
932
  { name: "journey_action", type_id: "ent_331", maturity: "proposed", since: "0.2.0" },
920
933
  { name: "design_decision", type_id: "ent_319", maturity: "deprecated", since: "0.2.0", deprecated_in: "0.2.0", replacement: "decision" },
@@ -1316,6 +1329,29 @@ var DEFER_EDGE_PROPERTY_SCHEMA = {
1316
1329
  description: 'Target period the parked work is deferred to. Freeform, mirroring strategic_theme.time_horizon. @example "Q4 2026", "FY27", "next release".'
1317
1330
  }
1318
1331
  };
1332
+ var CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA = {
1333
+ present_under: {
1334
+ type: "string[]",
1335
+ description: 'Values of the axis under which this surface exists. Every entry must name a member of the axis\'s closed `values` list, and an empty list is a modelling error rather than a way to say "never" (a surface that exists under no configuration should be deleted, not declared). Omitting the edge entirely is how a surface says it is present in every configuration. @example ["legacy_nav"]'
1336
+ }
1337
+ };
1338
+ var CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA = {
1339
+ active_when: {
1340
+ type: "object",
1341
+ description: 'Configuration condition under which this relationship holds. The edge is present in a projection that sets `axis` to one of `values`, and absent otherwise. Omit the key entirely for an invariant relationship: absence means "always", never "unknown".',
1342
+ properties: {
1343
+ axis: {
1344
+ type: "string",
1345
+ description: "Node id of the `configuration_axis` this condition reads."
1346
+ },
1347
+ values: {
1348
+ type: "string[]",
1349
+ description: "Values of that axis under which the relationship holds. Every entry must be a member of the axis's closed `values` list."
1350
+ }
1351
+ },
1352
+ required: ["axis", "values"]
1353
+ }
1354
+ };
1319
1355
  var UPG_EDGE_CATALOG = {
1320
1356
  // ── Part 1: Core Narrative Spine (Ring 1) ──────────────────────────────────
1321
1357
  // 1.1 User Domain
@@ -1842,7 +1878,7 @@ var UPG_EDGE_CATALOG = {
1842
1878
  // portfolio_shared, so the reading it names commonly lives in the
1843
1879
  // registry rather than in the product graph.
1844
1880
  // supersedes → semantic (prompt_version_supersedes_prompt_version).
1845
- surface_contains_surface: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "surface", target_type: "surface" },
1881
+ surface_contains_surface: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "surface", target_type: "surface", carries_properties: true, property_schema: CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA },
1846
1882
  surface_serves_job: { forward_verb: "serves", reverse_verb: "served_by", classification: "cross-domain", source_type: "surface", target_type: "job" },
1847
1883
  surface_governed_by_design_guideline: { forward_verb: "governed_by", reverse_verb: "governs", classification: "cross-domain", source_type: "surface", target_type: "design_guideline", cross_product_eligible: true },
1848
1884
  surface_renders_design_component: { forward_verb: "renders", reverse_verb: "rendered_on", classification: "hierarchy", source_type: "surface", target_type: "design_component", cross_product_eligible: true },
@@ -1877,11 +1913,53 @@ var UPG_EDGE_CATALOG = {
1877
1913
  // not cross_product_eligible → a deviation is a fact about one product's
1878
1914
  // code, and `surface` is not portfolio_shared in the first place.
1879
1915
  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" },
1916
+ // ── surface, 0.30.0: composition that varies by configuration ───────────────
1917
+ // A product's surface tree is not one tree; it is a family of trees selected
1918
+ // by a configuration lever. The stored graph is the UNION of that family and
1919
+ // a single configuration is a PROJECTION of it, so these two edges are how a
1920
+ // graph says which member a fact belongs to. Silence still means "every
1921
+ // member", which is why a graph written before 0.30.0 needs no migration.
1922
+ //
1923
+ // varies_by — CONDITIONAL EXISTENCE. The surface exists only under the
1924
+ // values in `present_under`. An edge qualifier cannot say
1925
+ // this: the reported case was a set of named chips REPLACED
1926
+ // by one generic badge, where which surface EXISTS changes,
1927
+ // and the two carry different capacities and occupants.
1928
+ // alternates — "one of these, depending" as distinct from "both,
1929
+ // together". Semantic, matching its sibling
1930
+ // `surface_supersedes_surface`: neither surface owns the
1931
+ // other, and a hierarchy classification would demand a
1932
+ // UPG_VALID_CHILDREN pair that would be false. Explicit and
1933
+ // validator-checked (same axis, disjoint present_under);
1934
+ // never auto-derived, because disjointness is necessary for
1935
+ // alternation and nowhere near sufficient.
1936
+ //
1937
+ // Direction on `alternates_with` is a CONVENTION, not an enforcement: declare
1938
+ // it once, sourced from the surface present under the axis's `default_value`.
1939
+ // An axis with no default is legal modelling, so a check keyed on the
1940
+ // convention would fire on a correct graph, which is the one thing a check
1941
+ // must never do.
1942
+ // ── configuration_axis, 0.30.0: the lever itself ───────────────────────────
1943
+ // Three layers, three owners, and keeping them apart is what makes the model
1944
+ // honest: `engineering` owns the MECHANISM (`feature_flag`, with its key and
1945
+ // rollout percentage), `product_spec` owns the LEVER (the named dimension
1946
+ // along which what-you-ship differs), `ux_design` owns the PLACE (`surface`).
1947
+ // Two code flags that move together are ONE lever with two values.
1948
+ //
1949
+ // `defines` is hierarchy so the axis has a home in `get_tree`. It earns the
1950
+ // UPG_VALID_CHILDREN.product pair (guardrail G2b) on an honest verb: an axis
1951
+ // exists only within the product whose composition it varies. The rejected
1952
+ // alternative mirrors `classification_axis_owned_by_product`, a cross-domain
1953
+ // ownership edge that leaves the node unparented and invisible in every tree.
1954
+ product_defines_configuration_axis: { forward_verb: "defines", reverse_verb: "defined_by", classification: "hierarchy", source_type: "product", target_type: "configuration_axis" },
1955
+ feature_flag_drives_configuration_axis: { forward_verb: "drives", reverse_verb: "driven_by", classification: "cross-domain", source_type: "feature_flag", target_type: "configuration_axis" },
1956
+ surface_varies_by_configuration_axis: { forward_verb: "varies_by", reverse_verb: "varies", classification: "cross-domain", source_type: "surface", target_type: "configuration_axis", carries_properties: true, property_schema: CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA },
1957
+ surface_alternates_with_surface: { forward_verb: "alternates_with", reverse_verb: "alternates_with", classification: "semantic", source_type: "surface", target_type: "surface" },
1880
1958
  // Inbound. `feature_occupies_surface` is the guest list: the edge that makes
1881
1959
  // "what else lives here?" answerable, and the one the contention anti-pattern
1882
1960
  // counts. cross-domain (product_spec → ux_design), like screen_surfaces_feature
1883
1961
  // running the other way.
1884
- feature_occupies_surface: { forward_verb: "occupies", reverse_verb: "occupied_by", classification: "cross-domain", source_type: "feature", target_type: "surface" },
1962
+ feature_occupies_surface: { forward_verb: "occupies", reverse_verb: "occupied_by", classification: "cross-domain", source_type: "feature", target_type: "surface", carries_properties: true, property_schema: CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA },
1885
1963
  screen_renders_surface: { forward_verb: "renders", reverse_verb: "rendered_by", classification: "hierarchy", source_type: "screen", target_type: "surface" },
1886
1964
  decision_affects_surface: { forward_verb: "affects", reverse_verb: "affected_by", classification: "cross-domain", source_type: "decision", target_type: "surface" },
1887
1965
  // The finer-grained sibling of journey_step_shown_on_screen: a step happens on
@@ -3947,6 +4025,10 @@ var UPG_VALID_CHILDREN = {
3947
4025
  "roadmap",
3948
4026
  "roadmap_theme",
3949
4027
  "planning_cycle",
4028
+ // Configuration (0.30.0): the levers whose values select which surface tree
4029
+ // the product renders. Few per product, and top-level because an axis is a
4030
+ // product-wide fact rather than a child of any one feature or surface.
4031
+ "configuration_axis",
3950
4032
  // Engineering
3951
4033
  "bounded_context",
3952
4034
  "code_repository",
@@ -4570,6 +4652,289 @@ function isContainmentFreeType(entityType) {
4570
4652
  return UPG_CONTAINMENT_FREE_TYPES.has(entityType);
4571
4653
  }
4572
4654
 
4655
+ // src/grammar/projection.ts
4656
+ var VARIES_BY_EDGE = "surface_varies_by_configuration_axis";
4657
+ var PRESENT_UNDER_PROPERTY = "present_under";
4658
+ var ACTIVE_WHEN_PROPERTY = "active_when";
4659
+ var QUALIFIABLE_EDGE_TYPES = [
4660
+ "surface_contains_surface",
4661
+ "feature_occupies_surface"
4662
+ ];
4663
+ function readStringArray(properties, key) {
4664
+ const raw = properties?.[key];
4665
+ if (!Array.isArray(raw)) return void 0;
4666
+ return raw.filter((v) => typeof v === "string");
4667
+ }
4668
+ function readActiveWhen(edge) {
4669
+ const raw = edge.properties?.[ACTIVE_WHEN_PROPERTY];
4670
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
4671
+ const axis = raw.axis;
4672
+ const values = readStringArray(raw, "values");
4673
+ if (typeof axis !== "string" || axis.length === 0 || !values || values.length === 0) {
4674
+ return void 0;
4675
+ }
4676
+ return { axis, values };
4677
+ }
4678
+ function projectGraph(nodes, edges, configuration) {
4679
+ const axes = Object.keys(configuration);
4680
+ if (axes.length === 0) {
4681
+ return {
4682
+ nodes: [...nodes],
4683
+ edges: [...edges],
4684
+ excluded_node_ids: [],
4685
+ deactivated_edge_count: 0,
4686
+ dangling_edge_count: 0
4687
+ };
4688
+ }
4689
+ const excluded = /* @__PURE__ */ new Set();
4690
+ for (const edge of edges) {
4691
+ if (edge.type !== VARIES_BY_EDGE) continue;
4692
+ const chosen = configuration[edge.target];
4693
+ if (chosen === void 0) continue;
4694
+ const presentUnder = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY);
4695
+ if (!presentUnder) continue;
4696
+ if (!presentUnder.includes(chosen)) excluded.add(edge.source);
4697
+ }
4698
+ const survivingNodes = excluded.size === 0 ? [...nodes] : nodes.filter((n) => !excluded.has(n.id));
4699
+ let deactivated = 0;
4700
+ let dangling = 0;
4701
+ const survivingEdges = [];
4702
+ for (const edge of edges) {
4703
+ const qualifier = readActiveWhen(edge);
4704
+ if (qualifier) {
4705
+ const chosen = configuration[qualifier.axis];
4706
+ if (chosen !== void 0 && !qualifier.values.includes(chosen)) {
4707
+ deactivated++;
4708
+ continue;
4709
+ }
4710
+ }
4711
+ if (excluded.has(edge.source) || excluded.has(edge.target)) {
4712
+ dangling++;
4713
+ continue;
4714
+ }
4715
+ survivingEdges.push(edge);
4716
+ }
4717
+ return {
4718
+ nodes: survivingNodes,
4719
+ edges: survivingEdges,
4720
+ excluded_node_ids: [...excluded].sort(),
4721
+ deactivated_edge_count: deactivated,
4722
+ dangling_edge_count: dangling
4723
+ };
4724
+ }
4725
+ function enumerateProjections(nodes) {
4726
+ const out = [
4727
+ { configuration: {} }
4728
+ ];
4729
+ for (const node of nodes) {
4730
+ if (node.type !== "configuration_axis") continue;
4731
+ const values = readStringArray(node.properties, "values");
4732
+ if (!values) continue;
4733
+ for (const value of values) {
4734
+ out.push({ axis: node.id, value, configuration: { [node.id]: value } });
4735
+ }
4736
+ }
4737
+ return out;
4738
+ }
4739
+
4740
+ // src/grammar/configuration-drift.ts
4741
+ var ALTERNATES_EDGE = "surface_alternates_with_surface";
4742
+ var PARENT_EDGE_TYPES = /* @__PURE__ */ new Set(["surface_contains_surface", "screen_renders_surface"]);
4743
+ function edgeLabel(edge) {
4744
+ return edge.id ?? `${edge.source} -> ${edge.target}`;
4745
+ }
4746
+ function checkConfigurationDrift(nodes, edges) {
4747
+ const findings = [];
4748
+ const axisValues = /* @__PURE__ */ new Map();
4749
+ for (const node of nodes) {
4750
+ if (node.type !== "configuration_axis") continue;
4751
+ const values = readStringArray(node.properties, "values");
4752
+ if (!values || values.length === 0) {
4753
+ findings.push({
4754
+ kind: "axis_values_empty",
4755
+ severity: "error",
4756
+ node_id: node.id,
4757
+ axis_id: node.id,
4758
+ message: "Configuration axis declares no values. An axis with no values selects nothing, so no projection can be taken along it."
4759
+ });
4760
+ axisValues.set(node.id, []);
4761
+ continue;
4762
+ }
4763
+ axisValues.set(node.id, values);
4764
+ const rawDefault = node.properties?.default_value;
4765
+ if (typeof rawDefault === "string" && rawDefault.length > 0 && !values.includes(rawDefault)) {
4766
+ findings.push({
4767
+ kind: "axis_default_not_a_value",
4768
+ severity: "error",
4769
+ node_id: node.id,
4770
+ axis_id: node.id,
4771
+ message: `default_value "${rawDefault}" is not one of the axis values [${values.join(", ")}]. It names a value of this axis, so it has to be one the axis declares.`
4772
+ });
4773
+ }
4774
+ }
4775
+ for (const edge of edges) {
4776
+ if (edge.type !== VARIES_BY_EDGE) continue;
4777
+ const values = axisValues.get(edge.target);
4778
+ const presentUnder = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY);
4779
+ if (!presentUnder || presentUnder.length === 0) {
4780
+ findings.push({
4781
+ kind: "present_under_empty",
4782
+ severity: "error",
4783
+ edge_id: edge.id,
4784
+ node_id: edge.source,
4785
+ axis_id: edge.target,
4786
+ message: `${edgeLabel(edge)} declares variance without a non-empty present_under. A surface that exists under no configuration should be deleted rather than declared; a surface that exists under all of them should carry no varies_by edge at all.`
4787
+ });
4788
+ continue;
4789
+ }
4790
+ if (!values) continue;
4791
+ for (const value of presentUnder) {
4792
+ if (!values.includes(value)) {
4793
+ findings.push({
4794
+ kind: "present_under_unknown_value",
4795
+ severity: "error",
4796
+ edge_id: edge.id,
4797
+ node_id: edge.source,
4798
+ axis_id: edge.target,
4799
+ message: `present_under names "${value}", which the axis does not declare. Its values are [${values.join(", ")}].`
4800
+ });
4801
+ }
4802
+ }
4803
+ }
4804
+ for (const edge of edges) {
4805
+ const hasQualifierKey = edge.properties !== void 0 && Object.prototype.hasOwnProperty.call(edge.properties, ACTIVE_WHEN_PROPERTY);
4806
+ if (!hasQualifierKey) continue;
4807
+ if (edge.type !== void 0 && !QUALIFIABLE_EDGE_TYPES.includes(edge.type)) {
4808
+ findings.push({
4809
+ kind: "qualifier_on_illegal_edge",
4810
+ severity: "error",
4811
+ edge_id: edge.id,
4812
+ message: `active_when is not legal on ${edge.type}. The qualifier is scoped to [${QUALIFIABLE_EDGE_TYPES.join(", ")}] deliberately: conditional composition is the question this release answers, and a general modality system is not.`
4813
+ });
4814
+ continue;
4815
+ }
4816
+ const qualifier = readActiveWhen(edge);
4817
+ if (!qualifier) {
4818
+ const rawQualifier = edge.properties?.[ACTIVE_WHEN_PROPERTY];
4819
+ const rawValues = rawQualifier && typeof rawQualifier === "object" && !Array.isArray(rawQualifier) ? rawQualifier.values : void 0;
4820
+ if (Array.isArray(rawValues) && rawValues.length === 0) {
4821
+ findings.push({
4822
+ kind: "qualifier_values_empty",
4823
+ severity: "error",
4824
+ edge_id: edge.id,
4825
+ message: `${edgeLabel(edge)} carries an active_when with an empty values list. Read literally that would remove the relationship from every configuration, which is a way to delete an edge by writing a property; it is treated as absent instead. To say a relationship never holds, delete the edge.`
4826
+ });
4827
+ continue;
4828
+ }
4829
+ findings.push({
4830
+ kind: "qualifier_axis_unresolved",
4831
+ severity: "error",
4832
+ edge_id: edge.id,
4833
+ message: `${edgeLabel(edge)} carries a malformed active_when. It must be an object with an axis id and a non-empty values array; anything else reads as absent, which silently makes the relationship invariant.`
4834
+ });
4835
+ continue;
4836
+ }
4837
+ const values = axisValues.get(qualifier.axis);
4838
+ if (!values) {
4839
+ findings.push({
4840
+ kind: "qualifier_axis_unresolved",
4841
+ severity: "error",
4842
+ edge_id: edge.id,
4843
+ axis_id: qualifier.axis,
4844
+ message: `active_when names axis "${qualifier.axis}", which is not a configuration_axis node in this graph.`
4845
+ });
4846
+ continue;
4847
+ }
4848
+ for (const value of qualifier.values) {
4849
+ if (!values.includes(value)) {
4850
+ findings.push({
4851
+ kind: "qualifier_unknown_value",
4852
+ severity: "error",
4853
+ edge_id: edge.id,
4854
+ axis_id: qualifier.axis,
4855
+ message: `active_when names value "${value}", which axis "${qualifier.axis}" does not declare. Its values are [${values.join(", ")}].`
4856
+ });
4857
+ }
4858
+ }
4859
+ }
4860
+ const varianceBySource = /* @__PURE__ */ new Map();
4861
+ for (const edge of edges) {
4862
+ if (edge.type !== VARIES_BY_EDGE) continue;
4863
+ const values = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY) ?? [];
4864
+ const list = varianceBySource.get(edge.source) ?? [];
4865
+ list.push({ axis: edge.target, values });
4866
+ varianceBySource.set(edge.source, list);
4867
+ }
4868
+ for (const edge of edges) {
4869
+ if (edge.type !== ALTERNATES_EDGE) continue;
4870
+ const left = varianceBySource.get(edge.source) ?? [];
4871
+ const right = varianceBySource.get(edge.target) ?? [];
4872
+ if (left.length === 0 || right.length === 0) {
4873
+ findings.push({
4874
+ kind: "alternation_axis_mismatch",
4875
+ severity: "error",
4876
+ edge_id: edge.id,
4877
+ message: `${edgeLabel(edge)} declares an alternation, but at least one endpoint declares no variance at all. Two surfaces that are both always present are not alternatives; they co-exist.`
4878
+ });
4879
+ continue;
4880
+ }
4881
+ const sharedAxes = left.filter((l) => right.some((r) => r.axis === l.axis)).map((l) => l.axis);
4882
+ if (sharedAxes.length === 0) {
4883
+ findings.push({
4884
+ kind: "alternation_axis_mismatch",
4885
+ severity: "error",
4886
+ edge_id: edge.id,
4887
+ message: `${edgeLabel(edge)} declares an alternation between surfaces that vary on different axes. Alternatives are selected by ONE lever; surfaces on different levers can both be present at once.`
4888
+ });
4889
+ continue;
4890
+ }
4891
+ for (const axis of sharedAxes) {
4892
+ const leftValues = left.find((l) => l.axis === axis)?.values ?? [];
4893
+ const rightValues = right.find((r) => r.axis === axis)?.values ?? [];
4894
+ const overlap = leftValues.filter((v) => rightValues.includes(v));
4895
+ if (overlap.length > 0) {
4896
+ findings.push({
4897
+ kind: "alternation_overlap",
4898
+ severity: "error",
4899
+ edge_id: edge.id,
4900
+ axis_id: axis,
4901
+ message: `${edgeLabel(edge)} declares an alternation, but both surfaces are present under [${overlap.join(", ")}] on axis "${axis}". They co-exist there, so the graph is asserting "one of these" and "both, together" about the same pair.`
4902
+ });
4903
+ }
4904
+ }
4905
+ }
4906
+ const parentsByChild = /* @__PURE__ */ new Map();
4907
+ for (const edge of edges) {
4908
+ if (edge.type && PARENT_EDGE_TYPES.has(edge.type)) {
4909
+ parentsByChild.set(edge.target, (parentsByChild.get(edge.target) ?? 0) + 1);
4910
+ }
4911
+ }
4912
+ for (const [axisId, values] of axisValues) {
4913
+ for (const value of values) {
4914
+ const projected = projectGraph(nodes, edges, { [axisId]: value });
4915
+ const survivingParents = /* @__PURE__ */ new Map();
4916
+ for (const edge of projected.edges) {
4917
+ if (edge.type && PARENT_EDGE_TYPES.has(edge.type)) {
4918
+ survivingParents.set(edge.target, (survivingParents.get(edge.target) ?? 0) + 1);
4919
+ }
4920
+ }
4921
+ for (const node of projected.nodes) {
4922
+ if (node.type !== "surface") continue;
4923
+ if ((parentsByChild.get(node.id) ?? 0) === 0) continue;
4924
+ if ((survivingParents.get(node.id) ?? 0) > 0) continue;
4925
+ findings.push({
4926
+ kind: "orphaned_under_projection",
4927
+ severity: "warning",
4928
+ node_id: node.id,
4929
+ axis_id: axisId,
4930
+ message: `Surface is present under ${axisId} = "${value}" but every containment parent it has is absent there. Either it needs a parent in that configuration, or it should declare that it is absent from it too.`
4931
+ });
4932
+ }
4933
+ }
4934
+ }
4935
+ return findings;
4936
+ }
4937
+
4573
4938
  // src/grammar/lifecycles.ts
4574
4939
  var PRODUCT_LIFECYCLE = {
4575
4940
  entity_type: "product",
@@ -6820,6 +7185,10 @@ var UPG_LIFECYCLE_FREE_TYPES = /* @__PURE__ */ new Set([
6820
7185
  "market_segment",
6821
7186
  "classification_axis",
6822
7187
  "classification_value",
7188
+ // ── Product Specification (1): a configuration_axis is a structural
7189
+ // DECLARATION (a lever and its closed value set), not a thing that
7190
+ // progresses through states. Same reading as classification_axis above.
7191
+ "configuration_axis",
6823
7192
  // ── User Research (5 of 8): participants, quotes, observations,
6824
7193
  // clusters, survey responses are immutable data points ────────────────
6825
7194
  "participant",
@@ -11329,6 +11698,12 @@ var UPG_PROPERTY_SCHEMA = {
11329
11698
  regulation: { type: "string", enum: ["gdpr", "ccpa", "hipaa", "soc2", "iso27001", "pci_dss", "other"], description: "Regulation or standard this requirement derives from" },
11330
11699
  compliance_status: { type: "string", enum: ["compliant", "non_compliant", "in_progress", "not_applicable"], description: "Current compliance posture" }
11331
11700
  },
11701
+ // ConfigurationAxisProperties: A named dimension along which the product's composition differs.
11702
+ configuration_axis: {
11703
+ values: { type: "string[]", description: "The closed set of values this axis can take. Required: an axis with no values selects nothing and cannot be projected along. Two values is the common case and three is not unusual (a plan ladder). Order is not significant; the axis is categorical, not ordinal. Where the ordering does matter (an entitlement ladder in which each tier includes the one below), that is a classification question, and `classification_axis` with `axis_kind: 'ordinal'` is the instrument for it." },
11704
+ default_value: { type: "string", description: "The value this axis is understood to sit at when nobody says otherwise. Must be a member of `values`. NOTHING APPLIES IT AUTOMATICALLY. Omitting `configuration` on a read returns the UNION, not this projection, because the union is the honest answer to an unqualified question: it is every configuration at once, and silently substituting one of them would hide the others from a reader who did not know to ask. What the field does carry is the declaration convention for `surface_alternates_with_surface` (declare the edge from the surface present under the default) and a documented anchor for tools that later want to offer a starting configuration. It is a claim about the model, not about deployment: it says which value most of the graph was written against, not which configuration most customers are on." },
11705
+ kind: { type: "string", enum: ["feature_flag", "plan_tier", "permission_level", "beta_program", "other"], description: "What kind of lever this is. Names the mechanism family." }
11706
+ },
11332
11707
  // ConstraintProperties: Constraint: a named limitation or boundary on product creation.
11333
11708
  constraint: {
11334
11709
  constraint_kind: { type: "string", enum: ["resource", "technical", "regulatory", "temporal", "compliance", "other"], description: "Limitation category" },
@@ -20197,6 +20572,44 @@ function validateValue(key, def, val) {
20197
20572
  }
20198
20573
  break;
20199
20574
  }
20575
+ case "string[]": {
20576
+ if (!Array.isArray(val)) {
20577
+ errors.push(`property "${key}" must be an array of strings`);
20578
+ break;
20579
+ }
20580
+ if (val.some((v) => typeof v !== "string")) {
20581
+ errors.push(`property "${key}" must contain only strings`);
20582
+ }
20583
+ if (val.length === 0) {
20584
+ errors.push(`property "${key}" must not be empty`);
20585
+ }
20586
+ break;
20587
+ }
20588
+ case "object": {
20589
+ if (typeof val !== "object" || val === null || Array.isArray(val)) {
20590
+ errors.push(`property "${key}" must be an object`);
20591
+ break;
20592
+ }
20593
+ const obj = val;
20594
+ for (const req2 of def.required ?? []) {
20595
+ if (obj[req2] === void 0 || obj[req2] === null) {
20596
+ errors.push(`property "${key}" is missing required key "${req2}"`);
20597
+ }
20598
+ }
20599
+ for (const [subKey, subDef] of Object.entries(def.properties ?? {})) {
20600
+ const subVal = obj[subKey];
20601
+ if (subVal === void 0 || subVal === null) continue;
20602
+ errors.push(...validateValue(`${key}.${subKey}`, subDef, subVal));
20603
+ }
20604
+ for (const presentKey of Object.keys(obj)) {
20605
+ if (def.properties && !(presentKey in def.properties)) {
20606
+ errors.push(
20607
+ `property "${key}" has unknown key "${presentKey}" (allowed: ${Object.keys(def.properties).join(", ")})`
20608
+ );
20609
+ }
20610
+ }
20611
+ break;
20612
+ }
20200
20613
  }
20201
20614
  return errors;
20202
20615
  }
@@ -20415,6 +20828,7 @@ var ENTITY_EMOJI = {
20415
20828
  pipeline_sales: "\u{1FA88}",
20416
20829
  pipeline_stage: "\u{1F9FD}",
20417
20830
  planning_cycle: "\u{1F5D3}\uFE0F",
20831
+ configuration_axis: "\u{1F39A}\uFE0F",
20418
20832
  playbook: "\u{1F4D2}",
20419
20833
  portfolio: "\u{1F9F8}",
20420
20834
  positioning: "\u{1F3AA}",
@@ -21109,6 +21523,7 @@ var STANDARD_LABELS = {
21109
21523
  roadmap_theme: { alt_labels: ["product theme", "roadmap theme"] },
21110
21524
  // UPG-660: renamed from bare 'theme' (N6 lineage)
21111
21525
  planning_cycle: { alt_labels: ["sprint", "iteration", "cycle", "program increment", "pi", "cadence", "time-box", "cooldown", "quarter"] },
21526
+ configuration_axis: { alt_labels: ["configuration", "config axis", "lever", "variant axis", "plan axis", "flag axis", "entitlement axis"] },
21112
21527
  // Engineering layer
21113
21528
  bounded_context: { alt_labels: ["context", "domain boundary", "module boundary"] },
21114
21529
  service: { alt_labels: ["microservice", "backend service", "api service"] },
@@ -23630,7 +24045,7 @@ var PRODUCT_SPEC_GUIDE = {
23630
24045
  // registered to product_spec but missing from the navigation order).
23631
24046
  // `changelog` lives here because it is a structural product-shipping
23632
24047
  // artefact; content domain references it only via cross-domain bridges.
23633
- creation_sequence: ["feature_area", "feature", "epic", "user_story", "acceptance_criterion", "task", "bug", "release", "roadmap", "roadmap_item", "roadmap_theme", "changelog", "planning_cycle"],
24048
+ creation_sequence: ["feature_area", "feature", "epic", "user_story", "acceptance_criterion", "task", "bug", "release", "roadmap", "roadmap_item", "roadmap_theme", "changelog", "planning_cycle", "configuration_axis"],
23634
24049
  patterns: [
23635
24050
  {
23636
24051
  name: "Feature Decomposition",
@@ -29045,6 +29460,11 @@ var UPG_REGIONS = [
29045
29460
  type: "planning_cycle",
29046
29461
  role: "container",
29047
29462
  notes: "self-nesting cadence axis"
29463
+ },
29464
+ {
29465
+ type: "configuration_axis",
29466
+ role: "leaf",
29467
+ notes: "the configuration lever whose values select which surface tree the product renders"
29048
29468
  }
29049
29469
  ],
29050
29470
  anchor: {
@@ -30961,7 +31381,7 @@ function checkHeaderSealText(text) {
30961
31381
  }
30962
31382
 
30963
31383
  // src/index.ts
30964
- var UPG_VERSION = "0.29.0";
31384
+ var UPG_VERSION = "0.30.0";
30965
31385
  var UPG_FORMAT_VERSION = "0.4.0";
30966
31386
  var MARKDOWN_FORMAT_VERSION = "0.1";
30967
31387
  var UPG_TYPES = getTypes();
@@ -31026,11 +31446,14 @@ var UPG_EDGE_COUNT = UPG_EDGE_TYPES.length;
31026
31446
  var UPG_META_COUNT = UPG_ENTITY_META.length;
31027
31447
  // Annotate the CommonJS export names for ESM import in node:
31028
31448
  0 && (module.exports = {
31449
+ ACTIVE_WHEN_PROPERTY,
31029
31450
  ANALYTICS_DATA_PLAYBOOK,
31030
31451
  BUSINESS_GROWTH_METRIC_DRIVEN_PLAYBOOK,
31031
31452
  BUSINESS_GTM_GROWTH_PLAYBOOK,
31032
31453
  BUSINESS_MARKETING_AUDIENCE_FIRST_PLAYBOOK,
31033
31454
  CLASSIFICATION_EDGE_PROPERTY_SCHEMA,
31455
+ CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA,
31456
+ CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA,
31034
31457
  CONTENT_DEPTH_WARNING_RULES,
31035
31458
  COVERAGE_ANTI_PATTERNS,
31036
31459
  DEFAULT_ENTITY_EMOJI,
@@ -31045,10 +31468,12 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
31045
31468
  MARKDOWN_FORMAT_VERSION,
31046
31469
  MARKET_COMPETITIVE_PLAYBOOK,
31047
31470
  OPERATIONS_QUALITY_PLAYBOOK,
31471
+ PRESENT_UNDER_PROPERTY,
31048
31472
  PRODUCT_DELIVERY_PLAYBOOK,
31049
31473
  PROPERTY_MODIFIERS,
31050
31474
  PROPERTY_MODIFIER_SEMANTICS,
31051
31475
  PROPERTY_SCALE_MAP,
31476
+ QUALIFIABLE_EDGE_TYPES,
31052
31477
  RECORD_ENTITY_TYPES,
31053
31478
  REFLECT_MODES,
31054
31479
  REGISTRY_PRODUCT_ID,
@@ -31131,9 +31556,11 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
31131
31556
  UPG_WILDCARD_ENDPOINT,
31132
31557
  USERS_NEEDS_PLAYBOOK,
31133
31558
  UnknownEntityTypeError,
31559
+ VARIES_BY_EDGE,
31134
31560
  backfillSlug,
31135
31561
  buildTypeAliases,
31136
31562
  canBeChildOf,
31563
+ checkConfigurationDrift,
31137
31564
  checkHeaderSeal,
31138
31565
  checkHeaderSealText,
31139
31566
  checkToEdgeCountSpec,
@@ -31154,6 +31581,7 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
31154
31581
  edgeCarriesProperties,
31155
31582
  edgeCountSpecKey,
31156
31583
  entityFilterKey,
31584
+ enumerateProjections,
31157
31585
  evaluateAntiPatterns,
31158
31586
  findInvalidStatusMigrationTargets,
31159
31587
  formatUpgText,
@@ -31245,6 +31673,9 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
31245
31673
  parseUpg,
31246
31674
  pickCanonicalEdge,
31247
31675
  presenceExceptKey,
31676
+ projectGraph,
31677
+ readActiveWhen,
31678
+ readStringArray,
31248
31679
  resolveAllEdges,
31249
31680
  resolveContainmentEdge,
31250
31681
  resolveEntityType,