@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,70 @@
1
+ import { z } from 'zod';
2
+ import type { Model } from '../model/schema.js';
3
+ /**
4
+ * Dynatrace Smartscape as a runtime provider.
5
+ *
6
+ * This is the counterpart to Terraform: Terraform says what is *supposed* to
7
+ * exist, Dynatrace says what is *actually running*. Reconciling the two is the
8
+ * point of the tool — a service Terraform declares but Dynatrace has never
9
+ * seen is a very different finding from one neither knows about.
10
+ *
11
+ * Read-only: this hits the Entities API v2 with a token that only needs
12
+ * `entities.read`. Nothing here mutates anything.
13
+ */
14
+ export declare const dynatraceConfigSchema: z.ZodObject<{
15
+ /** Environment URL, e.g. https://abc12345.live.dynatrace.com */
16
+ url: z.ZodString;
17
+ /** Name of the env var holding the API token. Never the token itself. */
18
+ tokenEnv: z.ZodDefault<z.ZodString>;
19
+ /** Entity selectors to pull. Defaults cover the common topology types. */
20
+ entitySelectors: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
21
+ platform: z.ZodDefault<z.ZodString>;
22
+ /** Guards against pulling an enormous environment by accident. */
23
+ pageSize: z.ZodDefault<z.ZodNumber>;
24
+ }, "strip", z.ZodTypeAny, {
25
+ platform: string;
26
+ url: string;
27
+ tokenEnv: string;
28
+ entitySelectors: string[];
29
+ pageSize: number;
30
+ }, {
31
+ url: string;
32
+ platform?: string | undefined;
33
+ tokenEnv?: string | undefined;
34
+ entitySelectors?: string[] | undefined;
35
+ pageSize?: number | undefined;
36
+ }>;
37
+ export type DynatraceConfig = z.infer<typeof dynatraceConfigSchema>;
38
+ interface DtEntity {
39
+ entityId?: string;
40
+ displayName?: string;
41
+ type?: string;
42
+ fromRelationships?: Record<string, Array<{
43
+ id?: string;
44
+ }>>;
45
+ }
46
+ export declare function toModel(entities: DtEntity[], platform: string): Model;
47
+ export declare const dynatraceProvider: import("./types.js").Provider<z.ZodObject<{
48
+ /** Environment URL, e.g. https://abc12345.live.dynatrace.com */
49
+ url: z.ZodString;
50
+ /** Name of the env var holding the API token. Never the token itself. */
51
+ tokenEnv: z.ZodDefault<z.ZodString>;
52
+ /** Entity selectors to pull. Defaults cover the common topology types. */
53
+ entitySelectors: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
54
+ platform: z.ZodDefault<z.ZodString>;
55
+ /** Guards against pulling an enormous environment by accident. */
56
+ pageSize: z.ZodDefault<z.ZodNumber>;
57
+ }, "strip", z.ZodTypeAny, {
58
+ platform: string;
59
+ url: string;
60
+ tokenEnv: string;
61
+ entitySelectors: string[];
62
+ pageSize: number;
63
+ }, {
64
+ url: string;
65
+ platform?: string | undefined;
66
+ tokenEnv?: string | undefined;
67
+ entitySelectors?: string[] | undefined;
68
+ pageSize?: number | undefined;
69
+ }>>;
70
+ export {};
@@ -0,0 +1,117 @@
1
+ import { z } from 'zod';
2
+ import { emptyModel } from '../model/schema.js';
3
+ import { defineProvider } from './types.js';
4
+ /**
5
+ * Dynatrace Smartscape as a runtime provider.
6
+ *
7
+ * This is the counterpart to Terraform: Terraform says what is *supposed* to
8
+ * exist, Dynatrace says what is *actually running*. Reconciling the two is the
9
+ * point of the tool — a service Terraform declares but Dynatrace has never
10
+ * seen is a very different finding from one neither knows about.
11
+ *
12
+ * Read-only: this hits the Entities API v2 with a token that only needs
13
+ * `entities.read`. Nothing here mutates anything.
14
+ */
15
+ export const dynatraceConfigSchema = z.object({
16
+ /** Environment URL, e.g. https://abc12345.live.dynatrace.com */
17
+ url: z.string().url(),
18
+ /** Name of the env var holding the API token. Never the token itself. */
19
+ tokenEnv: z.string().default('DYNATRACE_API_TOKEN'),
20
+ /** Entity selectors to pull. Defaults cover the common topology types. */
21
+ entitySelectors: z
22
+ .array(z.string())
23
+ .default(['type("SERVICE")', 'type("HOST")', 'type("PROCESS_GROUP")']),
24
+ platform: z.string().default('dynatrace'),
25
+ /** Guards against pulling an enormous environment by accident. */
26
+ pageSize: z.number().int().min(1).max(4000).default(500),
27
+ });
28
+ /** Maps a Dynatrace entity type to a group label. */
29
+ function groupFor(type) {
30
+ if (!type)
31
+ return undefined;
32
+ return type.toLowerCase().replace(/_/g, '-');
33
+ }
34
+ export function toModel(entities, platform) {
35
+ const model = emptyModel('dynatrace');
36
+ const out = [];
37
+ const edges = [];
38
+ const known = new Set();
39
+ for (const e of entities) {
40
+ if (!e.entityId)
41
+ continue;
42
+ if (known.has(e.entityId))
43
+ continue;
44
+ known.add(e.entityId);
45
+ out.push({
46
+ id: e.entityId,
47
+ kind: e.type ?? 'UNKNOWN',
48
+ name: e.displayName ?? e.entityId,
49
+ group: groupFor(e.type),
50
+ platform,
51
+ source: 'dynatrace',
52
+ level: 'container',
53
+ });
54
+ }
55
+ for (const e of entities) {
56
+ if (!e.entityId)
57
+ continue;
58
+ for (const [relation, targets] of Object.entries(e.fromRelationships ?? {})) {
59
+ for (const t of targets ?? []) {
60
+ // Relationships routinely point at entity types outside the selector,
61
+ // which would otherwise produce dangling edges the validator rejects.
62
+ if (!t.id || !known.has(t.id) || t.id === e.entityId)
63
+ continue;
64
+ if (edges.some((x) => x.from === e.entityId && x.to === t.id))
65
+ continue;
66
+ edges.push({ from: e.entityId, to: t.id, kind: relation });
67
+ }
68
+ }
69
+ }
70
+ model.entities = out.sort((a, b) => a.id.localeCompare(b.id));
71
+ model.edges = edges.sort((a, b) => (a.from + a.to).localeCompare(b.from + b.to));
72
+ return model;
73
+ }
74
+ async function fetchAll(config, token) {
75
+ const collected = [];
76
+ for (const selector of config.entitySelectors) {
77
+ let pageKey;
78
+ do {
79
+ const url = new URL('/api/v2/entities', config.url);
80
+ if (pageKey) {
81
+ url.searchParams.set('nextPageKey', pageKey);
82
+ }
83
+ else {
84
+ url.searchParams.set('entitySelector', selector);
85
+ url.searchParams.set('pageSize', String(config.pageSize));
86
+ url.searchParams.set('fields', '+fromRelationships');
87
+ }
88
+ const res = await fetch(url, {
89
+ headers: { Authorization: `Api-Token ${token}`, Accept: 'application/json' },
90
+ });
91
+ if (!res.ok) {
92
+ throw new Error(`Dynatrace API ${res.status} ${res.statusText} for selector ${selector}`);
93
+ }
94
+ const body = (await res.json());
95
+ collected.push(...(body.entities ?? []));
96
+ pageKey = body.nextPageKey;
97
+ } while (pageKey);
98
+ }
99
+ return collected;
100
+ }
101
+ export const dynatraceProvider = defineProvider({
102
+ name: 'dynatrace',
103
+ description: 'Dynatrace Smartscape topology (Entities API v2, read-only)',
104
+ kind: 'runtime',
105
+ platforms: ['aws', 'gcp', 'azure', 'onprem', 'kubernetes'],
106
+ configSchema: dynatraceConfigSchema,
107
+ async observe(config, ctx) {
108
+ const token = ctx.secret(config.tokenEnv);
109
+ if (!token) {
110
+ throw new Error(`Dynatrace token not found. Set $${config.tokenEnv} to a token with the 'entities.read' scope.`);
111
+ }
112
+ ctx.log(`dynatrace: querying ${config.entitySelectors.length} selector(s)`);
113
+ const entities = await fetchAll(config, token);
114
+ ctx.log(`dynatrace: ${entities.length} entities`);
115
+ return toModel(entities, config.platform);
116
+ },
117
+ });
@@ -0,0 +1,12 @@
1
+ import { Registry } from '../registry.js';
2
+ import type { Provider } from './types.js';
3
+ import { terraformProvider } from './terraform.js';
4
+ import { dynatraceProvider } from './dynatrace.js';
5
+ /**
6
+ * Built-in providers. Third parties register their own against the same
7
+ * registry — there is no separate plugin API to learn.
8
+ */
9
+ export declare const providers: Registry<Provider<import("zod").ZodTypeAny>>;
10
+ export { terraformProvider, dynatraceProvider };
11
+ export type { Provider, ProviderContext, ProviderKind } from './types.js';
12
+ export { defineProvider } from './types.js';
@@ -0,0 +1,12 @@
1
+ import { Registry } from '../registry.js';
2
+ import { terraformProvider } from './terraform.js';
3
+ import { dynatraceProvider } from './dynatrace.js';
4
+ /**
5
+ * Built-in providers. Third parties register their own against the same
6
+ * registry — there is no separate plugin API to learn.
7
+ */
8
+ export const providers = new Registry('provider');
9
+ providers.register(terraformProvider);
10
+ providers.register(dynatraceProvider);
11
+ export { terraformProvider, dynatraceProvider };
12
+ export { defineProvider } from './types.js';
@@ -0,0 +1,61 @@
1
+ import type { Model } from '../model/schema.js';
2
+ /**
3
+ * Terraform state (format v4) as the first provider.
4
+ *
5
+ * Rationale from the brief: it's already a graph, already declarative, and it
6
+ * carries real cloud resource IDs — which sidesteps the identity-resolution
7
+ * problem that kills CMDBs. Entity id is the Terraform address, which is
8
+ * stable across plans and human-readable in a diff.
9
+ */
10
+ interface TfInstance {
11
+ attributes?: Record<string, unknown>;
12
+ dependencies?: string[];
13
+ }
14
+ interface TfResource {
15
+ mode?: string;
16
+ type?: string;
17
+ name?: string;
18
+ module?: string;
19
+ provider?: string;
20
+ instances?: TfInstance[];
21
+ }
22
+ interface TfState {
23
+ version?: number;
24
+ resources?: TfResource[];
25
+ }
26
+ export interface ImportOptions {
27
+ /** Data sources describe things Terraform reads but doesn't own. */
28
+ includeDataSources?: boolean;
29
+ modelName?: string;
30
+ }
31
+ export declare function importTerraformState(state: TfState, opts?: ImportOptions): Model;
32
+ export declare function parseTerraformState(source: string): TfState;
33
+ import { z } from 'zod';
34
+ export declare const terraformConfigSchema: z.ZodObject<{
35
+ /** Path to terraform.tfstate, or the output of `terraform show -json`. */
36
+ statePath: z.ZodString;
37
+ includeDataSources: z.ZodDefault<z.ZodBoolean>;
38
+ }, "strip", z.ZodTypeAny, {
39
+ statePath: string;
40
+ includeDataSources: boolean;
41
+ }, {
42
+ statePath: string;
43
+ includeDataSources?: boolean | undefined;
44
+ }>;
45
+ /**
46
+ * Terraform is platform-agnostic on purpose: the same provider covers AWS,
47
+ * GCP, Azure, vSphere, and on-prem, because the platform is simply whatever
48
+ * the state file declares. One provider, every target.
49
+ */
50
+ export declare const terraformProvider: import("./types.js").Provider<z.ZodObject<{
51
+ /** Path to terraform.tfstate, or the output of `terraform show -json`. */
52
+ statePath: z.ZodString;
53
+ includeDataSources: z.ZodDefault<z.ZodBoolean>;
54
+ }, "strip", z.ZodTypeAny, {
55
+ statePath: string;
56
+ includeDataSources: boolean;
57
+ }, {
58
+ statePath: string;
59
+ includeDataSources?: boolean | undefined;
60
+ }>>;
61
+ export {};
@@ -0,0 +1,132 @@
1
+ import { emptyModel } from '../model/schema.js';
2
+ function address(r) {
3
+ const base = `${r.type}.${r.name}`;
4
+ return r.module ? `${r.module}.${base}` : base;
5
+ }
6
+ /** `provider["registry.terraform.io/hashicorp/aws"]` -> `aws` */
7
+ function providerName(raw) {
8
+ if (!raw)
9
+ return undefined;
10
+ const m = raw.match(/([^/"\]]+)"?\]?$/);
11
+ return m?.[1];
12
+ }
13
+ /**
14
+ * Terraform resource types are prefixed by provider (`aws_s3_bucket`), and the
15
+ * segment after that prefix is a good-enough grouping for a first diagram
16
+ * (`s3`, `lambda`, `route53`). Not clever, but stable and predictable.
17
+ */
18
+ function groupFor(type) {
19
+ if (!type)
20
+ return undefined;
21
+ const parts = type.split('_');
22
+ if (parts.length < 2)
23
+ return type;
24
+ return parts[1];
25
+ }
26
+ /**
27
+ * Human label. Terraform has no universal "name" attribute, so try the
28
+ * conventional ones in order of specificity before falling back to the
29
+ * resource's local name.
30
+ */
31
+ function displayName(r, attrs) {
32
+ const candidates = ['name', 'bucket', 'domain_name', 'function_name', 'identifier'];
33
+ for (const key of candidates) {
34
+ const v = attrs?.[key];
35
+ if (typeof v === 'string' && v.length > 0)
36
+ return v;
37
+ }
38
+ const tags = attrs?.['tags'];
39
+ if (tags && typeof tags === 'object' && 'Name' in tags) {
40
+ const n = tags['Name'];
41
+ if (typeof n === 'string' && n.length > 0)
42
+ return n;
43
+ }
44
+ return r.name ?? address(r);
45
+ }
46
+ export function importTerraformState(state, opts = {}) {
47
+ const model = emptyModel(opts.modelName ?? 'terraform');
48
+ const entities = [];
49
+ const edges = [];
50
+ const known = new Set();
51
+ const resources = state.resources ?? [];
52
+ for (const r of resources) {
53
+ if (!r.type || !r.name)
54
+ continue;
55
+ const isData = r.mode === 'data';
56
+ if (isData && !opts.includeDataSources)
57
+ continue;
58
+ const id = address(r);
59
+ if (known.has(id))
60
+ continue;
61
+ known.add(id);
62
+ const attrs = r.instances?.[0]?.attributes;
63
+ entities.push({
64
+ id,
65
+ kind: r.type,
66
+ name: displayName(r, attrs),
67
+ group: groupFor(r.type),
68
+ platform: providerName(r.provider),
69
+ source: 'terraform',
70
+ level: 'container',
71
+ ...(isData ? { tags: { mode: 'data' } } : {}),
72
+ });
73
+ }
74
+ for (const r of resources) {
75
+ if (!r.type || !r.name)
76
+ continue;
77
+ const from = address(r);
78
+ if (!known.has(from))
79
+ continue;
80
+ for (const inst of r.instances ?? []) {
81
+ for (const dep of inst.dependencies ?? []) {
82
+ // Dependencies on filtered-out resources (e.g. data sources) would
83
+ // produce dangling edges, which the validator rightly rejects.
84
+ if (!known.has(dep))
85
+ continue;
86
+ if (dep === from)
87
+ continue;
88
+ if (edges.some((e) => e.from === from && e.to === dep))
89
+ continue;
90
+ edges.push({ from, to: dep, kind: 'depends-on' });
91
+ }
92
+ }
93
+ }
94
+ model.entities = entities.sort((a, b) => a.id.localeCompare(b.id));
95
+ model.edges = edges.sort((a, b) => (a.from + a.to).localeCompare(b.from + b.to));
96
+ return model;
97
+ }
98
+ export function parseTerraformState(source) {
99
+ const parsed = JSON.parse(source);
100
+ if (parsed.version !== undefined && parsed.version !== 4) {
101
+ throw new Error(`unsupported Terraform state version ${parsed.version} (expected 4)`);
102
+ }
103
+ return parsed;
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Provider interface binding
107
+ // ---------------------------------------------------------------------------
108
+ import { readFileSync } from 'node:fs';
109
+ import { z } from 'zod';
110
+ import { defineProvider } from './types.js';
111
+ export const terraformConfigSchema = z.object({
112
+ /** Path to terraform.tfstate, or the output of `terraform show -json`. */
113
+ statePath: z.string(),
114
+ includeDataSources: z.boolean().default(false),
115
+ });
116
+ /**
117
+ * Terraform is platform-agnostic on purpose: the same provider covers AWS,
118
+ * GCP, Azure, vSphere, and on-prem, because the platform is simply whatever
119
+ * the state file declares. One provider, every target.
120
+ */
121
+ export const terraformProvider = defineProvider({
122
+ name: 'terraform',
123
+ description: 'Terraform state (format v4) — any platform Terraform manages',
124
+ kind: 'declarative',
125
+ platforms: ['*'],
126
+ configSchema: terraformConfigSchema,
127
+ observe(config, ctx) {
128
+ const path = ctx.resolvePath(config.statePath);
129
+ const state = parseTerraformState(readFileSync(path, 'utf8'));
130
+ return importTerraformState(state, { includeDataSources: config.includeDataSources });
131
+ },
132
+ });
@@ -0,0 +1,41 @@
1
+ import type { ZodTypeAny, z } from 'zod';
2
+ import type { Model } from '../model/schema.js';
3
+ /**
4
+ * Where a provider gets its facts. This drives how conflicts are resolved when
5
+ * two providers describe the same entity: declarative sources describe intent,
6
+ * runtime sources describe what is actually running, and runtime wins on
7
+ * liveness while declarative wins on ownership and naming.
8
+ */
9
+ export type ProviderKind = 'declarative' | 'runtime';
10
+ export interface ProviderContext {
11
+ /** Resolves a secret by name. Never read process.env directly in a provider. */
12
+ secret(name: string): string | undefined;
13
+ /** Resolves a path relative to the config file's directory. */
14
+ resolvePath(p: string): string;
15
+ log(message: string): void;
16
+ }
17
+ /**
18
+ * The provider extension point.
19
+ *
20
+ * A provider observes some system and returns a `Model`. It never reads the
21
+ * committed model, never writes files, and never mutates infrastructure — it
22
+ * answers one question: "what is actually out there right now?"
23
+ *
24
+ * `configSchema` is a Zod schema so a config file can be validated with a
25
+ * useful error message before any network call is attempted.
26
+ */
27
+ export interface Provider<S extends ZodTypeAny = ZodTypeAny> {
28
+ name: string;
29
+ description: string;
30
+ kind: ProviderKind;
31
+ /**
32
+ * Platforms this provider can describe: 'aws', 'gcp', 'azure', 'onprem',
33
+ * 'kubernetes', or '*' when it is platform-agnostic (Terraform is, because
34
+ * the platform is whatever the state file happens to contain).
35
+ */
36
+ platforms: string[];
37
+ configSchema: S;
38
+ observe(config: z.infer<S>, ctx: ProviderContext): Promise<Model> | Model;
39
+ }
40
+ /** Helper that preserves the config type through registration. */
41
+ export declare function defineProvider<S extends ZodTypeAny>(p: Provider<S>): Provider<S>;
@@ -0,0 +1,4 @@
1
+ /** Helper that preserves the config type through registration. */
2
+ export function defineProvider(p) {
3
+ return p;
4
+ }
@@ -0,0 +1,40 @@
1
+ import type { Edge, Entity, Model } from '../model/schema.js';
2
+ /**
3
+ * Declared (the model in git) vs observed (what a provider found).
4
+ *
5
+ * The drift policy is the thing most likely to sink this in practice: report
6
+ * too much and every run becomes noise that gets muted, report too little and
7
+ * the model rots anyway. Default: structural facts count, metadata doesn't.
8
+ * A new tag is not drift. A new resource, a removed edge, or a changed kind is.
9
+ */
10
+ export declare const COMPARED_FIELDS: readonly ["kind", "name", "group"];
11
+ export type ComparedField = (typeof COMPARED_FIELDS)[number];
12
+ export interface FieldChange {
13
+ id: string;
14
+ field: ComparedField;
15
+ declared: string | undefined;
16
+ observed: string | undefined;
17
+ }
18
+ export interface Drift {
19
+ entities: {
20
+ added: Entity[];
21
+ removed: Entity[];
22
+ changed: FieldChange[];
23
+ };
24
+ edges: {
25
+ added: Edge[];
26
+ removed: Edge[];
27
+ };
28
+ ignored: {
29
+ entities: number;
30
+ edges: number;
31
+ };
32
+ coverage: Array<{
33
+ scope: string;
34
+ reason: string;
35
+ }>;
36
+ hasDrift: boolean;
37
+ }
38
+ export declare function reconcile(declared: Model, observed: Model): Drift;
39
+ /** Markdown, because the output's destination is a pull request body. */
40
+ export declare function formatDrift(drift: Drift): string;
@@ -0,0 +1,146 @@
1
+ import { matches } from '../model/validate.js';
2
+ /**
3
+ * Declared (the model in git) vs observed (what a provider found).
4
+ *
5
+ * The drift policy is the thing most likely to sink this in practice: report
6
+ * too much and every run becomes noise that gets muted, report too little and
7
+ * the model rots anyway. Default: structural facts count, metadata doesn't.
8
+ * A new tag is not drift. A new resource, a removed edge, or a changed kind is.
9
+ */
10
+ export const COMPARED_FIELDS = ['kind', 'name', 'group'];
11
+ const edgeKey = (e) => `${e.from} ${e.to}`;
12
+ function isIgnoredEntity(model, e) {
13
+ if (model.ignore.kinds.some((k) => matches(k, e.kind)))
14
+ return true;
15
+ return model.ignore.entities.some((p) => matches(p, e.id));
16
+ }
17
+ function isIgnoredEdge(model, edge) {
18
+ if (model.ignore.edges.some((i) => matches(i.from, edge.from) && matches(i.to, edge.to)))
19
+ return true;
20
+ // An edge touching an ignored entity is ignored by implication, otherwise
21
+ // ignoring a resource would still surface all of its edges as drift.
22
+ const ignoredId = (id) => model.ignore.entities.some((p) => matches(p, id));
23
+ return ignoredId(edge.from) || ignoredId(edge.to);
24
+ }
25
+ export function reconcile(declared, observed) {
26
+ const declaredById = new Map(declared.entities.map((e) => [e.id, e]));
27
+ const observedById = new Map(observed.entities.map((e) => [e.id, e]));
28
+ const added = [];
29
+ const removed = [];
30
+ const changed = [];
31
+ let ignoredEntities = 0;
32
+ for (const [id, obs] of observedById) {
33
+ if (declaredById.has(id))
34
+ continue;
35
+ if (isIgnoredEntity(declared, obs)) {
36
+ ignoredEntities++;
37
+ continue;
38
+ }
39
+ added.push(obs);
40
+ }
41
+ for (const [id, dec] of declaredById) {
42
+ if (observedById.has(id))
43
+ continue;
44
+ if (isIgnoredEntity(declared, dec)) {
45
+ ignoredEntities++;
46
+ continue;
47
+ }
48
+ removed.push(dec);
49
+ }
50
+ for (const [id, dec] of declaredById) {
51
+ const obs = observedById.get(id);
52
+ if (!obs)
53
+ continue;
54
+ if (isIgnoredEntity(declared, dec))
55
+ continue;
56
+ for (const field of COMPARED_FIELDS) {
57
+ if (dec[field] !== obs[field]) {
58
+ changed.push({ id, field, declared: dec[field], observed: obs[field] });
59
+ }
60
+ }
61
+ }
62
+ const declaredEdges = new Map(declared.edges.map((e) => [edgeKey(e), e]));
63
+ const observedEdges = new Map(observed.edges.map((e) => [edgeKey(e), e]));
64
+ const addedEdges = [];
65
+ const removedEdges = [];
66
+ let ignoredEdges = 0;
67
+ for (const [key, obs] of observedEdges) {
68
+ if (declaredEdges.has(key))
69
+ continue;
70
+ if (isIgnoredEdge(declared, obs)) {
71
+ ignoredEdges++;
72
+ continue;
73
+ }
74
+ addedEdges.push(obs);
75
+ }
76
+ for (const [key, dec] of declaredEdges) {
77
+ if (observedEdges.has(key))
78
+ continue;
79
+ if (isIgnoredEdge(declared, dec)) {
80
+ ignoredEdges++;
81
+ continue;
82
+ }
83
+ removedEdges.push(dec);
84
+ }
85
+ const hasDrift = added.length > 0 ||
86
+ removed.length > 0 ||
87
+ changed.length > 0 ||
88
+ addedEdges.length > 0 ||
89
+ removedEdges.length > 0;
90
+ return {
91
+ entities: { added, removed, changed },
92
+ edges: { added: addedEdges, removed: removedEdges },
93
+ ignored: { entities: ignoredEntities, edges: ignoredEdges },
94
+ coverage: declared.coverage,
95
+ hasDrift,
96
+ };
97
+ }
98
+ /** Markdown, because the output's destination is a pull request body. */
99
+ export function formatDrift(drift) {
100
+ if (!drift.hasDrift) {
101
+ const parts = ['No drift. The model matches observed infrastructure.'];
102
+ if (drift.ignored.entities || drift.ignored.edges) {
103
+ parts.push(`\n_Ignored: ${drift.ignored.entities} entities, ${drift.ignored.edges} edges._`);
104
+ }
105
+ return parts.join('\n');
106
+ }
107
+ const out = ['## Architecture drift detected', ''];
108
+ const { added, removed, changed } = drift.entities;
109
+ if (added.length) {
110
+ out.push(`### Present in infrastructure, missing from the model (${added.length})`, '');
111
+ for (const e of added)
112
+ out.push(`- \`${e.id}\` - ${e.kind}${e.name ? ` (${e.name})` : ''}`);
113
+ out.push('');
114
+ }
115
+ if (removed.length) {
116
+ out.push(`### Declared in the model, not found in infrastructure (${removed.length})`, '');
117
+ for (const e of removed)
118
+ out.push(`- \`${e.id}\` - ${e.kind}${e.name ? ` (${e.name})` : ''}`);
119
+ out.push('');
120
+ }
121
+ if (changed.length) {
122
+ out.push(`### Changed (${changed.length})`, '', '| Entity | Field | Declared | Observed |', '|---|---|---|---|');
123
+ for (const c of changed) {
124
+ out.push(`| \`${c.id}\` | ${c.field} | ${c.declared ?? '-'} | ${c.observed ?? '-'} |`);
125
+ }
126
+ out.push('');
127
+ }
128
+ if (drift.edges.added.length || drift.edges.removed.length) {
129
+ out.push('### Relationships', '');
130
+ for (const e of drift.edges.added)
131
+ out.push(`- **added** \`${e.from}\` -> \`${e.to}\``);
132
+ for (const e of drift.edges.removed)
133
+ out.push(`- **removed** \`${e.from}\` -> \`${e.to}\``);
134
+ out.push('');
135
+ }
136
+ if (drift.ignored.entities || drift.ignored.edges) {
137
+ out.push(`_Ignored by policy: ${drift.ignored.entities} entities, ${drift.ignored.edges} edges._`, '');
138
+ }
139
+ if (drift.coverage.length) {
140
+ out.push('### Known coverage gaps', '', '_These are declared blind spots - absence here does not mean absence in reality._', '');
141
+ for (const c of drift.coverage)
142
+ out.push(`- \`${c.scope}\` - ${c.reason}`);
143
+ out.push('');
144
+ }
145
+ return out.join('\n');
146
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A tiny name -> implementation registry, shared by providers and renderers.
3
+ *
4
+ * Extension points are the whole point of this file: adding a provider or a
5
+ * renderer must never require editing core code. Built-ins register themselves
6
+ * at startup; third parties call `register()` with the same API, so a plugin
7
+ * is not a second-class citizen.
8
+ */
9
+ export declare class Registry<T extends {
10
+ name: string;
11
+ }> {
12
+ private readonly label;
13
+ private readonly items;
14
+ constructor(label: string);
15
+ register(item: T): this;
16
+ /** Replaces an existing entry. Used by tests and by deliberate overrides. */
17
+ override(item: T): this;
18
+ get(name: string): T;
19
+ has(name: string): boolean;
20
+ names(): string[];
21
+ all(): T[];
22
+ }