@clossys/architect 0.1.2

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 (64) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +171 -0
  4. package/dist/assessment.d.ts +9 -0
  5. package/dist/assessment.d.ts.map +1 -0
  6. package/dist/assessment.js +98 -0
  7. package/dist/assessment.js.map +1 -0
  8. package/dist/cli.d.ts +6 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +96 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +5 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/ontology/compatibility.d.ts +7 -0
  17. package/dist/ontology/compatibility.d.ts.map +1 -0
  18. package/dist/ontology/compatibility.js +135 -0
  19. package/dist/ontology/compatibility.js.map +1 -0
  20. package/dist/ontology/define.d.ts +8 -0
  21. package/dist/ontology/define.d.ts.map +1 -0
  22. package/dist/ontology/define.js +30 -0
  23. package/dist/ontology/define.js.map +1 -0
  24. package/dist/ontology/index.d.ts +9 -0
  25. package/dist/ontology/index.d.ts.map +1 -0
  26. package/dist/ontology/index.js +8 -0
  27. package/dist/ontology/index.js.map +1 -0
  28. package/dist/ontology/normalize.d.ts +6 -0
  29. package/dist/ontology/normalize.d.ts.map +1 -0
  30. package/dist/ontology/normalize.js +60 -0
  31. package/dist/ontology/normalize.js.map +1 -0
  32. package/dist/ontology/snapshot.d.ts +14 -0
  33. package/dist/ontology/snapshot.d.ts.map +1 -0
  34. package/dist/ontology/snapshot.js +299 -0
  35. package/dist/ontology/snapshot.js.map +1 -0
  36. package/dist/ontology/types.d.ts +123 -0
  37. package/dist/ontology/types.d.ts.map +1 -0
  38. package/dist/ontology/types.js +16 -0
  39. package/dist/ontology/types.js.map +1 -0
  40. package/dist/ontology/validate.d.ts +8 -0
  41. package/dist/ontology/validate.d.ts.map +1 -0
  42. package/dist/ontology/validate.js +190 -0
  43. package/dist/ontology/validate.js.map +1 -0
  44. package/dist/topology.d.ts +11 -0
  45. package/dist/topology.d.ts.map +1 -0
  46. package/dist/topology.js +237 -0
  47. package/dist/topology.js.map +1 -0
  48. package/dist/types.d.ts +110 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +11 -0
  51. package/dist/types.js.map +1 -0
  52. package/package.json +69 -0
  53. package/src/assessment.ts +98 -0
  54. package/src/cli.ts +74 -0
  55. package/src/index.ts +39 -0
  56. package/src/ontology/compatibility.ts +132 -0
  57. package/src/ontology/define.ts +35 -0
  58. package/src/ontology/index.ts +29 -0
  59. package/src/ontology/normalize.ts +63 -0
  60. package/src/ontology/snapshot.ts +324 -0
  61. package/src/ontology/types.ts +156 -0
  62. package/src/ontology/validate.ts +208 -0
  63. package/src/topology.ts +211 -0
  64. package/src/types.ts +142 -0
@@ -0,0 +1,208 @@
1
+ import { PRIMITIVE_VALUE_TYPES, RELATION_CARDINALITIES } from "./types.js";
2
+ import type { DomainModelFinding, FieldDefinition } from "./types.js";
3
+
4
+ type RecordValue = Record<string, unknown>;
5
+
6
+ function isRecord(value: unknown): value is RecordValue {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ function finding(rule: string, message: string, path: string): DomainModelFinding {
11
+ return { rule, severity: "error", message, path };
12
+ }
13
+
14
+ function arrayAt(record: RecordValue, key: string, findings: DomainModelFinding[], path: string): unknown[] {
15
+ const value = record[key];
16
+ if (value === undefined) return [];
17
+ if (Array.isArray(value)) return value;
18
+ findings.push(finding("collection-shape", `${key} must be an array when provided.`, path));
19
+ return [];
20
+ }
21
+
22
+ function stringAt(record: RecordValue, key: string, findings: DomainModelFinding[], path: string): string | undefined {
23
+ const value = record[key];
24
+ if (typeof value === "string" && value.length > 0) return value;
25
+ findings.push(finding("required-string", `${key} must be a non-empty string.`, path));
26
+ return undefined;
27
+ }
28
+
29
+ function optionalLabel(record: RecordValue, findings: DomainModelFinding[], path: string): void {
30
+ if (record.label !== undefined && typeof record.label !== "string") {
31
+ findings.push(finding("label-shape", "label must be a string when provided.", path));
32
+ }
33
+ }
34
+
35
+ function isPrimitive(value: unknown): boolean {
36
+ return typeof value === "string" && (PRIMITIVE_VALUE_TYPES as readonly string[]).includes(value);
37
+ }
38
+
39
+ function isCardinality(value: unknown): boolean {
40
+ return typeof value === "string" && (RELATION_CARDINALITIES as readonly string[]).includes(value);
41
+ }
42
+
43
+ function hasNamespace(id: string, modelId: string): boolean {
44
+ return id.startsWith(`${modelId}.`) && id.length > modelId.length + 1;
45
+ }
46
+
47
+ function isStableId(id: string): boolean {
48
+ return /^[a-z][a-z0-9-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/.test(id);
49
+ }
50
+
51
+ function validateStableId(
52
+ id: string | undefined,
53
+ modelId: string | undefined,
54
+ findings: DomainModelFinding[],
55
+ path: string,
56
+ expectedPrefix?: string,
57
+ ): void {
58
+ if (id === undefined) return;
59
+ if (!isStableId(id)) {
60
+ findings.push(finding("stable-id-shape", "id must be a dotted, namespaced identifier.", path));
61
+ }
62
+ if (modelId !== undefined && !hasNamespace(id, modelId)) {
63
+ findings.push(finding("stable-id-namespace", `id must begin with "${modelId}.".`, path));
64
+ }
65
+ if (expectedPrefix !== undefined && !id.startsWith(`${expectedPrefix}.`)) {
66
+ findings.push(finding("field-parent", `id must begin with "${expectedPrefix}.".`, path));
67
+ }
68
+ }
69
+
70
+ function validateFields(
71
+ fields: unknown[],
72
+ fieldName: "fields" | "properties",
73
+ ownerId: string | undefined,
74
+ modelId: string | undefined,
75
+ knownValueIds: Set<string>,
76
+ findings: DomainModelFinding[],
77
+ path: string,
78
+ ): void {
79
+ const ids = new Set<string>();
80
+ fields.forEach((candidate, index) => {
81
+ const fieldPath = `${path}.${fieldName}[${index}]`;
82
+ if (!isRecord(candidate)) {
83
+ findings.push(finding("field-shape", "A field must be an object.", fieldPath));
84
+ return;
85
+ }
86
+ const id = stringAt(candidate, "id", findings, `${fieldPath}.id`);
87
+ validateStableId(id, modelId, findings, `${fieldPath}.id`, ownerId);
88
+ if (id !== undefined) {
89
+ if (ids.has(id)) findings.push(finding("duplicate-field-id", `Duplicate field id "${id}".`, `${fieldPath}.id`));
90
+ ids.add(id);
91
+ }
92
+ const valueType = stringAt(candidate, "valueType", findings, `${fieldPath}.valueType`);
93
+ if (valueType !== undefined && !isPrimitive(valueType) && !knownValueIds.has(valueType)) {
94
+ findings.push(finding("unknown-value-type", `valueType "${valueType}" does not reference a primitive, value type, or vocabulary.`, `${fieldPath}.valueType`));
95
+ }
96
+ if (candidate.required !== undefined && typeof candidate.required !== "boolean") {
97
+ findings.push(finding("required-shape", "required must be a boolean when provided.", `${fieldPath}.required`));
98
+ }
99
+ optionalLabel(candidate, findings, `${fieldPath}.label`);
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Validates real definitions rather than a consumer-provided success flag.
105
+ * It never throws for malformed input and reports every independently
106
+ * checkable structural and referential finding.
107
+ */
108
+ export function validateDomainModel(value: unknown): DomainModelFinding[] {
109
+ const findings: DomainModelFinding[] = [];
110
+ if (!isRecord(value)) return [finding("model-shape", "A domain model must be an object.", "$")];
111
+
112
+ const modelId = stringAt(value, "id", findings, "id");
113
+ if (modelId !== undefined && !/^[a-z][a-z0-9-]*$/.test(modelId)) {
114
+ findings.push(finding("model-id-shape", "id must be a lowercase namespace token.", "id"));
115
+ }
116
+ stringAt(value, "schemaVersion", findings, "schemaVersion");
117
+
118
+ const valueTypes = arrayAt(value, "valueTypes", findings, "valueTypes");
119
+ const vocabularies = arrayAt(value, "vocabularies", findings, "vocabularies");
120
+ const types = arrayAt(value, "types", findings, "types");
121
+ const relations = arrayAt(value, "relations", findings, "relations");
122
+
123
+ const knownValueIds = new Set<string>();
124
+ const allNamedIds = new Set<string>();
125
+ const recordNamed = (id: string | undefined, kind: string, path: string, isValueReference = false): void => {
126
+ if (id === undefined) return;
127
+ if (allNamedIds.has(id)) findings.push(finding("duplicate-id", `Duplicate ${kind} id "${id}".`, path));
128
+ allNamedIds.add(id);
129
+ if (isValueReference) knownValueIds.add(id);
130
+ };
131
+
132
+ valueTypes.forEach((candidate, index) => {
133
+ const path = `valueTypes[${index}]`;
134
+ if (!isRecord(candidate)) {
135
+ findings.push(finding("value-type-shape", "A value type must be an object.", path));
136
+ return;
137
+ }
138
+ const id = stringAt(candidate, "id", findings, `${path}.id`);
139
+ validateStableId(id, modelId, findings, `${path}.id`);
140
+ recordNamed(id, "value type", `${path}.id`, true);
141
+ if (!isPrimitive(candidate.primitive)) findings.push(finding("primitive-known", "primitive must be a known primitive value type.", `${path}.primitive`));
142
+ optionalLabel(candidate, findings, `${path}.label`);
143
+ });
144
+
145
+ vocabularies.forEach((candidate, index) => {
146
+ const path = `vocabularies[${index}]`;
147
+ if (!isRecord(candidate)) {
148
+ findings.push(finding("vocabulary-shape", "A vocabulary must be an object.", path));
149
+ return;
150
+ }
151
+ const id = stringAt(candidate, "id", findings, `${path}.id`);
152
+ validateStableId(id, modelId, findings, `${path}.id`);
153
+ recordNamed(id, "vocabulary", `${path}.id`, true);
154
+ const values = arrayAt(candidate, "values", findings, `${path}.values`);
155
+ const seen = new Set<string>();
156
+ values.forEach((entry, valueIndex) => {
157
+ if (typeof entry !== "string" || entry.length === 0) {
158
+ findings.push(finding("vocabulary-value-shape", "Vocabulary values must be non-empty strings.", `${path}.values[${valueIndex}]`));
159
+ } else if (seen.has(entry)) {
160
+ findings.push(finding("duplicate-vocabulary-value", `Duplicate vocabulary value "${entry}".`, `${path}.values[${valueIndex}]`));
161
+ } else seen.add(entry);
162
+ });
163
+ optionalLabel(candidate, findings, `${path}.label`);
164
+ });
165
+
166
+ const typeIds = new Set<string>();
167
+ types.forEach((candidate, index) => {
168
+ const path = `types[${index}]`;
169
+ if (!isRecord(candidate)) {
170
+ findings.push(finding("type-shape", "A domain type must be an object.", path));
171
+ return;
172
+ }
173
+ const id = stringAt(candidate, "id", findings, `${path}.id`);
174
+ validateStableId(id, modelId, findings, `${path}.id`);
175
+ if (id !== undefined) {
176
+ if (typeIds.has(id)) findings.push(finding("duplicate-type-id", `Duplicate type id "${id}".`, `${path}.id`));
177
+ typeIds.add(id);
178
+ }
179
+ recordNamed(id, "type", `${path}.id`);
180
+ validateFields(arrayAt(candidate, "fields", findings, `${path}.fields`), "fields", id, modelId, knownValueIds, findings, path);
181
+ optionalLabel(candidate, findings, `${path}.label`);
182
+ });
183
+
184
+ const relationIds = new Set<string>();
185
+ relations.forEach((candidate, index) => {
186
+ const path = `relations[${index}]`;
187
+ if (!isRecord(candidate)) {
188
+ findings.push(finding("relation-shape", "A relation must be an object.", path));
189
+ return;
190
+ }
191
+ const id = stringAt(candidate, "id", findings, `${path}.id`);
192
+ validateStableId(id, modelId, findings, `${path}.id`);
193
+ if (id !== undefined) {
194
+ if (relationIds.has(id)) findings.push(finding("duplicate-relation-id", `Duplicate relation id "${id}".`, `${path}.id`));
195
+ relationIds.add(id);
196
+ }
197
+ recordNamed(id, "relation", `${path}.id`);
198
+ const from = stringAt(candidate, "from", findings, `${path}.from`);
199
+ const to = stringAt(candidate, "to", findings, `${path}.to`);
200
+ if (from !== undefined && !typeIds.has(from)) findings.push(finding("relation-from-known", `from endpoint "${from}" does not reference a type.`, `${path}.from`));
201
+ if (to !== undefined && !typeIds.has(to)) findings.push(finding("relation-to-known", `to endpoint "${to}" does not reference a type.`, `${path}.to`));
202
+ if (!isCardinality(candidate.cardinality)) findings.push(finding("relation-cardinality-known", "cardinality must be a supported relation cardinality.", `${path}.cardinality`));
203
+ validateFields(arrayAt(candidate, "properties", findings, `${path}.properties`), "properties", id, modelId, knownValueIds, findings, path);
204
+ optionalLabel(candidate, findings, `${path}.label`);
205
+ });
206
+
207
+ return findings;
208
+ }
@@ -0,0 +1,211 @@
1
+ import {
2
+ OPERATING_RESPONSIBILITIES,
3
+ OPERATING_SCOPE_KINDS,
4
+ OPERATING_SYSTEM_KINDS,
5
+ type ArchitectureFinding,
6
+ type AuthorityDefinition,
7
+ type OperatingInterfaceDefinition,
8
+ type OperatingSystemDefinition,
9
+ type OperatingTopology,
10
+ type OperatingTopologyCompatibilityReport,
11
+ type OperatingTopologyDefinition,
12
+ type OperatingTopologyChange,
13
+ } from "./types.js";
14
+
15
+ type UnknownRecord = Record<string, unknown>;
16
+
17
+ function isRecord(value: unknown): value is UnknownRecord {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ function finding(rule: string, message: string, path: string): ArchitectureFinding {
22
+ return { rule, severity: "error", message, path };
23
+ }
24
+
25
+ function requiredString(value: unknown, key: string, path: string, findings: ArchitectureFinding[]): string | undefined {
26
+ if (!isRecord(value) || typeof value[key] !== "string" || value[key].length === 0) {
27
+ findings.push(finding("required-string", `${key} must be a non-empty string.`, path));
28
+ return undefined;
29
+ }
30
+ return value[key] as string;
31
+ }
32
+
33
+ function arrayAt(value: UnknownRecord, key: string, path: string, findings: ArchitectureFinding[]): unknown[] {
34
+ if (value[key] === undefined) return [];
35
+ if (Array.isArray(value[key])) return value[key] as unknown[];
36
+ findings.push(finding("collection-shape", `${key} must be an array when provided.`, path));
37
+ return [];
38
+ }
39
+
40
+ function uniqueStrings(value: unknown, path: string, findings: ArchitectureFinding[]): string[] {
41
+ if (!Array.isArray(value) || value.length === 0) {
42
+ findings.push(finding("responsibilities-shape", "responsibilities must be a non-empty array.", path));
43
+ return [];
44
+ }
45
+ const result: string[] = [];
46
+ for (const [index, item] of value.entries()) {
47
+ if (typeof item !== "string" || !(OPERATING_RESPONSIBILITIES as readonly string[]).includes(item)) {
48
+ findings.push(finding("responsibility-known", "responsibility must be a supported value.", `${path}[${index}]`));
49
+ } else if (result.includes(item)) {
50
+ findings.push(finding("duplicate-responsibility", `Duplicate responsibility "${item}".`, `${path}[${index}]`));
51
+ } else result.push(item);
52
+ }
53
+ return result;
54
+ }
55
+
56
+ /** Creates a detached topology definition without asserting that it is valid. */
57
+ export function defineOperatingTopology(definition: OperatingTopologyDefinition): OperatingTopology {
58
+ return {
59
+ id: definition.id,
60
+ schemaVersion: definition.schemaVersion,
61
+ scope: { ...definition.scope },
62
+ systems: (definition.systems ?? []).map((system) => ({ ...system, responsibilities: [...system.responsibilities] })),
63
+ authorities: (definition.authorities ?? []).map((authority) => ({ ...authority })),
64
+ interfaces: (definition.interfaces ?? []).map((entry) => ({ ...entry, responsibilities: [...entry.responsibilities] })),
65
+ };
66
+ }
67
+
68
+ /** Validates shape, identities, references, ownership and declared interfaces without I/O. */
69
+ export function validateOperatingTopology(value: unknown): ArchitectureFinding[] {
70
+ const findings: ArchitectureFinding[] = [];
71
+ if (!isRecord(value)) return [finding("topology-shape", "An operating topology must be an object.", "$")];
72
+ const topologyId = requiredString(value, "id", "id", findings);
73
+ if (topologyId !== undefined && !/^[a-z][a-z0-9-]*$/.test(topologyId)) findings.push(finding("topology-id-shape", "id must be a lowercase namespace token.", "id"));
74
+ requiredString(value, "schemaVersion", "schemaVersion", findings);
75
+
76
+ const scope = value.scope;
77
+ if (!isRecord(scope)) findings.push(finding("scope-shape", "scope must be an object.", "scope"));
78
+ else {
79
+ requiredString(scope, "id", "scope.id", findings);
80
+ const kind = requiredString(scope, "kind", "scope.kind", findings);
81
+ if (kind !== undefined && !(OPERATING_SCOPE_KINDS as readonly string[]).includes(kind)) findings.push(finding("scope-kind-known", "scope.kind must be portfolio or business.", "scope.kind"));
82
+ }
83
+
84
+ const systems = arrayAt(value, "systems", "systems", findings);
85
+ const systemIds = new Set<string>();
86
+ const systemResponsibilities = new Map<string, ReadonlySet<string>>();
87
+ const responsibilityCoverage = new Set<string>();
88
+ for (const [index, candidate] of systems.entries()) {
89
+ const path = `systems[${index}]`;
90
+ if (!isRecord(candidate)) { findings.push(finding("system-shape", "A system must be an object.", path)); continue; }
91
+ const id = requiredString(candidate, "id", `${path}.id`, findings);
92
+ if (id !== undefined) {
93
+ if (systemIds.has(id)) findings.push(finding("duplicate-system-id", `Duplicate system id "${id}".`, `${path}.id`));
94
+ systemIds.add(id);
95
+ }
96
+ const kind = requiredString(candidate, "kind", `${path}.kind`, findings);
97
+ if (kind !== undefined && !(OPERATING_SYSTEM_KINDS as readonly string[]).includes(kind)) findings.push(finding("system-kind-known", "kind must be a supported system kind.", `${path}.kind`));
98
+ const responsibilities = uniqueStrings(candidate.responsibilities, `${path}.responsibilities`, findings);
99
+ for (const responsibility of responsibilities) responsibilityCoverage.add(responsibility);
100
+ if (id !== undefined && !systemResponsibilities.has(id)) systemResponsibilities.set(id, new Set(responsibilities));
101
+ for (const optional of ["provider", "locator"] as const) if (candidate[optional] !== undefined && typeof candidate[optional] !== "string") findings.push(finding(`${optional}-shape`, `${optional} must be a string when provided.`, `${path}.${optional}`));
102
+ if (candidate.visibility !== undefined && !["public", "private", "restricted"].includes(candidate.visibility as string)) findings.push(finding("visibility-known", "visibility must be public, private, or restricted.", `${path}.visibility`));
103
+ }
104
+ if (systems.length === 0) findings.push(finding("systems-required", "At least one system is required.", "systems"));
105
+ if (!responsibilityCoverage.has("control-plane")) findings.push(finding("control-plane-required", "At least one system must implement the control-plane responsibility.", "systems"));
106
+
107
+ const authorities = arrayAt(value, "authorities", "authorities", findings);
108
+ if (authorities.length === 0) findings.push(finding("authorities-required", "At least one authority is required.", "authorities"));
109
+ const authorityResponsibilities = new Set<string>();
110
+ for (const [index, candidate] of authorities.entries()) {
111
+ const path = `authorities[${index}]`;
112
+ if (!isRecord(candidate)) { findings.push(finding("authority-shape", "An authority must be an object.", path)); continue; }
113
+ const responsibility = requiredString(candidate, "responsibility", `${path}.responsibility`, findings);
114
+ if (responsibility !== undefined) {
115
+ if (!(OPERATING_RESPONSIBILITIES as readonly string[]).includes(responsibility)) findings.push(finding("responsibility-known", "responsibility must be a supported value.", `${path}.responsibility`));
116
+ if (!responsibilityCoverage.has(responsibility)) findings.push(finding("responsibility-unimplemented", `No system implements responsibility "${responsibility}".`, `${path}.responsibility`));
117
+ if (authorityResponsibilities.has(responsibility)) findings.push(finding("duplicate-authority", `Responsibility "${responsibility}" has more than one authority.`, `${path}.responsibility`));
118
+ authorityResponsibilities.add(responsibility);
119
+ }
120
+ requiredString(candidate, "owner", `${path}.owner`, findings);
121
+ const record = requiredString(candidate, "systemOfRecord", `${path}.systemOfRecord`, findings);
122
+ if (record !== undefined && !systemIds.has(record)) findings.push(finding("system-of-record-known", `systemOfRecord "${record}" is not a declared system.`, `${path}.systemOfRecord`));
123
+ else if (record !== undefined && responsibility !== undefined && (OPERATING_RESPONSIBILITIES as readonly string[]).includes(responsibility) && !systemResponsibilities.get(record)?.has(responsibility)) {
124
+ findings.push(finding("system-of-record-responsibility", `systemOfRecord "${record}" does not implement responsibility "${responsibility}".`, `${path}.systemOfRecord`));
125
+ }
126
+ }
127
+ for (const responsibility of responsibilityCoverage) if (!authorityResponsibilities.has(responsibility)) findings.push(finding("authority-required", `Responsibility "${responsibility}" needs exactly one authority.`, "authorities"));
128
+
129
+ const interfaces = arrayAt(value, "interfaces", "interfaces", findings);
130
+ const interfaceIds = new Set<string>();
131
+ for (const [index, candidate] of interfaces.entries()) {
132
+ const path = `interfaces[${index}]`;
133
+ if (!isRecord(candidate)) { findings.push(finding("interface-shape", "An interface must be an object.", path)); continue; }
134
+ const id = requiredString(candidate, "id", `${path}.id`, findings);
135
+ if (id !== undefined) {
136
+ if (interfaceIds.has(id)) findings.push(finding("duplicate-interface-id", `Duplicate interface id "${id}".`, `${path}.id`));
137
+ interfaceIds.add(id);
138
+ }
139
+ for (const endpoint of ["from", "to"] as const) {
140
+ const system = requiredString(candidate, endpoint, `${path}.${endpoint}`, findings);
141
+ if (system !== undefined && !systemIds.has(system)) findings.push(finding("interface-system-known", `${endpoint} system "${system}" is not declared.`, `${path}.${endpoint}`));
142
+ }
143
+ if (candidate.from !== undefined && candidate.from === candidate.to) findings.push(finding("interface-boundary", "An interface must cross two different systems.", path));
144
+ const responsibilities = uniqueStrings(candidate.responsibilities, `${path}.responsibilities`, findings);
145
+ for (const responsibility of responsibilities) {
146
+ if (!responsibilityCoverage.has(responsibility)) findings.push(finding("interface-responsibility-unimplemented", `No system implements interface responsibility "${responsibility}".`, `${path}.responsibilities`));
147
+ }
148
+ if (candidate.description !== undefined && typeof candidate.description !== "string") findings.push(finding("description-shape", "description must be a string when provided.", `${path}.description`));
149
+ }
150
+ return findings;
151
+ }
152
+
153
+ function byId<T extends { id: string }>(left: T, right: T): number { return left.id.localeCompare(right.id); }
154
+
155
+ /** Returns one canonical property layout and ordering. */
156
+ export function normalizeOperatingTopology(definition: OperatingTopologyDefinition): OperatingTopology {
157
+ const value = defineOperatingTopology(definition);
158
+ const systems: OperatingSystemDefinition[] = value.systems.map((system) => {
159
+ const next: OperatingSystemDefinition = { id: system.id, kind: system.kind, responsibilities: [...system.responsibilities].sort() };
160
+ if (system.provider !== undefined) next.provider = system.provider;
161
+ if (system.locator !== undefined) next.locator = system.locator;
162
+ if (system.visibility !== undefined) next.visibility = system.visibility;
163
+ return next;
164
+ });
165
+ const authorities: AuthorityDefinition[] = value.authorities.map((entry) => ({ responsibility: entry.responsibility, owner: entry.owner, systemOfRecord: entry.systemOfRecord }));
166
+ const interfaces: OperatingInterfaceDefinition[] = value.interfaces.map((entry) => {
167
+ const next: OperatingInterfaceDefinition = { id: entry.id, from: entry.from, to: entry.to, responsibilities: [...entry.responsibilities].sort() };
168
+ if (entry.description !== undefined) next.description = entry.description;
169
+ return next;
170
+ });
171
+ return { id: value.id, schemaVersion: value.schemaVersion, scope: { id: value.scope.id, kind: value.scope.kind }, systems: systems.sort(byId), authorities: authorities.sort((a, b) => a.responsibility.localeCompare(b.responsibility)), interfaces: interfaces.sort(byId) };
172
+ }
173
+
174
+ export function serializeOperatingTopology(definition: OperatingTopologyDefinition): string {
175
+ return `${JSON.stringify(normalizeOperatingTopology(definition), null, 2)}\n`;
176
+ }
177
+
178
+ function mapById<T extends { id: string }>(values: readonly T[]): Map<string, T> { return new Map(values.map((value) => [value.id, value])); }
179
+
180
+ /** Compares stable architectural contracts. Description and schemaVersion are not compatibility surface. */
181
+ export function compareOperatingTopologies(previous: OperatingTopologyDefinition, next: OperatingTopologyDefinition): OperatingTopologyCompatibilityReport {
182
+ const previousFindings = validateOperatingTopology(previous);
183
+ const nextFindings = validateOperatingTopology(next);
184
+ if (previousFindings.some((entry) => entry.severity === "error") || nextFindings.some((entry) => entry.severity === "error")) return { compatible: false, changes: [], previousFindings, nextFindings };
185
+ const before = normalizeOperatingTopology(previous);
186
+ const after = normalizeOperatingTopology(next);
187
+ const changes: OperatingTopologyChange[] = [];
188
+ if (before.id !== after.id) changes.push({ kind: "breaking", subject: "topology", id: after.id, message: `Topology id changed from "${before.id}" to "${after.id}".` });
189
+ if (JSON.stringify(before.scope) !== JSON.stringify(after.scope)) changes.push({ kind: "breaking", subject: "scope", id: after.scope.id, message: "Operating scope changed." });
190
+ const compareCollection = <T extends { id: string }>(subject: "system" | "interface", left: readonly T[], right: readonly T[], contract: (value: T) => unknown): void => {
191
+ const oldValues = mapById(left); const newValues = mapById(right);
192
+ for (const [id, value] of oldValues) {
193
+ const replacement = newValues.get(id);
194
+ if (replacement === undefined) changes.push({ kind: "breaking", subject, id, message: `${subject} "${id}" was removed.` });
195
+ else if (JSON.stringify(contract(value)) !== JSON.stringify(contract(replacement))) changes.push({ kind: "breaking", subject, id, message: `${subject} "${id}" changed contract.` });
196
+ }
197
+ for (const [id] of newValues) if (!oldValues.has(id)) changes.push({ kind: "additive", subject, id, message: `${subject} "${id}" was added.` });
198
+ };
199
+ compareCollection("system", before.systems, after.systems, (entry) => entry);
200
+ compareCollection("interface", before.interfaces, after.interfaces, (entry) => ({ id: entry.id, from: entry.from, to: entry.to, responsibilities: entry.responsibilities }));
201
+ const oldAuthorities = new Map(before.authorities.map((entry) => [entry.responsibility, entry]));
202
+ const newAuthorities = new Map(after.authorities.map((entry) => [entry.responsibility, entry]));
203
+ for (const [id, value] of oldAuthorities) {
204
+ const replacement = newAuthorities.get(id);
205
+ if (replacement === undefined) changes.push({ kind: "breaking", subject: "authority", id, message: `authority "${id}" was removed.` });
206
+ else if (JSON.stringify(value) !== JSON.stringify(replacement)) changes.push({ kind: "breaking", subject: "authority", id, message: `authority "${id}" changed contract.` });
207
+ }
208
+ for (const [id] of newAuthorities) if (!oldAuthorities.has(id)) changes.push({ kind: "additive", subject: "authority", id, message: `authority "${id}" was added.` });
209
+ changes.sort((a, b) => `${a.subject}:${a.id}:${a.kind}`.localeCompare(`${b.subject}:${b.id}:${b.kind}`));
210
+ return { compatible: changes.every((entry) => entry.kind !== "breaking"), changes, previousFindings, nextFindings };
211
+ }
package/src/types.ts ADDED
@@ -0,0 +1,142 @@
1
+ /** The business boundary whose operating architecture is being described. */
2
+ export type OperatingScopeKind = "portfolio" | "business";
3
+
4
+ /** A provider-neutral kind of system in an operating topology. */
5
+ export type OperatingSystemKind = "workspace" | "repository" | "service" | "data-store" | "external-system";
6
+
7
+ /** Stable responsibilities that systems can perform without prescribing repository names. */
8
+ export type OperatingResponsibility =
9
+ | "control-plane"
10
+ | "product"
11
+ | "commercial"
12
+ | "delivery"
13
+ | "knowledge"
14
+ | "platform";
15
+
16
+ export interface OperatingScopeDefinition {
17
+ id: string;
18
+ kind: OperatingScopeKind;
19
+ }
20
+
21
+ /** One addressable system. Provider locators are descriptive, never credentials. */
22
+ export interface OperatingSystemDefinition {
23
+ id: string;
24
+ kind: OperatingSystemKind;
25
+ responsibilities: readonly OperatingResponsibility[];
26
+ provider?: string;
27
+ locator?: string;
28
+ visibility?: "public" | "private" | "restricted";
29
+ }
30
+
31
+ /** One consumer-owned declaration of authority and its authoritative record. */
32
+ export interface AuthorityDefinition {
33
+ responsibility: OperatingResponsibility;
34
+ owner: string;
35
+ systemOfRecord: string;
36
+ }
37
+
38
+ /** An allowed directional crossing between two declared systems. */
39
+ export interface OperatingInterfaceDefinition {
40
+ id: string;
41
+ from: string;
42
+ to: string;
43
+ responsibilities: readonly OperatingResponsibility[];
44
+ description?: string;
45
+ }
46
+
47
+ /** Provider-neutral desired operating architecture. */
48
+ export interface OperatingTopologyDefinition {
49
+ id: string;
50
+ schemaVersion: string;
51
+ scope: OperatingScopeDefinition;
52
+ systems?: readonly OperatingSystemDefinition[];
53
+ authorities?: readonly AuthorityDefinition[];
54
+ interfaces?: readonly OperatingInterfaceDefinition[];
55
+ }
56
+
57
+ export interface OperatingTopology extends OperatingTopologyDefinition {
58
+ systems: readonly OperatingSystemDefinition[];
59
+ authorities: readonly AuthorityDefinition[];
60
+ interfaces: readonly OperatingInterfaceDefinition[];
61
+ }
62
+
63
+ export type ArchitectureFindingSeverity = "error" | "warning";
64
+
65
+ export interface ArchitectureFinding {
66
+ rule: string;
67
+ severity: ArchitectureFindingSeverity;
68
+ message: string;
69
+ path?: string;
70
+ }
71
+
72
+ export interface OperatingTopologyChange {
73
+ kind: "additive" | "breaking";
74
+ subject: "topology" | "scope" | "system" | "authority" | "interface";
75
+ id: string;
76
+ message: string;
77
+ }
78
+
79
+ export interface OperatingTopologyCompatibilityReport {
80
+ compatible: boolean;
81
+ changes: readonly OperatingTopologyChange[];
82
+ previousFindings: readonly ArchitectureFinding[];
83
+ nextFindings: readonly ArchitectureFinding[];
84
+ }
85
+
86
+ /** One actual system-to-system crossing observed during a change. */
87
+ export interface BoundaryCrossingObservation {
88
+ from: string;
89
+ to: string;
90
+ responsibility: OperatingResponsibility;
91
+ interface?: string;
92
+ }
93
+
94
+ /** Evidence about one actual change. Non-material changes do not enter the metric. */
95
+ export interface ArchitectureChangeObservation {
96
+ id: string;
97
+ observedAt: string;
98
+ material: boolean;
99
+ crossings: readonly BoundaryCrossingObservation[];
100
+ }
101
+
102
+ export interface ArchitectureAssessmentOptions {
103
+ /** Inclusive setpoint in [0, 1]. */
104
+ maximumExceptionRate: number;
105
+ }
106
+
107
+ export type ArchitectureAssessmentState = "satisfied" | "violated" | "indeterminate";
108
+
109
+ export interface AssessedBoundaryCrossing extends BoundaryCrossingObservation {
110
+ declared: boolean;
111
+ }
112
+
113
+ export interface AssessedArchitectureChange {
114
+ id: string;
115
+ observedAt: string;
116
+ material: boolean;
117
+ crossings: readonly AssessedBoundaryCrossing[];
118
+ hasException: boolean;
119
+ }
120
+
121
+ /** Pure assessment result. `exceptionRate` is null whenever evidence is indeterminate. */
122
+ export interface ArchitectureExceptionAssessment {
123
+ state: ArchitectureAssessmentState;
124
+ exceptionRate: number | null;
125
+ maximumExceptionRate: number;
126
+ observedChanges: number;
127
+ observedMaterialChanges: number;
128
+ materialChangesWithExceptions: number;
129
+ changes: readonly AssessedArchitectureChange[];
130
+ findings: readonly ArchitectureFinding[];
131
+ }
132
+
133
+ export const OPERATING_SCOPE_KINDS: readonly OperatingScopeKind[] = ["portfolio", "business"];
134
+ export const OPERATING_SYSTEM_KINDS: readonly OperatingSystemKind[] = ["workspace", "repository", "service", "data-store", "external-system"];
135
+ export const OPERATING_RESPONSIBILITIES: readonly OperatingResponsibility[] = [
136
+ "control-plane",
137
+ "product",
138
+ "commercial",
139
+ "delivery",
140
+ "knowledge",
141
+ "platform",
142
+ ];