@topogram/cli 0.3.38 → 0.3.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.38",
3
+ "version": "0.3.39",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -6,7 +6,7 @@ import {
6
6
  reviewBoundaryForImportProposal
7
7
  } from "../policy/review-boundaries.js";
8
8
 
9
- const ADOPT_SELECTORS = new Set(["from-plan", "actors", "roles", "enums", "shapes", "entities", "capabilities", "docs", "journeys", "workflows", "verification", "ui"]);
9
+ const ADOPT_SELECTORS = new Set(["from-plan", "actors", "roles", "enums", "shapes", "entities", "capabilities", "components", "docs", "journeys", "workflows", "verification", "ui"]);
10
10
 
11
11
  function stableSortedStrings(values) {
12
12
  return [...new Set((values || []).filter(Boolean))].sort();
@@ -355,6 +355,7 @@ export function selectorMatchesItem(selector, item) {
355
355
  if (selector === "shapes") return item.kind === "shape";
356
356
  if (selector === "entities") return item.kind === "entity";
357
357
  if (selector === "capabilities") return item.kind === "capability";
358
+ if (selector === "components") return item.kind === "component";
358
359
  if (selector === "docs") return item.track === "docs";
359
360
  if (selector === "journeys") return item.track === "docs" && String(item.canonical_rel_path || "").startsWith("docs/journeys/");
360
361
  if (selector === "workflows") return item.track === "workflows" || item.kind === "decision";
@@ -21,7 +21,7 @@ function messageFromError(error) {
21
21
  /**
22
22
  * @param {string} templateName
23
23
  * @param {string|null} [source]
24
- * @returns {{ templateName: string, provenance: { id: string, source: string, package: string, version: string, packageSpec: string }|null }}
24
+ * @returns {{ templateName: string, provenance: { id: string, source: string, package: string, version: string, packageSpec: string, includesExecutableImplementation: boolean }|null }}
25
25
  */
26
26
  export function resolveCatalogTemplateAlias(templateName, source = null) {
27
27
  if (!isCatalogAliasCandidate(templateName)) {
@@ -47,7 +47,8 @@ export function resolveCatalogTemplateAlias(templateName, source = null) {
47
47
  source: loaded.source,
48
48
  package: entry.package,
49
49
  version: entry.defaultVersion,
50
- packageSpec
50
+ packageSpec,
51
+ includesExecutableImplementation: Boolean(entry.trust?.includesExecutableImplementation)
51
52
  }
52
53
  };
53
54
  } catch (error) {
package/src/cli.js CHANGED
@@ -238,6 +238,14 @@ function printUsage(options = {}) {
238
238
  console.log(" topogram import status ./imported-topogram");
239
239
  console.log(" topogram import history ./imported-topogram --verify");
240
240
  console.log("");
241
+ console.log("Fresh install:");
242
+ console.log(" npm install --save-dev @topogram/cli");
243
+ console.log(" npx topogram doctor");
244
+ console.log(" npx topogram template list");
245
+ console.log(" npx topogram new ./my-app --template hello-web");
246
+ console.log(" cd ./my-app && npm install && npm run check && npm run generate");
247
+ console.log(" npm --prefix app run compile");
248
+ console.log("");
241
249
  console.log("Template and catalog discovery:");
242
250
  console.log(" topogram template list");
243
251
  console.log(" topogram catalog list");
@@ -327,7 +335,7 @@ function printUsage(options = {}) {
327
335
  console.log("Targets: json-schema, docs, docs-index, verification-plan, verification-checklist, shape-transform-graph, shape-transform-debug, api-contract-graph, api-contract-debug, ui-contract-graph, ui-contract-debug, ui-component-contract, component-conformance-report, component-behavior-report, ui-web-contract, ui-web-debug, sveltekit-app, swiftui-app, db-contract-graph, db-contract-debug, db-schema-snapshot, db-migration-plan, db-lifecycle-plan, db-lifecycle-bundle, environment-plan, environment-bundle, deployment-plan, deployment-bundle, runtime-smoke-plan, runtime-smoke-bundle, runtime-check-plan, runtime-check-bundle, compile-check-plan, compile-check-bundle, app-bundle-plan, app-bundle, native-parity-plan, native-parity-bundle, sql-migration, sql-schema, prisma-schema, drizzle-schema, persistence-scaffold, server-contract, hono-server, openapi, context-digest, context-diff, context-slice, context-bundle, context-report, context-task-mode");
328
336
  console.log("Workflows: import-app, scan-docs, reconcile, adoption-status, generate-docs, generate-journeys, refresh-docs, report-gaps");
329
337
  console.log("Import tracks: db, api, ui, workflows, verification");
330
- console.log("Reconcile adopt selectors: from-plan, actors, roles, enums, shapes, entities, capabilities, docs, journeys, workflows, ui, bundle:<slug>, projection-review:<id>, ui-review:<id>, workflow-review:<id>, bundle-review:<slug>");
338
+ console.log("Reconcile adopt selectors: from-plan, actors, roles, enums, shapes, entities, capabilities, components, docs, journeys, workflows, ui, bundle:<slug>, projection-review:<id>, ui-review:<id>, workflow-review:<id>, bundle-review:<slug>");
331
339
  }
332
340
 
333
341
  function printNewHelp() {
@@ -336,6 +344,13 @@ function printNewHelp() {
336
344
  console.log("");
337
345
  console.log("Creates a new editable Topogram workspace from a template package or local template path.");
338
346
  console.log("");
347
+ console.log("Fresh install flow:");
348
+ console.log(" npm install --save-dev @topogram/cli");
349
+ console.log(" npx topogram template list");
350
+ console.log(" npx topogram new ./my-app --template hello-web");
351
+ console.log(" cd ./my-app && npm install && npm run check && npm run generate");
352
+ console.log(" npm --prefix app run compile");
353
+ console.log("");
339
354
  console.log("Examples:");
340
355
  console.log(" topogram new ./my-app");
341
356
  console.log(" topogram new --list-templates");
@@ -617,6 +632,12 @@ function printDoctorHelp() {
617
632
  console.log("");
618
633
  console.log("Checks local runtime, npm, public package access, CLI lockfile metadata, and catalog access.");
619
634
  console.log("");
635
+ console.log("Fresh install check:");
636
+ console.log(" npm install --save-dev @topogram/cli");
637
+ console.log(" npx topogram doctor");
638
+ console.log(" npx topogram template list");
639
+ console.log(" npx topogram new ./my-app --template hello-web");
640
+ console.log("");
620
641
  console.log("Related setup commands:");
621
642
  console.log(" topogram setup package-auth");
622
643
  console.log(" topogram setup catalog-auth");
@@ -5885,6 +5906,15 @@ function diagnosticForTemplateCreateFailure(message, templateSpec, step) {
5885
5906
  step
5886
5907
  });
5887
5908
  }
5909
+ if (message.includes("contains implementation/") && message.includes("includesExecutableImplementation: true")) {
5910
+ return templateCheckDiagnostic({
5911
+ code: "template_implementation_undeclared",
5912
+ message,
5913
+ path: localTemplatePath(templateSpec, "topogram-template.json"),
5914
+ suggestedFix: "Set includesExecutableImplementation to true after reviewing implementation/, or remove implementation/.",
5915
+ step
5916
+ });
5917
+ }
5888
5918
  if (message.includes("is missing required string field") || message.includes("topogram-template.json")) {
5889
5919
  return templateCheckDiagnostic({
5890
5920
  code: "template_manifest_invalid",
@@ -65,14 +65,71 @@ function projectionRealizesIds(projection) {
65
65
  return new Set((projection?.realizes || []).map((ref) => ref.id).filter(Boolean));
66
66
  }
67
67
 
68
- function projectionScreenMap(projection) {
68
+ function ownProjectionScreenMap(projection) {
69
69
  return new Map((projection?.uiScreens || []).map((screen) => [screen.id, screen]));
70
70
  }
71
71
 
72
- function projectionRegionKeys(projection) {
72
+ function ownProjectionRegionKeys(projection) {
73
73
  return new Set((projection?.uiScreenRegions || []).map((entry) => `${entry.screenId}:${entry.region}`));
74
74
  }
75
75
 
76
+ function projectionById(graph) {
77
+ return byId(graph.byKind.projection || []);
78
+ }
79
+
80
+ function projectionContext(graph, projection) {
81
+ const projections = [];
82
+ const seen = new Set();
83
+ const projectionsById = projectionById(graph);
84
+
85
+ function visit(current) {
86
+ if (!current || seen.has(current.id)) {
87
+ return;
88
+ }
89
+ seen.add(current.id);
90
+ projections.push(current);
91
+ for (const ref of current.realizes || []) {
92
+ const target = projectionsById.get(ref.id);
93
+ if (target) {
94
+ visit(target);
95
+ }
96
+ }
97
+ }
98
+
99
+ visit(projection);
100
+ return projections;
101
+ }
102
+
103
+ function projectionScreenMap(graph, projection) {
104
+ const screens = new Map();
105
+ for (const contextProjection of projectionContext(graph, projection).reverse()) {
106
+ for (const [id, screen] of ownProjectionScreenMap(contextProjection)) {
107
+ screens.set(id, screen);
108
+ }
109
+ }
110
+ return screens;
111
+ }
112
+
113
+ function projectionRegionKeys(graph, projection) {
114
+ const regions = new Set();
115
+ for (const contextProjection of projectionContext(graph, projection)) {
116
+ for (const key of ownProjectionRegionKeys(contextProjection)) {
117
+ regions.add(key);
118
+ }
119
+ }
120
+ return regions;
121
+ }
122
+
123
+ function projectionContextRealizesIds(graph, projection) {
124
+ const ids = new Set();
125
+ for (const contextProjection of projectionContext(graph, projection)) {
126
+ for (const id of projectionRealizesIds(contextProjection)) {
127
+ ids.add(id);
128
+ }
129
+ }
130
+ return ids;
131
+ }
132
+
76
133
  function checkRecord({
77
134
  code,
78
135
  severity,
@@ -122,9 +179,9 @@ function collectUsageChecks({ graph, projection, sourceProjection, usage, compon
122
179
  const eventNames = new Set(events.map((event) => event.id));
123
180
  const boundProps = new Set((usage.dataBindings || []).map((binding) => binding.prop).filter(Boolean));
124
181
  const statements = byId(graph.statements || []);
125
- const screens = projectionScreenMap(sourceProjection);
126
- const regionKeys = projectionRegionKeys(sourceProjection);
127
- const realizedIds = projectionRealizesIds(sourceProjection);
182
+ const screens = projectionScreenMap(graph, sourceProjection);
183
+ const regionKeys = projectionRegionKeys(graph, sourceProjection);
184
+ const realizedIds = projectionContextRealizesIds(graph, sourceProjection);
128
185
 
129
186
  if (!component) {
130
187
  checks.push(checkRecord({
@@ -270,13 +327,13 @@ function collectUsageChecks({ graph, projection, sourceProjection, usage, compon
270
327
  checks.push(checkRecord({
271
328
  code: "component_event_action_not_in_projection",
272
329
  severity: "error",
273
- message: `Event '${binding.event}' targets capability '${target.id}', but projection '${sourceProjection.id}' does not realize it.`,
330
+ message: `Event '${binding.event}' targets capability '${target.id}', but projection '${sourceProjection.id}' does not realize it through its UI context.`,
274
331
  projection,
275
332
  sourceProjection,
276
333
  component,
277
334
  usage,
278
335
  event: binding.event || null,
279
- suggestedFix: `Add '${target.id}' to projection '${sourceProjection.id}' realizes or choose a capability already in this projection context.`
336
+ suggestedFix: `Add '${target.id}' to projection '${sourceProjection.id}' or an inherited shared projection realizes list, or choose a capability already in this projection context.`
280
337
  }));
281
338
  }
282
339
  } else {
@@ -491,8 +548,8 @@ export function generateComponentConformanceReport(graph, options = {}) {
491
548
  source_projection: entry.sourceProjection.id === entry.projection.id ? null : summarizeProjection(entry.sourceProjection),
492
549
  screen: {
493
550
  id: entry.usage.screenId || null,
494
- kind: projectionScreenMap(entry.sourceProjection).get(entry.usage.screenId)?.kind || null,
495
- title: projectionScreenMap(entry.sourceProjection).get(entry.usage.screenId)?.title || null
551
+ kind: projectionScreenMap(graph, entry.sourceProjection).get(entry.usage.screenId)?.kind || null,
552
+ title: projectionScreenMap(graph, entry.sourceProjection).get(entry.usage.screenId)?.title || null
496
553
  },
497
554
  region: entry.usage.region || null,
498
555
  component: summarizeComponent(component) || { id: componentId, name: componentId, category: null, version: null, status: null, source_path: null },
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @typedef {{ id?: string, name?: string }} ComponentReference
5
- * @typedef {{ component?: ComponentReference, region?: string }} ComponentUsage
5
+ * @typedef {{ component?: ComponentReference, region?: string, pattern?: string }} ComponentUsage
6
6
  * @typedef {{ patterns?: string[] }} ComponentContract
7
7
  * @typedef {Record<string, ComponentContract>} ComponentContractMap
8
8
  * @typedef {{ itemsExpression?: string, componentContracts?: ComponentContractMap, useTypescript?: boolean }} RenderOptions
@@ -63,8 +63,22 @@ function componentPatterns(usage, componentContracts) {
63
63
  * @param {string} pattern
64
64
  * @returns {boolean}
65
65
  */
66
- function hasPattern(usage, componentContracts, pattern) {
67
- return componentPatterns(usage, componentContracts).includes(pattern);
66
+ function usagePattern(usage, componentContracts) {
67
+ return usage?.pattern || componentPatterns(usage, componentContracts)[0] || null;
68
+ }
69
+
70
+ export function reactComponentUsageSupport(usage, componentContracts) {
71
+ const pattern = usagePattern(usage, componentContracts);
72
+ return {
73
+ pattern,
74
+ supported: [
75
+ "summary_stats",
76
+ "board_view",
77
+ "calendar_view",
78
+ "resource_table",
79
+ "data_grid_view"
80
+ ].includes(pattern || "")
81
+ };
68
82
  }
69
83
 
70
84
  /**
@@ -198,16 +212,17 @@ function renderCalendar(usage, options) {
198
212
  */
199
213
  function renderUsage(usage, options) {
200
214
  const componentContracts = options.componentContracts || {};
201
- if (hasPattern(usage, componentContracts, "summary_stats")) {
215
+ const { pattern } = reactComponentUsageSupport(usage, componentContracts);
216
+ if (pattern === "summary_stats") {
202
217
  return renderSummaryStats(usage, options);
203
218
  }
204
- if (hasPattern(usage, componentContracts, "board_view")) {
219
+ if (pattern === "board_view") {
205
220
  return renderBoard(usage, options);
206
221
  }
207
- if (hasPattern(usage, componentContracts, "calendar_view")) {
222
+ if (pattern === "calendar_view") {
208
223
  return renderCalendar(usage, options);
209
224
  }
210
- if (hasPattern(usage, componentContracts, "resource_table") || hasPattern(usage, componentContracts, "data_grid_view")) {
225
+ if (pattern === "resource_table" || pattern === "data_grid_view") {
211
226
  return renderCollectionTable(usage, options);
212
227
  }
213
228
  return "";
@@ -1,6 +1,9 @@
1
1
  import { buildWebRealization } from "../../../realization/ui/index.js";
2
2
  import { getExampleImplementation } from "../../../example-implementation.js";
3
- import { renderReactComponentRegion } from "./react-components.js";
3
+ import {
4
+ reactComponentUsageSupport,
5
+ renderReactComponentRegion
6
+ } from "./react-components.js";
4
7
  import { renderApiClientModule, renderLookupModule, renderVisibilityModule } from "./shared.js";
5
8
 
6
9
  function componentNameForScreen(screenId) {
@@ -214,7 +217,21 @@ function buildReactGenerationCoverage(contract, files, routeScreens) {
214
217
  const componentUsages = screenComponentUsages(screen).map((usage) => {
215
218
  const componentId = usage.component?.id || null;
216
219
  const marker = componentId ? `data-topogram-component="${componentId}"` : null;
220
+ const support = reactComponentUsageSupport(usage, contract.components);
217
221
  const usageRendered = Boolean(marker && contents.includes(marker));
222
+ if (componentId && rendered && !support.supported) {
223
+ diagnostics.push({
224
+ code: "component_pattern_not_supported",
225
+ severity: "error",
226
+ screen: screen.id,
227
+ route: screen.route,
228
+ region: usage.region || null,
229
+ pattern: support.pattern || null,
230
+ component: componentId,
231
+ message: `Screen '${screen.id}' uses component '${componentId}' with unsupported React component pattern '${support.pattern || "(missing)"}'.`,
232
+ suggested_fix: "Use a supported component pattern for this generator or provide an implementation override."
233
+ });
234
+ }
218
235
  if (componentId && rendered && !usageRendered) {
219
236
  diagnostics.push({
220
237
  code: "component_usage_not_rendered",
@@ -230,6 +247,8 @@ function buildReactGenerationCoverage(contract, files, routeScreens) {
230
247
  return {
231
248
  component: componentId,
232
249
  region: usage.region || null,
250
+ pattern: support.pattern || null,
251
+ supported: support.supported,
233
252
  rendered: usageRendered,
234
253
  marker
235
254
  };
@@ -272,6 +291,15 @@ function buildReactGenerationCoverage(contract, files, routeScreens) {
272
291
  };
273
292
  }
274
293
 
294
+ function assertGenerationCoverage(coverage) {
295
+ const errors = (coverage.diagnostics || []).filter((diagnostic) => diagnostic.severity === "error");
296
+ if (errors.length === 0) {
297
+ return;
298
+ }
299
+ const details = errors.map((diagnostic) => diagnostic.message).join("; ");
300
+ throw new Error(`React generation coverage failed: ${details}`);
301
+ }
302
+
275
303
  function buildAppTsx(contract, webReference) {
276
304
  const navLinks = resolveNavLinks(contract, webReference);
277
305
  const brandName = contract.appShell?.brand || webReference.brandName;
@@ -528,7 +556,9 @@ button, .button-link { display: inline-flex; align-items: center; justify-conten
528
556
  files[screenPagePath(screen)] = buildReactScreenPage(screen, contract);
529
557
  }
530
558
 
531
- files["src/lib/topogram/generation-coverage.json"] = `${JSON.stringify(buildReactGenerationCoverage(contract, files, routeScreens), null, 2)}\n`;
559
+ const coverage = buildReactGenerationCoverage(contract, files, routeScreens);
560
+ assertGenerationCoverage(coverage);
561
+ files["src/lib/topogram/generation-coverage.json"] = `${JSON.stringify(coverage, null, 2)}\n`;
532
562
  return files;
533
563
  }
534
564
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @typedef {{ id?: string, name?: string }} ComponentReference
5
- * @typedef {{ component?: ComponentReference, region?: string }} ComponentUsage
5
+ * @typedef {{ component?: ComponentReference, region?: string, pattern?: string }} ComponentUsage
6
6
  * @typedef {{ patterns?: string[] }} ComponentContract
7
7
  * @typedef {Record<string, ComponentContract>} ComponentContractMap
8
8
  * @typedef {{ itemsExpression?: string, componentContracts?: ComponentContractMap, useTypescript?: boolean }} RenderOptions
@@ -54,8 +54,22 @@ function componentPatterns(usage, componentContracts) {
54
54
  * @param {string} pattern
55
55
  * @returns {boolean}
56
56
  */
57
- function hasPattern(usage, componentContracts, pattern) {
58
- return componentPatterns(usage, componentContracts).includes(pattern);
57
+ function usagePattern(usage, componentContracts) {
58
+ return usage?.pattern || componentPatterns(usage, componentContracts)[0] || null;
59
+ }
60
+
61
+ export function svelteKitComponentUsageSupport(usage, componentContracts) {
62
+ const pattern = usagePattern(usage, componentContracts);
63
+ return {
64
+ pattern,
65
+ supported: [
66
+ "summary_stats",
67
+ "board_view",
68
+ "calendar_view",
69
+ "resource_table",
70
+ "data_grid_view"
71
+ ].includes(pattern || "")
72
+ };
59
73
  }
60
74
 
61
75
  /**
@@ -184,16 +198,17 @@ function renderCalendar(usage, options) {
184
198
  */
185
199
  function renderUsage(usage, options) {
186
200
  const componentContracts = options.componentContracts || {};
187
- if (hasPattern(usage, componentContracts, "summary_stats")) {
201
+ const { pattern } = svelteKitComponentUsageSupport(usage, componentContracts);
202
+ if (pattern === "summary_stats") {
188
203
  return renderSummaryStats(usage, options);
189
204
  }
190
- if (hasPattern(usage, componentContracts, "board_view")) {
205
+ if (pattern === "board_view") {
191
206
  return renderBoard(usage, options);
192
207
  }
193
- if (hasPattern(usage, componentContracts, "calendar_view")) {
208
+ if (pattern === "calendar_view") {
194
209
  return renderCalendar(usage, options);
195
210
  }
196
- if (hasPattern(usage, componentContracts, "resource_table") || hasPattern(usage, componentContracts, "data_grid_view")) {
211
+ if (pattern === "resource_table" || pattern === "data_grid_view") {
197
212
  return renderCollectionTable(usage, options);
198
213
  }
199
214
  return "";
@@ -2,7 +2,10 @@ import { buildWebRealization } from "../../../realization/ui/index.js";
2
2
  import { lookupRouteSegment } from "../services/runtime-helpers.js";
3
3
  import { getExampleImplementation } from "../../../example-implementation.js";
4
4
  import { renderApiClientModule, renderLookupModule, renderVisibilityModule } from "./shared.js";
5
- import { renderSvelteKitComponentRegion } from "./sveltekit-components.js";
5
+ import {
6
+ renderSvelteKitComponentRegion,
7
+ svelteKitComponentUsageSupport
8
+ } from "./sveltekit-components.js";
6
9
 
7
10
  function routePathToSvelteKitDirectory(routePath) {
8
11
  if (!routePath || routePath === "/") {
@@ -186,7 +189,21 @@ function buildSvelteKitGenerationCoverage(contract, files, implementationScreenI
186
189
  const componentUsages = screenComponentUsages(screen).map((usage) => {
187
190
  const componentId = usage.component?.id || null;
188
191
  const marker = componentId ? `data-topogram-component="${componentId}"` : null;
192
+ const support = svelteKitComponentUsageSupport(usage, contract.components);
189
193
  const usageRendered = Boolean(marker && contents.includes(marker));
194
+ if (componentId && rendered && renderer !== "implementation" && !support.supported) {
195
+ diagnostics.push({
196
+ code: "component_pattern_not_supported",
197
+ severity: "error",
198
+ screen: screen.id,
199
+ route: screen.route,
200
+ region: usage.region || null,
201
+ pattern: support.pattern || null,
202
+ component: componentId,
203
+ message: `Screen '${screen.id}' uses component '${componentId}' with unsupported SvelteKit component pattern '${support.pattern || "(missing)"}'.`,
204
+ suggested_fix: "Use a supported component pattern for this generator or provide an implementation override."
205
+ });
206
+ }
190
207
  if (componentId && rendered && !usageRendered) {
191
208
  diagnostics.push({
192
209
  code: "component_usage_not_rendered",
@@ -202,6 +219,8 @@ function buildSvelteKitGenerationCoverage(contract, files, implementationScreenI
202
219
  return {
203
220
  component: componentId,
204
221
  region: usage.region || null,
222
+ pattern: support.pattern || null,
223
+ supported: support.supported,
205
224
  rendered: usageRendered,
206
225
  marker
207
226
  };
@@ -244,6 +263,15 @@ function buildSvelteKitGenerationCoverage(contract, files, implementationScreenI
244
263
  };
245
264
  }
246
265
 
266
+ function assertGenerationCoverage(coverage) {
267
+ const errors = (coverage.diagnostics || []).filter((diagnostic) => diagnostic.severity === "error");
268
+ if (errors.length === 0) {
269
+ return;
270
+ }
271
+ const details = errors.map((diagnostic) => diagnostic.message).join("; ");
272
+ throw new Error(`SvelteKit generation coverage failed: ${details}`);
273
+ }
274
+
247
275
  function resolveNavLinks(contract, webReference) {
248
276
  const contractLinks = (contract.navigation?.items || [])
249
277
  .filter((item) => item.visible && item.route && item.placement === "primary")
@@ -415,7 +443,9 @@ function buildSvelteKitScaffold(contract, apiContracts, options = {}) {
415
443
  }
416
444
  }
417
445
 
418
- files["src/lib/topogram/generation-coverage.json"] = `${JSON.stringify(buildSvelteKitGenerationCoverage(contract, files, implementationScreenIds), null, 2)}\n`;
446
+ const coverage = buildSvelteKitGenerationCoverage(contract, files, implementationScreenIds);
447
+ assertGenerationCoverage(coverage);
448
+ files["src/lib/topogram/generation-coverage.json"] = `${JSON.stringify(coverage, null, 2)}\n`;
419
449
  files["src/lib/topogram/ui-web-contract.json"] = `${JSON.stringify(contract, null, 2)}\n`;
420
450
  return files;
421
451
  }
@@ -122,6 +122,7 @@ const SURFACE_ORDER = new Map([
122
122
  * @property {string} package
123
123
  * @property {string} version
124
124
  * @property {string} packageSpec
125
+ * @property {boolean} [includesExecutableImplementation]
125
126
  */
126
127
 
127
128
  /**
@@ -351,6 +352,13 @@ function validateTemplateRoot(templateRoot) {
351
352
  `Template '${manifest.id}' declares executable implementation code but is missing implementation/.`
352
353
  );
353
354
  }
355
+ } else {
356
+ const implementationRoot = path.join(templateRoot, "implementation");
357
+ if (fs.existsSync(implementationRoot) && fs.statSync(implementationRoot).isDirectory()) {
358
+ throw new Error(
359
+ `Template '${manifest.id}' contains implementation/ but ${TEMPLATE_MANIFEST} does not declare includesExecutableImplementation: true.`
360
+ );
361
+ }
354
362
  }
355
363
  return manifest;
356
364
  }
@@ -2076,6 +2084,16 @@ export function createNewProject({
2076
2084
  const projectRoot = path.resolve(targetPath);
2077
2085
  assertProjectOutsideEngine(projectRoot, engineRoot);
2078
2086
  const template = resolveTemplate(templateName, templatesRoot);
2087
+ if (
2088
+ templateProvenance &&
2089
+ typeof templateProvenance.includesExecutableImplementation === "boolean" &&
2090
+ templateProvenance.includesExecutableImplementation !== Boolean(template.manifest.includesExecutableImplementation)
2091
+ ) {
2092
+ throw new Error(
2093
+ `Catalog entry '${templateProvenance.id}' declares includesExecutableImplementation: ${templateProvenance.includesExecutableImplementation}, ` +
2094
+ `but template package '${template.packageSpec || template.requested}' declares includesExecutableImplementation: ${Boolean(template.manifest.includesExecutableImplementation)}.`
2095
+ );
2096
+ }
2079
2097
 
2080
2098
  ensureCreatableProjectRoot(projectRoot);
2081
2099
  copyTopogramWorkspace(template.root, projectRoot);
@@ -84,12 +84,19 @@ function summarizeComponentRef(graph, componentId) {
84
84
  };
85
85
  }
86
86
 
87
- export function buildComponentUsageContract(graph, entry) {
87
+ function regionContractFor(regionEntries, regionName) {
88
+ return (regionEntries || []).find((entry) => entry.region === regionName) || null;
89
+ }
90
+
91
+ export function buildComponentUsageContract(graph, entry, options = {}) {
88
92
  const componentId = entry.component?.id || null;
89
93
  const contract = componentId ? componentContractFor(graph, componentId) : null;
94
+ const region = options.region || null;
90
95
  return {
91
96
  type: "ui_component_usage",
92
97
  region: entry.region || null,
98
+ pattern: region?.pattern || null,
99
+ placement: region?.placement || null,
93
100
  component: componentId ? summarizeComponentRef(graph, componentId) : null,
94
101
  dataBindings: (entry.dataBindings || []).map((binding) => ({
95
102
  prop: binding.prop || null,
@@ -255,7 +262,9 @@ function buildUiScreenContract(graph, projection, screen, ownershipFields) {
255
262
  state: entry.state || null,
256
263
  variant: entry.variant || null
257
264
  })),
258
- components: componentEntries.map((entry) => buildComponentUsageContract(graph, entry)),
265
+ components: componentEntries.map((entry) => buildComponentUsageContract(graph, entry, {
266
+ region: regionContractFor(regionEntries, entry.region)
267
+ })),
259
268
  patterns: [...patterns]
260
269
  };
261
270
  }
@@ -83,7 +83,9 @@ export function buildWebRealization(graph, options = {}) {
83
83
  if ((screen.components || []).some((usage) => componentUsageFingerprint(usage) === componentUsageFingerprintFromEntry(entry))) {
84
84
  continue;
85
85
  }
86
- screen.components = [...(screen.components || []), buildComponentUsageContract(graph, entry)];
86
+ screen.components = [...(screen.components || []), buildComponentUsageContract(graph, entry, {
87
+ region: (screen.regions || []).find((region) => region.region === entry.region) || null
88
+ })];
87
89
  }
88
90
 
89
91
  const appShell = projection.uiAppShell?.length || !sharedProjection ? concreteContract.appShell : sharedContract.appShell;
package/src/workflows.js CHANGED
@@ -1464,6 +1464,27 @@ function renderCandidateUiReportDoc(screen, routes, actions) {
1464
1464
  return renderMarkdownDoc(metadata, body);
1465
1465
  }
1466
1466
 
1467
+ function renderCandidateComponent(component) {
1468
+ const propName = component.data_prop || "rows";
1469
+ const pattern = component.pattern || "search_results";
1470
+ const region = component.region || "results";
1471
+ return ensureTrailingNewline(
1472
+ [
1473
+ `component ${component.id_hint} {`,
1474
+ ` name "${component.label || component.id_hint}"`,
1475
+ ' description "Candidate reusable component inferred from imported UI evidence. Review props, behavior, events, and reuse before adoption."',
1476
+ " category collection",
1477
+ " props {",
1478
+ ` ${propName} array required`,
1479
+ " }",
1480
+ ` patterns [${pattern}]`,
1481
+ ` regions [${region}]`,
1482
+ " status proposed",
1483
+ "}"
1484
+ ].join("\n")
1485
+ );
1486
+ }
1487
+
1467
1488
  function renderProjectionPatchDoc(patch) {
1468
1489
  const lines = [
1469
1490
  `# ${patch.projection_id} Patch Candidate`,
@@ -2393,6 +2414,7 @@ function getOrCreateCandidateBundle(bundles, conceptId, label) {
2393
2414
  enums: [],
2394
2415
  capabilities: [],
2395
2416
  shapes: [],
2417
+ components: [],
2396
2418
  screens: [],
2397
2419
  uiRoutes: [],
2398
2420
  uiActions: [],
@@ -3160,6 +3182,18 @@ function buildBundleAdoptionPlan(bundle, canonicalShapeIndex) {
3160
3182
  canonical_rel_path: `verifications/${dashedTopogramId(entry.id_hint)}.tg`
3161
3183
  });
3162
3184
  }
3185
+ for (const entry of bundle.components || []) {
3186
+ steps.push({
3187
+ action: "promote_component",
3188
+ item: entry.id_hint,
3189
+ target: null,
3190
+ confidence: entry.confidence || "low",
3191
+ inference_summary: entry.inference_summary || null,
3192
+ related_capabilities: [entry.data_source].filter(Boolean),
3193
+ source_path: `candidates/reconcile/model/bundles/${bundle.slug}/components/${entry.id_hint}.tg`,
3194
+ canonical_rel_path: `components/${dashedTopogramId(entry.id_hint)}.tg`
3195
+ });
3196
+ }
3163
3197
  for (const screen of bundle.screens) {
3164
3198
  steps.push({
3165
3199
  action: "promote_ui_report",
@@ -3640,7 +3674,7 @@ function bestContextBundleForCandidate(bundles, candidate) {
3640
3674
  function buildCandidateModelBundles(graph, appImport, topogramRoot) {
3641
3675
  const dbCandidates = appImport.candidates.db || { entities: [], enums: [] };
3642
3676
  const apiCandidates = appImport.candidates.api || { capabilities: [] };
3643
- const uiCandidates = appImport.candidates.ui || { screens: [], routes: [], actions: [] };
3677
+ const uiCandidates = appImport.candidates.ui || { screens: [], routes: [], actions: [], components: [] };
3644
3678
  const workflowCandidates = appImport.candidates.workflows || { workflows: [], workflow_states: [], workflow_transitions: [] };
3645
3679
  const verificationCandidates = appImport.candidates.verification || { verifications: [], scenarios: [], frameworks: [], scripts: [] };
3646
3680
  const docCandidates = appImport.candidates.docs || [];
@@ -3651,6 +3685,7 @@ function buildCandidateModelBundles(graph, appImport, topogramRoot) {
3651
3685
  const canonicalRoleIds = new Set((graph?.byKind.role || []).map((entry) => entry.id));
3652
3686
  const canonicalEntityIds = new Set((graph?.byKind.entity || []).map((entry) => entry.id));
3653
3687
  const canonicalEnumIds = new Set((graph?.byKind.enum || []).map((entry) => entry.id));
3688
+ const canonicalComponentIds = new Set((graph?.byKind.component || []).map((entry) => entry.id));
3654
3689
  const canonicalUi = collectCanonicalUiSurface(graph || { byKind: { projection: [] } });
3655
3690
  const canonicalWorkflow = collectCanonicalWorkflowSurface(graph || { byKind: { decision: [] }, docs: [] });
3656
3691
  const canonicalDocsByKind = new Map();
@@ -3749,6 +3784,24 @@ function buildCandidateModelBundles(graph, appImport, topogramRoot) {
3749
3784
  const bundle = getOrCreateCandidateBundle(bundles, conceptId, bundleLabelFromConceptId(conceptId || entry.screen_id || entry.id_hint));
3750
3785
  bundle.uiActions.push(entry);
3751
3786
  }
3787
+ function componentConceptId(entry) {
3788
+ if (entry.entity_id || entry.concept_id) {
3789
+ return entry.entity_id || entry.concept_id;
3790
+ }
3791
+ const screenStem = String(entry.screen_id || entry.id_hint || "")
3792
+ .replace(/_(list|index|table|grid|results)$/, "")
3793
+ .replace(/^list_/, "");
3794
+ return `entity_${canonicalCandidateTerm(screenStem || entry.id_hint)}`;
3795
+ }
3796
+
3797
+ for (const entry of uiCandidates.components || []) {
3798
+ if (canonicalComponentIds.has(entry.id_hint)) {
3799
+ continue;
3800
+ }
3801
+ const conceptId = componentConceptId(entry);
3802
+ const bundle = getOrCreateCandidateBundle(bundles, conceptId, bundleLabelFromConceptId(conceptId || entry.screen_id || entry.id_hint));
3803
+ bundle.components.push(entry);
3804
+ }
3752
3805
  for (const entry of workflowCandidates.workflows || []) {
3753
3806
  if (canonicalWorkflow.workflow_docs.includes(entry.id_hint)) {
3754
3807
  continue;
@@ -3834,6 +3887,7 @@ function buildCandidateModelBundles(graph, appImport, topogramRoot) {
3834
3887
  bundle.enums.length > 0 ||
3835
3888
  bundle.capabilities.length > 0 ||
3836
3889
  bundle.shapes.length > 0 ||
3890
+ bundle.components.length > 0 ||
3837
3891
  bundle.screens.length > 0 ||
3838
3892
  bundle.uiRoutes.length > 0 ||
3839
3893
  bundle.uiActions.length > 0 ||
@@ -3851,6 +3905,7 @@ function buildCandidateModelBundles(graph, appImport, topogramRoot) {
3851
3905
  enums: bundle.enums.sort((a, b) => a.id_hint.localeCompare(b.id_hint)),
3852
3906
  capabilities: bundle.capabilities.sort((a, b) => a.id_hint.localeCompare(b.id_hint)),
3853
3907
  shapes: bundle.shapes.sort((a, b) => a.id.localeCompare(b.id)),
3908
+ components: bundle.components.sort((a, b) => a.id_hint.localeCompare(b.id_hint)),
3854
3909
  screens: bundle.screens.sort((a, b) => a.id_hint.localeCompare(b.id_hint)),
3855
3910
  uiRoutes: bundle.uiRoutes.sort((a, b) => a.id_hint.localeCompare(b.id_hint)),
3856
3911
  uiActions: bundle.uiActions.sort((a, b) => a.id_hint.localeCompare(b.id_hint)),
@@ -3952,6 +4007,9 @@ function buildCandidateModelFiles(graph, appImport, topogramRoot) {
3952
4007
  for (const entry of bundle.verifications || []) {
3953
4008
  files[`${bundleRoot}/verifications/${entry.id_hint}.tg`] = renderCandidateVerification(entry, entry.scenarios || []);
3954
4009
  }
4010
+ for (const entry of bundle.components || []) {
4011
+ files[`${bundleRoot}/components/${entry.id_hint}.tg`] = renderCandidateComponent(entry);
4012
+ }
3955
4013
  for (const entry of bundle.docs) {
3956
4014
  if (entry.existing_canonical) {
3957
4015
  continue;
@@ -4021,6 +4079,8 @@ function canonicalRelativePathForItem(kind, item) {
4021
4079
  return `shapes/${dashedTopogramId(item)}.tg`;
4022
4080
  case "capability":
4023
4081
  return `capabilities/${dashedTopogramId(item)}.tg`;
4082
+ case "component":
4083
+ return `components/${dashedTopogramId(item)}.tg`;
4024
4084
  case "verification":
4025
4085
  return `verifications/${dashedTopogramId(item)}.tg`;
4026
4086
  default:
@@ -4048,6 +4108,8 @@ function candidateSourcePathForItem(bundle, kind, item) {
4048
4108
  return `${base}/shapes/${item}.tg`;
4049
4109
  case "capability":
4050
4110
  return `${base}/capabilities/${item}.tg`;
4111
+ case "component":
4112
+ return `${base}/components/${item}.tg`;
4051
4113
  case "verification":
4052
4114
  return `${base}/verifications/${item}.tg`;
4053
4115
  default:
@@ -4069,6 +4131,8 @@ function reasonForAdoptionItem(step) {
4069
4131
  return step.target ? `Promote this shape to support concept ${step.target}.` : "Promote this imported shape into canonical Topogram.";
4070
4132
  case "promote_capability":
4071
4133
  return "Promote this imported capability into canonical Topogram.";
4134
+ case "promote_component":
4135
+ return "Promote this imported reusable UI component into canonical Topogram.";
4072
4136
  case "merge_capability_into_existing_entity":
4073
4137
  return `Adopt this capability while preserving the existing canonical entity ${step.target}.`;
4074
4138
  case "promote_doc":
@@ -4114,6 +4178,9 @@ function recommendationForAdoptionItem(step) {
4114
4178
  if (step.action === "apply_projection_ownership_patch") {
4115
4179
  return `Update \`${step.target}\` with inferred ownership auth rules for ${(step.related_capabilities || []).map((item) => `\`${item}\``).join(", ") || "the related capabilities"}.`;
4116
4180
  }
4181
+ if (step.action === "promote_component") {
4182
+ return "Promote this reviewed component candidate before binding or reusing it from canonical projections.";
4183
+ }
4117
4184
  if (!["promote_actor", "promote_role"].includes(step.action)) {
4118
4185
  return null;
4119
4186
  }
@@ -4232,6 +4299,7 @@ function buildAdoptionPlan(bundles) {
4232
4299
  step.action.includes("doc") ? "doc" :
4233
4300
  step.action.includes("decision") ? "decision" :
4234
4301
  step.action.includes("verification") ? "verification" :
4302
+ step.action.includes("component") ? "component" :
4235
4303
  step.action.includes("ui_") ? "ui" :
4236
4304
  step.action.includes("actor") ? "actor" :
4237
4305
  step.action.includes("role") ? "role" :
@@ -4403,7 +4471,7 @@ function buildAdoptionPlan(bundles) {
4403
4471
  );
4404
4472
  }
4405
4473
 
4406
- const ADOPT_SELECTORS = new Set(["from-plan", "actors", "roles", "enums", "shapes", "entities", "capabilities", "docs", "journeys", "workflows", "verification", "ui"]);
4474
+ const ADOPT_SELECTORS = new Set(["from-plan", "actors", "roles", "enums", "shapes", "entities", "capabilities", "components", "docs", "journeys", "workflows", "verification", "ui"]);
4407
4475
 
4408
4476
  function readAdoptionPlan(paths) {
4409
4477
  return readJsonIfExists(path.join(paths.topogramRoot, "candidates", "reconcile", "adoption-plan.json"));