@growthub/cli 0.14.9 → 0.14.10

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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Growthub Workspace Patch-Impact V1 — the single, authoritative "what does this
3
+ * patch actually change?" deriver, shared by the preflight route and the CLI.
4
+ *
5
+ * A dataModel/dashboards PATCH REPLACES the whole array, so the patch body alone
6
+ * is never "what changed". This diffs the CURRENT config against the MERGED
7
+ * (post-patch) config and reports THREE classes of change, each with downstream
8
+ * impact:
9
+ *
10
+ * - added / modified objects + dashboards → seeded on the MERGED graph
11
+ * (their new dependents).
12
+ * - REMOVED objects + dashboards → seeded on the CURRENT graph, because the
13
+ * deleted node no longer exists in the merged graph; the surfaces that
14
+ * depended on it are the blast radius of deleting it. Without this, deleting
15
+ * a business surface would silently report no impact — the highest-risk
16
+ * false confidence.
17
+ *
18
+ * Pure: composes `deriveStaleSurfaces` (which composes the blast-radius spine),
19
+ * no new traversal, no writes, no secrets.
20
+ */
21
+
22
+ import { deriveStaleSurfaces } from "./workspace-stale-surfaces.js";
23
+
24
+ const PATCH_IMPACT_KIND = "growthub-workspace-patch-impact-v1";
25
+ const PATCH_IMPACT_VERSION = 1;
26
+
27
+ function objectIndex(config) {
28
+ return new Map((config?.dataModel?.objects || []).map((o) => [o && o.id, JSON.stringify(o)]));
29
+ }
30
+ function dashboardIndex(config) {
31
+ return new Map((config?.dashboards || []).map((d) => [d && (d.id || d.name), JSON.stringify(d)]));
32
+ }
33
+
34
+ /**
35
+ * @param {object} currentGraph graph built from the CURRENT config (pre-patch)
36
+ * @param {object} mergedGraph graph built from the MERGED config (post-patch)
37
+ * @param {object} currentConfig
38
+ * @param {object} mergedConfig
39
+ * @returns {object} `{ kind, version, scope, seeds[], total, byType, staleSurfaces[], removed[], summary, warnings }`
40
+ */
41
+ function derivePatchImpact(currentGraph, mergedGraph, currentConfig, mergedConfig) {
42
+ const empty = (warning) => ({
43
+ kind: PATCH_IMPACT_KIND,
44
+ version: PATCH_IMPACT_VERSION,
45
+ scope: "changed-only",
46
+ seeds: [],
47
+ total: 0,
48
+ byType: {},
49
+ staleSurfaces: [],
50
+ removed: [],
51
+ summary: "No object or dashboard added, modified, or removed.",
52
+ warnings: warning ? [warning] : []
53
+ });
54
+
55
+ if (!mergedGraph || !Array.isArray(mergedGraph.nodes)) return empty("merged graph missing");
56
+
57
+ const curObjects = objectIndex(currentConfig);
58
+ const mergedObjects = objectIndex(mergedConfig);
59
+ const curDashboards = dashboardIndex(currentConfig);
60
+ const mergedDashboards = dashboardIndex(mergedConfig);
61
+
62
+ const changedObjectIds = new Set();
63
+ const removedObjectIds = new Set();
64
+ for (const [id, json] of mergedObjects) if (id && curObjects.get(id) !== json) changedObjectIds.add(id);
65
+ for (const [id] of curObjects) if (id && !mergedObjects.has(id)) removedObjectIds.add(id);
66
+
67
+ const changedDashboardIds = new Set();
68
+ const removedDashboardIds = new Set();
69
+ for (const [id, json] of mergedDashboards) if (id && curDashboards.get(id) !== json) changedDashboardIds.add(id);
70
+ for (const [id] of curDashboards) if (id && !mergedDashboards.has(id)) removedDashboardIds.add(id);
71
+
72
+ // ── added / modified: seed on the MERGED graph ──────────────────────────
73
+ const changedSeeds = [];
74
+ for (const node of mergedGraph.nodes) {
75
+ if (node.type === "dataModelObject" && (changedObjectIds.has(node.summary?.objectId) || changedObjectIds.has(node.metadataId))) {
76
+ changedSeeds.push(node.id);
77
+ } else if (node.type === "dashboard" && (changedDashboardIds.has(node.metadataId) || changedDashboardIds.has(node.label))) {
78
+ changedSeeds.push(node.id);
79
+ }
80
+ }
81
+ const changedStale = changedSeeds.length ? deriveStaleSurfaces(mergedGraph, { seedIds: changedSeeds }) : null;
82
+
83
+ // ── removed: seed on the CURRENT graph (the deleted node lived there) ────
84
+ const removed = [];
85
+ const currentNodes = (currentGraph && Array.isArray(currentGraph.nodes)) ? currentGraph.nodes : [];
86
+ for (const node of currentNodes) {
87
+ const isRemovedObject = node.type === "dataModelObject" && (removedObjectIds.has(node.summary?.objectId) || removedObjectIds.has(node.metadataId));
88
+ const isRemovedDashboard = node.type === "dashboard" && (removedDashboardIds.has(node.metadataId) || removedDashboardIds.has(node.label));
89
+ if (!isRemovedObject && !isRemovedDashboard) continue;
90
+ const downstream = deriveStaleSurfaces(currentGraph, { seedIds: [node.id] });
91
+ removed.push({
92
+ id: node.id,
93
+ type: node.type,
94
+ label: node.label,
95
+ metadataId: node.metadataId,
96
+ affectedTotal: downstream.total,
97
+ affected: downstream.staleSurfaces,
98
+ summary: `Removing "${node.label}" affects ${downstream.total} downstream surface(s) that depend on it.`
99
+ });
100
+ }
101
+
102
+ if (!changedStale && !removed.length) return empty();
103
+
104
+ return {
105
+ kind: PATCH_IMPACT_KIND,
106
+ version: PATCH_IMPACT_VERSION,
107
+ scope: "changed-only",
108
+ seeds: changedStale ? changedStale.seeds : [],
109
+ total: changedStale ? changedStale.total : 0,
110
+ byType: changedStale ? changedStale.byType : {},
111
+ staleSurfaces: changedStale ? changedStale.staleSurfaces : [],
112
+ removed,
113
+ summary: summarizePatchImpact(changedStale, removed),
114
+ warnings: removed.length ? [`${removed.length} object/dashboard removal(s) — review affected downstream before applying.`] : []
115
+ };
116
+ }
117
+
118
+ function summarizePatchImpact(changedStale, removed) {
119
+ const parts = [];
120
+ if (changedStale && changedStale.total) parts.push(`${changedStale.total} surface(s) stale from add/modify`);
121
+ if (removed.length) {
122
+ const affected = removed.reduce((sum, r) => sum + (r.affectedTotal || 0), 0);
123
+ parts.push(`${removed.length} removal(s) affecting ${affected} downstream surface(s)`);
124
+ }
125
+ return parts.length ? `Patch impact: ${parts.join("; ")}.` : "No downstream impact.";
126
+ }
127
+
128
+ export {
129
+ PATCH_IMPACT_KIND,
130
+ PATCH_IMPACT_VERSION,
131
+ derivePatchImpact,
132
+ summarizePatchImpact
133
+ };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Growthub Workspace Provenance-Lineage V1 — bidirectional lineage deriver.
3
+ *
4
+ * The mirror twin of `deriveBlastRadius`. It exposes BOTH transitive directions
5
+ * over the SAME edge taxonomy, named to MATCH the graph's own helper contract
6
+ * (`findDependents` = incoming, `findDependencies` = outgoing) so an agent never
7
+ * mis-reads a consumer as a producer:
8
+ *
9
+ * - dependents — transitive INCOMING closure (generalises `findDependents`):
10
+ * the nodes that DEPEND ON this node — its consumers and the
11
+ * things impacted if it changes (e.g. a widget that binds an
12
+ * object is the object's dependent). "What depends on this?"
13
+ * - dependencies — transitive OUTGOING closure (generalises `findDependencies`):
14
+ * the nodes this one DEPENDS ON — what it is built from /
15
+ * reads (e.g. an object's source record). "What does this
16
+ * depend on?"
17
+ *
18
+ * `ancestors` / `descendants` are kept ONLY as backward-compatible aliases of
19
+ * `dependents` / `dependencies` respectively — they read intuitively for the
20
+ * run→artifact case but mislead for objects/widgets/dashboards, so prefer the
21
+ * canonical names. The `direction` option accepts `dependents` | `dependencies`
22
+ * | `both` (and the legacy `ancestors` | `descendants`).
23
+ *
24
+ * One bounded, cycle-safe, deterministic BFS — the same skeleton the spine uses.
25
+ * No new graph, no new edges, no mutation, no secrets. `selectRunLineage` is the
26
+ * flat single-run ancestor of this module.
27
+ */
28
+
29
+ import { summarizeGraphNode } from "./workspace-metadata-graph.js";
30
+ import { deriveBlastRadius } from "./workspace-metadata-impact.js";
31
+
32
+ const PROVENANCE_KIND = "growthub-workspace-provenance-lineage-v1";
33
+ const PROVENANCE_VERSION = 1;
34
+
35
+ const DEFAULT_MAX_NODES = 500;
36
+
37
+ function safeString(value) {
38
+ if (value == null) return "";
39
+ return typeof value === "string" ? value : String(value);
40
+ }
41
+
42
+ /**
43
+ * Build the OUTGOING adjacency index once: `Map<fromId, Array<{ to, relation }>>`.
44
+ * (The INCOMING/dependents direction is NOT re-implemented here — it reuses the
45
+ * shipped `deriveBlastRadius` reverse closure, the single source of truth for
46
+ * incoming-edge traversal.)
47
+ */
48
+ function buildOutgoingIndex(graph) {
49
+ const adjacency = new Map();
50
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
51
+ for (const edge of edges) {
52
+ if (!edge || edge.from == null || edge.to == null) continue;
53
+ const key = String(edge.from);
54
+ if (!adjacency.has(key)) adjacency.set(key, []);
55
+ adjacency.get(key).push({ to: String(edge.to), relation: edge.relation });
56
+ }
57
+ return adjacency;
58
+ }
59
+
60
+ /**
61
+ * Bounded, cycle-safe BFS from `originId` over a pre-built adjacency index.
62
+ * Mirrors the blast-radius walk: FIFO queue → shortest path first, each node
63
+ * visited once, honest truncation past `maxNodes`.
64
+ */
65
+ function walk(adjacency, nodesById, originId, maxNodes) {
66
+ const visited = new Set([originId]);
67
+ const reached = [];
68
+ let truncated = false;
69
+ let maxDistanceReached = 0;
70
+ const queue = [{ id: originId, distance: 0 }];
71
+ while (queue.length) {
72
+ const current = queue.shift();
73
+ const neighbours = adjacency.get(current.id) || [];
74
+ for (const { to, relation } of neighbours) {
75
+ if (visited.has(to)) continue;
76
+ const node = nodesById.get(to);
77
+ if (!node) continue;
78
+ if (reached.length >= maxNodes) {
79
+ truncated = true;
80
+ continue;
81
+ }
82
+ visited.add(to);
83
+ const distance = current.distance + 1;
84
+ maxDistanceReached = Math.max(maxDistanceReached, distance);
85
+ reached.push({
86
+ id: node.id,
87
+ type: node.type,
88
+ label: node.label,
89
+ metadataId: node.metadataId,
90
+ distance,
91
+ viaRelation: relation
92
+ });
93
+ queue.push({ id: to, distance });
94
+ }
95
+ }
96
+ reached.sort((a, b) =>
97
+ a.distance - b.distance ||
98
+ a.type.localeCompare(b.type) ||
99
+ a.id.localeCompare(b.id)
100
+ );
101
+ return { reached, truncated, maxDistanceReached };
102
+ }
103
+
104
+ /**
105
+ * @param {object} graph a `buildWorkspaceMetadataGraph` envelope
106
+ * @param {string} originId the metadataId to trace lineage for
107
+ * @param {object} [options]
108
+ * @param {"dependents"|"dependencies"|"both"|"ancestors"|"descendants"} [options.direction="both"]
109
+ * @param {number} [options.maxNodes=500] hard cap PER direction
110
+ * @returns {object} `{ kind, version, origin, direction, dependents[], dependencies[],
111
+ * ancestors[] (alias of dependents), descendants[] (alias of dependencies),
112
+ * byType, truncated, summary, warnings }`
113
+ */
114
+ function deriveProvenanceLineage(graph, originId, options = {}) {
115
+ const maxNodes = Number.isFinite(options.maxNodes) && options.maxNodes > 0
116
+ ? Math.floor(options.maxNodes)
117
+ : DEFAULT_MAX_NODES;
118
+ // Normalise the requested direction to the canonical names (legacy aliases
119
+ // ancestors→dependents, descendants→dependencies).
120
+ const requested = safeString(options.direction) || "both";
121
+ const direction = requested === "ancestors" ? "dependents"
122
+ : requested === "descendants" ? "dependencies"
123
+ : ["dependents", "dependencies", "both"].includes(requested) ? requested
124
+ : "both";
125
+
126
+ const empty = (warning) => ({
127
+ kind: PROVENANCE_KIND,
128
+ version: PROVENANCE_VERSION,
129
+ origin: null,
130
+ direction,
131
+ dependents: [],
132
+ dependencies: [],
133
+ ancestors: [],
134
+ descendants: [],
135
+ byType: { dependents: {}, dependencies: {} },
136
+ truncated: false,
137
+ summary: "No lineage computed.",
138
+ warnings: warning ? [warning] : []
139
+ });
140
+
141
+ if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
142
+ return empty("graph missing or malformed");
143
+ }
144
+ const id = safeString(originId).trim();
145
+ if (!id) return empty("originId missing");
146
+
147
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
148
+ const originNode = nodesById.get(id);
149
+ if (!originNode) return empty(`origin "${id}" not found in graph`);
150
+
151
+ let dependents = [];
152
+ let dependencies = [];
153
+ let truncated = false;
154
+
155
+ if (direction === "dependents" || direction === "both") {
156
+ // Reuse the spine — `dependents` IS the transitive incoming (reverse) closure
157
+ // that deriveBlastRadius already computes. No second incoming BFS.
158
+ const blast = deriveBlastRadius(graph, id, { maxNodes });
159
+ dependents = blast.impacted;
160
+ truncated = truncated || blast.truncated;
161
+ }
162
+ if (direction === "dependencies" || direction === "both") {
163
+ const res = walk(buildOutgoingIndex(graph), nodesById, id, maxNodes);
164
+ dependencies = res.reached;
165
+ truncated = truncated || res.truncated;
166
+ }
167
+
168
+ const countByType = (entries) => {
169
+ const out = {};
170
+ for (const entry of entries) out[entry.type] = (out[entry.type] || 0) + 1;
171
+ return out;
172
+ };
173
+
174
+ return {
175
+ kind: PROVENANCE_KIND,
176
+ version: PROVENANCE_VERSION,
177
+ origin: summarizeGraphNode(originNode),
178
+ direction,
179
+ // Canonical names — match findDependents (incoming) / findDependencies (outgoing).
180
+ dependents,
181
+ dependencies,
182
+ // Backward-compatible aliases (deprecated; can mislead for non-run nodes).
183
+ ancestors: dependents,
184
+ descendants: dependencies,
185
+ byType: { dependents: countByType(dependents), dependencies: countByType(dependencies) },
186
+ truncated,
187
+ summary: summarizeLineage(originNode, dependents, dependencies, direction, truncated),
188
+ warnings: []
189
+ };
190
+ }
191
+
192
+ function summarizeLineage(originNode, dependents, dependencies, direction, truncated) {
193
+ const label = originNode?.label || originNode?.id || "node";
194
+ const tail = truncated ? " (truncated)" : "";
195
+ if (direction === "dependents") {
196
+ return dependents.length
197
+ ? `${dependents.length} node(s) depend on "${label}"${tail}.`
198
+ : `Nothing depends on "${label}".`;
199
+ }
200
+ if (direction === "dependencies") {
201
+ return dependencies.length
202
+ ? `"${label}" depends on ${dependencies.length} node(s)${tail}.`
203
+ : `"${label}" depends on nothing.`;
204
+ }
205
+ return `"${label}": ${dependents.length} dependent(s), ${dependencies.length} dependenc(ies)${tail}.`;
206
+ }
207
+
208
+ export {
209
+ PROVENANCE_KIND,
210
+ PROVENANCE_VERSION,
211
+ DEFAULT_MAX_NODES,
212
+ deriveProvenanceLineage,
213
+ summarizeLineage
214
+ };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Growthub Workspace Stale-Surfaces V1 — freshness-aware impact deriver.
3
+ *
4
+ * Answers the question the spine (`deriveBlastRadius`) cannot answer on its
5
+ * own: *given the changes the graph already records, which downstream surfaces
6
+ * are stale RIGHT NOW?* — with no change-event argument required.
7
+ *
8
+ * The metadata graph already timestamps the only nodes whose freshness is
9
+ * observable: `run.ranAt`, `sourceRecord.fetchedAt`, and
10
+ * `pipelineHealth.latestRanAt`. When one of those upstream nodes changes, the
11
+ * surfaces that DEPEND on it (its reverse-edge closure) are the surfaces now
12
+ * showing old data. This module seeds the EXISTING `deriveBlastRadius` closure
13
+ * from those freshly-changed nodes and labels the reachable dependents stale.
14
+ *
15
+ * It builds NO new graph and NO second traversal engine — it composes the
16
+ * shipped blast-radius deriver and the timestamps the graph already carries.
17
+ * Pure: no React, no fetch, no fs, no writes, no secrets. Deterministic:
18
+ * results are ordered (distance → type → id) and the union de-duplicates, so
19
+ * the output diffs cleanly between calls.
20
+ *
21
+ * Relationship to `selectStaleMetadataGroups` (single-hop, group-level): that
22
+ * selector answers "given THIS change event, which metadata GROUPS reload?".
23
+ * This deriver answers "given the timestamps already in the graph, which
24
+ * specific NODES are stale, transitively?". They are complementary; this one
25
+ * is node-level and needs no caller-supplied event.
26
+ */
27
+
28
+ import { deriveBlastRadius } from "./workspace-metadata-impact.js";
29
+
30
+ const STALE_SURFACES_KIND = "growthub-workspace-stale-surfaces-v1";
31
+ const STALE_SURFACES_VERSION = 1;
32
+
33
+ const DEFAULT_MAX_NODES = 500;
34
+
35
+ // Summary fields that observably timestamp a node's last refresh. Ordered by
36
+ // the precision of "this node changed at T". The newest of these wins.
37
+ const FRESHNESS_FIELDS = ["ranAt", "fetchedAt", "latestRanAt"];
38
+
39
+ function safeString(value) {
40
+ if (value == null) return "";
41
+ return typeof value === "string" ? value : String(value);
42
+ }
43
+
44
+ /**
45
+ * Parse a node's last-known-fresh timestamp (ms epoch) from its summary, or
46
+ * null when the node carries no observable freshness. Never throws.
47
+ */
48
+ function nodeFreshAt(node) {
49
+ const summary = node && typeof node === "object" ? node.summary : null;
50
+ if (!summary || typeof summary !== "object") return null;
51
+ let newest = null;
52
+ for (const field of FRESHNESS_FIELDS) {
53
+ const raw = summary[field];
54
+ if (!raw) continue;
55
+ const ms = Date.parse(safeString(raw));
56
+ if (Number.isNaN(ms)) continue;
57
+ if (newest == null || ms > newest) newest = ms;
58
+ }
59
+ return newest;
60
+ }
61
+
62
+ /**
63
+ * Compute the surfaces that are stale given the freshness already recorded in
64
+ * the graph.
65
+ *
66
+ * @param {object} graph a `buildWorkspaceMetadataGraph` envelope
67
+ * @param {object} [options]
68
+ * @param {string|number} [options.since] only treat nodes changed at/after
69
+ * this instant (ISO string or ms epoch) as seeds. Omit to seed from
70
+ * every node that carries a freshness timestamp.
71
+ * @param {string[]} [options.seedIds] explicit seed node ids (e.g. the nodes a
72
+ * just-applied PATCH touched). When given, `since` is ignored and these
73
+ * ids are the change set — this is the preflight/`plan` entry point.
74
+ * @param {number} [options.maxNodes=500] hard cap on stale surfaces.
75
+ * @returns {object} `{ kind, version, since, seeds[], staleSurfaces[], byType, total, truncated, summary, warnings }`
76
+ */
77
+ function deriveStaleSurfaces(graph, options = {}) {
78
+ const maxNodes = Number.isFinite(options.maxNodes) && options.maxNodes > 0
79
+ ? Math.floor(options.maxNodes)
80
+ : DEFAULT_MAX_NODES;
81
+
82
+ const empty = (warning) => ({
83
+ kind: STALE_SURFACES_KIND,
84
+ version: STALE_SURFACES_VERSION,
85
+ since: null,
86
+ seeds: [],
87
+ staleSurfaces: [],
88
+ byType: {},
89
+ total: 0,
90
+ truncated: false,
91
+ summary: "No stale surfaces computed.",
92
+ warnings: warning ? [warning] : []
93
+ });
94
+
95
+ if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
96
+ return empty("graph missing or malformed");
97
+ }
98
+
99
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
100
+
101
+ // ── Resolve the change set (the seeds) ──────────────────────────────────
102
+ let sinceMs = null;
103
+ let seeds = [];
104
+ const explicit = Array.isArray(options.seedIds) ? options.seedIds.map(safeString).filter(Boolean) : null;
105
+
106
+ if (explicit && explicit.length) {
107
+ for (const id of explicit) {
108
+ const node = nodesById.get(id);
109
+ if (node) seeds.push({ node, changedAtMs: nodeFreshAt(node) });
110
+ }
111
+ } else {
112
+ if (options.since != null) {
113
+ const raw = options.since;
114
+ sinceMs = typeof raw === "number" ? raw : Date.parse(safeString(raw));
115
+ if (Number.isNaN(sinceMs)) sinceMs = null;
116
+ }
117
+ for (const node of graph.nodes) {
118
+ const changedAtMs = nodeFreshAt(node);
119
+ if (changedAtMs == null) continue;
120
+ if (sinceMs != null && changedAtMs < sinceMs) continue;
121
+ seeds.push({ node, changedAtMs });
122
+ }
123
+ }
124
+
125
+ if (!seeds.length) {
126
+ const out = empty();
127
+ out.since = sinceMs;
128
+ out.summary = "No recently-changed nodes — nothing is stale.";
129
+ return out;
130
+ }
131
+
132
+ // ── Union the reverse closures of every seed (reuse the spine) ──────────
133
+ // A downstream node is stale relative to a seed when its own last-fresh
134
+ // timestamp predates the seed's change (or it carries none — it cannot prove
135
+ // freshness, so it is reported stale honestly rather than silently fresh).
136
+ const staleById = new Map();
137
+ for (const { node: seedNode, changedAtMs } of seeds) {
138
+ const blast = deriveBlastRadius(graph, seedNode.id, { maxNodes });
139
+ for (const impacted of blast.impacted) {
140
+ const target = nodesById.get(impacted.id);
141
+ const targetFreshAt = nodeFreshAt(target);
142
+ const isStale = changedAtMs == null
143
+ ? true
144
+ : targetFreshAt == null || targetFreshAt < changedAtMs;
145
+ if (!isStale) continue;
146
+ const existing = staleById.get(impacted.id);
147
+ // Keep the nearest / most-recent reason for each stale surface.
148
+ if (!existing || impacted.distance < existing.distance) {
149
+ staleById.set(impacted.id, {
150
+ id: impacted.id,
151
+ type: impacted.type,
152
+ label: impacted.label,
153
+ metadataId: impacted.metadataId,
154
+ distance: impacted.distance,
155
+ viaRelation: impacted.viaRelation,
156
+ staleSinceSeed: seedNode.id,
157
+ lastFreshAt: targetFreshAt != null ? new Date(targetFreshAt).toISOString() : null
158
+ });
159
+ }
160
+ }
161
+ }
162
+
163
+ let staleSurfaces = Array.from(staleById.values());
164
+ let truncated = false;
165
+ if (staleSurfaces.length > maxNodes) {
166
+ staleSurfaces = staleSurfaces.slice(0, maxNodes);
167
+ truncated = true;
168
+ }
169
+
170
+ staleSurfaces.sort((a, b) =>
171
+ a.distance - b.distance ||
172
+ a.type.localeCompare(b.type) ||
173
+ a.id.localeCompare(b.id)
174
+ );
175
+
176
+ const byType = {};
177
+ for (const entry of staleSurfaces) {
178
+ byType[entry.type] = (byType[entry.type] || 0) + 1;
179
+ }
180
+
181
+ return {
182
+ kind: STALE_SURFACES_KIND,
183
+ version: STALE_SURFACES_VERSION,
184
+ since: sinceMs,
185
+ seeds: seeds.map(({ node }) => ({ id: node.id, type: node.type, label: node.label, metadataId: node.metadataId })),
186
+ staleSurfaces,
187
+ byType,
188
+ total: staleSurfaces.length,
189
+ truncated,
190
+ summary: summarizeStaleSurfaces(seeds, staleSurfaces, byType, truncated),
191
+ warnings: []
192
+ };
193
+ }
194
+
195
+ /**
196
+ * One human sentence for the inspector chip / preflight line / CLI output.
197
+ * Pure string assembly — never throws.
198
+ */
199
+ function summarizeStaleSurfaces(seeds, staleSurfaces, byType, truncated) {
200
+ if (!staleSurfaces.length) {
201
+ return `${seeds.length} recent change(s) — no downstream surface is stale.`;
202
+ }
203
+ const parts = Object.keys(byType)
204
+ .sort()
205
+ .map((type) => `${byType[type]} ${type}`);
206
+ const tail = truncated ? " (truncated)" : "";
207
+ return `${seeds.length} recent change(s) leave ${staleSurfaces.length} surface(s) stale: ${parts.join(", ")}${tail}.`;
208
+ }
209
+
210
+ export {
211
+ STALE_SURFACES_KIND,
212
+ STALE_SURFACES_VERSION,
213
+ DEFAULT_MAX_NODES,
214
+ deriveStaleSurfaces,
215
+ summarizeStaleSurfaces,
216
+ nodeFreshAt
217
+ };