@topogram/cli 0.3.38 → 0.3.40
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 +1 -1
- package/src/adoption/plan.js +2 -1
- package/src/cli/catalog-alias.js +3 -2
- package/src/cli.js +33 -3
- package/src/generator/component-conformance.js +66 -9
- package/src/generator/surfaces/web/react-components.js +22 -7
- package/src/generator/surfaces/web/react.js +32 -2
- package/src/generator/surfaces/web/sveltekit-components.js +22 -7
- package/src/generator/surfaces/web/sveltekit.js +32 -2
- package/src/new-project.js +60 -0
- package/src/realization/ui/build-ui-shared-realization.js +11 -2
- package/src/realization/ui/build-web-realization.js +3 -1
- package/src/template-trust.js +48 -4
- package/src/workflows.js +70 -2
package/package.json
CHANGED
package/src/adoption/plan.js
CHANGED
|
@@ -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";
|
package/src/cli/catalog-alias.js
CHANGED
|
@@ -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",
|
|
@@ -6140,7 +6170,7 @@ function buildTemplateCheckPayload(templateSpec) {
|
|
|
6140
6170
|
configDir: projectConfigInfo.configDir
|
|
6141
6171
|
}
|
|
6142
6172
|
: null;
|
|
6143
|
-
if (implementationInfo && implementationRequiresTrust(implementationInfo)) {
|
|
6173
|
+
if (implementationInfo && implementationRequiresTrust(implementationInfo, projectConfigInfo.config)) {
|
|
6144
6174
|
const trustStatus = getTemplateTrustStatus(implementationInfo, projectConfigInfo.config);
|
|
6145
6175
|
const trustDiagnostics = trustStatus.issues.map((issue) => templateCheckDiagnostic({
|
|
6146
6176
|
code: "template_trust_invalid",
|
|
@@ -7766,7 +7796,7 @@ try {
|
|
|
7766
7796
|
configPath: projectConfigInfo.configPath,
|
|
7767
7797
|
configDir: projectConfigInfo.configDir
|
|
7768
7798
|
};
|
|
7769
|
-
if (implementationRequiresTrust(implementationInfo)) {
|
|
7799
|
+
if (implementationRequiresTrust(implementationInfo, projectConfigInfo.config)) {
|
|
7770
7800
|
const trustRecord = writeTemplateTrustRecord(projectConfigInfo.configDir, projectConfigInfo.config);
|
|
7771
7801
|
console.log(`Wrote ${TEMPLATE_TRUST_FILE} for ${trustRecord.implementation.module}.`);
|
|
7772
7802
|
if (trustRecord.template.id) {
|
|
@@ -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
|
|
68
|
+
function ownProjectionScreenMap(projection) {
|
|
69
69
|
return new Map((projection?.uiScreens || []).map((screen) => [screen.id, screen]));
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function
|
|
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 =
|
|
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
|
|
67
|
-
return componentPatterns(usage, componentContracts)
|
|
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
|
-
|
|
215
|
+
const { pattern } = reactComponentUsageSupport(usage, componentContracts);
|
|
216
|
+
if (pattern === "summary_stats") {
|
|
202
217
|
return renderSummaryStats(usage, options);
|
|
203
218
|
}
|
|
204
|
-
if (
|
|
219
|
+
if (pattern === "board_view") {
|
|
205
220
|
return renderBoard(usage, options);
|
|
206
221
|
}
|
|
207
|
-
if (
|
|
222
|
+
if (pattern === "calendar_view") {
|
|
208
223
|
return renderCalendar(usage, options);
|
|
209
224
|
}
|
|
210
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
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
|
|
58
|
-
return componentPatterns(usage, componentContracts)
|
|
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
|
-
|
|
201
|
+
const { pattern } = svelteKitComponentUsageSupport(usage, componentContracts);
|
|
202
|
+
if (pattern === "summary_stats") {
|
|
188
203
|
return renderSummaryStats(usage, options);
|
|
189
204
|
}
|
|
190
|
-
if (
|
|
205
|
+
if (pattern === "board_view") {
|
|
191
206
|
return renderBoard(usage, options);
|
|
192
207
|
}
|
|
193
|
-
if (
|
|
208
|
+
if (pattern === "calendar_view") {
|
|
194
209
|
return renderCalendar(usage, options);
|
|
195
210
|
}
|
|
196
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/new-project.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -330,6 +331,34 @@ function readTemplateManifest(templateRoot) {
|
|
|
330
331
|
return validateTemplateManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
/**
|
|
335
|
+
* @param {string} root
|
|
336
|
+
* @param {string} currentDir
|
|
337
|
+
* @param {string} label
|
|
338
|
+
* @param {string} templateId
|
|
339
|
+
* @returns {void}
|
|
340
|
+
*/
|
|
341
|
+
function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
|
|
342
|
+
const rootStat = fs.lstatSync(currentDir);
|
|
343
|
+
const relativeRoot = path.relative(root, currentDir).replace(/\\/g, "/") || label;
|
|
344
|
+
if (rootStat.isSymbolicLink()) {
|
|
345
|
+
throw new Error(`Template '${templateId}' contains unsupported symlink '${relativeRoot}'.`);
|
|
346
|
+
}
|
|
347
|
+
if (!rootStat.isDirectory()) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
351
|
+
const entryPath = path.join(currentDir, entry.name);
|
|
352
|
+
const relativePath = path.relative(root, entryPath).replace(/\\/g, "/");
|
|
353
|
+
if (entry.isSymbolicLink()) {
|
|
354
|
+
throw new Error(`Template '${templateId}' contains unsupported symlink '${relativePath}'.`);
|
|
355
|
+
}
|
|
356
|
+
if (entry.isDirectory()) {
|
|
357
|
+
assertTemplateTreeHasNoSymlinks(root, entryPath, label, templateId);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
333
362
|
/**
|
|
334
363
|
* @param {string} templateRoot
|
|
335
364
|
* @returns {TemplateManifest}
|
|
@@ -338,19 +367,37 @@ function validateTemplateRoot(templateRoot) {
|
|
|
338
367
|
const manifest = readTemplateManifest(templateRoot);
|
|
339
368
|
const topogramRoot = path.join(templateRoot, "topogram");
|
|
340
369
|
const projectConfigPath = path.join(templateRoot, "topogram.project.json");
|
|
370
|
+
if (fs.existsSync(topogramRoot) && fs.lstatSync(topogramRoot).isSymbolicLink()) {
|
|
371
|
+
throw new Error(`Template '${manifest.id}' contains unsupported symlink 'topogram'.`);
|
|
372
|
+
}
|
|
373
|
+
if (fs.existsSync(projectConfigPath) && fs.lstatSync(projectConfigPath).isSymbolicLink()) {
|
|
374
|
+
throw new Error(`Template '${manifest.id}' contains unsupported symlink 'topogram.project.json'.`);
|
|
375
|
+
}
|
|
341
376
|
if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
|
|
342
377
|
throw new Error(`Template '${manifest.id}' is missing topogram/.`);
|
|
343
378
|
}
|
|
344
379
|
if (!fs.existsSync(projectConfigPath) || !fs.statSync(projectConfigPath).isFile()) {
|
|
345
380
|
throw new Error(`Template '${manifest.id}' is missing topogram.project.json.`);
|
|
346
381
|
}
|
|
382
|
+
assertTemplateTreeHasNoSymlinks(templateRoot, topogramRoot, "topogram", manifest.id);
|
|
347
383
|
if (manifest.includesExecutableImplementation) {
|
|
348
384
|
const implementationRoot = path.join(templateRoot, "implementation");
|
|
385
|
+
if (fs.existsSync(implementationRoot) && fs.lstatSync(implementationRoot).isSymbolicLink()) {
|
|
386
|
+
throw new Error(`Template '${manifest.id}' contains unsupported symlink 'implementation'.`);
|
|
387
|
+
}
|
|
349
388
|
if (!fs.existsSync(implementationRoot) || !fs.statSync(implementationRoot).isDirectory()) {
|
|
350
389
|
throw new Error(
|
|
351
390
|
`Template '${manifest.id}' declares executable implementation code but is missing implementation/.`
|
|
352
391
|
);
|
|
353
392
|
}
|
|
393
|
+
assertTemplateTreeHasNoSymlinks(templateRoot, implementationRoot, "implementation", manifest.id);
|
|
394
|
+
} else {
|
|
395
|
+
const implementationRoot = path.join(templateRoot, "implementation");
|
|
396
|
+
if (fs.existsSync(implementationRoot) && fs.statSync(implementationRoot).isDirectory()) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`Template '${manifest.id}' contains implementation/ but ${TEMPLATE_MANIFEST} does not declare includesExecutableImplementation: true.`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
354
401
|
}
|
|
355
402
|
return manifest;
|
|
356
403
|
}
|
|
@@ -1121,6 +1168,9 @@ function collectFiles(root, currentDir, files) {
|
|
|
1121
1168
|
continue;
|
|
1122
1169
|
}
|
|
1123
1170
|
const entryPath = path.join(currentDir, entry.name);
|
|
1171
|
+
if (entry.isSymbolicLink()) {
|
|
1172
|
+
throw new Error(`Template-owned files cannot include symlink '${path.relative(root, entryPath).replace(/\\/g, "/")}'.`);
|
|
1173
|
+
}
|
|
1124
1174
|
if (entry.isDirectory()) {
|
|
1125
1175
|
collectFiles(root, entryPath, files);
|
|
1126
1176
|
continue;
|
|
@@ -2076,6 +2126,16 @@ export function createNewProject({
|
|
|
2076
2126
|
const projectRoot = path.resolve(targetPath);
|
|
2077
2127
|
assertProjectOutsideEngine(projectRoot, engineRoot);
|
|
2078
2128
|
const template = resolveTemplate(templateName, templatesRoot);
|
|
2129
|
+
if (
|
|
2130
|
+
templateProvenance &&
|
|
2131
|
+
typeof templateProvenance.includesExecutableImplementation === "boolean" &&
|
|
2132
|
+
templateProvenance.includesExecutableImplementation !== Boolean(template.manifest.includesExecutableImplementation)
|
|
2133
|
+
) {
|
|
2134
|
+
throw new Error(
|
|
2135
|
+
`Catalog entry '${templateProvenance.id}' declares includesExecutableImplementation: ${templateProvenance.includesExecutableImplementation}, ` +
|
|
2136
|
+
`but template package '${template.packageSpec || template.requested}' declares includesExecutableImplementation: ${Boolean(template.manifest.includesExecutableImplementation)}.`
|
|
2137
|
+
);
|
|
2138
|
+
}
|
|
2079
2139
|
|
|
2080
2140
|
ensureCreatableProjectRoot(projectRoot);
|
|
2081
2141
|
copyTopogramWorkspace(template.root, projectRoot);
|
|
@@ -84,12 +84,19 @@ function summarizeComponentRef(graph, componentId) {
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
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/template-trust.js
CHANGED
|
@@ -185,12 +185,16 @@ function collectImplementationFiles(implementationRoot, currentDir, files) {
|
|
|
185
185
|
continue;
|
|
186
186
|
}
|
|
187
187
|
const entryPath = path.join(currentDir, entry.name);
|
|
188
|
+
const relativePath = normalizeRelativePath(path.relative(implementationRoot, entryPath));
|
|
189
|
+
if (entry.isSymbolicLink()) {
|
|
190
|
+
throw new Error(`Template implementation contains unsupported symlink '${relativePath}'.`);
|
|
191
|
+
}
|
|
188
192
|
if (entry.isDirectory()) {
|
|
189
193
|
collectImplementationFiles(implementationRoot, entryPath, files);
|
|
190
194
|
continue;
|
|
191
195
|
}
|
|
192
196
|
if (entry.isFile()) {
|
|
193
|
-
files.push(
|
|
197
|
+
files.push(relativePath);
|
|
194
198
|
}
|
|
195
199
|
}
|
|
196
200
|
}
|
|
@@ -250,17 +254,38 @@ export function implementationTrustFingerprint(config) {
|
|
|
250
254
|
};
|
|
251
255
|
}
|
|
252
256
|
|
|
257
|
+
/**
|
|
258
|
+
* @param {{ config: Record<string, any>, configDir: string }} implementationInfo
|
|
259
|
+
* @param {Record<string, any>|null} [projectConfig]
|
|
260
|
+
* @returns {boolean}
|
|
261
|
+
*/
|
|
262
|
+
export function implementationRequiresTrust(implementationInfo, projectConfig = null) {
|
|
263
|
+
const fingerprint = implementationTrustFingerprint(implementationInfo.config);
|
|
264
|
+
const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
|
|
265
|
+
const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
|
|
266
|
+
return isSameOrInside(implementationRoot, modulePath) || projectHasTemplateAttachment(projectConfig);
|
|
267
|
+
}
|
|
268
|
+
|
|
253
269
|
/**
|
|
254
270
|
* @param {{ config: Record<string, any>, configDir: string }} implementationInfo
|
|
255
271
|
* @returns {boolean}
|
|
256
272
|
*/
|
|
257
|
-
|
|
273
|
+
function implementationModuleIsUnderRoot(implementationInfo) {
|
|
258
274
|
const fingerprint = implementationTrustFingerprint(implementationInfo.config);
|
|
259
275
|
const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
|
|
260
276
|
const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
|
|
261
277
|
return isSameOrInside(implementationRoot, modulePath);
|
|
262
278
|
}
|
|
263
279
|
|
|
280
|
+
/**
|
|
281
|
+
* @param {Record<string, any>|null} projectConfig
|
|
282
|
+
* @returns {boolean}
|
|
283
|
+
*/
|
|
284
|
+
function projectHasTemplateAttachment(projectConfig) {
|
|
285
|
+
const template = projectConfig?.template || null;
|
|
286
|
+
return Boolean(template?.id || template?.sourceSpec || template?.requested);
|
|
287
|
+
}
|
|
288
|
+
|
|
264
289
|
/**
|
|
265
290
|
* @param {string} configDir
|
|
266
291
|
* @returns {TemplateTrustRecord|null}
|
|
@@ -312,6 +337,14 @@ export function writeTemplateTrustRecord(configDir, projectConfig) {
|
|
|
312
337
|
if (!implementationConfig) {
|
|
313
338
|
throw new Error("Cannot trust template implementation because topogram.project.json has no implementation config.");
|
|
314
339
|
}
|
|
340
|
+
const implementationInfo = {
|
|
341
|
+
config: implementationConfig,
|
|
342
|
+
configDir
|
|
343
|
+
};
|
|
344
|
+
if (!implementationModuleIsUnderRoot(implementationInfo)) {
|
|
345
|
+
const implementation = implementationTrustFingerprint(implementationConfig);
|
|
346
|
+
throw new Error(`Template implementation module '${implementation.module}' must be under implementation/.`);
|
|
347
|
+
}
|
|
315
348
|
const implementation = implementationTrustFingerprint(implementationConfig);
|
|
316
349
|
const record = buildTrustRecord(configDir, projectConfig, implementation);
|
|
317
350
|
fs.writeFileSync(path.join(configDir, TEMPLATE_TRUST_FILE), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
@@ -393,7 +426,8 @@ function implementationReviewFile(configDir, relativePath, file) {
|
|
|
393
426
|
* @returns {{ ok: boolean, requiresTrust: boolean, trustPath: string, trustRecord: TemplateTrustRecord|null, template: { id: string|null, version: string|null, source: string|null, sourceSpec: string|null, requested: string|null, sourceRoot: string|null, catalog?: Record<string, any>|null, includesExecutableImplementation: boolean|null }, implementation: { id: string|null, module: string|null, export: string|null }, content: { trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }, issues: string[] }}
|
|
394
427
|
*/
|
|
395
428
|
export function getTemplateTrustStatus(implementationInfo, projectConfig = null) {
|
|
396
|
-
|
|
429
|
+
const templateAttached = projectHasTemplateAttachment(projectConfig);
|
|
430
|
+
if (!implementationRequiresTrust(implementationInfo, projectConfig)) {
|
|
397
431
|
return {
|
|
398
432
|
ok: true,
|
|
399
433
|
requiresTrust: false,
|
|
@@ -406,6 +440,7 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
|
|
|
406
440
|
};
|
|
407
441
|
}
|
|
408
442
|
const fingerprint = implementationTrustFingerprint(implementationInfo.config);
|
|
443
|
+
const moduleInsideImplementation = implementationModuleIsUnderRoot(implementationInfo);
|
|
409
444
|
const trustRecord = readTemplateTrustRecord(implementationInfo.configDir);
|
|
410
445
|
const configLabel = implementationInfo.configPath || "topogram.project.json";
|
|
411
446
|
const trustPath = path.join(implementationInfo.configDir, TEMPLATE_TRUST_FILE);
|
|
@@ -415,6 +450,12 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
|
|
|
415
450
|
/** @type {{ trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }} */
|
|
416
451
|
const contentStatus = { trustedDigest: null, currentDigest: null, added: [], removed: [], changed: [] };
|
|
417
452
|
|
|
453
|
+
if (templateAttached && !moduleInsideImplementation) {
|
|
454
|
+
issues.push(
|
|
455
|
+
`Template implementation module '${fingerprint.module}' must be under implementation/ for template-attached projects`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
418
459
|
if (!trustRecord) {
|
|
419
460
|
issues.push(
|
|
420
461
|
`Refusing to load executable implementation '${fingerprint.module}' from ${normalizeRoot(configLabel)} without ${TEMPLATE_TRUST_FILE}`
|
|
@@ -441,7 +482,10 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
|
|
|
441
482
|
issues.push(`${TEMPLATE_TRUST_FILE} trusts template version '${trustRecord.template?.version}', but topogram.project.json declares '${projectTemplate.version}'`);
|
|
442
483
|
}
|
|
443
484
|
|
|
444
|
-
if (!
|
|
485
|
+
if (!moduleInsideImplementation) {
|
|
486
|
+
// The module itself is outside the only supported trust root. Do not
|
|
487
|
+
// pretend the implementation/ content digest covers the executable code.
|
|
488
|
+
} else if (!trustRecord.content) {
|
|
445
489
|
issues.push(`${TEMPLATE_TRUST_FILE} is missing implementation content hashes`);
|
|
446
490
|
} else if (trustRecord.content.algorithm !== "sha256") {
|
|
447
491
|
issues.push(`${TEMPLATE_TRUST_FILE} uses unsupported content hash algorithm '${trustRecord.content.algorithm}'`);
|
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"));
|