Skip to content

Commit 16e27f2

Browse files
Merge pull request finos#2737 from rocketstack-matt/fix/calm-validate-interface-reference
fix(shared): validate singular interface reference in connects relationships
2 parents a7d480b + 0f2a787 commit 16e27f2

4 files changed

Lines changed: 170 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ Running `npm install` or tests on a different Node major version causes:
165165
### Package-Specific Guides
166166

167167
For detailed guidance on specific packages, see:
168+
- **[calm/AGENTS.md](calm/AGENTS.md)** - CALM JSON Meta Schema, schema change workflow, draft/release rules
168169
- **[cli/AGENTS.md](cli/AGENTS.md)** - CLI commands, build pipeline, Commander.js patterns
169170
- **[calm-hub/AGENTS.md](calm-hub/AGENTS.md)** - Java/Quarkus backend, storage modes, security
170171
- **[calm-hub-ui/AGENTS.md](calm-hub-ui/AGENTS.md)** - React frontend, service patterns, component conventions

calm/AGENTS.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# CALM Schema - AI Assistant Guide
2+
3+
## Project Overview
4+
5+
The `calm/` directory holds the **CALM JSON Meta Schema** — the authoritative definition of
6+
the Common Architecture Language Model. Everything here is specification, not application code:
7+
JSON Schema documents under `meta/`, reference components (`controls/`, `interfaces/`), worked
8+
examples, and the governance process that controls how the schema evolves.
9+
10+
Also include all rules from [the root level AGENTS.md](../AGENTS.md).
11+
12+
The human-facing governance process lives in [`README.md`](README.md) and is authoritative.
13+
This guide is the machine-facing summary of *how to make a schema change correctly* — read it
14+
before editing anything under `calm/`.
15+
16+
## Directory Layout
17+
18+
```
19+
calm/
20+
├── draft/<issue-number>/ # In-progress schema changes (freely editable)
21+
│ ├── meta/ # the proposed schema documents
22+
│ └── prototype/ # examples demonstrating the change
23+
├── release/<version>/ # Published, IMMUTABLE releases (1.0, 1.1, 1.2, …)
24+
│ └── meta/ # the published schema documents
25+
├── release/<version>-rcN/ # Release candidates (e.g. 1.0-rc1) — siblings of the release, during testing
26+
├── controls/ # Golden-source standardised control definitions
27+
├── interfaces/ # Golden-source standardised interface definitions
28+
├── architecture/ # Reference architectures (e.g. calm-1.json, calm-2.json)
29+
└── getting-started/ # Tutorial material
30+
```
31+
32+
## Critical Rules for Schema Changes
33+
34+
### 1. Never edit a released schema
35+
36+
Everything under `calm/release/<version>/` is **immutable** once published. Each schema is pinned
37+
by its `$id` (e.g. `https://calm.finos.org/release/1.2/meta/interface.json`), and existing
38+
architectures resolve against those URLs. Editing a released schema is a silent breaking change.
39+
40+
To change the schema, you create or edit a **draft** — never a release.
41+
42+
### 2. All schema work goes in `calm/draft/<issue-number>/`
43+
44+
- A schema change starts with a GitHub issue (see step 1 of the workflow below).
45+
- The proposed schema lives in `calm/draft/<issue-number>/meta/`.
46+
- Worked examples demonstrating the change live in `calm/draft/<issue-number>/prototype/`.
47+
- Drafts are **freely editable** — iterate without restriction; acceptance as a draft is not a
48+
guarantee it will be released.
49+
50+
### 3. Provide prototype examples
51+
52+
A schema change without an example is incomplete. Add at least one example architecture under
53+
`prototype/` that exercises the new or changed construct, so reviewers and tooling can validate it.
54+
55+
## Schema Change Workflow
56+
57+
1. **Propose** — open a GitHub issue using the
58+
[Schema Change Proposal template](https://github.com/finos/architecture-as-code/issues/new?template=Schema_change_proposal.md).
59+
2. **Draft** — implement the change in `calm/draft/<issue-number>/meta/`, with examples in
60+
`calm/draft/<issue-number>/prototype/`.
61+
3. **Review** — schema PRs require approval from at least one member of the
62+
[`calm-schema-governance`](https://github.com/orgs/finos/teams/calm-schema-governance) team.
63+
4. **Validate** — when selected for release, the draft must pass validation against the CALM CLI
64+
and CALM Hub, including backward-compatibility checks.
65+
5. **Release** — a Release Candidate is published under `calm/release/`, tested by the community
66+
for four weeks, then promoted to an official release with a changelog (and migration guide for
67+
breaking changes).
68+
69+
See [`README.md`](README.md) for the full governance detail, roles, and release policy.
70+
71+
## Tooling Impact
72+
73+
Schema changes ripple into the TypeScript tooling — account for these when proposing a change:
74+
75+
- **Validation rules** — structural JSON Schema constraints are enforced by the meta schema, but
76+
cross-reference and semantic checks (e.g. "a referenced interface exists on the target node")
77+
live in Spectral rules under `shared/src/spectral/`. A schema change that adds a referential
78+
constraint usually needs a matching Spectral rule and tests.
79+
- **Bundled schemas**`shared` resolves schemas via its schema directory; a new schema version
80+
must be wired in there for the CLI and Hub to recognise it.
81+
- **CLI + Hub** — both must validate cleanly against a new schema before it is released.
82+
83+
When tightening a constraint (e.g. adding `additionalProperties: false`, adding a `required`
84+
field, or narrowing a type), treat it as **potentially breaking**: only existing draft schemas may
85+
change freely; the same change against a release requires a new version and migration guidance.

shared/src/spectral/functions/architecture/interface-id-exists-on-node.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,74 @@ describe('interfaceIdExistsOnNode', () => {
102102
expect(result[0].path).toEqual(['/relationships/0/connects/destination']);
103103
});
104104

105+
it('should return an empty array when a singular interface exists', () => {
106+
const input = { node: 'node1', interface: 'intf1' };
107+
const context = {
108+
document: {
109+
data: {
110+
nodes: [
111+
{
112+
'unique-id': 'node1',
113+
'interfaces': [
114+
{'unique-id': 'intf1'}
115+
]
116+
}
117+
]
118+
}
119+
}
120+
};
121+
122+
const result = interfaceIdExistsOnNode(input, null, asContext(context));
123+
expect(result).toEqual([]);
124+
});
125+
126+
it('should return a message when a singular interface does not exist', () => {
127+
const input = { node: 'node1', interface: 'intf2' };
128+
const context = {
129+
document: {
130+
data: {
131+
nodes: [
132+
{
133+
'unique-id': 'node1',
134+
'interfaces': [
135+
{'unique-id': 'intf1'}
136+
]
137+
}
138+
]
139+
}
140+
},
141+
path: ['/relationships/0/connects/destination']
142+
};
143+
144+
const result = interfaceIdExistsOnNode(input, null, asContext(context));
145+
expect(result.length).toBe(1);
146+
expect(result[0].message).toBe(`Referenced interface with ID '${input.interface}' was not defined on the node with ID '${input.node}'.`);
147+
expect(result[0].path).toEqual(['/relationships/0/connects/destination']);
148+
});
149+
150+
it('should report a missing interface once when both singular and plural forms reference it', () => {
151+
const input = { node: 'node1', interface: 'intf2', interfaces: ['intf2'] };
152+
const context = {
153+
document: {
154+
data: {
155+
nodes: [
156+
{
157+
'unique-id': 'node1',
158+
'interfaces': [
159+
{'unique-id': 'intf1'}
160+
]
161+
}
162+
]
163+
}
164+
},
165+
path: ['/relationships/0/connects/destination']
166+
};
167+
168+
const result = interfaceIdExistsOnNode(input, null, asContext(context));
169+
expect(result.length).toBe(1);
170+
expect(result[0].message).toBe(`Referenced interface with ID 'intf2' was not defined on the node with ID '${input.node}'.`);
171+
});
172+
105173
it('should return a message when one interface does not exist', () => {
106174
const input = { node: 'node1', interfaces: ['intf1', 'intf2'] };
107175
const context = {

shared/src/spectral/functions/architecture/interface-id-exists-on-node.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,28 @@ import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-cor
55
interface ConnectsRelationship {
66
node?: string;
77
interfaces?: string[];
8+
interface?: string;
89
}
910

1011
/**
1112
* Checks that the input value exists as an interface with matching unique ID defined under a node in the document.
13+
*
14+
* Interfaces may be referenced either as an array (`interfaces`) or as a single string (`interface`); both forms
15+
* are validated against the referenced node so that neither can reference an interface that does not exist.
1216
*/
1317
export function interfaceIdExistsOnNode(input: ConnectsRelationship | null | undefined, _: unknown, context: RulesetFunctionContext): IFunctionResult[] {
14-
if (!input || !input.interfaces) {
18+
if (!input) {
19+
return [];
20+
}
21+
22+
// all of these must be present on the referenced node; dedupe so the same
23+
// interface referenced via both the array and string forms is reported once
24+
const desiredInterfaces = [...new Set([
25+
...(Array.isArray(input.interfaces) ? input.interfaces : []),
26+
...(typeof input.interface === 'string' ? [input.interface] : [])
27+
])];
28+
29+
if (desiredInterfaces.length === 0) {
1530
return [];
1631
}
1732

@@ -29,9 +44,6 @@ export function interfaceIdExistsOnNode(input: ConnectsRelationship | null | und
2944
return [];
3045
}
3146

32-
// all of these must be present on the referenced node
33-
const desiredInterfaces = input.interfaces;
34-
3547
const node = nodeMatch[0];
3648

3749
const nodeInterfaces = JSONPath({ path: '$.interfaces[*].unique-id', json: node });

0 commit comments

Comments
 (0)