@brignano/driftwood 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ export { Model, Entity, Edge, View, Ignore, Level, emptyModel } from './model/schema.js';
2
+ export { loadModel, dumpModel, validateModel, matches } from './model/validate.js';
3
+ export type { Issue, ValidationResult } from './model/validate.js';
4
+ export { mergeModels, formatConflicts } from './model/merge.js';
5
+ export type { MergeResult, MergeConflict, SourcedModel } from './model/merge.js';
6
+ export { Registry } from './registry.js';
7
+ export { providers, defineProvider, terraformProvider, dynatraceProvider } from './providers/index.js';
8
+ export type { Provider, ProviderContext, ProviderKind } from './providers/index.js';
9
+ export { renderers, render, resolveRenderer } from './render/index.js';
10
+ export { defineRenderer } from './render/types.js';
11
+ export type { Renderer, RenderContext, Availability } from './render/types.js';
12
+ export { importTerraformState, parseTerraformState } from './providers/terraform.js';
13
+ export type { ImportOptions } from './providers/terraform.js';
14
+ export { toModel as dynatraceToModel } from './providers/dynatrace.js';
15
+ export { renderMermaid } from './render/mermaid.js';
16
+ export { renderDot } from './render/dot.js';
17
+ export { renderGraphvizSvg, detectTier, probeNativeDot } from './render/graphviz.js';
18
+ export { selectEntities } from './render/select.js';
19
+ export { reconcile, formatDrift, COMPARED_FIELDS } from './reconcile/index.js';
20
+ export type { Drift, FieldChange, ComparedField } from './reconcile/index.js';
21
+ export { loadConfig, observeAll, makeContext, Config } from './config.js';
22
+ export type { LoadedConfig } from './config.js';
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ // Model
2
+ export { Model, Entity, Edge, View, Ignore, Level, emptyModel } from './model/schema.js';
3
+ export { loadModel, dumpModel, validateModel, matches } from './model/validate.js';
4
+ export { mergeModels, formatConflicts } from './model/merge.js';
5
+ // Extension points
6
+ export { Registry } from './registry.js';
7
+ export { providers, defineProvider, terraformProvider, dynatraceProvider } from './providers/index.js';
8
+ export { renderers, render, resolveRenderer } from './render/index.js';
9
+ export { defineRenderer } from './render/types.js';
10
+ // Built-in providers
11
+ export { importTerraformState, parseTerraformState } from './providers/terraform.js';
12
+ export { toModel as dynatraceToModel } from './providers/dynatrace.js';
13
+ // Renderers
14
+ export { renderMermaid } from './render/mermaid.js';
15
+ export { renderDot } from './render/dot.js';
16
+ export { renderGraphvizSvg, detectTier, probeNativeDot } from './render/graphviz.js';
17
+ export { selectEntities } from './render/select.js';
18
+ // Reconciliation
19
+ export { reconcile, formatDrift, COMPARED_FIELDS } from './reconcile/index.js';
20
+ // Config
21
+ export { loadConfig, observeAll, makeContext, Config } from './config.js';
@@ -0,0 +1,32 @@
1
+ import type { Model } from './schema.js';
2
+ /**
3
+ * Combining what several providers observed into one model.
4
+ *
5
+ * The genuinely hard part is identity: Terraform calls it
6
+ * `aws_lambda_function.forwarder`, Dynatrace calls it `SERVICE-A1B2`, and
7
+ * nothing in either payload proves they're the same thing. This module does
8
+ * NOT guess. It applies the explicit `aliases` map from the committed model
9
+ * and otherwise keeps ids separate — a duplicated node is a visible, fixable
10
+ * problem, whereas a wrongly merged node silently corrupts the graph.
11
+ */
12
+ export interface SourcedModel {
13
+ provider: string;
14
+ kind: 'declarative' | 'runtime';
15
+ model: Model;
16
+ }
17
+ export interface MergeConflict {
18
+ id: string;
19
+ field: 'kind' | 'name' | 'group' | 'platform';
20
+ values: Array<{
21
+ provider: string;
22
+ value: string | undefined;
23
+ }>;
24
+ /** The value that won, and why. */
25
+ resolved: string | undefined;
26
+ }
27
+ export interface MergeResult {
28
+ model: Model;
29
+ conflicts: MergeConflict[];
30
+ }
31
+ export declare function mergeModels(sources: SourcedModel[], aliases?: Record<string, string>): MergeResult;
32
+ export declare function formatConflicts(conflicts: MergeConflict[]): string;
@@ -0,0 +1,96 @@
1
+ import { emptyModel } from './schema.js';
2
+ const CONFLICT_FIELDS = ['kind', 'name', 'group', 'platform'];
3
+ /**
4
+ * Declarative sources describe intent and own naming and grouping; runtime
5
+ * sources describe what's actually live. When both saw the same entity, the
6
+ * declarative value wins on these descriptive fields — a monitoring agent's
7
+ * display name is usually noisier than the IaC resource name.
8
+ */
9
+ function preferred(candidates) {
10
+ const withValue = candidates.filter((c) => c.value !== undefined);
11
+ if (withValue.length === 0)
12
+ return undefined;
13
+ return (withValue.find((c) => c.kind === 'declarative') ?? withValue[0]).value;
14
+ }
15
+ export function mergeModels(sources, aliases = {}) {
16
+ const resolveId = (id) => aliases[id] ?? id;
17
+ const byId = new Map();
18
+ for (const src of sources) {
19
+ for (const entity of src.model.entities) {
20
+ const id = resolveId(entity.id);
21
+ const list = byId.get(id) ?? [];
22
+ list.push({ provider: src.provider, kind: src.kind, entity });
23
+ byId.set(id, list);
24
+ }
25
+ }
26
+ const entities = [];
27
+ const conflicts = [];
28
+ for (const [id, observations] of byId) {
29
+ const first = observations[0].entity;
30
+ const merged = { ...first, id, level: first.level };
31
+ for (const field of CONFLICT_FIELDS) {
32
+ const candidates = observations.map((o) => ({
33
+ provider: o.provider,
34
+ kind: o.kind,
35
+ value: o.entity[field],
36
+ }));
37
+ const distinct = [...new Set(candidates.map((c) => c.value).filter((v) => v !== undefined))];
38
+ const winner = preferred(candidates);
39
+ if (field === 'kind') {
40
+ merged.kind = winner ?? first.kind;
41
+ }
42
+ else {
43
+ merged[field] = winner;
44
+ }
45
+ if (distinct.length > 1) {
46
+ conflicts.push({
47
+ id,
48
+ field,
49
+ values: candidates.map((c) => ({ provider: c.provider, value: c.value })),
50
+ resolved: winner,
51
+ });
52
+ }
53
+ }
54
+ // Provenance: every provider that saw this entity, so a drift report can
55
+ // say "Terraform knows about it, Dynatrace has never seen it run".
56
+ merged.source = [...new Set(observations.map((o) => o.provider))].sort().join('+');
57
+ const tags = {};
58
+ for (const o of observations)
59
+ Object.assign(tags, o.entity.tags ?? {});
60
+ if (Object.keys(tags).length > 0)
61
+ merged.tags = tags;
62
+ entities.push(merged);
63
+ }
64
+ const known = new Set(entities.map((e) => e.id));
65
+ const edgeMap = new Map();
66
+ for (const src of sources) {
67
+ for (const edge of src.model.edges) {
68
+ const from = resolveId(edge.from);
69
+ const to = resolveId(edge.to);
70
+ // Aliasing can collapse two ids into one and turn an edge into a loop.
71
+ if (from === to)
72
+ continue;
73
+ if (!known.has(from) || !known.has(to))
74
+ continue;
75
+ const key = `${from} ${to}`;
76
+ if (!edgeMap.has(key))
77
+ edgeMap.set(key, { ...edge, from, to });
78
+ }
79
+ }
80
+ const model = emptyModel(sources[0]?.model.name ?? 'merged');
81
+ model.entities = entities.sort((a, b) => a.id.localeCompare(b.id));
82
+ model.edges = [...edgeMap.values()].sort((a, b) => (a.from + a.to).localeCompare(b.from + b.to));
83
+ model.aliases = aliases;
84
+ return { model, conflicts };
85
+ }
86
+ export function formatConflicts(conflicts) {
87
+ if (conflicts.length === 0)
88
+ return '';
89
+ const out = [`### Provider disagreements (${conflicts.length})`, '', '| Entity | Field | Values | Resolved |', '|---|---|---|---|'];
90
+ for (const c of conflicts) {
91
+ const values = c.values.map((v) => `${v.provider}=${v.value ?? '-'}`).join(', ');
92
+ out.push(`| \`${c.id}\` | ${c.field} | ${values} | ${c.resolved ?? '-'} |`);
93
+ }
94
+ out.push('');
95
+ return out.join('\n');
96
+ }
@@ -0,0 +1,322 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The model is the only thing that lives in git. Providers write into it,
4
+ * renderers read from it, and the reconciler diffs it against reality.
5
+ *
6
+ * `kind` is deliberately an open string rather than an enum — any provider
7
+ * must be able to emit its own vocabulary (`aws_s3_bucket`, `service`,
8
+ * `k8s_deployment`) without a schema change. Renderers map known kinds to
9
+ * shapes and fall back gracefully for the rest.
10
+ */
11
+ export declare const Level: z.ZodEnum<["context", "container", "component"]>;
12
+ export type Level = z.infer<typeof Level>;
13
+ export declare const Entity: z.ZodObject<{
14
+ /** Stable identity. For Terraform this is the resource address. */
15
+ id: z.ZodString;
16
+ kind: z.ZodString;
17
+ name: z.ZodOptional<z.ZodString>;
18
+ /** Visual + logical grouping — becomes a subgraph when rendered. */
19
+ group: z.ZodOptional<z.ZodString>;
20
+ /** Where it runs: aws, gcp, azure, onprem, kubernetes. */
21
+ platform: z.ZodOptional<z.ZodString>;
22
+ /** Which provider observed it. Provenance, set by the merge step. */
23
+ source: z.ZodOptional<z.ZodString>;
24
+ level: z.ZodDefault<z.ZodEnum<["context", "container", "component"]>>;
25
+ /** Free-form metadata. Excluded from drift comparison by default. */
26
+ tags: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
27
+ }, "strip", z.ZodTypeAny, {
28
+ id: string;
29
+ kind: string;
30
+ level: "context" | "container" | "component";
31
+ name?: string | undefined;
32
+ group?: string | undefined;
33
+ platform?: string | undefined;
34
+ source?: string | undefined;
35
+ tags?: Record<string, string> | undefined;
36
+ }, {
37
+ id: string;
38
+ kind: string;
39
+ name?: string | undefined;
40
+ group?: string | undefined;
41
+ platform?: string | undefined;
42
+ source?: string | undefined;
43
+ level?: "context" | "container" | "component" | undefined;
44
+ tags?: Record<string, string> | undefined;
45
+ }>;
46
+ export type Entity = z.infer<typeof Entity>;
47
+ export declare const Edge: z.ZodObject<{
48
+ from: z.ZodString;
49
+ to: z.ZodString;
50
+ kind: z.ZodDefault<z.ZodString>;
51
+ label: z.ZodOptional<z.ZodString>;
52
+ }, "strip", z.ZodTypeAny, {
53
+ kind: string;
54
+ from: string;
55
+ to: string;
56
+ label?: string | undefined;
57
+ }, {
58
+ from: string;
59
+ to: string;
60
+ kind?: string | undefined;
61
+ label?: string | undefined;
62
+ }>;
63
+ export type Edge = z.infer<typeof Edge>;
64
+ /**
65
+ * A scoped slice of the model. Flat Mermaid becomes unreadable past roughly
66
+ * 150 nodes, so views exist from v1 rather than being a later optimization.
67
+ * Patterns match an entity's id or group, and support a trailing `*`.
68
+ */
69
+ export declare const View: z.ZodObject<{
70
+ id: z.ZodString;
71
+ title: z.ZodOptional<z.ZodString>;
72
+ level: z.ZodDefault<z.ZodEnum<["context", "container", "component"]>>;
73
+ include: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
74
+ exclude: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
75
+ }, "strip", z.ZodTypeAny, {
76
+ id: string;
77
+ level: "context" | "container" | "component";
78
+ include: string[];
79
+ exclude: string[];
80
+ title?: string | undefined;
81
+ }, {
82
+ id: string;
83
+ level?: "context" | "container" | "component" | undefined;
84
+ title?: string | undefined;
85
+ include?: string[] | undefined;
86
+ exclude?: string[] | undefined;
87
+ }>;
88
+ export type View = z.infer<typeof View>;
89
+ /** Intentional divergence, reviewed like code. */
90
+ export declare const Ignore: z.ZodObject<{
91
+ entities: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
92
+ kinds: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
93
+ edges: z.ZodDefault<z.ZodArray<z.ZodObject<{
94
+ from: z.ZodString;
95
+ to: z.ZodString;
96
+ }, "strip", z.ZodTypeAny, {
97
+ from: string;
98
+ to: string;
99
+ }, {
100
+ from: string;
101
+ to: string;
102
+ }>, "many">>;
103
+ }, "strip", z.ZodTypeAny, {
104
+ entities: string[];
105
+ kinds: string[];
106
+ edges: {
107
+ from: string;
108
+ to: string;
109
+ }[];
110
+ }, {
111
+ entities?: string[] | undefined;
112
+ kinds?: string[] | undefined;
113
+ edges?: {
114
+ from: string;
115
+ to: string;
116
+ }[] | undefined;
117
+ }>;
118
+ export type Ignore = z.infer<typeof Ignore>;
119
+ export declare const Model: z.ZodObject<{
120
+ version: z.ZodLiteral<1>;
121
+ name: z.ZodString;
122
+ entities: z.ZodDefault<z.ZodArray<z.ZodObject<{
123
+ /** Stable identity. For Terraform this is the resource address. */
124
+ id: z.ZodString;
125
+ kind: z.ZodString;
126
+ name: z.ZodOptional<z.ZodString>;
127
+ /** Visual + logical grouping — becomes a subgraph when rendered. */
128
+ group: z.ZodOptional<z.ZodString>;
129
+ /** Where it runs: aws, gcp, azure, onprem, kubernetes. */
130
+ platform: z.ZodOptional<z.ZodString>;
131
+ /** Which provider observed it. Provenance, set by the merge step. */
132
+ source: z.ZodOptional<z.ZodString>;
133
+ level: z.ZodDefault<z.ZodEnum<["context", "container", "component"]>>;
134
+ /** Free-form metadata. Excluded from drift comparison by default. */
135
+ tags: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
136
+ }, "strip", z.ZodTypeAny, {
137
+ id: string;
138
+ kind: string;
139
+ level: "context" | "container" | "component";
140
+ name?: string | undefined;
141
+ group?: string | undefined;
142
+ platform?: string | undefined;
143
+ source?: string | undefined;
144
+ tags?: Record<string, string> | undefined;
145
+ }, {
146
+ id: string;
147
+ kind: string;
148
+ name?: string | undefined;
149
+ group?: string | undefined;
150
+ platform?: string | undefined;
151
+ source?: string | undefined;
152
+ level?: "context" | "container" | "component" | undefined;
153
+ tags?: Record<string, string> | undefined;
154
+ }>, "many">>;
155
+ edges: z.ZodDefault<z.ZodArray<z.ZodObject<{
156
+ from: z.ZodString;
157
+ to: z.ZodString;
158
+ kind: z.ZodDefault<z.ZodString>;
159
+ label: z.ZodOptional<z.ZodString>;
160
+ }, "strip", z.ZodTypeAny, {
161
+ kind: string;
162
+ from: string;
163
+ to: string;
164
+ label?: string | undefined;
165
+ }, {
166
+ from: string;
167
+ to: string;
168
+ kind?: string | undefined;
169
+ label?: string | undefined;
170
+ }>, "many">>;
171
+ views: z.ZodDefault<z.ZodArray<z.ZodObject<{
172
+ id: z.ZodString;
173
+ title: z.ZodOptional<z.ZodString>;
174
+ level: z.ZodDefault<z.ZodEnum<["context", "container", "component"]>>;
175
+ include: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
176
+ exclude: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
177
+ }, "strip", z.ZodTypeAny, {
178
+ id: string;
179
+ level: "context" | "container" | "component";
180
+ include: string[];
181
+ exclude: string[];
182
+ title?: string | undefined;
183
+ }, {
184
+ id: string;
185
+ level?: "context" | "container" | "component" | undefined;
186
+ title?: string | undefined;
187
+ include?: string[] | undefined;
188
+ exclude?: string[] | undefined;
189
+ }>, "many">>;
190
+ ignore: z.ZodDefault<z.ZodObject<{
191
+ entities: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
192
+ kinds: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
193
+ edges: z.ZodDefault<z.ZodArray<z.ZodObject<{
194
+ from: z.ZodString;
195
+ to: z.ZodString;
196
+ }, "strip", z.ZodTypeAny, {
197
+ from: string;
198
+ to: string;
199
+ }, {
200
+ from: string;
201
+ to: string;
202
+ }>, "many">>;
203
+ }, "strip", z.ZodTypeAny, {
204
+ entities: string[];
205
+ kinds: string[];
206
+ edges: {
207
+ from: string;
208
+ to: string;
209
+ }[];
210
+ }, {
211
+ entities?: string[] | undefined;
212
+ kinds?: string[] | undefined;
213
+ edges?: {
214
+ from: string;
215
+ to: string;
216
+ }[] | undefined;
217
+ }>>;
218
+ /**
219
+ * Cross-provider identity. Maps a provider's native id to the canonical
220
+ * entity id, so Dynatrace's `SERVICE-A1B2` and Terraform's
221
+ * `aws_lambda_function.forwarder` collapse into one node.
222
+ *
223
+ * This is deliberately manual. Automatic identity resolution across
224
+ * declarative and runtime sources is the hard, unsolved part of this
225
+ * problem, and guessing wrong silently corrupts the graph.
226
+ */
227
+ aliases: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
228
+ /**
229
+ * Declared blind spots. Read-only credentials never see everything, and
230
+ * "unknown" must never be silently reported as "absent".
231
+ */
232
+ coverage: z.ZodDefault<z.ZodArray<z.ZodObject<{
233
+ scope: z.ZodString;
234
+ reason: z.ZodString;
235
+ }, "strip", z.ZodTypeAny, {
236
+ scope: string;
237
+ reason: string;
238
+ }, {
239
+ scope: string;
240
+ reason: string;
241
+ }>, "many">>;
242
+ }, "strip", z.ZodTypeAny, {
243
+ name: string;
244
+ entities: {
245
+ id: string;
246
+ kind: string;
247
+ level: "context" | "container" | "component";
248
+ name?: string | undefined;
249
+ group?: string | undefined;
250
+ platform?: string | undefined;
251
+ source?: string | undefined;
252
+ tags?: Record<string, string> | undefined;
253
+ }[];
254
+ edges: {
255
+ kind: string;
256
+ from: string;
257
+ to: string;
258
+ label?: string | undefined;
259
+ }[];
260
+ version: 1;
261
+ views: {
262
+ id: string;
263
+ level: "context" | "container" | "component";
264
+ include: string[];
265
+ exclude: string[];
266
+ title?: string | undefined;
267
+ }[];
268
+ ignore: {
269
+ entities: string[];
270
+ kinds: string[];
271
+ edges: {
272
+ from: string;
273
+ to: string;
274
+ }[];
275
+ };
276
+ aliases: Record<string, string>;
277
+ coverage: {
278
+ scope: string;
279
+ reason: string;
280
+ }[];
281
+ }, {
282
+ name: string;
283
+ version: 1;
284
+ entities?: {
285
+ id: string;
286
+ kind: string;
287
+ name?: string | undefined;
288
+ group?: string | undefined;
289
+ platform?: string | undefined;
290
+ source?: string | undefined;
291
+ level?: "context" | "container" | "component" | undefined;
292
+ tags?: Record<string, string> | undefined;
293
+ }[] | undefined;
294
+ edges?: {
295
+ from: string;
296
+ to: string;
297
+ kind?: string | undefined;
298
+ label?: string | undefined;
299
+ }[] | undefined;
300
+ views?: {
301
+ id: string;
302
+ level?: "context" | "container" | "component" | undefined;
303
+ title?: string | undefined;
304
+ include?: string[] | undefined;
305
+ exclude?: string[] | undefined;
306
+ }[] | undefined;
307
+ ignore?: {
308
+ entities?: string[] | undefined;
309
+ kinds?: string[] | undefined;
310
+ edges?: {
311
+ from: string;
312
+ to: string;
313
+ }[] | undefined;
314
+ } | undefined;
315
+ aliases?: Record<string, string> | undefined;
316
+ coverage?: {
317
+ scope: string;
318
+ reason: string;
319
+ }[] | undefined;
320
+ }>;
321
+ export type Model = z.infer<typeof Model>;
322
+ export declare function emptyModel(name: string): Model;
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The model is the only thing that lives in git. Providers write into it,
4
+ * renderers read from it, and the reconciler diffs it against reality.
5
+ *
6
+ * `kind` is deliberately an open string rather than an enum — any provider
7
+ * must be able to emit its own vocabulary (`aws_s3_bucket`, `service`,
8
+ * `k8s_deployment`) without a schema change. Renderers map known kinds to
9
+ * shapes and fall back gracefully for the rest.
10
+ */
11
+ export const Level = z.enum(['context', 'container', 'component']);
12
+ export const Entity = z.object({
13
+ /** Stable identity. For Terraform this is the resource address. */
14
+ id: z.string().min(1),
15
+ kind: z.string().min(1),
16
+ name: z.string().optional(),
17
+ /** Visual + logical grouping — becomes a subgraph when rendered. */
18
+ group: z.string().optional(),
19
+ /** Where it runs: aws, gcp, azure, onprem, kubernetes. */
20
+ platform: z.string().optional(),
21
+ /** Which provider observed it. Provenance, set by the merge step. */
22
+ source: z.string().optional(),
23
+ level: Level.default('container'),
24
+ /** Free-form metadata. Excluded from drift comparison by default. */
25
+ tags: z.record(z.string()).optional(),
26
+ });
27
+ export const Edge = z.object({
28
+ from: z.string().min(1),
29
+ to: z.string().min(1),
30
+ kind: z.string().default('depends-on'),
31
+ label: z.string().optional(),
32
+ });
33
+ /**
34
+ * A scoped slice of the model. Flat Mermaid becomes unreadable past roughly
35
+ * 150 nodes, so views exist from v1 rather than being a later optimization.
36
+ * Patterns match an entity's id or group, and support a trailing `*`.
37
+ */
38
+ export const View = z.object({
39
+ id: z.string().min(1),
40
+ title: z.string().optional(),
41
+ level: Level.default('container'),
42
+ include: z.array(z.string()).default([]),
43
+ exclude: z.array(z.string()).default([]),
44
+ });
45
+ /** Intentional divergence, reviewed like code. */
46
+ export const Ignore = z.object({
47
+ entities: z.array(z.string()).default([]),
48
+ kinds: z.array(z.string()).default([]),
49
+ edges: z.array(z.object({ from: z.string(), to: z.string() })).default([]),
50
+ });
51
+ export const Model = z.object({
52
+ version: z.literal(1),
53
+ name: z.string().min(1),
54
+ entities: z.array(Entity).default([]),
55
+ edges: z.array(Edge).default([]),
56
+ views: z.array(View).default([]),
57
+ ignore: Ignore.default({ entities: [], kinds: [], edges: [] }),
58
+ /**
59
+ * Cross-provider identity. Maps a provider's native id to the canonical
60
+ * entity id, so Dynatrace's `SERVICE-A1B2` and Terraform's
61
+ * `aws_lambda_function.forwarder` collapse into one node.
62
+ *
63
+ * This is deliberately manual. Automatic identity resolution across
64
+ * declarative and runtime sources is the hard, unsolved part of this
65
+ * problem, and guessing wrong silently corrupts the graph.
66
+ */
67
+ aliases: z.record(z.string()).default({}),
68
+ /**
69
+ * Declared blind spots. Read-only credentials never see everything, and
70
+ * "unknown" must never be silently reported as "absent".
71
+ */
72
+ coverage: z
73
+ .array(z.object({ scope: z.string(), reason: z.string() }))
74
+ .default([]),
75
+ });
76
+ export function emptyModel(name) {
77
+ return Model.parse({ version: 1, name });
78
+ }
@@ -0,0 +1,21 @@
1
+ import { Model } from './schema.js';
2
+ export interface Issue {
3
+ severity: 'error' | 'warning';
4
+ message: string;
5
+ }
6
+ export interface ValidationResult {
7
+ ok: boolean;
8
+ model?: Model;
9
+ issues: Issue[];
10
+ }
11
+ /** Matches an id or group against a pattern supporting a trailing `*`. */
12
+ export declare function matches(pattern: string, value: string): boolean;
13
+ /**
14
+ * Schema validation alone isn't enough — a model can be well-formed YAML and
15
+ * still be nonsense (an edge pointing at an entity that doesn't exist, two
16
+ * entities sharing an id). Those are the failures that would produce a broken
17
+ * diagram silently, so they're errors, not warnings.
18
+ */
19
+ export declare function validateModel(raw: unknown): ValidationResult;
20
+ export declare function loadModel(source: string): ValidationResult;
21
+ export declare function dumpModel(model: Model): string;
@@ -0,0 +1,76 @@
1
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
2
+ import { Model } from './schema.js';
3
+ /** Matches an id or group against a pattern supporting a trailing `*`. */
4
+ export function matches(pattern, value) {
5
+ if (pattern === '*')
6
+ return true;
7
+ if (pattern.endsWith('*'))
8
+ return value.startsWith(pattern.slice(0, -1));
9
+ return pattern === value;
10
+ }
11
+ /**
12
+ * Schema validation alone isn't enough — a model can be well-formed YAML and
13
+ * still be nonsense (an edge pointing at an entity that doesn't exist, two
14
+ * entities sharing an id). Those are the failures that would produce a broken
15
+ * diagram silently, so they're errors, not warnings.
16
+ */
17
+ export function validateModel(raw) {
18
+ const parsed = Model.safeParse(raw);
19
+ if (!parsed.success) {
20
+ return {
21
+ ok: false,
22
+ issues: parsed.error.issues.map((i) => ({
23
+ severity: 'error',
24
+ message: `${i.path.join('.') || '(root)'}: ${i.message}`,
25
+ })),
26
+ };
27
+ }
28
+ const model = parsed.data;
29
+ const issues = [];
30
+ const ids = new Set();
31
+ for (const e of model.entities) {
32
+ if (ids.has(e.id)) {
33
+ issues.push({ severity: 'error', message: `duplicate entity id: ${e.id}` });
34
+ }
35
+ ids.add(e.id);
36
+ }
37
+ for (const edge of model.edges) {
38
+ if (!ids.has(edge.from)) {
39
+ issues.push({ severity: 'error', message: `edge references unknown entity: ${edge.from} (in ${edge.from} -> ${edge.to})` });
40
+ }
41
+ if (!ids.has(edge.to)) {
42
+ issues.push({ severity: 'error', message: `edge references unknown entity: ${edge.to} (in ${edge.from} -> ${edge.to})` });
43
+ }
44
+ if (edge.from === edge.to) {
45
+ issues.push({ severity: 'warning', message: `self-referencing edge on ${edge.from}` });
46
+ }
47
+ }
48
+ const viewIds = new Set();
49
+ for (const view of model.views) {
50
+ if (viewIds.has(view.id)) {
51
+ issues.push({ severity: 'error', message: `duplicate view id: ${view.id}` });
52
+ }
53
+ viewIds.add(view.id);
54
+ // A view that selects nothing is almost always a typo in a pattern.
55
+ const hit = model.entities.some((e) => view.include.length === 0
56
+ ? true
57
+ : view.include.some((p) => matches(p, e.id) || (e.group != null && matches(p, e.group))));
58
+ if (!hit) {
59
+ issues.push({ severity: 'warning', message: `view '${view.id}' matches no entities` });
60
+ }
61
+ }
62
+ return { ok: !issues.some((i) => i.severity === 'error'), model, issues };
63
+ }
64
+ export function loadModel(source) {
65
+ let raw;
66
+ try {
67
+ raw = parseYaml(source);
68
+ }
69
+ catch (err) {
70
+ return { ok: false, issues: [{ severity: 'error', message: `invalid YAML: ${err.message}` }] };
71
+ }
72
+ return validateModel(raw);
73
+ }
74
+ export function dumpModel(model) {
75
+ return stringifyYaml(model, { lineWidth: 0 });
76
+ }