@wairon/cli 5.1.1-dev.90 → 5.1.1-dev.92

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
@@ -3725,6 +3725,7 @@ __export(src_exports, {
3725
3725
  registerExporter: () => registerExporter,
3726
3726
  registerPackRef: () => registerPackRef,
3727
3727
  reinjectLocalGuides: () => reinjectLocalGuides,
3728
+ relationHealth: () => relationHealth,
3728
3729
  removeDomain: () => removeDomain,
3729
3730
  removeExternal: () => removeExternal,
3730
3731
  removeMember: () => removeMember,
@@ -7727,6 +7728,12 @@ var ParentExcerptSchema = import_zod8.z.object({
7727
7728
  /** sha256:… over the canonical excerpt without its commit. */
7728
7729
  digest: import_zod8.z.string()
7729
7730
  });
7731
+ function relationHealth(status) {
7732
+ if (status.uses.some((u) => u.state === "changed" || u.state === "removed")) return "incompatible";
7733
+ if (!status.reachable || status.outOfReach || status.uses.some((u) => u.state === "unavailable" || u.state === "unlocked")) return "unavailable";
7734
+ if (status.drifted) return "drifted";
7735
+ return "ok";
7736
+ }
7730
7737
  var NamedOpenApiSpecSchema = import_zod8.z.object({
7731
7738
  portalId: import_zod8.z.string(),
7732
7739
  name: import_zod8.z.string(),
@@ -9048,12 +9055,16 @@ function memberProjectNodes() {
9048
9055
  trustedLinks: [],
9049
9056
  project: true
9050
9057
  }));
9051
- const referenced = (family.nodes.find((n) => n.namespace === "")?.externals ?? []).filter((e) => e.role === "member").map((e) => ({ id: e.alias, name: e.alias, description: "", trustedLinks: [], project: true, storage: storageBadge(e.sourceKind === "family" ? "contained" : e.sourceKind, e.directory, e.commit) }));
9052
- return [...contained, ...referenced];
9058
+ const rootExternals = family.nodes.find((n) => n.namespace === "")?.externals ?? [];
9059
+ const referenced = rootExternals.filter((e) => e.role === "member").map((e) => ({ id: e.alias, name: e.alias, description: "", trustedLinks: [], project: true, storage: storageBadge(e.sourceKind === "family" ? "contained" : e.sourceKind, e.directory, e.commit) }));
9060
+ const taken = new Set([...contained, ...referenced].map((n) => n.id));
9061
+ const external = rootExternals.filter((e) => e.role !== "member" && e.sourceKind !== "family" && !taken.has(e.alias)).map((e) => ({ id: e.alias, name: e.alias, description: e.project === e.alias ? "" : `project ${e.project}`, trustedLinks: [], project: true, external: true, storage: storageBadge(e.sourceKind, e.directory, e.commit) }));
9062
+ return [...contained, ...referenced, ...external];
9053
9063
  }
9054
9064
  function storageBadge(storage, directory, commit2) {
9055
9065
  if (storage === "git") return `git ${commit2 ? commit2.slice(0, 7) : ""}`.trim();
9056
9066
  if (storage === "hosted") return "hosted";
9067
+ if (storage === "unresolved") return "unresolved";
9057
9068
  if (storage === "path") return directory ? `../${directory.split(/[\\/]/).pop()}` : "sibling";
9058
9069
  return "contained";
9059
9070
  }
@@ -9065,7 +9076,60 @@ function partBadges() {
9065
9076
  }
9066
9077
  return out;
9067
9078
  }
9068
- function buildCanvasModel(issues = []) {
9079
+ function useLabel(use) {
9080
+ if (use.publicName === void 0) return use.member ?? "(the external)";
9081
+ return use.member === void 0 || use.member === "type" ? use.publicName : `${use.publicName}.${use.member}`;
9082
+ }
9083
+ function relationFrom(id, consumer, producer, status, comparedAt) {
9084
+ const changed = status.uses.filter((u) => u.state !== "unchanged");
9085
+ const health = relationHealth(status);
9086
+ return {
9087
+ id,
9088
+ consumer,
9089
+ producer,
9090
+ health,
9091
+ uses: changed.map((u) => ({ name: useLabel(u), state: u.state, ...u.detail !== void 0 ? { detail: u.detail } : {} })),
9092
+ unchanged: status.uses.length - changed.length,
9093
+ ...status.pinnedCommit !== void 0 ? { pinnedCommit: status.pinnedCommit } : {},
9094
+ ...status.pinnedDigest !== void 0 ? { pinnedDigest: status.pinnedDigest } : {},
9095
+ comparedAt,
9096
+ ...health === "unavailable" && status.detail !== void 0 ? { detail: status.detail } : {}
9097
+ };
9098
+ }
9099
+ function statusFor(entry, consumer, producer) {
9100
+ const { alias, node } = producer;
9101
+ return entry.externals.find((s) => alias !== void 0 && s.alias === alias || node !== void 0 && (node.parent === consumer && s.alias === node.mountAlias || s.sourceKind === "family" && node.id !== void 0 && s.project === node.id));
9102
+ }
9103
+ function nameRelations(edges, relations, outsideIds, ownerOf4) {
9104
+ const family = graph();
9105
+ const now = (/* @__PURE__ */ new Date()).toISOString();
9106
+ const out = /* @__PURE__ */ new Map();
9107
+ for (const edge of edges) {
9108
+ if (!edge.consumption) continue;
9109
+ const consumer = ownerOf4(edge.from);
9110
+ const node = outsideIds.has(edge.to) ? void 0 : familyNode(family, ownerOf4(edge.to)) ?? void 0;
9111
+ const alias = outsideIds.has(edge.to) ? edge.to : void 0;
9112
+ const entry = relations.find((r) => r.project === consumer);
9113
+ const status = entry && !entry.detail ? statusFor(entry, consumer, { alias, node }) : void 0;
9114
+ const producer = status?.alias ?? alias ?? (node?.parent === consumer ? node.mountAlias : void 0) ?? node?.id ?? ownerOf4(edge.to);
9115
+ const id = `${consumer}\u2192${producer}`;
9116
+ if (!out.has(id)) {
9117
+ out.set(id, status ? relationFrom(id, consumer, producer, status, entry.comparedAt) : {
9118
+ id,
9119
+ consumer,
9120
+ producer,
9121
+ health: "unavailable",
9122
+ uses: [],
9123
+ unchanged: 0,
9124
+ comparedAt: entry?.comparedAt ?? now,
9125
+ detail: entry?.detail ?? (entry ? `the consumer's statuses name no producer "${producer}"` : "the consumer's statuses were not read")
9126
+ });
9127
+ }
9128
+ edge.relation = id;
9129
+ }
9130
+ return [...out.values()];
9131
+ }
9132
+ function buildCanvasModel(issues = [], relations) {
9069
9133
  const system = loadSystemSpec();
9070
9134
  const subsystems = loadSubsystemSpecs();
9071
9135
  const components = loadComponentSpecs();
@@ -9173,22 +9237,26 @@ function buildCanvasModel(issues = []) {
9173
9237
  const badges = partBadges();
9174
9238
  const componentSub = new Map(components.map((c) => [c.id, c.subsystem]));
9175
9239
  const edges = [];
9176
- const referencedIds = new Set(projectNodes.filter((p) => p.storage !== void 0).map((p) => p.id));
9240
+ const outsideIds = new Set(projectNodes.filter((p) => p.storage !== void 0).map((p) => p.id));
9241
+ const owners = graph().owners;
9242
+ const ownerOfSpec = (id) => owners.get(id) ?? "";
9177
9243
  for (const comp of components) {
9178
9244
  for (const depId of comp.dependsOn) {
9179
9245
  const alias = depId.includes("::") ? depId.split("::")[0] : "";
9180
- if (referencedIds.has(alias) && !componentIds.has(depId)) {
9181
- edges.push({ from: comp.id, to: alias, cross: true });
9246
+ if (outsideIds.has(alias) && !componentIds.has(depId)) {
9247
+ edges.push({ from: comp.id, to: alias, cross: true, consumption: true });
9182
9248
  continue;
9183
9249
  }
9184
9250
  if (!componentIds.has(depId)) continue;
9185
9251
  edges.push({
9186
9252
  from: comp.id,
9187
9253
  to: depId,
9188
- cross: componentSub.get(depId) !== comp.subsystem
9254
+ cross: componentSub.get(depId) !== comp.subsystem,
9255
+ ...ownerOfSpec(depId) !== ownerOfSpec(comp.id) ? { consumption: true } : {}
9189
9256
  });
9190
9257
  }
9191
9258
  }
9259
+ const canvasRelations = relations === void 0 ? void 0 : nameRelations(edges, relations, outsideIds, ownerOfSpec);
9192
9260
  const typeSpecs = loadTypeSpecs();
9193
9261
  const usedByFor = (t) => {
9194
9262
  const qualified = t.subsystem && !t.id.startsWith(`${t.subsystem}::`) ? `${t.subsystem}::${t.id}` : t.id;
@@ -9310,6 +9378,7 @@ function buildCanvasModel(issues = []) {
9310
9378
  ],
9311
9379
  components: modelComponents,
9312
9380
  edges,
9381
+ ...canvasRelations !== void 0 ? { relations: canvasRelations } : {},
9313
9382
  types: modelTypes,
9314
9383
  typeEdges,
9315
9384
  dataEdges,
@@ -9483,6 +9552,7 @@ body[data-theme="light"] .selectControl { color-scheme:light; }
9483
9552
  #cy { position:absolute; inset:0; }
9484
9553
  .legend { position:absolute; left:12px; bottom:12px; background:var(--chrome); border:1px solid var(--chrome-border); border-radius:10px; padding:8px 12px; font-size:11px; color:var(--dim); z-index:5; pointer-events:none; }
9485
9554
  .legend .sw { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:4px; vertical-align:-1px; border:1.5px solid; }
9555
+ .reltip { position:absolute; display:none; background:var(--chrome); border:1px solid var(--chrome-border); border-radius:8px; padding:6px 10px; font-size:11px; color:var(--dim); white-space:pre; z-index:6; pointer-events:none; max-width:520px; overflow:hidden; }
9486
9556
  /* Stage overlays clear the floating header (52px + 10px top + 10px gap). The
9487
9557
  header hides in presentation mode, where they return to the top edge. */
9488
9558
  .viewhint { position:absolute; top:72px; left:12px; color:var(--dim); font-size:11px; background:var(--chrome); border:1px solid var(--chrome-border); border-radius:9px; padding:5px 10px; z-index:5; pointer-events:none; }
@@ -9668,6 +9738,7 @@ body.presentation #exitPresent, body.presentation #presentDetails { display:bloc
9668
9738
  <div class="viewhint" id="viewHint"></div>
9669
9739
  <div id="typesWarn"></div>
9670
9740
  <div class="legend" id="legend"></div>
9741
+ <div class="reltip" id="relTip"></div>
9671
9742
  </div>
9672
9743
  <div id="panelResizer" title="Drag to resize details sidebar"></div>
9673
9744
  <div id="panel"></div>
@@ -9995,6 +10066,7 @@ var MODEL = __MODEL_JSON__;
9995
10066
  retired: { fill: '#ebe8e4', stroke: '#857b73', text: '#35302b' },
9996
10067
  },
9997
10068
  issue: '#b3261e', selGlow: '#3465b4', bgLabel: '#f2f5f8', png: '#f2f5f8',
10069
+ relOk: '#2e7d4f', relDrift: '#8a6116', relBad: '#b3261e', relUnknown: '#5f6b78',
9998
10070
  },
9999
10071
  syw: {
10000
10072
  pageEdge: '#7c8ca3', edgeText: '#aebdd2', cross: '#ff6b81', ink: '#eef2f8',
@@ -10016,6 +10088,7 @@ var MODEL = __MODEL_JSON__;
10016
10088
  retired: { fill: '#24211f', stroke: '#a8a29e', text: '#e7e5e4' },
10017
10089
  },
10018
10090
  issue: '#ff6b81', selGlow: '#22ddff', bgLabel: '#0a0a0f', png: '#0a0a0f',
10091
+ relOk: '#34d399', relDrift: '#f59e0b', relBad: '#ff6b81', relUnknown: '#93a1b8',
10019
10092
  },
10020
10093
  };
10021
10094
 
@@ -10078,6 +10151,15 @@ var MODEL = __MODEL_JSON__;
10078
10151
  { selector: 'edge.bundle', style: { width: 4.5, opacity: 0.7 } },
10079
10152
  { selector: 'edge.toghost', style: { 'line-style': 'dashed', opacity: 0.75 } },
10080
10153
  { selector: 'edge.inneredge', style: { width: 1.1, 'arrow-scale': 0.6, opacity: 0.8 } },
10154
+ // Relation health on consumption edges: the health colour replaces the
10155
+ // boundary-hop colour, and unavailable is dotted as well as grey, so the
10156
+ // state never rests on colour alone (the glyph is the edge's label).
10157
+ { selector: 'edge.rel-unchecked', style: { 'line-color': t.pageEdge, 'target-arrow-color': t.pageEdge } },
10158
+ { selector: 'edge.rel-ok', style: { 'line-color': t.relOk, 'target-arrow-color': t.relOk } },
10159
+ { selector: 'edge.rel-drifted', style: { 'line-color': t.relDrift, 'target-arrow-color': t.relDrift } },
10160
+ { selector: 'edge.rel-incompatible', style: { 'line-color': t.relBad, 'target-arrow-color': t.relBad } },
10161
+ { selector: 'edge.rel-unavailable', style: { 'line-color': t.relUnknown, 'target-arrow-color': t.relUnknown, 'line-style': 'dotted' } },
10162
+ { selector: '.extProject', style: { 'border-style': 'dashed' } },
10081
10163
  { selector: 'edge.stubHover', style: { 'line-color': t.selGlow, 'target-arrow-color': t.selGlow, width: 2.6, opacity: 1, 'z-compound-depth': 'top' } },
10082
10164
  { selector: '.dimmed', style: { opacity: 0.13 } },
10083
10165
  { selector: '.hasIssue', style: { 'border-color': t.issue, 'border-style': 'dashed', 'border-width': 3 } },
@@ -10114,6 +10196,83 @@ var MODEL = __MODEL_JSON__;
10114
10196
  ];
10115
10197
  }
10116
10198
 
10199
+ // ---- relation health (consumption edges) ----------------------------------
10200
+ // The model carries relations only when health was computed; without them a
10201
+ // consumption edge is drawn neutral and the legend says it was not checked \u2014
10202
+ // never green for something nobody compared.
10203
+ var HAS_RELATIONS = Array.isArray(MODEL.relations);
10204
+ var REL_BY_ID = {};
10205
+ (MODEL.relations || []).forEach(function (r) { REL_BY_ID[r.id] = r; });
10206
+ var HEALTH_RANK = { ok: 1, drifted: 2, unavailable: 3, incompatible: 4 };
10207
+ var HEALTH_GLYPH = { ok: '\\u2713', drifted: '\\u0394', incompatible: '\\u2715', unavailable: '?' };
10208
+ var HAS_CONSUMPTION = MODEL.edges.some(function (e) { return !!e.consumption; });
10209
+ function edgeHealth(edge) {
10210
+ var r = edge.relation ? REL_BY_ID[edge.relation] : null;
10211
+ return r ? r.health : (HAS_RELATIONS ? 'unavailable' : null);
10212
+ }
10213
+ // Where edges fold into one, the worst health wins.
10214
+ function worseHealth(a, b) {
10215
+ if (!a) return b;
10216
+ if (!b) return a;
10217
+ return HEALTH_RANK[b] > HEALTH_RANK[a] ? b : a;
10218
+ }
10219
+ function relationsComparedAt() {
10220
+ var at = null;
10221
+ (MODEL.relations || []).forEach(function (r) { if (!at || r.comparedAt < at) at = r.comparedAt; });
10222
+ return at;
10223
+ }
10224
+ function shortPin(r) {
10225
+ if (r.pinnedCommit) return r.pinnedCommit.slice(0, 7);
10226
+ return r.pinnedDigest ? r.pinnedDigest.replace(/^[a-z0-9-]+:/, '').slice(0, 12) : '';
10227
+ }
10228
+ function relationName(r) { return (r.consumer || 'this project') + ' \\u2192 ' + r.producer; }
10229
+ function relationText(r) {
10230
+ var lines = [relationName(r) + ': ' + HEALTH_GLYPH[r.health] + ' ' + r.health];
10231
+ r.uses.forEach(function (u) { lines.push(' ' + u.name + ' \\u2014 ' + u.state + (u.detail ? ': ' + u.detail : '')); });
10232
+ if (r.unchanged) lines.push(' ' + r.unchanged + ' use' + (r.unchanged === 1 ? '' : 's') + ' unchanged');
10233
+ if (r.detail) lines.push(' ' + r.detail);
10234
+ var pin = shortPin(r);
10235
+ lines.push((pin ? 'pinned ' + pin + ' \\u00B7 ' : '') + 'compared ' + r.comparedAt);
10236
+ return lines.join('\\n');
10237
+ }
10238
+ function relationHtml(r) {
10239
+ var pin = shortPin(r);
10240
+ return '<div class="method"><div class="mname">' + esc(relationName(r)) + '<span class="grow"></span><span class="chip">' + HEALTH_GLYPH[r.health] + ' ' + esc(r.health) + '</span></div>'
10241
+ + r.uses.map(function (u) { return '<div class="mdesc">' + esc(u.name) + ' \\u2014 ' + esc(u.state) + (u.detail ? ': ' + esc(u.detail) : '') + '</div>'; }).join('')
10242
+ + (r.unchanged ? '<div class="mdesc">' + r.unchanged + ' use' + (r.unchanged === 1 ? '' : 's') + ' unchanged</div>' : '')
10243
+ + (r.detail ? '<div class="mdesc">' + esc(r.detail) + '</div>' : '')
10244
+ + '<div class="mdesc">' + (pin ? 'pinned ' + esc(pin) + ' \\u00B7 ' : '') + 'compared ' + esc(r.comparedAt) + '</div></div>';
10245
+ }
10246
+ // The relations whose consumption edges start or land inside a node.
10247
+ function relationsTouching(sid) {
10248
+ var seen = {}, out = [];
10249
+ var inside = function (x) {
10250
+ var c = compById[x];
10251
+ return x === sid || x.indexOf(sid + '::') === 0 || !!(c && (c.subsystem === sid || c.subsystem.indexOf(sid + '::') === 0));
10252
+ };
10253
+ MODEL.edges.forEach(function (e) {
10254
+ if (!e.relation || seen[e.relation] || !(inside(e.to) || inside(e.from))) return;
10255
+ seen[e.relation] = true;
10256
+ if (REL_BY_ID[e.relation]) out.push(REL_BY_ID[e.relation]);
10257
+ });
10258
+ return out;
10259
+ }
10260
+ // The one legend row, only when the model has a consumption edge.
10261
+ function relationLegend(t) {
10262
+ if (!HAS_CONSUMPTION) return '';
10263
+ if (!HAS_RELATIONS) return '<br>relations: <span style="color:' + t.pageEdge + '">health not checked</span>';
10264
+ var at = relationsComparedAt();
10265
+ return '<br>relations: <span style="color:' + t.relOk + '">\\u2713 ok</span>&nbsp; <span style="color:' + t.relDrift + '">\\u0394 drifted</span>&nbsp; '
10266
+ + '<span style="color:' + t.relBad + '">\\u2715 incompatible</span>&nbsp; <span style="color:' + t.relUnknown + '">? unavailable (dotted)</span>'
10267
+ + (at ? '&nbsp; \\u00B7 as of ' + esc(at) : '');
10268
+ }
10269
+ // A project's own page, only through the host's open-project hook (the web
10270
+ // app): never in a standalone file or on a shared page, which have none.
10271
+ function openProjectButton(s) {
10272
+ if (!s.recordId || typeof opts === 'undefined' || !opts || typeof opts.onOpenProject !== 'function') return '';
10273
+ return '<div class="openbtn"><button class="tbtn" data-openproject-id="' + esc(s.recordId) + '">Open project \\u2197</button></div>';
10274
+ }
10275
+
10117
10276
  function renderLegend() {
10118
10277
  var t = THEMES[state.theme];
10119
10278
  var sw = function (c) { return '<span class="sw" style="background:' + c.fill + ';border-color:' + c.stroke + '"></span>'; };
@@ -10127,7 +10286,8 @@ var MODEL = __MODEL_JSON__;
10127
10286
  sw(t.proxyIn) + '\\u21E0 in-port&nbsp; ' + sw(t.proxyOut) + '\\u21E2 out-port&nbsp; \u2014 bold border = published \xB7 ' +
10128
10287
  '<span style="color:' + t.cross + '">red</span> = boundary hop \xB7 double-click = open<br>' +
10129
10288
  'on select: <span style="color:' + t.selGlow + '">\\u2192 depends on</span>&nbsp; <span style="color:' + t.warn + '">\\u2190 used by</span>' +
10130
- (state.dataCoupling ? '&nbsp; \xB7 &nbsp;<span style="color:' + t.typeV.stroke + '">- - \\u25B8 uses models</span>' : '');
10289
+ (state.dataCoupling ? '&nbsp; \xB7 &nbsp;<span style="color:' + t.typeV.stroke + '">- - \\u25B8 uses models</span>' : '') +
10290
+ relationLegend(t);
10131
10291
  }
10132
10292
 
10133
10293
  // ---- view layout ---------------------------------------------------------------
@@ -10594,6 +10754,14 @@ var MODEL = __MODEL_JSON__;
10594
10754
  if (!agg[key]) agg[key] = { src: src, tgt: tgt, n: 0, cross: false, ghost: ghost };
10595
10755
  agg[key].n++;
10596
10756
  if (edge.cross) agg[key].cross = true;
10757
+ if (edge.consumption) {
10758
+ agg[key].consumption = true;
10759
+ agg[key].health = worseHealth(agg[key].health, edgeHealth(edge));
10760
+ if (edge.relation) {
10761
+ var rels = agg[key].rels || (agg[key].rels = []);
10762
+ if (rels.indexOf(edge.relation) < 0) rels.push(edge.relation);
10763
+ }
10764
+ }
10597
10765
  });
10598
10766
  return { agg: agg, ghosts: ghosts };
10599
10767
  }
@@ -11154,7 +11322,7 @@ var MODEL = __MODEL_JSON__;
11154
11322
  var classes, label;
11155
11323
  var isPub = e.kind === 'component' && compById[e.id] && compById[e.id].public;
11156
11324
  if (e.kind === 'subsystem') {
11157
- classes = 'subsysBox';
11325
+ classes = 'subsysBox' + (subById[e.id] && subById[e.id].external ? ' extProject' : '');
11158
11326
  label = nameOf(e) + (e.hasKids && !inner ? '\\n\\u25B8 open' : '');
11159
11327
  } else {
11160
11328
  var c = compById[e.id];
@@ -11362,9 +11530,13 @@ var MODEL = __MODEL_JSON__;
11362
11530
  var bundle = e.n > 1;
11363
11531
  var dim = state.query && (dimmedAnchors[e.src] || dimmedAnchors[e.tgt]);
11364
11532
  var route = routeData(e.src, e.tgt, key);
11533
+ // A consumption edge carries its relation's health: the glyph at its middle,
11534
+ // its colour (and line, dotted when unavailable) by class.
11535
+ var glyph = e.health ? HEALTH_GLYPH[e.health] : '';
11536
+ var relCls = e.consumption ? 'rel rel-' + (e.health || 'unchecked') + ' ' : '';
11365
11537
  eles.push({
11366
- data: { id: 'e' + (i++), source: e.src, target: e.tgt, lbl: bundle ? e.n + ' links' : '', cpDist: route.cpDist, cpWeight: route.cpWeight, taxiTurn: route.taxiTurn },
11367
- classes: 'routed ' + (e.cross ? 'cross ' : '') + (bundle ? 'bundle ' : '') + (e.ghost ? 'toghost ' : '') + (dim ? 'dimmed' : ''),
11538
+ data: { id: 'e' + (i++), source: e.src, target: e.tgt, lbl: (bundle ? e.n + ' links' : '') + (glyph ? (bundle ? ' ' : '') + glyph : ''), rels: (e.rels || []).join('|'), cpDist: route.cpDist, cpWeight: route.cpWeight, taxiTurn: route.taxiTurn },
11539
+ classes: 'routed ' + (e.cross ? 'cross ' : '') + (bundle ? 'bundle ' : '') + (e.ghost ? 'toghost ' : '') + relCls + (dim ? 'dimmed' : ''),
11368
11540
  });
11369
11541
  });
11370
11542
 
@@ -11864,6 +12036,27 @@ var MODEL = __MODEL_JSON__;
11864
12036
  cy.on('mouseover', 'node', function (ev) { setHover(ev.target); });
11865
12037
  cy.on('mouseout', 'node', function (ev) { if (hoveredNode && hoveredNode.id() === ev.target.id()) setHover(null); });
11866
12038
 
12039
+ // Relation health: hovering a consumption edge shows its relation(s) \u2014 which
12040
+ // uses changed or were removed, why one could not be compared, the pin and
12041
+ // when it was compared; tapping it shows the same in the details panel.
12042
+ function relationsOfEdge(edge) {
12043
+ return (edge.data('rels') || '').split('|').filter(Boolean).map(function (rid) { return REL_BY_ID[rid]; }).filter(Boolean);
12044
+ }
12045
+ cy.on('mouseover', 'edge.rel', function (ev) {
12046
+ var tip = document.getElementById('relTip');
12047
+ var rels = relationsOfEdge(ev.target);
12048
+ tip.textContent = rels.length ? rels.map(relationText).join('\\n\\n') : 'relation health not checked';
12049
+ var at = ev.renderedPosition || { x: 0, y: 0 };
12050
+ tip.style.left = (at.x + 12) + 'px';
12051
+ tip.style.top = (at.y + 12) + 'px';
12052
+ tip.style.display = 'block';
12053
+ });
12054
+ cy.on('mouseout', 'edge.rel', function () { document.getElementById('relTip').style.display = 'none'; });
12055
+ cy.on('tap', 'edge.rel', function (ev) {
12056
+ var ids = ev.target.data('rels') || '';
12057
+ if (ids) select('relation', ids, false);
12058
+ });
12059
+
11867
12060
  cy.on('tap', 'node', function (ev) {
11868
12061
  var t = idOf(ev.target);
11869
12062
  if (t.group) return;
@@ -11921,7 +12114,15 @@ var MODEL = __MODEL_JSON__;
11921
12114
  var hasKids = t.kind === 'subsystem'
11922
12115
  ? (childSubsOf(t.id).length + childCompsOf(t.id).length) > 0
11923
12116
  : memberCompsOf(t.id).length > 0;
11924
- if (hasKids) navigateTo(t.kind, t.id);
12117
+ if (hasKids) { navigateTo(t.kind, t.id); return; }
12118
+ // A project node with nothing to drill into (a referenced or external one):
12119
+ // its own page through the host's open-project hook when it carries a
12120
+ // record id, else its details panel \u2014 its storage and its relations.
12121
+ var ps = t.kind === 'subsystem' ? subById[t.id] : null;
12122
+ if (ps && ps.project) {
12123
+ if (ps.recordId && typeof opts !== 'undefined' && opts && typeof opts.onOpenProject === 'function') { opts.onOpenProject(ps.recordId); return; }
12124
+ select('subsystem', t.id, false);
12125
+ }
11925
12126
  });
11926
12127
  cy.on('dragfree', 'node', function () { harvestPositions(); });
11927
12128
 
@@ -13158,7 +13359,7 @@ var MODEL = __MODEL_JSON__;
13158
13359
  cy.nodes().removeClass('sel');
13159
13360
  cy.edges().removeClass('fieldhl');
13160
13361
  if (id) {
13161
- var node = nodeForRef(kind, id);
13362
+ var node = kind === 'relation' ? cy.collection() : nodeForRef(kind, id);
13162
13363
  if (node.length) {
13163
13364
  node.addClass('sel');
13164
13365
  applyFocus(node);
@@ -13328,6 +13529,10 @@ var MODEL = __MODEL_JSON__;
13328
13529
  via.map(function (v) { return chip(v.label, v.kind, v.id); }).join(''), true);
13329
13530
  }
13330
13531
  }
13532
+ } else if (focusKind === 'relation') {
13533
+ var rl = String(focusId).split('|').map(function (rid) { return REL_BY_ID[rid]; }).filter(Boolean);
13534
+ head = '<h2>' + esc(rl.length === 1 ? relationName(rl[0]) : rl.length + ' relations') + '</h2>' + staticChip('relation');
13535
+ body += rl.map(relationHtml).join('');
13331
13536
  } else if (focusKind === 'type') {
13332
13537
  var ty = null;
13333
13538
  MODEL.types.forEach(function (t2) { if (t2.id === focusId) ty = t2; });
@@ -13411,8 +13616,14 @@ var MODEL = __MODEL_JSON__;
13411
13616
  + (scopeFocus ? staticChip('current view') : '')
13412
13617
  + (scopeFocus ? '' : openViewButton('subsystem', s.id, subKids > 0))
13413
13618
  + openApiButton(subsystemExposesApi(s.id), '')
13414
- + openSpecButton('subsystem', s.id);
13619
+ + openSpecButton('subsystem', s.id)
13620
+ + (s.project ? staticChip(s.external ? 'external project' : 'project') : '')
13621
+ + (s.storage ? staticChip(s.storage) : '')
13622
+ + openProjectButton(s);
13415
13623
  body += '<p class="desc">' + esc(s.description) + '</p>';
13624
+ var nodeRels = relationsTouching(s.id);
13625
+ if (nodeRels.length) body += section('Relations', nodeRels.length, nodeRels.map(relationHtml).join(''), true);
13626
+ else if (s.project && HAS_CONSUMPTION && !HAS_RELATIONS) body += section('Relations', null, '<span class="desc">relation health was not checked</span>', true);
13416
13627
  if (s.trustedLinks.length) {
13417
13628
  body += section('Trusted links (fast lanes)', s.trustedLinks.length, s.trustedLinks.map(function (t2) {
13418
13629
  return '<div class="method">' + chip(t2.subsystem, 'subsystem', t2.subsystem) + '<div class="mdesc">' + esc(t2.reason) + '</div></div>';
@@ -13479,6 +13690,14 @@ var MODEL = __MODEL_JSON__;
13479
13690
  });
13480
13691
  })(ospecs[si]);
13481
13692
  }
13693
+ var oprojs = panel.querySelectorAll('[data-openproject-id]');
13694
+ for (var opi = 0; opi < oprojs.length; opi++) {
13695
+ (function (b) {
13696
+ b.addEventListener('click', function () {
13697
+ if (typeof opts !== 'undefined' && opts && typeof opts.onOpenProject === 'function') opts.onOpenProject(b.getAttribute('data-openproject-id'));
13698
+ });
13699
+ })(oprojs[opi]);
13700
+ }
13482
13701
  var flows = panel.querySelectorAll('[data-flow-comp]');
13483
13702
  for (var j = 0; j < flows.length; j++) {
13484
13703
  (function (b) {
@@ -14074,7 +14293,14 @@ function externalStatus(binding, lock) {
14074
14293
  const { external } = binding;
14075
14294
  const entry = lock?.externals[external.alias];
14076
14295
  const pinned = entry !== void 0 && externalsRepository.readSnapshot(external.alias) !== null;
14077
- const base = { alias: external.alias, project: external.project, sourceKind: external.sourceKind, pinned };
14296
+ const base = {
14297
+ alias: external.alias,
14298
+ project: external.project,
14299
+ sourceKind: external.sourceKind,
14300
+ pinned,
14301
+ ...entry !== void 0 ? { pinnedDigest: entry.digest } : {},
14302
+ ...entry?.commit !== void 0 ? { pinnedCommit: entry.commit } : {}
14303
+ };
14078
14304
  const unreadable = external.sourceKind === "unresolved" ? hostedOnly(external) ? external.problem : `the external does not resolve: ${external.problem}` : producerUnreadable(external);
14079
14305
  let live = null;
14080
14306
  let reason = unreadable;
@@ -14314,7 +14540,8 @@ function reachCeiling(root, family) {
14314
14540
  }
14315
14541
  function within2(dir, ceiling, fn) {
14316
14542
  if (ceiling === null) return runWithProjectRoot(dir, fn);
14317
- return runWithProjectBinding(dir, { topRoot: ceiling.topRoot, parentReach: true, narrowed: ceiling.narrowed }, fn);
14543
+ const lookup2 = getHostedLookup();
14544
+ return runWithProjectBinding(dir, { topRoot: ceiling.topRoot, parentReach: true, narrowed: ceiling.narrowed, ...lookup2 ? { hostedLookup: lookup2 } : {} }, fn);
14318
14545
  }
14319
14546
  function depthOf(family, node) {
14320
14547
  let depth = 0;
@@ -14365,7 +14592,8 @@ function referencedOf(nodes) {
14365
14592
  return out;
14366
14593
  }
14367
14594
  function atReferenced(ref, fn) {
14368
- return runWithProjectBinding(ref.directory, { topRoot: ref.directory, parentReach: true, narrowed: true }, fn);
14595
+ const lookup2 = getHostedLookup();
14596
+ return runWithProjectBinding(ref.directory, { topRoot: ref.directory, parentReach: true, narrowed: true, ...lookup2 ? { hostedLookup: lookup2 } : {} }, fn);
14369
14597
  }
14370
14598
  function narrowed(family, selected) {
14371
14599
  const keys = /* @__PURE__ */ new Set(["", ...selected.map((s) => s.node.namespace)]);
@@ -14853,7 +15081,7 @@ function defaultTargetConfig(type) {
14853
15081
  enabled: true
14854
15082
  };
14855
15083
  }
14856
- var WAIRON_VERSION = "5.1.1-dev.90";
15084
+ var WAIRON_VERSION = "5.1.1-dev.92";
14857
15085
  var GITHUB_REPO = "SYW-Apps/Waffle-AIron";
14858
15086
  var ARCHITECT_AGENT_ID = "agent-architect";
14859
15087
  var ARCHITECT_TEMPLATE_ID = "architect";
@@ -25349,10 +25577,11 @@ function familyApprovals(depth) {
25349
25577
  }
25350
25578
 
25351
25579
  // src/core/diagram.ts
25352
- function renderDiagram(format) {
25580
+ function renderDiagram(format, relations) {
25353
25581
  switch (format) {
25582
+ // Only the canvas carries relation health; the other formats ignore it.
25354
25583
  case "canvas":
25355
- return renderCanvasHtml(buildCanvasModel(diagramIssues()));
25584
+ return renderCanvasHtml(buildCanvasModel(diagramIssues(), relations));
25356
25585
  case "mermaid":
25357
25586
  return generateComponentDiagram();
25358
25587
  case "drawio":
@@ -25363,8 +25592,8 @@ function renderDiagram(format) {
25363
25592
  throw new Error(`Unsupported diagram format "${format}" (canvas | mermaid | drawio | excalidraw).`);
25364
25593
  }
25365
25594
  }
25366
- function buildCanvasDataModel() {
25367
- return buildCanvasModel(diagramIssues());
25595
+ function buildCanvasDataModel(relations) {
25596
+ return buildCanvasModel(diagramIssues(), relations);
25368
25597
  }
25369
25598
  function diagramIssues() {
25370
25599
  try {
@@ -27880,6 +28109,16 @@ function identityFieldsOf(field) {
27880
28109
  return ["name", "id"];
27881
28110
  }
27882
28111
  }
28112
+ function withoutAbsentFields(value) {
28113
+ if (Array.isArray(value)) return value.map(withoutAbsentFields);
28114
+ if (typeof value !== "object" || value === null) return value;
28115
+ const out = {};
28116
+ for (const [key, field] of Object.entries(value)) {
28117
+ if (field === void 0 || field === null) continue;
28118
+ out[key] = withoutAbsentFields(field);
28119
+ }
28120
+ return out;
28121
+ }
27883
28122
  function carriesEditMarker(item) {
27884
28123
  if (item === null || typeof item !== "object") return false;
27885
28124
  const o = item;
@@ -30425,7 +30664,7 @@ var SpecWorkspace = class {
30425
30664
  return out;
30426
30665
  };
30427
30666
  const unsetFields = Array.isArray(delta.unset) ? delta.unset.filter((f) => typeof f === "string") : [];
30428
- const { unset: _unset, ...mergeableDelta } = delta;
30667
+ const { unset: _unset, ...mergeableDelta } = withoutAbsentFields(delta);
30429
30668
  const storedBefore = JSON.parse(JSON.stringify(result));
30430
30669
  const qualifiedDelta = qualifyDeltaRefs(mergeableDelta);
30431
30670
  const mergedResult = mergeDelta(result, qualifiedDelta);
@@ -39515,7 +39754,10 @@ var externalStatusesOutput = {
39515
39754
  stale: import_zod11.z.boolean(),
39516
39755
  drifted: import_zod11.z.boolean().optional(),
39517
39756
  uses: import_zod11.z.array(import_zod11.z.record(import_zod11.z.unknown())),
39518
- detail: import_zod11.z.string().optional()
39757
+ detail: import_zod11.z.string().optional(),
39758
+ outOfReach: import_zod11.z.boolean().optional(),
39759
+ pinnedDigest: import_zod11.z.string().optional().describe("The content digest the lock recorded; provenance, never compared here."),
39760
+ pinnedCommit: import_zod11.z.string().optional().describe("The producer commit the lock recorded, for one in another repository; provenance only.")
39519
39761
  })).describe("One status per declared external, in declaration order; EXTERNAL_CHECK_UNAVAILABLE marks what could not be compared."),
39520
39762
  ...staleServerOutput
39521
39763
  };
@@ -41146,7 +41388,7 @@ ${testsBlock}` : ""}`, deletion);
41146
41388
  inputSchema: {
41147
41389
  kind: import_zod11.z.enum(["system", "subsystem", "component", "interface", "implementation", "type"]).describe("The spec kind to update (system = the singleton L0 \u2014 vision, boundaries, globalRequirements, databases, and publicInterfaces: the project export table, each entry a re-export {from, component, interface, typeDef, as, name, type, details, audience: project|department|instance|partner|external} \u2014 from alone re-exports everything a subsystem exports, and a legacy {id, subsystem} reads as {as, from})"),
41148
41390
  id: import_zod11.z.string().describe("The ID of the spec to update (namespaced if needed)"),
41149
- delta: import_zod11.z.record(import_zod11.z.any()).describe(`The partial fields to merge into the spec. ARRAYS UPSERT, they do not replace: an array whose elements carry an identity is merged element-by-element, so a delta naming ONE element leaves the others intact. Identity is "name" or "id" by default, and per field: dispatch by "capability", a listener's mounts by "portal", lifecycle by phase+component+method, emits/subscribesTo by topic+event, trustedLinks by "subsystem", a subsystem's publicInterfaces by component+interface (or type+details for an entry not yet bound to a component, and by from+item+interface+as for a re-export or a type export), invariants and patterns by "id", lint.allow by "code" AND "at" (an allow covers one occurrence, so several may share a code on one spec), an interface method's findings by "code", boundaries by "name", globalRequirements by "description", switch cases by "value", try catches by "error". Identity merging applies at EVERY depth, including an array INSIDE an element (a method's params, a step's catches). Add "action: 'delete'" (or "remove: true") alongside that identity to REMOVE an element \u2014 including a stale lint allow. Arrays of plain STRINGS (owns, dependsOn, guarantees) carry no per-element identity and are replaced wholesale; pass [] to clear any array outright. To REMOVE an optional field entirely, list it in "unset": e.g. {"unset": ["basePath", "variant"]} \u2014 passing null/undefined means "no change" (they are skipped), and writing "" would leave the field present but empty, which is a different and usually wrong spec. Unsetting a required field is refused by schema validation, which names it. For narrative steps, match by "stepNumber" and use "action: 'insert'" (shifts subsequent steps up) or "action: 'delete'" (shifts subsequent steps down and removes it). Step entries apply in ASCENDING stepNumber order, each against the numbering the earlier entries of the SAME delta left behind \u2014 delete step 3 and step 7 becomes step 6 \u2014 so prefer labels, and restate the step's "label" or "description" on a delete to have it checked against the step actually addressed. Renumbering RELOCATES every flow jump field (onTrueStep/onFalseStep/cases.step/defaultStep/endStep/catches.step/finallyStep/toStep) in the same narrative. Only STORED jumps relocate: a jump the delta itself writes (on an inserted, appended or edited step) names its target in the numbering the WHOLE delta leaves behind, after all of its inserts and deletes, and no later step of the same delta moves it. A method the delta ADDS without a narrative is an intent-level method with none ([]). A delete is REJECTED when the narrative has no such step, when a jump still targets it (retarget the referrers first), when a restated label/description does not match, or when it is a loop/try/parallel header whose body would be left standing (retype the header first to dissolve the region, then delete it). Changing a step's "type" REBUILDS it for the new type: its description and label are kept and every field the new type cannot carry is dropped (returned as a NOTICE); a delta that retypes AND sets such a field is refused. A step delta is also refused when it carries a marker the merge does not recognise: a non-boolean "remove", an "action" that is neither "insert" nor "delete", a "captureJumps" outside an insert, or no "stepNumber" to address. Inserting AT a jump target relocates those jumps past the inserted step by default (a NOTICE is returned) \u2014 add "captureJumps": true on the inserted step to retarget entry jumps onto it (loop/try endStep region tails always relocate with the body and are never captured). Every jump field has a "*Label" twin (toLabel, onFalseLabel, endLabel, \u2026, and "label" on a cases/catches entry) resolved against step labels AFTER the merge, so a delta may anchor on a label only pre-existing steps carry; a label the delta supplies REPLACES the stored number it twins, while setting the number and its label together in one delta is refused as a contradiction. Reference ids in deltas may use LOCAL names \u2014 they are qualified against the spec's namespace exactly as the loader would. Per-spec lint suppression: set "lint: { allow: [{ code, at, covers, reason }] }" to silence a WARNING or NOTICE code on this spec only (errors always surface; stale allows are flagged). An allow covers EXACTLY the occurrence it names: "at" is the site the finding names (a contract method, an import edge "from -> to", a declared edge "component -> target") and is REQUIRED for a code whose findings report one, while a finding that reports no site is covered only by an allow that names none; "covers" lists the units an aggregating finding reports, and the allow silences it only when every one is listed. This delta is deliberately OPEN below its top level \u2014 the shapes nest further than a schema here should restate \u2014 so a key that is not a field at its depth is not refused, it is NAMED BACK under NO EFFECT in the answer, together with any value the spec already held and any "unset" that removed nothing. Read that list: it is where a nested typo shows up.`),
41391
+ delta: import_zod11.z.record(import_zod11.z.any()).describe(`The partial fields to merge into the spec. ARRAYS UPSERT, they do not replace: an array whose elements carry an identity is merged element-by-element, so a delta naming ONE element leaves the others intact. Identity is "name" or "id" by default, and per field: dispatch by "capability", a listener's mounts by "portal", lifecycle by phase+component+method, emits/subscribesTo by topic+event, trustedLinks by "subsystem", a subsystem's publicInterfaces by component+interface (or type+details for an entry not yet bound to a component, and by from+item+interface+as for a re-export or a type export), invariants and patterns by "id", lint.allow by "code" AND "at" (an allow covers one occurrence, so several may share a code on one spec), an interface method's findings by "code", boundaries by "name", globalRequirements by "description", switch cases by "value", try catches by "error". Identity merging applies at EVERY depth, including an array INSIDE an element (a method's params, a step's catches). Add "action: 'delete'" (or "remove: true") alongside that identity to REMOVE an element \u2014 including a stale lint allow. Arrays of plain STRINGS (owns, dependsOn, guarantees) carry no per-element identity and are replaced wholesale; pass [] to clear any array outright. To REMOVE an optional field entirely, list it in "unset": e.g. {"unset": ["basePath", "variant"]} \u2014 passing null/undefined means "no change" (they are skipped), and writing "" would leave the field present but empty, which is a different and usually wrong spec. Unsetting a required field is refused by schema validation, which names it. For narrative steps, match by "stepNumber" and use "action: 'insert'" (shifts subsequent steps up) or "action: 'delete'" (shifts subsequent steps down and removes it). Step entries apply in ASCENDING stepNumber order, each against the numbering the earlier entries of the SAME delta left behind \u2014 delete step 3 and step 7 becomes step 6 \u2014 so prefer labels, and restate the step's "label" or "description" on a delete to have it checked against the step actually addressed. Renumbering RELOCATES every flow jump field (onTrueStep/onFalseStep/cases.step/defaultStep/endStep/catches.step/finallyStep/toStep) in the same narrative. Only STORED jumps relocate: a jump the delta itself writes (on an inserted, appended or edited step) names its target in the numbering the WHOLE delta leaves behind, after all of its inserts and deletes, and no later step of the same delta moves it. A method the delta ADDS without a narrative is an intent-level method with none ([]). An element the delta NAMES merges field by field: a field it leaves out, or passes as null, keeps its stored value at EVERY depth (a method named without "narrative" keeps its steps, a step named without "catches" keeps them), while "narrative": [] CLEARS that method's steps \u2014 so leave a method you are not editing out of the delta. A delete is REJECTED when the narrative has no such step, when a jump still targets it (retarget the referrers first), when a restated label/description does not match, or when it is a loop/try/parallel header whose body would be left standing (retype the header first to dissolve the region, then delete it). Changing a step's "type" REBUILDS it for the new type: its description and label are kept and every field the new type cannot carry is dropped (returned as a NOTICE); a delta that retypes AND sets such a field is refused. A step delta is also refused when it carries a marker the merge does not recognise: a non-boolean "remove", an "action" that is neither "insert" nor "delete", a "captureJumps" outside an insert, or no "stepNumber" to address. Inserting AT a jump target relocates those jumps past the inserted step by default (a NOTICE is returned) \u2014 add "captureJumps": true on the inserted step to retarget entry jumps onto it (loop/try endStep region tails always relocate with the body and are never captured). Every jump field has a "*Label" twin (toLabel, onFalseLabel, endLabel, \u2026, and "label" on a cases/catches entry) resolved against step labels AFTER the merge, so a delta may anchor on a label only pre-existing steps carry; a label the delta supplies REPLACES the stored number it twins, while setting the number and its label together in one delta is refused as a contradiction. Reference ids in deltas may use LOCAL names \u2014 they are qualified against the spec's namespace exactly as the loader would. Per-spec lint suppression: set "lint: { allow: [{ code, at, covers, reason }] }" to silence a WARNING or NOTICE code on this spec only (errors always surface; stale allows are flagged). An allow covers EXACTLY the occurrence it names: "at" is the site the finding names (a contract method, an import edge "from -> to", a declared edge "component -> target") and is REQUIRED for a code whose findings report one, while a finding that reports no site is covered only by an allow that names none; "covers" lists the units an aggregating finding reports, and the allow silences it only when every one is listed. This delta is deliberately OPEN below its top level \u2014 the shapes nest further than a schema here should restate \u2014 so a key that is not a field at its depth is not refused, it is NAMED BACK under NO EFFECT in the answer, together with any value the spec already held and any "unset" that removed nothing. Read that list: it is where a nested typo shows up.`),
41150
41392
  dryRun: import_zod11.z.boolean().optional().describe("Ask what this delta WOULD do instead of doing it. The whole write runs, the candidate gate included, and the answer is the change report it would have produced \u2014 marked DRY RUN, with nothing stamped and not one byte of the stored file moved. Use it before a delta that renumbers a long narrative.")
41151
41393
  },
41152
41394
  outputSchema: specChangeReportOutput
@@ -41707,6 +41949,7 @@ function loadProjectConfig3() {
41707
41949
  registerExporter,
41708
41950
  registerPackRef,
41709
41951
  reinjectLocalGuides,
41952
+ relationHealth,
41710
41953
  removeDomain,
41711
41954
  removeExternal,
41712
41955
  removeMember,