@grafema/cli 0.3.24 → 0.3.27

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.
Files changed (48) hide show
  1. package/README.md +59 -45
  2. package/dist/cli.js +10 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/analyzeAction.d.ts.map +1 -1
  5. package/dist/commands/analyzeAction.js +134 -3
  6. package/dist/commands/analyzeAction.js.map +1 -1
  7. package/dist/commands/doctor/checks.d.ts.map +1 -1
  8. package/dist/commands/doctor/checks.js +7 -3
  9. package/dist/commands/doctor/checks.js.map +1 -1
  10. package/dist/commands/export.d.ts +15 -0
  11. package/dist/commands/export.d.ts.map +1 -0
  12. package/dist/commands/export.js +88 -0
  13. package/dist/commands/export.js.map +1 -0
  14. package/dist/commands/exportAction.d.ts +35 -0
  15. package/dist/commands/exportAction.d.ts.map +1 -0
  16. package/dist/commands/exportAction.js +58 -0
  17. package/dist/commands/exportAction.js.map +1 -0
  18. package/dist/commands/features.d.ts +13 -0
  19. package/dist/commands/features.d.ts.map +1 -0
  20. package/dist/commands/features.js +69 -0
  21. package/dist/commands/features.js.map +1 -0
  22. package/dist/commands/featuresAction.d.ts +82 -0
  23. package/dist/commands/featuresAction.d.ts.map +1 -0
  24. package/dist/commands/featuresAction.js +139 -0
  25. package/dist/commands/featuresAction.js.map +1 -0
  26. package/dist/commands/start.d.ts +12 -0
  27. package/dist/commands/start.d.ts.map +1 -0
  28. package/dist/commands/start.js +294 -0
  29. package/dist/commands/start.js.map +1 -0
  30. package/dist/commands/trace.d.ts.map +1 -1
  31. package/dist/commands/trace.js +50 -30
  32. package/dist/commands/trace.js.map +1 -1
  33. package/dist/commands/upgrade.d.ts +3 -0
  34. package/dist/commands/upgrade.d.ts.map +1 -0
  35. package/dist/commands/upgrade.js +279 -0
  36. package/dist/commands/upgrade.js.map +1 -0
  37. package/package.json +4 -4
  38. package/src/cli.ts +11 -0
  39. package/src/commands/analyzeAction.ts +135 -2
  40. package/src/commands/doctor/checks.ts +4 -3
  41. package/src/commands/explore.tsx +29 -2
  42. package/src/commands/export.ts +102 -0
  43. package/src/commands/exportAction.ts +107 -0
  44. package/src/commands/features.ts +88 -0
  45. package/src/commands/featuresAction.ts +218 -0
  46. package/src/commands/start.ts +303 -0
  47. package/src/commands/trace.ts +49 -29
  48. package/src/commands/upgrade.ts +310 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `grafema export` — emit a multi-format spec from the feature graph
3
+ * (REG-1116, REG-1118).
4
+ *
5
+ * Modes shipped:
6
+ * --as openapi-3.1 → http:route features only
7
+ * --as docs-md → all FEATURE categories (single + bulk)
8
+ *
9
+ * Phase 2 formats (mcp-schema, asyncapi, json-schema, ts-declarations,
10
+ * mermaid) plug in by adding renderers to packages/util/src/exporters; the
11
+ * CLI surface here doesn't change.
12
+ */
13
+ import { Command } from 'commander';
14
+ import { resolve, join, dirname } from 'path';
15
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
16
+
17
+ import { RFDBServerBackend, RFDBClient, RENDERERS, type ExportBackendLike } from '@grafema/util';
18
+ import { exitWithError } from '../utils/errorFormatter.js';
19
+ import { runExport } from './exportAction.js';
20
+
21
+ interface ExportCliOptions {
22
+ feature?: string;
23
+ as?: string;
24
+ output?: string;
25
+ project: string;
26
+ }
27
+
28
+ const KNOWN_FORMATS = Object.keys(RENDERERS).sort().join(', ');
29
+
30
+ export const exportCommand = new Command('export')
31
+ .description('Export FEATURE entries from the graph in a chosen format')
32
+ .requiredOption('--feature <pattern>', "Feature glob (e.g. 'cli:*', 'http:*', \"cli:command:'analyze'\")")
33
+ .requiredOption('--as <format>', `Output format (${KNOWN_FORMATS})`)
34
+ .option('-o, --output <path>', 'Write output to <path> instead of stdout')
35
+ .option('-p, --project <path>', 'Project path', '.')
36
+ .addHelpText('after', `
37
+ Examples:
38
+ grafema export --feature 'http:*' --as openapi-3.1 --output api.yaml
39
+ grafema export --feature 'cli:*' --as docs-md
40
+ grafema export --feature "cli:command:'analyze'" --as docs-md
41
+
42
+ Phase 1 supports two formats: openapi-3.1 (http:route only) and
43
+ docs-md (all categories). Other formats — mcp-schema, asyncapi,
44
+ json-schema, ts-declarations, mermaid — are tracked separately.
45
+ `)
46
+ .action(async (options: ExportCliOptions) => {
47
+ if (!options.feature || !options.as) {
48
+ exitWithError('Both --feature and --as are required', [
49
+ 'See: grafema export --help',
50
+ ]);
51
+ }
52
+ const projectPath = resolve(options.project);
53
+ const grafemaDir = join(projectPath, '.grafema');
54
+ const dbPath = join(grafemaDir, 'graph.rfdb');
55
+
56
+ if (!existsSync(dbPath)) {
57
+ exitWithError('No graph database found', ['Run: grafema analyze']);
58
+ }
59
+
60
+ // RFDBServerBackend negotiates protocol v3, which returns semantic edge
61
+ // dst values that don't resolve via getNode(). Connect with a plain
62
+ // RFDBClient (protocol v2) so edges preserve raw numeric ids end-to-end.
63
+ // Still use RFDBServerBackend for its auto-start side effect.
64
+ const server = new RFDBServerBackend({ dbPath, clientName: 'cli-bootstrap' });
65
+ await server.connect();
66
+ const socketPath = (server as unknown as { socketPath: string }).socketPath;
67
+ const rawClient = new RFDBClient(socketPath, 'export');
68
+ await rawClient.connect();
69
+
70
+ try {
71
+ await runExport(
72
+ {
73
+ feature: options.feature,
74
+ as: options.as,
75
+ output: options.output,
76
+ },
77
+ {
78
+ backend: rawClient as unknown as ExportBackendLike,
79
+ writeText: writeOutput,
80
+ warn: (msg) => console.error(msg),
81
+ fail: (msg, code) => {
82
+ console.error(`✗ ${msg}`);
83
+ process.exit(code);
84
+ },
85
+ },
86
+ );
87
+ } finally {
88
+ rawClient.close();
89
+ await server.close();
90
+ }
91
+ });
92
+
93
+ /** Default writer: stdout when path == null, otherwise file. */
94
+ function writeOutput(path: string | null, text: string): void {
95
+ if (path === null) {
96
+ process.stdout.write(text);
97
+ return;
98
+ }
99
+ const dir = dirname(path);
100
+ if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
101
+ writeFileSync(path, text, 'utf8');
102
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `grafema export` action (REG-1116, REG-1118).
3
+ *
4
+ * Pure orchestration logic — connects feature glob → renderer, with all I/O
5
+ * surfaced as inputs (backend) and effects (writeText / log / exit). The
6
+ * Commander shell in `export.ts` plugs in concrete implementations; tests
7
+ * supply mocks.
8
+ */
9
+ import {
10
+ collectFeatureSnapshots,
11
+ parseFeaturePattern,
12
+ resolveCategories,
13
+ RENDERERS,
14
+ type ExportBackendLike,
15
+ type FeatureExportSnapshot,
16
+ type Renderer,
17
+ } from '@grafema/util';
18
+
19
+ export interface ExportActionOptions {
20
+ /** Feature glob — required. */
21
+ feature: string;
22
+ /** Output format — required. Must be a key of RENDERERS. */
23
+ as: string;
24
+ /** Optional output file path. When unset, stdout. */
25
+ output?: string;
26
+ }
27
+
28
+ export interface ExportActionDeps {
29
+ backend: ExportBackendLike;
30
+ /** Write file/stdout content. Receives `null` for path → stdout. */
31
+ writeText: (path: string | null, text: string) => Promise<void> | void;
32
+ /** Print to stderr (for diagnostic notes that should not contaminate stdout). */
33
+ warn: (msg: string) => void;
34
+ /** Reported when an unrecoverable error happens — caller decides exit code. */
35
+ fail: (msg: string, code: number) => never;
36
+ /** Renderers map — defaults to the package-shipped RENDERERS. */
37
+ renderers?: Record<string, Renderer>;
38
+ }
39
+
40
+ /**
41
+ * Run the export action. Returns the rendered text on success — convenient
42
+ * for tests that don't want to mock writeText. The deps' writeText is also
43
+ * invoked so production callers don't need to handle the return value.
44
+ */
45
+ export async function runExport(
46
+ options: ExportActionOptions,
47
+ deps: ExportActionDeps,
48
+ ): Promise<string> {
49
+ const renderers = deps.renderers ?? RENDERERS;
50
+ const renderer = renderers[options.as];
51
+ if (!renderer) {
52
+ const known = Object.keys(renderers).sort().join(', ');
53
+ deps.fail(
54
+ `Unknown format '${options.as}'. Known formats: ${known}.`,
55
+ 2,
56
+ );
57
+ }
58
+
59
+ const parsed = parseFeaturePattern(options.feature);
60
+ if (!parsed) {
61
+ deps.fail(`Invalid --feature pattern: '${options.feature}'.`, 2);
62
+ }
63
+
64
+ // Validate format-category compatibility before doing any graph work.
65
+ const categories = resolveCategories(parsed.categoryGlob);
66
+ if (categories.length === 0) {
67
+ deps.fail(
68
+ `Feature pattern '${options.feature}' matches no known category.`,
69
+ 2,
70
+ );
71
+ }
72
+ const unsupported = categories.filter(c => !renderer.supports(c));
73
+ if (unsupported.length > 0 && unsupported.length === categories.length) {
74
+ deps.fail(
75
+ `Format '${options.as}' does not support category '${unsupported[0]}'. ` +
76
+ `Try '--as docs-md' or narrow the pattern.`,
77
+ 2,
78
+ );
79
+ }
80
+
81
+ const snapshots = await collectFeatureSnapshots(deps.backend, options.feature);
82
+
83
+ // If the renderer rejects some categories, drop those snapshots before
84
+ // rendering. Warn so the user sees the gap.
85
+ const accepted: FeatureExportSnapshot[] = [];
86
+ let droppedForCategory = 0;
87
+ for (const s of snapshots) {
88
+ if (renderer.supports(s.category)) accepted.push(s);
89
+ else droppedForCategory++;
90
+ }
91
+ if (droppedForCategory > 0) {
92
+ deps.warn(
93
+ `Skipping ${droppedForCategory} feature${droppedForCategory === 1 ? '' : 's'} ` +
94
+ `unsupported by --as ${options.as}.`,
95
+ );
96
+ }
97
+
98
+ if (accepted.length === 0) {
99
+ deps.warn(
100
+ `No features matched '${options.feature}' for format '${options.as}'.`,
101
+ );
102
+ }
103
+
104
+ const text = renderer.render(accepted);
105
+ await deps.writeText(options.output ?? null, text);
106
+ return text;
107
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `grafema features` — surface FEATURE-level cross-modality insights.
3
+ *
4
+ * Currently exposes one mode:
5
+ * --duplicates List clusters of FEATUREs whose entry-points share an
6
+ * identical BEHAVIOR (same forward-slice hash).
7
+ *
8
+ * Other modes (--by-effect, --by-domain, …) are anticipated but not in scope
9
+ * for REG-1119.
10
+ */
11
+
12
+ import { Command } from 'commander';
13
+ import { resolve, join } from 'path';
14
+ import { existsSync } from 'fs';
15
+ import { RFDBServerBackend } from '@grafema/util';
16
+ import { exitWithError } from '../utils/errorFormatter.js';
17
+ import {
18
+ findSharedBehaviorClusters,
19
+ formatSharedBehaviorClusters,
20
+ type FeaturesBackendLike,
21
+ } from './featuresAction.js';
22
+
23
+ interface FeaturesCliOptions {
24
+ project: string;
25
+ json?: boolean;
26
+ duplicates?: boolean;
27
+ minClusterSize?: string;
28
+ limit?: string;
29
+ }
30
+
31
+ export const featuresCommand = new Command('features')
32
+ .description('List FEATURE-level cross-modality insights (e.g. duplicate behaviors)')
33
+ .option('-p, --project <path>', 'Project path', '.')
34
+ .option('-j, --json', 'Output as JSON')
35
+ .option('-d, --duplicates', 'List clusters of FEATUREs that share a BEHAVIOR')
36
+ .option('--min-cluster-size <n>', 'Minimum features per cluster (default: 2)', '2')
37
+ .option('--limit <n>', 'Maximum clusters to return (default: 100)', '100')
38
+ .addHelpText('after', `
39
+ Examples:
40
+ grafema features --duplicates List FEATUREs with shared behaviors
41
+ grafema features --duplicates --json Same, machine-readable JSON
42
+ grafema features -d --min-cluster-size 3 Only clusters with >=3 features
43
+ `)
44
+ .action(async (options: FeaturesCliOptions) => {
45
+ if (!options.duplicates) {
46
+ exitWithError('No subcommand selected', [
47
+ 'Try: grafema features --duplicates',
48
+ 'See: grafema features --help',
49
+ ]);
50
+ }
51
+
52
+ const projectPath = resolve(options.project);
53
+ const grafemaDir = join(projectPath, '.grafema');
54
+ const dbPath = join(grafemaDir, 'graph.rfdb');
55
+
56
+ if (!existsSync(dbPath)) {
57
+ exitWithError('No graph database found', ['Run: grafema analyze']);
58
+ }
59
+
60
+ const minClusterSize = parsePositiveInt(options.minClusterSize, 2);
61
+ const limit = parsePositiveInt(options.limit, 100);
62
+
63
+ const backend = new RFDBServerBackend({ dbPath, clientName: 'cli' });
64
+ await backend.connect();
65
+
66
+ try {
67
+ // RFDBServerBackend matches FeaturesBackendLike at runtime.
68
+ const clusters = await findSharedBehaviorClusters(
69
+ backend as unknown as FeaturesBackendLike,
70
+ { minClusterSize, limit },
71
+ );
72
+
73
+ if (options.json) {
74
+ console.log(JSON.stringify(clusters, null, 2));
75
+ } else {
76
+ console.log(formatSharedBehaviorClusters(clusters));
77
+ }
78
+ } finally {
79
+ await backend.close();
80
+ }
81
+ });
82
+
83
+ function parsePositiveInt(raw: string | undefined, fallback: number): number {
84
+ if (!raw) return fallback;
85
+ const n = Number.parseInt(raw, 10);
86
+ if (!Number.isFinite(n) || n <= 0) return fallback;
87
+ return n;
88
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Features command action — surface FEATURE-level cross-modality insights.
3
+ *
4
+ * Currently implements --duplicates: groups BEHAVIOR nodes by their
5
+ * `metadata.hash` and lists every cluster of size >= 2. Each cluster shows the
6
+ * feature ENTRY_POINTs (incoming `IMPLEMENTED_BY` edges) that share the
7
+ * implementation.
8
+ *
9
+ * Surfaces the data emitted by `behaviorEnricher` Pass 2: SHARES_BEHAVIOR_WITH
10
+ * edges already exist, but they're not user-facing. This subcommand makes the
11
+ * insight visible from the CLI ("this CLI command and that MCP tool are thin
12
+ * wrappers around the same library function").
13
+ *
14
+ * REG-1119.
15
+ */
16
+ import type { EdgeType } from '@grafema/types';
17
+
18
+ /**
19
+ * Minimal backend interface — matches the subset of RFDBServerBackend
20
+ * (and the graph-handlers' GraphBackendLike) that we need. Lets us test with
21
+ * a simple in-memory mock without requiring a live RFDB server.
22
+ */
23
+ export interface FeaturesBackendLike {
24
+ queryNodes(query: { type?: string; nodeType?: string }): AsyncIterable<Record<string, unknown>>;
25
+ getNode(id: string): Promise<Record<string, unknown> | null>;
26
+ getIncomingEdges(
27
+ nodeId: string,
28
+ edgeTypes?: EdgeType[] | null,
29
+ ): Promise<Array<{ src: string; dst: string; type: string; metadata?: Record<string, unknown> | undefined }>>;
30
+ }
31
+
32
+ /** A FEATURE participating in a shared-behavior cluster. */
33
+ export interface SharedFeatureRef {
34
+ /** Feature semantic id. */
35
+ id: string;
36
+ /** Feature node type — e.g. `cli:command`, `mcp:tool`, `vscode:command`. */
37
+ type: string;
38
+ /** Feature name (e.g. `analyze`). */
39
+ name: string;
40
+ /** Source file (may be empty for synthetic features). */
41
+ file: string;
42
+ }
43
+
44
+ /** A group of FEATUREs whose behavior hashes are identical. */
45
+ export interface SharedBehaviorCluster {
46
+ /** sha256 hash from BEHAVIOR.metadata.hash — the cluster key. */
47
+ hash: string;
48
+ /** Effects array carried on the shared BEHAVIOR. */
49
+ effects: string[];
50
+ /** Forward-slice size of the shared behavior. */
51
+ coreNodeCount: number;
52
+ /** Features that implement the same behavior. */
53
+ features: SharedFeatureRef[];
54
+ }
55
+
56
+ export interface FindSharedBehaviorOptions {
57
+ /** Minimum cluster size to include. Default 2. */
58
+ minClusterSize?: number;
59
+ /** Maximum number of clusters to return. Default 100. */
60
+ limit?: number;
61
+ }
62
+
63
+ const DEFAULT_MIN_CLUSTER_SIZE = 2;
64
+ const DEFAULT_LIMIT = 100;
65
+
66
+ /**
67
+ * Core algorithm — testable, accepts any backend implementing
68
+ * `FeaturesBackendLike`.
69
+ *
70
+ * Steps:
71
+ * 1. Iterate every BEHAVIOR node, read its `hash` + `effects` + `coreNodeCount`.
72
+ * 2. Bucket behaviors by hash.
73
+ * 3. For each bucket of size >= minClusterSize, walk incoming `IMPLEMENTED_BY`
74
+ * edges to enumerate the FEATUREs.
75
+ *
76
+ * Returns clusters sorted by size (desc), then by hash (asc, deterministic).
77
+ */
78
+ export async function findSharedBehaviorClusters(
79
+ backend: FeaturesBackendLike,
80
+ options: FindSharedBehaviorOptions = {},
81
+ ): Promise<SharedBehaviorCluster[]> {
82
+ const minClusterSize = Math.max(2, options.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE);
83
+ const limit = Math.max(1, options.limit ?? DEFAULT_LIMIT);
84
+
85
+ // 1. Collect all BEHAVIOR nodes.
86
+ interface BehaviorRow {
87
+ id: string;
88
+ hash: string;
89
+ effects: string[];
90
+ coreNodeCount: number;
91
+ }
92
+ const behaviors: BehaviorRow[] = [];
93
+ for await (const node of backend.queryNodes({ type: 'BEHAVIOR' })) {
94
+ const id = String(node.id ?? '');
95
+ if (!id) continue;
96
+ const hash = readString(node.hash);
97
+ if (!hash) continue;
98
+ const effects = readStringArray(node.effects);
99
+ const coreNodeCount = readNumber(node.coreNodeCount) ?? 0;
100
+ behaviors.push({ id, hash, effects, coreNodeCount });
101
+ }
102
+
103
+ // 2. Group by hash.
104
+ const byHash = new Map<string, BehaviorRow[]>();
105
+ for (const b of behaviors) {
106
+ let bucket = byHash.get(b.hash);
107
+ if (!bucket) {
108
+ bucket = [];
109
+ byHash.set(b.hash, bucket);
110
+ }
111
+ bucket.push(b);
112
+ }
113
+
114
+ // 3. For each multi-behavior bucket, expand incoming IMPLEMENTED_BY edges
115
+ // into FEATURE refs.
116
+ const clusters: SharedBehaviorCluster[] = [];
117
+ for (const [hash, bucket] of byHash) {
118
+ if (bucket.length < minClusterSize) continue;
119
+
120
+ const features: SharedFeatureRef[] = [];
121
+ const seenFeatureIds = new Set<string>();
122
+
123
+ for (const beh of bucket) {
124
+ const edges = await backend.getIncomingEdges(beh.id, ['IMPLEMENTED_BY'] as EdgeType[]);
125
+ for (const edge of edges) {
126
+ const featureId = String(edge.src);
127
+ if (!featureId || seenFeatureIds.has(featureId)) continue;
128
+ seenFeatureIds.add(featureId);
129
+ const node = await backend.getNode(featureId);
130
+ if (!node) continue;
131
+ features.push({
132
+ id: featureId,
133
+ type: readString(node.type) ?? 'UNKNOWN',
134
+ name: readString(node.name) ?? '',
135
+ file: readString(node.file) ?? '',
136
+ });
137
+ }
138
+ }
139
+
140
+ // De-dup may have dropped behaviors back below threshold (e.g. multiple
141
+ // BEHAVIOR rows from the same FEATURE). Re-check on the resolved feature
142
+ // count.
143
+ if (features.length < minClusterSize) continue;
144
+
145
+ // Stable order of features within a cluster: by type then name.
146
+ features.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type.localeCompare(b.type)));
147
+
148
+ // Take effects + coreNodeCount from the first behavior — they're identical
149
+ // for behaviors with the same hash by construction.
150
+ clusters.push({
151
+ hash,
152
+ effects: bucket[0].effects,
153
+ coreNodeCount: bucket[0].coreNodeCount,
154
+ features,
155
+ });
156
+ }
157
+
158
+ // Sort clusters: largest first, hash ascending for tie-break (deterministic).
159
+ clusters.sort((a, b) => (b.features.length - a.features.length) || a.hash.localeCompare(b.hash));
160
+
161
+ return clusters.slice(0, limit);
162
+ }
163
+
164
+ /**
165
+ * Format clusters as a human-readable text report. Mirrors the symmetric MCP
166
+ * handler output: same field ordering, same labels. JSON output is the same
167
+ * structured array.
168
+ */
169
+ export function formatSharedBehaviorClusters(clusters: SharedBehaviorCluster[]): string {
170
+ if (clusters.length === 0) {
171
+ return 'No FEATUREs share a BEHAVIOR (each entry-point has a unique implementation).';
172
+ }
173
+
174
+ const lines: string[] = [];
175
+ lines.push(`Shared-behavior clusters: ${clusters.length}`);
176
+ lines.push('='.repeat(40));
177
+ for (let i = 0; i < clusters.length; i++) {
178
+ const c = clusters[i];
179
+ lines.push('');
180
+ lines.push(`Cluster ${i + 1} — ${c.features.length} feature(s) share behavior`);
181
+ lines.push(` hash: ${c.hash.slice(0, 16)}…`);
182
+ lines.push(` coreNodeCount: ${c.coreNodeCount}`);
183
+ lines.push(` effects: ${c.effects.length === 0 ? '(none)' : c.effects.join(', ')}`);
184
+ lines.push(' features:');
185
+ for (const f of c.features) {
186
+ const file = f.file ? ` [${f.file}]` : '';
187
+ lines.push(` - ${f.type} ${f.name}${file}`);
188
+ }
189
+ }
190
+ return lines.join('\n');
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Internal helpers — defensive parsers tolerating both string-encoded JSON
195
+ // metadata (from raw client) and already-parsed values (from RFDBServerBackend
196
+ // _parseNode).
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function readString(v: unknown): string | undefined {
200
+ return typeof v === 'string' ? v : undefined;
201
+ }
202
+
203
+ function readNumber(v: unknown): number | undefined {
204
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
205
+ }
206
+
207
+ function readStringArray(v: unknown): string[] {
208
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string');
209
+ if (typeof v === 'string') {
210
+ try {
211
+ const parsed = JSON.parse(v);
212
+ if (Array.isArray(parsed)) return parsed.filter((x): x is string => typeof x === 'string');
213
+ } catch {
214
+ // not JSON
215
+ }
216
+ }
217
+ return [];
218
+ }