@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/CHANGELOG.md +18 -1
- package/README.md +1 -1
- package/dist/index.cjs +436 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +401 -15
- package/dist/index.d.ts +401 -15
- package/dist/index.js +425 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -106,7 +106,8 @@ var UPG_DOMAINS = [
|
|
|
106
106
|
"roadmap_item",
|
|
107
107
|
"roadmap_theme",
|
|
108
108
|
"changelog",
|
|
109
|
-
"planning_cycle"
|
|
109
|
+
"planning_cycle",
|
|
110
|
+
"configuration_axis"
|
|
110
111
|
]
|
|
111
112
|
},
|
|
112
113
|
{
|
|
@@ -646,6 +647,7 @@ var UPG_ENTITY_META = [
|
|
|
646
647
|
// it, carrying its occupants and the rule that arbitrates between them.
|
|
647
648
|
// Enters `proposed` per the maturity rubric (precedent: planning_cycle 0.20.0).
|
|
648
649
|
{ name: "surface", type_id: "ent_359", maturity: "proposed", since: "0.27.0" },
|
|
650
|
+
{ name: "configuration_axis", type_id: "ent_360", maturity: "proposed", since: "0.30.0" },
|
|
649
651
|
{ name: "journey_phase", type_id: "ent_330", maturity: "proposed", since: "0.2.0" },
|
|
650
652
|
{ name: "journey_action", type_id: "ent_331", maturity: "proposed", since: "0.2.0" },
|
|
651
653
|
{ name: "design_decision", type_id: "ent_319", maturity: "deprecated", since: "0.2.0", deprecated_in: "0.2.0", replacement: "decision" },
|
|
@@ -1047,6 +1049,29 @@ var DEFER_EDGE_PROPERTY_SCHEMA = {
|
|
|
1047
1049
|
description: 'Target period the parked work is deferred to. Freeform, mirroring strategic_theme.time_horizon. @example "Q4 2026", "FY27", "next release".'
|
|
1048
1050
|
}
|
|
1049
1051
|
};
|
|
1052
|
+
var CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA = {
|
|
1053
|
+
present_under: {
|
|
1054
|
+
type: "string[]",
|
|
1055
|
+
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"]'
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
var CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA = {
|
|
1059
|
+
active_when: {
|
|
1060
|
+
type: "object",
|
|
1061
|
+
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".',
|
|
1062
|
+
properties: {
|
|
1063
|
+
axis: {
|
|
1064
|
+
type: "string",
|
|
1065
|
+
description: "Node id of the `configuration_axis` this condition reads."
|
|
1066
|
+
},
|
|
1067
|
+
values: {
|
|
1068
|
+
type: "string[]",
|
|
1069
|
+
description: "Values of that axis under which the relationship holds. Every entry must be a member of the axis's closed `values` list."
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
required: ["axis", "values"]
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1050
1075
|
var UPG_EDGE_CATALOG = {
|
|
1051
1076
|
// ── Part 1: Core Narrative Spine (Ring 1) ──────────────────────────────────
|
|
1052
1077
|
// 1.1 User Domain
|
|
@@ -1573,7 +1598,7 @@ var UPG_EDGE_CATALOG = {
|
|
|
1573
1598
|
// portfolio_shared, so the reading it names commonly lives in the
|
|
1574
1599
|
// registry rather than in the product graph.
|
|
1575
1600
|
// supersedes → semantic (prompt_version_supersedes_prompt_version).
|
|
1576
|
-
surface_contains_surface: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "surface", target_type: "surface" },
|
|
1601
|
+
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 },
|
|
1577
1602
|
surface_serves_job: { forward_verb: "serves", reverse_verb: "served_by", classification: "cross-domain", source_type: "surface", target_type: "job" },
|
|
1578
1603
|
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 },
|
|
1579
1604
|
surface_renders_design_component: { forward_verb: "renders", reverse_verb: "rendered_on", classification: "hierarchy", source_type: "surface", target_type: "design_component", cross_product_eligible: true },
|
|
@@ -1608,11 +1633,53 @@ var UPG_EDGE_CATALOG = {
|
|
|
1608
1633
|
// not cross_product_eligible → a deviation is a fact about one product's
|
|
1609
1634
|
// code, and `surface` is not portfolio_shared in the first place.
|
|
1610
1635
|
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" },
|
|
1636
|
+
// ── surface, 0.30.0: composition that varies by configuration ───────────────
|
|
1637
|
+
// A product's surface tree is not one tree; it is a family of trees selected
|
|
1638
|
+
// by a configuration lever. The stored graph is the UNION of that family and
|
|
1639
|
+
// a single configuration is a PROJECTION of it, so these two edges are how a
|
|
1640
|
+
// graph says which member a fact belongs to. Silence still means "every
|
|
1641
|
+
// member", which is why a graph written before 0.30.0 needs no migration.
|
|
1642
|
+
//
|
|
1643
|
+
// varies_by — CONDITIONAL EXISTENCE. The surface exists only under the
|
|
1644
|
+
// values in `present_under`. An edge qualifier cannot say
|
|
1645
|
+
// this: the reported case was a set of named chips REPLACED
|
|
1646
|
+
// by one generic badge, where which surface EXISTS changes,
|
|
1647
|
+
// and the two carry different capacities and occupants.
|
|
1648
|
+
// alternates — "one of these, depending" as distinct from "both,
|
|
1649
|
+
// together". Semantic, matching its sibling
|
|
1650
|
+
// `surface_supersedes_surface`: neither surface owns the
|
|
1651
|
+
// other, and a hierarchy classification would demand a
|
|
1652
|
+
// UPG_VALID_CHILDREN pair that would be false. Explicit and
|
|
1653
|
+
// validator-checked (same axis, disjoint present_under);
|
|
1654
|
+
// never auto-derived, because disjointness is necessary for
|
|
1655
|
+
// alternation and nowhere near sufficient.
|
|
1656
|
+
//
|
|
1657
|
+
// Direction on `alternates_with` is a CONVENTION, not an enforcement: declare
|
|
1658
|
+
// it once, sourced from the surface present under the axis's `default_value`.
|
|
1659
|
+
// An axis with no default is legal modelling, so a check keyed on the
|
|
1660
|
+
// convention would fire on a correct graph, which is the one thing a check
|
|
1661
|
+
// must never do.
|
|
1662
|
+
// ── configuration_axis, 0.30.0: the lever itself ───────────────────────────
|
|
1663
|
+
// Three layers, three owners, and keeping them apart is what makes the model
|
|
1664
|
+
// honest: `engineering` owns the MECHANISM (`feature_flag`, with its key and
|
|
1665
|
+
// rollout percentage), `product_spec` owns the LEVER (the named dimension
|
|
1666
|
+
// along which what-you-ship differs), `ux_design` owns the PLACE (`surface`).
|
|
1667
|
+
// Two code flags that move together are ONE lever with two values.
|
|
1668
|
+
//
|
|
1669
|
+
// `defines` is hierarchy so the axis has a home in `get_tree`. It earns the
|
|
1670
|
+
// UPG_VALID_CHILDREN.product pair (guardrail G2b) on an honest verb: an axis
|
|
1671
|
+
// exists only within the product whose composition it varies. The rejected
|
|
1672
|
+
// alternative mirrors `classification_axis_owned_by_product`, a cross-domain
|
|
1673
|
+
// ownership edge that leaves the node unparented and invisible in every tree.
|
|
1674
|
+
product_defines_configuration_axis: { forward_verb: "defines", reverse_verb: "defined_by", classification: "hierarchy", source_type: "product", target_type: "configuration_axis" },
|
|
1675
|
+
feature_flag_drives_configuration_axis: { forward_verb: "drives", reverse_verb: "driven_by", classification: "cross-domain", source_type: "feature_flag", target_type: "configuration_axis" },
|
|
1676
|
+
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 },
|
|
1677
|
+
surface_alternates_with_surface: { forward_verb: "alternates_with", reverse_verb: "alternates_with", classification: "semantic", source_type: "surface", target_type: "surface" },
|
|
1611
1678
|
// Inbound. `feature_occupies_surface` is the guest list: the edge that makes
|
|
1612
1679
|
// "what else lives here?" answerable, and the one the contention anti-pattern
|
|
1613
1680
|
// counts. cross-domain (product_spec → ux_design), like screen_surfaces_feature
|
|
1614
1681
|
// running the other way.
|
|
1615
|
-
feature_occupies_surface: { forward_verb: "occupies", reverse_verb: "occupied_by", classification: "cross-domain", source_type: "feature", target_type: "surface" },
|
|
1682
|
+
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 },
|
|
1616
1683
|
screen_renders_surface: { forward_verb: "renders", reverse_verb: "rendered_by", classification: "hierarchy", source_type: "screen", target_type: "surface" },
|
|
1617
1684
|
decision_affects_surface: { forward_verb: "affects", reverse_verb: "affected_by", classification: "cross-domain", source_type: "decision", target_type: "surface" },
|
|
1618
1685
|
// The finer-grained sibling of journey_step_shown_on_screen: a step happens on
|
|
@@ -3678,6 +3745,10 @@ var UPG_VALID_CHILDREN = {
|
|
|
3678
3745
|
"roadmap",
|
|
3679
3746
|
"roadmap_theme",
|
|
3680
3747
|
"planning_cycle",
|
|
3748
|
+
// Configuration (0.30.0): the levers whose values select which surface tree
|
|
3749
|
+
// the product renders. Few per product, and top-level because an axis is a
|
|
3750
|
+
// product-wide fact rather than a child of any one feature or surface.
|
|
3751
|
+
"configuration_axis",
|
|
3681
3752
|
// Engineering
|
|
3682
3753
|
"bounded_context",
|
|
3683
3754
|
"code_repository",
|
|
@@ -4301,6 +4372,289 @@ function isContainmentFreeType(entityType) {
|
|
|
4301
4372
|
return UPG_CONTAINMENT_FREE_TYPES.has(entityType);
|
|
4302
4373
|
}
|
|
4303
4374
|
|
|
4375
|
+
// src/grammar/projection.ts
|
|
4376
|
+
var VARIES_BY_EDGE = "surface_varies_by_configuration_axis";
|
|
4377
|
+
var PRESENT_UNDER_PROPERTY = "present_under";
|
|
4378
|
+
var ACTIVE_WHEN_PROPERTY = "active_when";
|
|
4379
|
+
var QUALIFIABLE_EDGE_TYPES = [
|
|
4380
|
+
"surface_contains_surface",
|
|
4381
|
+
"feature_occupies_surface"
|
|
4382
|
+
];
|
|
4383
|
+
function readStringArray(properties, key) {
|
|
4384
|
+
const raw = properties?.[key];
|
|
4385
|
+
if (!Array.isArray(raw)) return void 0;
|
|
4386
|
+
return raw.filter((v) => typeof v === "string");
|
|
4387
|
+
}
|
|
4388
|
+
function readActiveWhen(edge) {
|
|
4389
|
+
const raw = edge.properties?.[ACTIVE_WHEN_PROPERTY];
|
|
4390
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
4391
|
+
const axis = raw.axis;
|
|
4392
|
+
const values = readStringArray(raw, "values");
|
|
4393
|
+
if (typeof axis !== "string" || axis.length === 0 || !values || values.length === 0) {
|
|
4394
|
+
return void 0;
|
|
4395
|
+
}
|
|
4396
|
+
return { axis, values };
|
|
4397
|
+
}
|
|
4398
|
+
function projectGraph(nodes, edges, configuration) {
|
|
4399
|
+
const axes = Object.keys(configuration);
|
|
4400
|
+
if (axes.length === 0) {
|
|
4401
|
+
return {
|
|
4402
|
+
nodes: [...nodes],
|
|
4403
|
+
edges: [...edges],
|
|
4404
|
+
excluded_node_ids: [],
|
|
4405
|
+
deactivated_edge_count: 0,
|
|
4406
|
+
dangling_edge_count: 0
|
|
4407
|
+
};
|
|
4408
|
+
}
|
|
4409
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
4410
|
+
for (const edge of edges) {
|
|
4411
|
+
if (edge.type !== VARIES_BY_EDGE) continue;
|
|
4412
|
+
const chosen = configuration[edge.target];
|
|
4413
|
+
if (chosen === void 0) continue;
|
|
4414
|
+
const presentUnder = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY);
|
|
4415
|
+
if (!presentUnder) continue;
|
|
4416
|
+
if (!presentUnder.includes(chosen)) excluded.add(edge.source);
|
|
4417
|
+
}
|
|
4418
|
+
const survivingNodes = excluded.size === 0 ? [...nodes] : nodes.filter((n) => !excluded.has(n.id));
|
|
4419
|
+
let deactivated = 0;
|
|
4420
|
+
let dangling = 0;
|
|
4421
|
+
const survivingEdges = [];
|
|
4422
|
+
for (const edge of edges) {
|
|
4423
|
+
const qualifier = readActiveWhen(edge);
|
|
4424
|
+
if (qualifier) {
|
|
4425
|
+
const chosen = configuration[qualifier.axis];
|
|
4426
|
+
if (chosen !== void 0 && !qualifier.values.includes(chosen)) {
|
|
4427
|
+
deactivated++;
|
|
4428
|
+
continue;
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
if (excluded.has(edge.source) || excluded.has(edge.target)) {
|
|
4432
|
+
dangling++;
|
|
4433
|
+
continue;
|
|
4434
|
+
}
|
|
4435
|
+
survivingEdges.push(edge);
|
|
4436
|
+
}
|
|
4437
|
+
return {
|
|
4438
|
+
nodes: survivingNodes,
|
|
4439
|
+
edges: survivingEdges,
|
|
4440
|
+
excluded_node_ids: [...excluded].sort(),
|
|
4441
|
+
deactivated_edge_count: deactivated,
|
|
4442
|
+
dangling_edge_count: dangling
|
|
4443
|
+
};
|
|
4444
|
+
}
|
|
4445
|
+
function enumerateProjections(nodes) {
|
|
4446
|
+
const out = [
|
|
4447
|
+
{ configuration: {} }
|
|
4448
|
+
];
|
|
4449
|
+
for (const node of nodes) {
|
|
4450
|
+
if (node.type !== "configuration_axis") continue;
|
|
4451
|
+
const values = readStringArray(node.properties, "values");
|
|
4452
|
+
if (!values) continue;
|
|
4453
|
+
for (const value of values) {
|
|
4454
|
+
out.push({ axis: node.id, value, configuration: { [node.id]: value } });
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
return out;
|
|
4458
|
+
}
|
|
4459
|
+
|
|
4460
|
+
// src/grammar/configuration-drift.ts
|
|
4461
|
+
var ALTERNATES_EDGE = "surface_alternates_with_surface";
|
|
4462
|
+
var PARENT_EDGE_TYPES = /* @__PURE__ */ new Set(["surface_contains_surface", "screen_renders_surface"]);
|
|
4463
|
+
function edgeLabel(edge) {
|
|
4464
|
+
return edge.id ?? `${edge.source} -> ${edge.target}`;
|
|
4465
|
+
}
|
|
4466
|
+
function checkConfigurationDrift(nodes, edges) {
|
|
4467
|
+
const findings = [];
|
|
4468
|
+
const axisValues = /* @__PURE__ */ new Map();
|
|
4469
|
+
for (const node of nodes) {
|
|
4470
|
+
if (node.type !== "configuration_axis") continue;
|
|
4471
|
+
const values = readStringArray(node.properties, "values");
|
|
4472
|
+
if (!values || values.length === 0) {
|
|
4473
|
+
findings.push({
|
|
4474
|
+
kind: "axis_values_empty",
|
|
4475
|
+
severity: "error",
|
|
4476
|
+
node_id: node.id,
|
|
4477
|
+
axis_id: node.id,
|
|
4478
|
+
message: "Configuration axis declares no values. An axis with no values selects nothing, so no projection can be taken along it."
|
|
4479
|
+
});
|
|
4480
|
+
axisValues.set(node.id, []);
|
|
4481
|
+
continue;
|
|
4482
|
+
}
|
|
4483
|
+
axisValues.set(node.id, values);
|
|
4484
|
+
const rawDefault = node.properties?.default_value;
|
|
4485
|
+
if (typeof rawDefault === "string" && rawDefault.length > 0 && !values.includes(rawDefault)) {
|
|
4486
|
+
findings.push({
|
|
4487
|
+
kind: "axis_default_not_a_value",
|
|
4488
|
+
severity: "error",
|
|
4489
|
+
node_id: node.id,
|
|
4490
|
+
axis_id: node.id,
|
|
4491
|
+
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.`
|
|
4492
|
+
});
|
|
4493
|
+
}
|
|
4494
|
+
}
|
|
4495
|
+
for (const edge of edges) {
|
|
4496
|
+
if (edge.type !== VARIES_BY_EDGE) continue;
|
|
4497
|
+
const values = axisValues.get(edge.target);
|
|
4498
|
+
const presentUnder = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY);
|
|
4499
|
+
if (!presentUnder || presentUnder.length === 0) {
|
|
4500
|
+
findings.push({
|
|
4501
|
+
kind: "present_under_empty",
|
|
4502
|
+
severity: "error",
|
|
4503
|
+
edge_id: edge.id,
|
|
4504
|
+
node_id: edge.source,
|
|
4505
|
+
axis_id: edge.target,
|
|
4506
|
+
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.`
|
|
4507
|
+
});
|
|
4508
|
+
continue;
|
|
4509
|
+
}
|
|
4510
|
+
if (!values) continue;
|
|
4511
|
+
for (const value of presentUnder) {
|
|
4512
|
+
if (!values.includes(value)) {
|
|
4513
|
+
findings.push({
|
|
4514
|
+
kind: "present_under_unknown_value",
|
|
4515
|
+
severity: "error",
|
|
4516
|
+
edge_id: edge.id,
|
|
4517
|
+
node_id: edge.source,
|
|
4518
|
+
axis_id: edge.target,
|
|
4519
|
+
message: `present_under names "${value}", which the axis does not declare. Its values are [${values.join(", ")}].`
|
|
4520
|
+
});
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
for (const edge of edges) {
|
|
4525
|
+
const hasQualifierKey = edge.properties !== void 0 && Object.prototype.hasOwnProperty.call(edge.properties, ACTIVE_WHEN_PROPERTY);
|
|
4526
|
+
if (!hasQualifierKey) continue;
|
|
4527
|
+
if (edge.type !== void 0 && !QUALIFIABLE_EDGE_TYPES.includes(edge.type)) {
|
|
4528
|
+
findings.push({
|
|
4529
|
+
kind: "qualifier_on_illegal_edge",
|
|
4530
|
+
severity: "error",
|
|
4531
|
+
edge_id: edge.id,
|
|
4532
|
+
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.`
|
|
4533
|
+
});
|
|
4534
|
+
continue;
|
|
4535
|
+
}
|
|
4536
|
+
const qualifier = readActiveWhen(edge);
|
|
4537
|
+
if (!qualifier) {
|
|
4538
|
+
const rawQualifier = edge.properties?.[ACTIVE_WHEN_PROPERTY];
|
|
4539
|
+
const rawValues = rawQualifier && typeof rawQualifier === "object" && !Array.isArray(rawQualifier) ? rawQualifier.values : void 0;
|
|
4540
|
+
if (Array.isArray(rawValues) && rawValues.length === 0) {
|
|
4541
|
+
findings.push({
|
|
4542
|
+
kind: "qualifier_values_empty",
|
|
4543
|
+
severity: "error",
|
|
4544
|
+
edge_id: edge.id,
|
|
4545
|
+
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.`
|
|
4546
|
+
});
|
|
4547
|
+
continue;
|
|
4548
|
+
}
|
|
4549
|
+
findings.push({
|
|
4550
|
+
kind: "qualifier_axis_unresolved",
|
|
4551
|
+
severity: "error",
|
|
4552
|
+
edge_id: edge.id,
|
|
4553
|
+
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.`
|
|
4554
|
+
});
|
|
4555
|
+
continue;
|
|
4556
|
+
}
|
|
4557
|
+
const values = axisValues.get(qualifier.axis);
|
|
4558
|
+
if (!values) {
|
|
4559
|
+
findings.push({
|
|
4560
|
+
kind: "qualifier_axis_unresolved",
|
|
4561
|
+
severity: "error",
|
|
4562
|
+
edge_id: edge.id,
|
|
4563
|
+
axis_id: qualifier.axis,
|
|
4564
|
+
message: `active_when names axis "${qualifier.axis}", which is not a configuration_axis node in this graph.`
|
|
4565
|
+
});
|
|
4566
|
+
continue;
|
|
4567
|
+
}
|
|
4568
|
+
for (const value of qualifier.values) {
|
|
4569
|
+
if (!values.includes(value)) {
|
|
4570
|
+
findings.push({
|
|
4571
|
+
kind: "qualifier_unknown_value",
|
|
4572
|
+
severity: "error",
|
|
4573
|
+
edge_id: edge.id,
|
|
4574
|
+
axis_id: qualifier.axis,
|
|
4575
|
+
message: `active_when names value "${value}", which axis "${qualifier.axis}" does not declare. Its values are [${values.join(", ")}].`
|
|
4576
|
+
});
|
|
4577
|
+
}
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
const varianceBySource = /* @__PURE__ */ new Map();
|
|
4581
|
+
for (const edge of edges) {
|
|
4582
|
+
if (edge.type !== VARIES_BY_EDGE) continue;
|
|
4583
|
+
const values = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY) ?? [];
|
|
4584
|
+
const list = varianceBySource.get(edge.source) ?? [];
|
|
4585
|
+
list.push({ axis: edge.target, values });
|
|
4586
|
+
varianceBySource.set(edge.source, list);
|
|
4587
|
+
}
|
|
4588
|
+
for (const edge of edges) {
|
|
4589
|
+
if (edge.type !== ALTERNATES_EDGE) continue;
|
|
4590
|
+
const left = varianceBySource.get(edge.source) ?? [];
|
|
4591
|
+
const right = varianceBySource.get(edge.target) ?? [];
|
|
4592
|
+
if (left.length === 0 || right.length === 0) {
|
|
4593
|
+
findings.push({
|
|
4594
|
+
kind: "alternation_axis_mismatch",
|
|
4595
|
+
severity: "error",
|
|
4596
|
+
edge_id: edge.id,
|
|
4597
|
+
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.`
|
|
4598
|
+
});
|
|
4599
|
+
continue;
|
|
4600
|
+
}
|
|
4601
|
+
const sharedAxes = left.filter((l) => right.some((r) => r.axis === l.axis)).map((l) => l.axis);
|
|
4602
|
+
if (sharedAxes.length === 0) {
|
|
4603
|
+
findings.push({
|
|
4604
|
+
kind: "alternation_axis_mismatch",
|
|
4605
|
+
severity: "error",
|
|
4606
|
+
edge_id: edge.id,
|
|
4607
|
+
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.`
|
|
4608
|
+
});
|
|
4609
|
+
continue;
|
|
4610
|
+
}
|
|
4611
|
+
for (const axis of sharedAxes) {
|
|
4612
|
+
const leftValues = left.find((l) => l.axis === axis)?.values ?? [];
|
|
4613
|
+
const rightValues = right.find((r) => r.axis === axis)?.values ?? [];
|
|
4614
|
+
const overlap = leftValues.filter((v) => rightValues.includes(v));
|
|
4615
|
+
if (overlap.length > 0) {
|
|
4616
|
+
findings.push({
|
|
4617
|
+
kind: "alternation_overlap",
|
|
4618
|
+
severity: "error",
|
|
4619
|
+
edge_id: edge.id,
|
|
4620
|
+
axis_id: axis,
|
|
4621
|
+
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.`
|
|
4622
|
+
});
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
}
|
|
4626
|
+
const parentsByChild = /* @__PURE__ */ new Map();
|
|
4627
|
+
for (const edge of edges) {
|
|
4628
|
+
if (edge.type && PARENT_EDGE_TYPES.has(edge.type)) {
|
|
4629
|
+
parentsByChild.set(edge.target, (parentsByChild.get(edge.target) ?? 0) + 1);
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
for (const [axisId, values] of axisValues) {
|
|
4633
|
+
for (const value of values) {
|
|
4634
|
+
const projected = projectGraph(nodes, edges, { [axisId]: value });
|
|
4635
|
+
const survivingParents = /* @__PURE__ */ new Map();
|
|
4636
|
+
for (const edge of projected.edges) {
|
|
4637
|
+
if (edge.type && PARENT_EDGE_TYPES.has(edge.type)) {
|
|
4638
|
+
survivingParents.set(edge.target, (survivingParents.get(edge.target) ?? 0) + 1);
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
for (const node of projected.nodes) {
|
|
4642
|
+
if (node.type !== "surface") continue;
|
|
4643
|
+
if ((parentsByChild.get(node.id) ?? 0) === 0) continue;
|
|
4644
|
+
if ((survivingParents.get(node.id) ?? 0) > 0) continue;
|
|
4645
|
+
findings.push({
|
|
4646
|
+
kind: "orphaned_under_projection",
|
|
4647
|
+
severity: "warning",
|
|
4648
|
+
node_id: node.id,
|
|
4649
|
+
axis_id: axisId,
|
|
4650
|
+
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.`
|
|
4651
|
+
});
|
|
4652
|
+
}
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
return findings;
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4304
4658
|
// src/grammar/lifecycles.ts
|
|
4305
4659
|
var PRODUCT_LIFECYCLE = {
|
|
4306
4660
|
entity_type: "product",
|
|
@@ -6551,6 +6905,10 @@ var UPG_LIFECYCLE_FREE_TYPES = /* @__PURE__ */ new Set([
|
|
|
6551
6905
|
"market_segment",
|
|
6552
6906
|
"classification_axis",
|
|
6553
6907
|
"classification_value",
|
|
6908
|
+
// ── Product Specification (1): a configuration_axis is a structural
|
|
6909
|
+
// DECLARATION (a lever and its closed value set), not a thing that
|
|
6910
|
+
// progresses through states. Same reading as classification_axis above.
|
|
6911
|
+
"configuration_axis",
|
|
6554
6912
|
// ── User Research (5 of 8): participants, quotes, observations,
|
|
6555
6913
|
// clusters, survey responses are immutable data points ────────────────
|
|
6556
6914
|
"participant",
|
|
@@ -11060,6 +11418,12 @@ var UPG_PROPERTY_SCHEMA = {
|
|
|
11060
11418
|
regulation: { type: "string", enum: ["gdpr", "ccpa", "hipaa", "soc2", "iso27001", "pci_dss", "other"], description: "Regulation or standard this requirement derives from" },
|
|
11061
11419
|
compliance_status: { type: "string", enum: ["compliant", "non_compliant", "in_progress", "not_applicable"], description: "Current compliance posture" }
|
|
11062
11420
|
},
|
|
11421
|
+
// ConfigurationAxisProperties: A named dimension along which the product's composition differs.
|
|
11422
|
+
configuration_axis: {
|
|
11423
|
+
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." },
|
|
11424
|
+
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." },
|
|
11425
|
+
kind: { type: "string", enum: ["feature_flag", "plan_tier", "permission_level", "beta_program", "other"], description: "What kind of lever this is. Names the mechanism family." }
|
|
11426
|
+
},
|
|
11063
11427
|
// ConstraintProperties: Constraint: a named limitation or boundary on product creation.
|
|
11064
11428
|
constraint: {
|
|
11065
11429
|
constraint_kind: { type: "string", enum: ["resource", "technical", "regulatory", "temporal", "compliance", "other"], description: "Limitation category" },
|
|
@@ -19928,6 +20292,44 @@ function validateValue(key, def, val) {
|
|
|
19928
20292
|
}
|
|
19929
20293
|
break;
|
|
19930
20294
|
}
|
|
20295
|
+
case "string[]": {
|
|
20296
|
+
if (!Array.isArray(val)) {
|
|
20297
|
+
errors.push(`property "${key}" must be an array of strings`);
|
|
20298
|
+
break;
|
|
20299
|
+
}
|
|
20300
|
+
if (val.some((v) => typeof v !== "string")) {
|
|
20301
|
+
errors.push(`property "${key}" must contain only strings`);
|
|
20302
|
+
}
|
|
20303
|
+
if (val.length === 0) {
|
|
20304
|
+
errors.push(`property "${key}" must not be empty`);
|
|
20305
|
+
}
|
|
20306
|
+
break;
|
|
20307
|
+
}
|
|
20308
|
+
case "object": {
|
|
20309
|
+
if (typeof val !== "object" || val === null || Array.isArray(val)) {
|
|
20310
|
+
errors.push(`property "${key}" must be an object`);
|
|
20311
|
+
break;
|
|
20312
|
+
}
|
|
20313
|
+
const obj = val;
|
|
20314
|
+
for (const req2 of def.required ?? []) {
|
|
20315
|
+
if (obj[req2] === void 0 || obj[req2] === null) {
|
|
20316
|
+
errors.push(`property "${key}" is missing required key "${req2}"`);
|
|
20317
|
+
}
|
|
20318
|
+
}
|
|
20319
|
+
for (const [subKey, subDef] of Object.entries(def.properties ?? {})) {
|
|
20320
|
+
const subVal = obj[subKey];
|
|
20321
|
+
if (subVal === void 0 || subVal === null) continue;
|
|
20322
|
+
errors.push(...validateValue(`${key}.${subKey}`, subDef, subVal));
|
|
20323
|
+
}
|
|
20324
|
+
for (const presentKey of Object.keys(obj)) {
|
|
20325
|
+
if (def.properties && !(presentKey in def.properties)) {
|
|
20326
|
+
errors.push(
|
|
20327
|
+
`property "${key}" has unknown key "${presentKey}" (allowed: ${Object.keys(def.properties).join(", ")})`
|
|
20328
|
+
);
|
|
20329
|
+
}
|
|
20330
|
+
}
|
|
20331
|
+
break;
|
|
20332
|
+
}
|
|
19931
20333
|
}
|
|
19932
20334
|
return errors;
|
|
19933
20335
|
}
|
|
@@ -20146,6 +20548,7 @@ var ENTITY_EMOJI = {
|
|
|
20146
20548
|
pipeline_sales: "\u{1FA88}",
|
|
20147
20549
|
pipeline_stage: "\u{1F9FD}",
|
|
20148
20550
|
planning_cycle: "\u{1F5D3}\uFE0F",
|
|
20551
|
+
configuration_axis: "\u{1F39A}\uFE0F",
|
|
20149
20552
|
playbook: "\u{1F4D2}",
|
|
20150
20553
|
portfolio: "\u{1F9F8}",
|
|
20151
20554
|
positioning: "\u{1F3AA}",
|
|
@@ -20840,6 +21243,7 @@ var STANDARD_LABELS = {
|
|
|
20840
21243
|
roadmap_theme: { alt_labels: ["product theme", "roadmap theme"] },
|
|
20841
21244
|
// UPG-660: renamed from bare 'theme' (N6 lineage)
|
|
20842
21245
|
planning_cycle: { alt_labels: ["sprint", "iteration", "cycle", "program increment", "pi", "cadence", "time-box", "cooldown", "quarter"] },
|
|
21246
|
+
configuration_axis: { alt_labels: ["configuration", "config axis", "lever", "variant axis", "plan axis", "flag axis", "entitlement axis"] },
|
|
20843
21247
|
// Engineering layer
|
|
20844
21248
|
bounded_context: { alt_labels: ["context", "domain boundary", "module boundary"] },
|
|
20845
21249
|
service: { alt_labels: ["microservice", "backend service", "api service"] },
|
|
@@ -23361,7 +23765,7 @@ var PRODUCT_SPEC_GUIDE = {
|
|
|
23361
23765
|
// registered to product_spec but missing from the navigation order).
|
|
23362
23766
|
// `changelog` lives here because it is a structural product-shipping
|
|
23363
23767
|
// artefact; content domain references it only via cross-domain bridges.
|
|
23364
|
-
creation_sequence: ["feature_area", "feature", "epic", "user_story", "acceptance_criterion", "task", "bug", "release", "roadmap", "roadmap_item", "roadmap_theme", "changelog", "planning_cycle"],
|
|
23768
|
+
creation_sequence: ["feature_area", "feature", "epic", "user_story", "acceptance_criterion", "task", "bug", "release", "roadmap", "roadmap_item", "roadmap_theme", "changelog", "planning_cycle", "configuration_axis"],
|
|
23365
23769
|
patterns: [
|
|
23366
23770
|
{
|
|
23367
23771
|
name: "Feature Decomposition",
|
|
@@ -28776,6 +29180,11 @@ var UPG_REGIONS = [
|
|
|
28776
29180
|
type: "planning_cycle",
|
|
28777
29181
|
role: "container",
|
|
28778
29182
|
notes: "self-nesting cadence axis"
|
|
29183
|
+
},
|
|
29184
|
+
{
|
|
29185
|
+
type: "configuration_axis",
|
|
29186
|
+
role: "leaf",
|
|
29187
|
+
notes: "the configuration lever whose values select which surface tree the product renders"
|
|
28779
29188
|
}
|
|
28780
29189
|
],
|
|
28781
29190
|
anchor: {
|
|
@@ -30692,7 +31101,7 @@ function checkHeaderSealText(text) {
|
|
|
30692
31101
|
}
|
|
30693
31102
|
|
|
30694
31103
|
// src/index.ts
|
|
30695
|
-
var UPG_VERSION = "0.
|
|
31104
|
+
var UPG_VERSION = "0.30.0";
|
|
30696
31105
|
var UPG_FORMAT_VERSION = "0.4.0";
|
|
30697
31106
|
var MARKDOWN_FORMAT_VERSION = "0.1";
|
|
30698
31107
|
var UPG_TYPES = getTypes();
|
|
@@ -30756,11 +31165,14 @@ var UPG_DOMAIN_COUNT = UPG_DOMAINS.length;
|
|
|
30756
31165
|
var UPG_EDGE_COUNT = UPG_EDGE_TYPES.length;
|
|
30757
31166
|
var UPG_META_COUNT = UPG_ENTITY_META.length;
|
|
30758
31167
|
export {
|
|
31168
|
+
ACTIVE_WHEN_PROPERTY,
|
|
30759
31169
|
ANALYTICS_DATA_PLAYBOOK,
|
|
30760
31170
|
BUSINESS_GROWTH_METRIC_DRIVEN_PLAYBOOK,
|
|
30761
31171
|
BUSINESS_GTM_GROWTH_PLAYBOOK,
|
|
30762
31172
|
BUSINESS_MARKETING_AUDIENCE_FIRST_PLAYBOOK,
|
|
30763
31173
|
CLASSIFICATION_EDGE_PROPERTY_SCHEMA,
|
|
31174
|
+
CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA,
|
|
31175
|
+
CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA,
|
|
30764
31176
|
CONTENT_DEPTH_WARNING_RULES,
|
|
30765
31177
|
COVERAGE_ANTI_PATTERNS,
|
|
30766
31178
|
DEFAULT_ENTITY_EMOJI,
|
|
@@ -30775,10 +31187,12 @@ export {
|
|
|
30775
31187
|
MARKDOWN_FORMAT_VERSION,
|
|
30776
31188
|
MARKET_COMPETITIVE_PLAYBOOK,
|
|
30777
31189
|
OPERATIONS_QUALITY_PLAYBOOK,
|
|
31190
|
+
PRESENT_UNDER_PROPERTY,
|
|
30778
31191
|
PRODUCT_DELIVERY_PLAYBOOK,
|
|
30779
31192
|
PROPERTY_MODIFIERS,
|
|
30780
31193
|
PROPERTY_MODIFIER_SEMANTICS,
|
|
30781
31194
|
PROPERTY_SCALE_MAP,
|
|
31195
|
+
QUALIFIABLE_EDGE_TYPES,
|
|
30782
31196
|
RECORD_ENTITY_TYPES,
|
|
30783
31197
|
REFLECT_MODES,
|
|
30784
31198
|
REGISTRY_PRODUCT_ID,
|
|
@@ -30861,9 +31275,11 @@ export {
|
|
|
30861
31275
|
UPG_WILDCARD_ENDPOINT,
|
|
30862
31276
|
USERS_NEEDS_PLAYBOOK,
|
|
30863
31277
|
UnknownEntityTypeError,
|
|
31278
|
+
VARIES_BY_EDGE,
|
|
30864
31279
|
backfillSlug,
|
|
30865
31280
|
buildTypeAliases,
|
|
30866
31281
|
canBeChildOf,
|
|
31282
|
+
checkConfigurationDrift,
|
|
30867
31283
|
checkHeaderSeal,
|
|
30868
31284
|
checkHeaderSealText,
|
|
30869
31285
|
checkToEdgeCountSpec,
|
|
@@ -30884,6 +31300,7 @@ export {
|
|
|
30884
31300
|
edgeCarriesProperties,
|
|
30885
31301
|
edgeCountSpecKey,
|
|
30886
31302
|
entityFilterKey,
|
|
31303
|
+
enumerateProjections,
|
|
30887
31304
|
evaluateAntiPatterns,
|
|
30888
31305
|
findInvalidStatusMigrationTargets,
|
|
30889
31306
|
formatUpgText,
|
|
@@ -30975,6 +31392,9 @@ export {
|
|
|
30975
31392
|
parseUpg,
|
|
30976
31393
|
pickCanonicalEdge,
|
|
30977
31394
|
presenceExceptKey,
|
|
31395
|
+
projectGraph,
|
|
31396
|
+
readActiveWhen,
|
|
31397
|
+
readStringArray,
|
|
30978
31398
|
resolveAllEdges,
|
|
30979
31399
|
resolveContainmentEdge,
|
|
30980
31400
|
resolveEntityType,
|