@topogram/cli 0.3.81 → 0.3.82

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.81",
3
+ "version": "0.3.82",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -15,6 +15,8 @@ export function summarizeDiffArtifact(diffArtifact) {
15
15
  review_boundary_change_count: (diffArtifact.review_boundary_changes || []).length,
16
16
  maintained_file_count: (diffArtifact.affected_maintained_surfaces?.maintained_files_in_scope || []).length,
17
17
  affected_verification_count: (diffArtifact.affected_verifications || []).length,
18
+ widget_migration_count: (diffArtifact.widget_contract_migration_plan?.widgets || []).length,
19
+ widget_migration_projection_count: (diffArtifact.widget_contract_migration_plan?.affected_projection_ids || []).length,
18
20
  affected_output_count: maintainedOutputs.length,
19
21
  affected_seam_count: maintainedSeams.length,
20
22
  highest_maintained_severity: highestMaintainedSeverity
@@ -19,6 +19,9 @@ export function parseSdlcCommandArgs(args) {
19
19
  if (args[0] === "sdlc" && args[1] === "prep" && args[2] === "commit") {
20
20
  return { sdlcCommand: "prep:commit", inputPath: commandPath(args, 3, ".") };
21
21
  }
22
+ if (args[0] === "sdlc" && args[1] === "audit") {
23
+ return { sdlcCommand: "audit", inputPath: commandPath(args, 2, ".") };
24
+ }
22
25
  if (args[0] === "sdlc" && args[1] === "link") {
23
26
  return {
24
27
  sdlcCommand: "link",
@@ -220,6 +220,21 @@ export async function runSdlcCommand(context) {
220
220
  return result.ok ? 0 : 1;
221
221
  }
222
222
 
223
+ if (commandArgs.sdlcCommand === "audit") {
224
+ const { auditWorkspace } = await import("../../sdlc/audit.js");
225
+ const resolved = resolveSdlcWorkspace(sdlcRoot);
226
+ if (!resolved) {
227
+ return 1;
228
+ }
229
+ const result = auditWorkspace(sdlcRoot, resolved);
230
+ if (json) {
231
+ console.log(stableStringify(result));
232
+ } else {
233
+ printSdlcAudit(result);
234
+ }
235
+ return result.ok ? 0 : 1;
236
+ }
237
+
223
238
  if (commandArgs.sdlcCommand === "link") {
224
239
  const { linkSdlcRecord } = await import("../../sdlc/link.js");
225
240
  const result = linkSdlcRecord(sdlcRoot, commandArgs.sdlcFromId, commandArgs.sdlcToId, {
@@ -374,3 +389,30 @@ export async function runSdlcCommand(context) {
374
389
 
375
390
  throw new Error(`Unknown sdlc command '${commandArgs.sdlcCommand}'`);
376
391
  }
392
+
393
+ /**
394
+ * @param {AnyRecord} result
395
+ */
396
+ function printSdlcAudit(result) {
397
+ console.log("SDLC audit");
398
+ console.log(`Workspace: ${result.workspaceRoot}`);
399
+ console.log(`Draft requirements with completed task evidence: ${result.counts?.draftRequirementsWithCompletedTasks || 0}`);
400
+ console.log(`Draft acceptance criteria with completed task evidence: ${result.counts?.draftAcceptanceCriteriaWithCompletedTasks || 0}`);
401
+ console.log(`Approved acceptance criteria with draft parent requirements: ${result.counts?.approvedAcceptanceCriteriaWithDraftRequirements || 0}`);
402
+ console.log(`Done tasks with draft refs: ${result.counts?.doneTasksWithDraftReferences || 0}`);
403
+ console.log(`Remaining draft backlog: ${result.counts?.remainingDraftPitches || 0} pitch(es), ${result.counts?.remainingDraftRequirements || 0} requirement(s), ${result.counts?.remainingDraftAcceptanceCriteria || 0} acceptance criterion/criteria`);
404
+ const findings = [
405
+ ...(result.findings?.draftRequirementsWithCompletedTasks || []),
406
+ ...(result.findings?.draftAcceptanceCriteriaWithCompletedTasks || []),
407
+ ...(result.findings?.approvedAcceptanceCriteriaWithDraftRequirements || []),
408
+ ...(result.findings?.doneTasksWithDraftReferences || [])
409
+ ];
410
+ if (findings.length > 0) {
411
+ console.log("Actionable findings:");
412
+ for (const finding of findings.slice(0, 10)) {
413
+ console.log(`- ${finding.id}: ${finding.recommendedCommand || "review status"}`);
414
+ }
415
+ } else {
416
+ console.log("Actionable findings: none");
417
+ }
418
+ }
package/src/cli/help.js CHANGED
@@ -19,6 +19,7 @@ export function printUsage(options = {}) {
19
19
  console.log(" or: topogram sdlc policy init|check|explain [path] [--json]");
20
20
  console.log(" or: topogram sdlc gate [path] --base <ref> --head <ref> [--sdlc-id <id>] [--exemption <text>] [--require-adopted] [--json]");
21
21
  console.log(" or: topogram sdlc prep commit [path] [--base <ref> --head <ref>] [--json]");
22
+ console.log(" or: topogram sdlc audit [path] [--json]");
22
23
  console.log(" or: topogram sdlc link <from-id> <to-id> [path] [--write]");
23
24
  console.log(" or: topogram sdlc complete <task-id> [path] --verification <verification-id> [--dry-run|--write]");
24
25
  console.log(" or: topogram sdlc plan create <task-id> <slug> [path] [--write]");
@@ -94,6 +95,7 @@ export function printUsage(options = {}) {
94
95
  console.log(" topogram agent brief");
95
96
  console.log(" topogram agent brief --json");
96
97
  console.log(" topogram sdlc policy explain");
98
+ console.log(" topogram sdlc audit . --json");
97
99
  console.log(" topogram sdlc prep commit . --base origin/main --head HEAD");
98
100
  console.log(" topogram sdlc gate . --require-adopted");
99
101
  console.log(" topogram sdlc plan explain plan_example --json");
@@ -128,6 +128,101 @@ function collectAffectedProjectionIds(graph, baselineGraph, diff) {
128
128
  ]);
129
129
  }
130
130
 
131
+ const WIDGET_CONTRACT_SECTIONS = [
132
+ "category",
133
+ "version",
134
+ "status",
135
+ "props",
136
+ "events",
137
+ "slots",
138
+ "behavior",
139
+ "behaviors",
140
+ "patterns",
141
+ "regions",
142
+ "approvals",
143
+ "lookups",
144
+ "dependencies"
145
+ ];
146
+
147
+ function changedWidgetContractSections(entry) {
148
+ if (entry.classification === "additive") {
149
+ return WIDGET_CONTRACT_SECTIONS.filter((section) => entry.current?.[section] != null);
150
+ }
151
+ if (entry.classification === "removed") {
152
+ return WIDGET_CONTRACT_SECTIONS.filter((section) => entry.baseline?.[section] != null);
153
+ }
154
+ return WIDGET_CONTRACT_SECTIONS.filter((section) =>
155
+ JSON.stringify(entry.current?.[section] ?? null) !== JSON.stringify(entry.baseline?.[section] ?? null)
156
+ );
157
+ }
158
+
159
+ function affectedProjectionIdsForWidgetChange(graph, baselineGraph, entry) {
160
+ if (entry.classification === "additive") {
161
+ return relatedProjectionsForWidget(graph, entry.id);
162
+ }
163
+ if (entry.classification === "removed") {
164
+ return relatedProjectionsForWidget(baselineGraph, entry.id);
165
+ }
166
+ return stableSortedStrings([
167
+ ...relatedProjectionsForWidget(graph, entry.id),
168
+ ...relatedProjectionsForWidget(baselineGraph, entry.id)
169
+ ]);
170
+ }
171
+
172
+ function projectionMigrationCommands(widgetId, projectionId) {
173
+ return [
174
+ {
175
+ target: "ui-widget-contract",
176
+ command: `topogram emit ui-widget-contract ./topo --widget ${widgetId} --json`,
177
+ reason: `Refresh the normalized widget contract for ${widgetId}.`
178
+ },
179
+ {
180
+ target: "widget-conformance-report",
181
+ command: `topogram emit widget-conformance-report ./topo --projection ${projectionId} --json`,
182
+ reason: `Review projection ${projectionId} widget placement, required props, events, and region/pattern compatibility.`
183
+ },
184
+ {
185
+ target: "widget-behavior-report",
186
+ command: `topogram widget behavior ./topo --projection ${projectionId} --widget ${widgetId} --json`,
187
+ reason: `Review behavior data/event/action wiring for ${widgetId} on projection ${projectionId}.`
188
+ },
189
+ {
190
+ target: "ui-surface-contract",
191
+ command: `topogram emit ui-surface-contract ./topo --projection ${projectionId} --json`,
192
+ reason: `Refresh the surface contract consumed by stack generators for projection ${projectionId}.`
193
+ }
194
+ ];
195
+ }
196
+
197
+ function buildWidgetContractMigrationPlan(graph, baselineGraph, diff) {
198
+ const widgets = (diff.widgets || []).map((entry) => {
199
+ const affectedProjectionIds = affectedProjectionIdsForWidgetChange(graph, baselineGraph, entry);
200
+ return {
201
+ widget_id: entry.id,
202
+ classification: entry.classification,
203
+ changed_sections: changedWidgetContractSections(entry),
204
+ affected_projection_ids: affectedProjectionIds,
205
+ affected_projections: affectedProjectionIds
206
+ .map((id) => summarizeById(graph, id) || summarizeById(baselineGraph, id))
207
+ .filter(Boolean),
208
+ review_commands: affectedProjectionIds.flatMap((projectionId) => projectionMigrationCommands(entry.id, projectionId)),
209
+ migration_steps: [
210
+ "Review changed_sections to understand the semantic widget contract delta.",
211
+ "Refresh the widget contract and affected surface contracts.",
212
+ "Run conformance and behavior reports for every affected projection.",
213
+ "Regenerate generated-owned outputs or manually update maintained surfaces after review."
214
+ ]
215
+ };
216
+ });
217
+
218
+ return {
219
+ widgets,
220
+ affected_widget_ids: stableSortedStrings(widgets.map((entry) => entry.widget_id)),
221
+ affected_projection_ids: stableSortedStrings(widgets.flatMap((entry) => entry.affected_projection_ids)),
222
+ review_command_count: widgets.reduce((count, entry) => count + entry.review_commands.length, 0)
223
+ };
224
+ }
225
+
131
226
  function collectAffectedCapabilityIds(graph, diff) {
132
227
  const changedCapabilities = stableSortedStrings((diff.capabilities || []).map((entry) => entry.id));
133
228
  const changedEntities = stableSortedStrings((diff.entities || []).map((entry) => entry.id));
@@ -213,6 +308,7 @@ export function generateContextDiff(graph, options = {}) {
213
308
  const affectedCapabilities = collectAffectedCapabilityIds(graph, diff);
214
309
  const affectedProjections = collectAffectedProjectionIds(graph, baselineGraph, diff);
215
310
  const affectedVerifications = collectAffectedVerificationIds(graph, diff);
311
+ const widgetContractMigrationPlan = buildWidgetContractMigrationPlan(graph, baselineGraph, diff);
216
312
  const changedSemanticIds = changedIdsForDiffSections(diff);
217
313
  const affectedMaintainedStories = maintainedProofMetadata(graph).filter((item) => {
218
314
  const emittedDependencies = item.emittedDependencies || [];
@@ -257,6 +353,7 @@ export function generateContextDiff(graph, options = {}) {
257
353
  }))
258
354
  },
259
355
  affected_verifications: affectedVerifications.map((id) => summarizeById(graph, id) || summarizeById(baselineGraph, id)).filter(Boolean),
356
+ widget_contract_migration_plan: widgetContractMigrationPlan,
260
357
  review_boundary_changes: reviewBoundaryChangeItems,
261
358
  sdlc: sdlcChanges
262
359
  };
@@ -6,7 +6,11 @@ import {
6
6
  packageInstallHint,
7
7
  resolvePackageManifestPath
8
8
  } from "../../package-adapters/index.js";
9
- import { UI_GENERATOR_RENDERED_COMPONENT_PATTERNS } from "../../ui/taxonomy.js";
9
+ import {
10
+ UI_GENERATOR_RENDERED_COMPONENT_PATTERNS,
11
+ UI_PATTERN_KINDS,
12
+ WIDGET_BEHAVIOR_KINDS
13
+ } from "../../ui/taxonomy.js";
10
14
 
11
15
  /**
12
16
  * @typedef {Object} GeneratorManifest
@@ -193,6 +197,18 @@ function isStringArray(value, nonEmpty = false) {
193
197
  value.every((entry) => typeof entry === "string" && entry.length > 0);
194
198
  }
195
199
 
200
+ /**
201
+ * @param {any} value
202
+ * @param {Set<string>} allowed
203
+ * @returns {string[]}
204
+ */
205
+ function unsupportedStringValues(value, allowed) {
206
+ if (!Array.isArray(value)) {
207
+ return [];
208
+ }
209
+ return value.filter((entry) => typeof entry === "string" && !allowed.has(entry));
210
+ }
211
+
196
212
  /**
197
213
  * @param {string} oldName
198
214
  * @param {string} newName
@@ -377,9 +393,15 @@ export function validateGeneratorManifest(manifest) {
377
393
  if (manifest.widgetSupport.patterns != null && !isStringArray(manifest.widgetSupport.patterns)) {
378
394
  errors.push(`${label} widgetSupport.patterns must be a string array`);
379
395
  }
396
+ for (const pattern of unsupportedStringValues(manifest.widgetSupport.patterns, UI_PATTERN_KINDS)) {
397
+ errors.push(`${label} widgetSupport.patterns contains unsupported pattern '${pattern}'`);
398
+ }
380
399
  if (manifest.widgetSupport.behaviors != null && !isStringArray(manifest.widgetSupport.behaviors)) {
381
400
  errors.push(`${label} widgetSupport.behaviors must be a string array`);
382
401
  }
402
+ for (const behavior of unsupportedStringValues(manifest.widgetSupport.behaviors, WIDGET_BEHAVIOR_KINDS)) {
403
+ errors.push(`${label} widgetSupport.behaviors contains unsupported behavior '${behavior}'`);
404
+ }
383
405
  if (
384
406
  manifest.widgetSupport.unsupported != null &&
385
407
  !["error", "warning", "contract-only"].includes(manifest.widgetSupport.unsupported)
@@ -0,0 +1,205 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {Record<string, any>} AnyRecord
5
+ */
6
+
7
+ const DONE_TASK_STATUSES = new Set(["done"]);
8
+
9
+ /**
10
+ * @param {unknown} refs
11
+ * @returns {string[]}
12
+ */
13
+ function refIds(refs) {
14
+ if (!Array.isArray(refs)) {
15
+ return [];
16
+ }
17
+ return refs
18
+ .map((ref) => {
19
+ if (typeof ref === "string") {
20
+ return ref;
21
+ }
22
+ if (ref && typeof ref === "object" && "id" in ref) {
23
+ return String(ref.id);
24
+ }
25
+ return "";
26
+ })
27
+ .filter(Boolean);
28
+ }
29
+
30
+ /**
31
+ * @param {AnyRecord} statement
32
+ * @returns {{ id: string, name: string|null, status: string|null, file: string|null }}
33
+ */
34
+ function summary(statement) {
35
+ return {
36
+ id: String(statement.id || ""),
37
+ name: typeof statement.name === "string" ? statement.name : null,
38
+ status: typeof statement.status === "string" ? statement.status : null,
39
+ file: typeof statement.loc?.file === "string" ? statement.loc.file : null
40
+ };
41
+ }
42
+
43
+ /**
44
+ * @param {Map<string, AnyRecord[]>} map
45
+ * @param {string} key
46
+ * @param {AnyRecord} value
47
+ */
48
+ function pushMap(map, key, value) {
49
+ const values = map.get(key) || [];
50
+ values.push(value);
51
+ map.set(key, values);
52
+ }
53
+
54
+ /**
55
+ * @param {AnyRecord} graph
56
+ * @param {string} id
57
+ * @returns {AnyRecord|null}
58
+ */
59
+ function byId(graph, id) {
60
+ const value = graph?.byId?.get ? graph.byId.get(id) : graph?.byId?.[id];
61
+ if (value && typeof value === "object") {
62
+ return value;
63
+ }
64
+ const byKind = graph?.byKind || {};
65
+ for (const statements of Object.values(byKind)) {
66
+ if (!Array.isArray(statements)) {
67
+ continue;
68
+ }
69
+ const found = statements.find((statement) => statement?.id === id);
70
+ if (found && typeof found === "object") {
71
+ return found;
72
+ }
73
+ }
74
+ return value && typeof value === "object" ? value : null;
75
+ }
76
+
77
+ /**
78
+ * Reports SDLC status hygiene issues that are easy to miss after implementation:
79
+ * draft requirements or acceptance criteria that already have completed task
80
+ * evidence, and approved acceptance criteria whose parent requirement is still
81
+ * draft. The audit is read-only.
82
+ *
83
+ * @param {string} workspaceRoot
84
+ * @param {AnyRecord} resolved
85
+ * @returns {AnyRecord}
86
+ */
87
+ export function auditWorkspace(workspaceRoot, resolved) {
88
+ const graph = resolved.graph || {};
89
+ const byKind = graph.byKind || {};
90
+ /** @type {AnyRecord[]} */
91
+ const tasks = Array.isArray(byKind.task) ? byKind.task : [];
92
+ /** @type {AnyRecord[]} */
93
+ const requirements = Array.isArray(byKind.requirement) ? byKind.requirement : [];
94
+ /** @type {AnyRecord[]} */
95
+ const acceptanceCriteria = Array.isArray(byKind.acceptance_criterion) ? byKind.acceptance_criterion : [];
96
+ /** @type {AnyRecord[]} */
97
+ const pitches = Array.isArray(byKind.pitch) ? byKind.pitch : [];
98
+
99
+ const doneTasks = tasks.filter((task) => DONE_TASK_STATUSES.has(String(task.status || "")));
100
+ /** @type {Map<string, AnyRecord[]>} */
101
+ const doneTasksByRequirement = new Map();
102
+ /** @type {Map<string, AnyRecord[]>} */
103
+ const doneTasksByAcceptance = new Map();
104
+
105
+ for (const task of doneTasks) {
106
+ for (const id of refIds(task.satisfies)) {
107
+ pushMap(doneTasksByRequirement, id, task);
108
+ }
109
+ for (const id of refIds(task.acceptanceRefs)) {
110
+ pushMap(doneTasksByAcceptance, id, task);
111
+ }
112
+ }
113
+
114
+ const draftRequirementsWithCompletedTasks = requirements
115
+ .filter((requirement) => requirement.status === "draft" && doneTasksByRequirement.has(String(requirement.id || "")))
116
+ .map((requirement) => ({
117
+ ...summary(requirement),
118
+ completedTasks: (doneTasksByRequirement.get(String(requirement.id || "")) || []).map(summary),
119
+ recommendedCommand: `topogram sdlc transition ${requirement.id} in-review . --actor <actor> --note "<reason>"`
120
+ }));
121
+
122
+ const draftAcceptanceCriteriaWithCompletedTasks = acceptanceCriteria
123
+ .filter((criterion) => criterion.status === "draft" && doneTasksByAcceptance.has(String(criterion.id || "")))
124
+ .map((criterion) => ({
125
+ ...summary(criterion),
126
+ completedTasks: (doneTasksByAcceptance.get(String(criterion.id || "")) || []).map(summary),
127
+ recommendedCommand: `topogram sdlc transition ${criterion.id} approved . --actor <actor> --note "<reason>"`
128
+ }));
129
+
130
+ const approvedAcceptanceCriteriaWithDraftRequirements = acceptanceCriteria
131
+ .filter((criterion) => {
132
+ if (criterion.status !== "approved") {
133
+ return false;
134
+ }
135
+ const requirementId = criterion.requirement?.id || "";
136
+ const requirement = requirementId ? byId(graph, requirementId) : null;
137
+ return requirement?.status === "draft";
138
+ })
139
+ .map((criterion) => {
140
+ const requirement = byId(graph, criterion.requirement?.id || "");
141
+ return {
142
+ ...summary(criterion),
143
+ requirement: requirement ? summary(requirement) : null,
144
+ recommendedCommand: requirement
145
+ ? `topogram sdlc transition ${requirement.id} in-review . --actor <actor> --note "<reason>"`
146
+ : null
147
+ };
148
+ });
149
+
150
+ const doneTasksWithDraftReferences = doneTasks
151
+ .map((task) => {
152
+ const draftRequirements = refIds(task.satisfies)
153
+ .map((id) => byId(graph, id))
154
+ .filter((statement) => statement?.status === "draft")
155
+ .map((statement) => summary(/** @type {AnyRecord} */ (statement)));
156
+ const draftAcceptanceCriteria = refIds(task.acceptanceRefs)
157
+ .map((id) => byId(graph, id))
158
+ .filter((statement) => statement?.status === "draft")
159
+ .map((statement) => summary(/** @type {AnyRecord} */ (statement)));
160
+ return {
161
+ ...summary(task),
162
+ draftRequirements,
163
+ draftAcceptanceCriteria
164
+ };
165
+ })
166
+ .filter((task) => task.draftRequirements.length > 0 || task.draftAcceptanceCriteria.length > 0);
167
+
168
+ const counts = {
169
+ draftRequirementsWithCompletedTasks: draftRequirementsWithCompletedTasks.length,
170
+ draftAcceptanceCriteriaWithCompletedTasks: draftAcceptanceCriteriaWithCompletedTasks.length,
171
+ approvedAcceptanceCriteriaWithDraftRequirements: approvedAcceptanceCriteriaWithDraftRequirements.length,
172
+ doneTasksWithDraftReferences: doneTasksWithDraftReferences.length,
173
+ remainingDraftPitches: pitches.filter((pitch) => pitch.status === "draft").length,
174
+ remainingDraftRequirements: requirements.filter((requirement) => requirement.status === "draft").length,
175
+ remainingDraftAcceptanceCriteria: acceptanceCriteria.filter((criterion) => criterion.status === "draft").length
176
+ };
177
+ const actionableFindings =
178
+ counts.draftRequirementsWithCompletedTasks +
179
+ counts.draftAcceptanceCriteriaWithCompletedTasks +
180
+ counts.approvedAcceptanceCriteriaWithDraftRequirements +
181
+ counts.doneTasksWithDraftReferences;
182
+
183
+ return {
184
+ type: "sdlc_audit",
185
+ version: "1",
186
+ ok: actionableFindings === 0,
187
+ workspaceRoot,
188
+ counts,
189
+ findings: {
190
+ draftRequirementsWithCompletedTasks,
191
+ draftAcceptanceCriteriaWithCompletedTasks,
192
+ approvedAcceptanceCriteriaWithDraftRequirements,
193
+ doneTasksWithDraftReferences
194
+ },
195
+ remainingDrafts: {
196
+ pitches: pitches.filter((pitch) => pitch.status === "draft").map(summary),
197
+ requirements: requirements.filter((requirement) => requirement.status === "draft").map(summary),
198
+ acceptanceCriteria: acceptanceCriteria.filter((criterion) => criterion.status === "draft").map(summary)
199
+ },
200
+ nextCommands: [
201
+ "topogram sdlc check --strict",
202
+ "topogram sdlc prep commit . --json"
203
+ ]
204
+ };
205
+ }
@@ -155,6 +155,51 @@ export const UI_GENERATOR_RENDERED_COMPONENT_PATTERNS = new Set([
155
155
  "data_grid_view"
156
156
  ]);
157
157
 
158
+ export const WIDGET_CATEGORIES = new Set([
159
+ "collection",
160
+ "form",
161
+ "display",
162
+ "navigation",
163
+ "dialog",
164
+ "feedback",
165
+ "lookup",
166
+ "layout",
167
+ "service"
168
+ ]);
169
+
170
+ export const WIDGET_BEHAVIOR_KINDS = new Set([
171
+ "selection",
172
+ "sorting",
173
+ "filtering",
174
+ "search",
175
+ "pagination",
176
+ "grouping",
177
+ "drag_drop",
178
+ "inline_edit",
179
+ "bulk_action",
180
+ "optimistic_update",
181
+ "realtime_update",
182
+ "keyboard_navigation"
183
+ ]);
184
+
185
+ export const WIDGET_BEHAVIOR_DIRECTIVES = {
186
+ selection: new Set(["mode", "state", "emits"]),
187
+ sorting: new Set(["fields", "default"]),
188
+ filtering: new Set(["fields"]),
189
+ search: new Set(["fields"]),
190
+ pagination: new Set(["mode", "page_size"]),
191
+ grouping: new Set(["fields"]),
192
+ drag_drop: new Set(["axis", "reorder"]),
193
+ inline_edit: new Set(["fields", "submit", "emits"]),
194
+ bulk_action: new Set(["actions", "state", "emits"]),
195
+ optimistic_update: new Set(["actions", "rollback"]),
196
+ realtime_update: new Set(["source", "merge"]),
197
+ keyboard_navigation: new Set(["scope", "shortcuts"])
198
+ };
199
+
200
+ export const WIDGET_SELECTION_MODES = new Set(["single", "multi", "none"]);
201
+ export const WIDGET_PAGINATION_MODES = new Set(["cursor", "paged", "infinite", "none"]);
202
+
158
203
  /**
159
204
  * @param {string[]} presentations
160
205
  * @returns {string}
@@ -47,4 +47,9 @@ export const UI_DESIGN_COLOR_ROLES: Set<string>;
47
47
  export const UI_DESIGN_TYPOGRAPHY_ROLES: Set<string>;
48
48
  export const UI_DESIGN_ACTION_ROLES: Set<string>;
49
49
  export const UI_DESIGN_ACCESSIBILITY_VALUES: Record<string, Set<string>>;
50
+ export const WIDGET_CATEGORIES: Set<string>;
51
+ export const WIDGET_BEHAVIOR_KINDS: Set<string>;
52
+ export const WIDGET_BEHAVIOR_DIRECTIVES: Record<string, Set<string>>;
53
+ export const WIDGET_SELECTION_MODES: Set<string>;
54
+ export const WIDGET_PAGINATION_MODES: Set<string>;
50
55
  export const FIELD_SPECS: Record<string, { required: string[]; allowed: string[] }>;
@@ -107,7 +107,12 @@ export {
107
107
  UI_DESIGN_COLOR_ROLES,
108
108
  UI_DESIGN_TYPOGRAPHY_ROLES,
109
109
  UI_DESIGN_ACTION_ROLES,
110
- UI_DESIGN_ACCESSIBILITY_VALUES
110
+ UI_DESIGN_ACCESSIBILITY_VALUES,
111
+ WIDGET_CATEGORIES,
112
+ WIDGET_BEHAVIOR_KINDS,
113
+ WIDGET_BEHAVIOR_DIRECTIVES,
114
+ WIDGET_SELECTION_MODES,
115
+ WIDGET_PAGINATION_MODES
111
116
  } from "../ui/taxonomy.js";
112
117
 
113
118
  // Kinds that may carry an optional singular `domain dom_x` field. Keep this
@@ -1,5 +1,10 @@
1
1
  // @ts-check
2
2
  import {
3
+ WIDGET_BEHAVIOR_DIRECTIVES,
4
+ WIDGET_BEHAVIOR_KINDS,
5
+ WIDGET_CATEGORIES,
6
+ WIDGET_PAGINATION_MODES,
7
+ WIDGET_SELECTION_MODES,
3
8
  UI_PATTERN_KINDS,
4
9
  UI_REGION_KINDS
5
10
  } from "../kinds.js";
@@ -10,49 +15,6 @@ import {
10
15
  symbolValues
11
16
  } from "../utils.js";
12
17
 
13
- const WIDGET_CATEGORIES = new Set([
14
- "collection",
15
- "form",
16
- "display",
17
- "navigation",
18
- "dialog",
19
- "feedback",
20
- "lookup",
21
- "layout",
22
- "service"
23
- ]);
24
-
25
- const WIDGET_BEHAVIOR_KINDS = new Set([
26
- "selection",
27
- "sorting",
28
- "filtering",
29
- "search",
30
- "pagination",
31
- "grouping",
32
- "drag_drop",
33
- "inline_edit",
34
- "bulk_action",
35
- "optimistic_update",
36
- "realtime_update",
37
- "keyboard_navigation"
38
- ]);
39
-
40
- /** @type {Record<string, Set<string>>} */
41
- const WIDGET_BEHAVIOR_DIRECTIVES = {
42
- selection: new Set(["mode", "state", "emits"]),
43
- sorting: new Set(["fields", "default"]),
44
- filtering: new Set(["fields"]),
45
- search: new Set(["fields"]),
46
- pagination: new Set(["mode", "page_size"]),
47
- grouping: new Set(["fields"]),
48
- drag_drop: new Set(["axis", "reorder"]),
49
- inline_edit: new Set(["fields", "submit", "emits"]),
50
- bulk_action: new Set(["actions", "state", "emits"]),
51
- optimistic_update: new Set(["actions", "rollback"]),
52
- realtime_update: new Set(["source", "merge"]),
53
- keyboard_navigation: new Set(["scope", "shortcuts"])
54
- };
55
-
56
18
  /** @param {TopogramToken | null | undefined} token @returns {any} */
57
19
  function tokenValue(token) {
58
20
  return token?.value ?? null;
@@ -262,10 +224,10 @@ function validateWidgetBehaviors(errors, statement, fieldMap, registry) {
262
224
  if (directive === "actions" || directive === "submit") {
263
225
  validateBehaviorActionReferences(errors, statement, registry, kind, directive, valueToken, eventNames);
264
226
  }
265
- if (kind === "selection" && directive === "mode" && !["single", "multi", "none"].includes(tokenValue(valueToken))) {
227
+ if (kind === "selection" && directive === "mode" && !WIDGET_SELECTION_MODES.has(tokenValue(valueToken))) {
266
228
  pushError(errors, `Widget ${statement.id} behavior 'selection' has invalid mode '${tokenValue(valueToken)}'`, valueToken.loc);
267
229
  }
268
- if (kind === "pagination" && directive === "mode" && !["cursor", "paged", "infinite", "none"].includes(tokenValue(valueToken))) {
230
+ if (kind === "pagination" && directive === "mode" && !WIDGET_PAGINATION_MODES.has(tokenValue(valueToken))) {
269
231
  pushError(errors, `Widget ${statement.id} behavior 'pagination' has invalid mode '${tokenValue(valueToken)}'`, valueToken.loc);
270
232
  }
271
233
  }