Skip to content

fix(shared): resolve prefixItems alternatives in pattern validation - #3059

Merged
YoofiTT96 merged 15 commits into
finos:mainfrom
YoofiTT96:fix/pattern-oneof-slot-duplicate-ids
Sep 17, 2026
Merged

YoofiTT96 merged 15 commits into
finos:mainfrom
YoofiTT96:fix/pattern-oneof-slot-duplicate-ids

Conversation

@YoofiTT96

@YoofiTT96 YoofiTT96 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Description

A prefixItems entry is either a node or a oneOf/anyOf holding alternatives. Every rule that walks $.properties.nodes.prefixItems[*] has to cope with both shapes, and each coped differently. Four coped wrongly or not at all.

Part of #2932 rework

declaration-paths.ts now owns where a pattern declares a node. It builds the queries that find declarations and reads the resulting pointers back, both driven by one keyword list, so a rule cannot disagree with another about where a declaration lives. The shape-handling disappears from the rules instead of being repeated four ways, and interface-id-exists-on-node is a net deletion.

None of this concerns #2859. These are pre-existing gaps in the prefixItems model, reachable by a pattern that uses no new construct. They are grouped because they are one theme with one release note, and because they carry every behaviour change in the wider items-support work — leaving that work free to be purely additive.

Closes #3058
Closes #3062
Closes #3068
Closes #3069

Behaviour changes — breaking

main never looked inside oneOf or anyOf, so every shape below was accepted. Each is now checked. No published API changes.

Shape Now Before
Two alternatives of one entry share a node unique-id
oneOf: [ node "db", node "db" ]
error calm generate emitted both nodes. Verified: with a decision selecting db, architecture validation reports the duplicate
A relationship names an interface belonging to a sibling alternative
webapp -> postgres, interfaces: ["my-port"] where my-port is on mysql
error interfaces were pooled across an entry, so the chosen node need never have declared it. Architecture validation caught it after generation
An entry declares both keywords
{ oneOf: [...], anyOf: [...] }
error an element must satisfy both, so the entry is unsatisfiable when the id sets are disjoint and declares unselectable alternatives when they overlap
Nothing references an alternative
oneOf: [ node "orphan" ]
warning invisible to the rule. Fails a build only under --strict

One further rejection is a deliberate contract, not a correction. A unique-id names one kind of thing, so a node id may not equal an interface id even across alternatives that never appear together. Both architectures such a pattern produces are valid, so this rejects something that was not broken. It is recorded in PATTERN-DECISIONS.md rather than justified by #3058.

No pattern in this repository changes result. Every tracked .json declaring properties.nodes.prefixItems or properties.relationships.prefixItems — 28 files, 13 of which use alternatives — was run against the real ruleset on main and on this branch. The findings are byte-identical.

Worth knowing

Two alternatives may expose the same interface id, because only one of them is ever built. A decision never names an interface on its own, so nothing has to tell them apart:

oneOf: [ postgres exposing "db-port", mysql exposing "db-port" ]      accepted

Do not give an entry its own properties as well as alternatives. calm generate keeps the selected alternative and discards the entry's own properties, so whatever it declares is silently lost. Validation reports a duplicate id when the two halves share one, which catches the common case without naming the fault. A rule that names it directly is worth a follow-up.

The two-keyword check is a filtered given with the built-in falsy, not a custom function. Spectral's xor cannot express it, because an entry may legitimately declare neither keyword.

When unique-ids-must-be-unique-in-pattern finds an id twice, it points at the second one. That is only useful if the second one is the later in the file.

It is not, because the rule finds ids by running one query per declaration site and joining the results. Every fixed entry arrives before any alternative, whatever order they appear in. So a pattern declaring dup as an alternative at index 0 and again as a fixed entry at index 1 was reported at index 0 — the earlier of the two.

The matches are now sorted by position before the rule looks for duplicates.

PATTERN-DECISIONS.md is a new behaviour contract at the repository root. It records what each tool guarantees, with no implementation detail and no test references, so it does not go stale when a function is renamed.

Review round

Four of the five review findings led to a change, and the fifth changed in a different way than its diagnosis suggested.

Ordering no longer compares pointer text at all. Text put an alternative ahead of the entry that holds it, because oneOf precedes properties, so a node id declared in both was blamed on the entry — the half an architecture always builds. byBuildOrder compares the parsed indices of the containing declaration instead, which also removes the digit padding that capped array indices at 999,999. There was no test pinning which side gets blamed for that shape; there is now.

JSONPathMatch is exported from helper-functions.ts rather than redeclared, and the hand-rolled groupBy is lodash's.

On the performance finding: these paths are rooted at properties.nodes, so they are lookups, not full-document traversals — adding 5,000 relationships to a document leaves the timing unchanged. Against main the trade runs the other way from how the comment reads, since main runs one query over the pattern and three per node inspected where this runs three over the pattern and one per node. But the eager part was real: all three queries ran before the search started, so a node declared as a plain prefixItems entry paid for two it never needed. The lookup now returns at the first path that matches, and declaredId reads its path with lodash instead of a JSONPath query. Over 600 endpoints on a 300-node pattern that is 23ms against main's 112ms.

Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Code style/formatting changes
  • ♻️ Refactoring (no functional changes)
  • ⚡ Performance improvements
  • ✅ Test additions or updates
  • 🔧 Chore (maintenance, dependencies, CI, etc.)

A pattern that validates clean today can fail after this change, so a BREAKING CHANGE: footer is on the commit. Without it fix(shared) would ship these rejections as a patch release.

Affected Components

  • CLI (cli/)
  • Schema (calm/)
  • CALM AI (calm-ai/)
  • CALM Hub (calm-hub/)
  • CALM Hub UI (calm-hub-ui/)
  • CALM Server (calm-server/)
  • CALM Widgets (calm-widgets/)
  • Documentation (docs/)
  • Shared (shared/)
  • VS Code Extension (calm-plugins/vscode/)
  • Dependencies
  • CI/CD

cli/ is the JUnit fixture only, which pins the live rule list. No CLI source changes.

Commit Message Format ✅

fix(shared): resolve prefixItems alternatives in pattern validation, carrying a BREAKING CHANGE: footer that lists the rejections above.

The review round adds fix(shared): order declarations by index, not by pointer text, refactor(shared): reuse the shared match type and lodash groupBy, and perf(shared): stop searching declaration sites at the first match.

Testing

  • I have tested my changes locally
  • I have added/updated unit tests
  • All existing tests pass

Four spec files are new: rules-pattern.spec.ts, node-id-exists.spec.ts, declaration-paths.spec.ts and is-defined-in-oneof-or-anyof.spec.ts. No ruleset-level test existed, because validate.spec.ts mocks Spectral out, so selectors and registration were untested. The last two rules had no unit test at all, on main or here.

Every new guarantee was mutation-tested rather than trusted. Reintroducing the interface pooling, dropping the document-order sort, treating an entry's properties as an alternative, and unregistering the keyword rule each turn the matching tests red and leave the rest green.

Coverage on the changed files is 100% statements and branches, except interface-id-exists-on-node at 95.45/92.85 on a pre-existing branch. Across src/spectral it is 94.64/93.04. Full root npm test is green.

After the review round: shared passes 105 files and 1208 tests, cli 29 files and 659 tests, and root npm test is green. Restoring the raw-pointer sort turns the new ordering test red along with the existing numeric one, and nothing else.

Checklist

  • My commits follow the conventional commit format
  • I have updated documentation if necessary
  • I have added tests for my changes (if applicable)
  • My changes follow the project's coding standards

A `prefixItems` entry is either a node or a `oneOf`/`anyOf` holding
alternatives. Each rule handled that ambiguity differently, and four
handled it wrongly or not at all.

`declaration-paths.ts` now answers where a pattern declares a node, so
the rules resolve declarations rather than entries.

Four gaps close. Duplicate ids across alternatives are reported.
Interfaces on an alternative are checked, and are no longer pooled
across the alternatives of one entry. An unreferenced alternative is
reported. An entry declaring both `oneOf` and `anyOf` is rejected: JSON
Schema requires an element to satisfy both, so some alternatives can
never be selected.

Closes finos#3058
Closes finos#3062
Closes finos#3068
Closes finos#3069
@YoofiTT96
YoofiTT96 force-pushed the fix/pattern-oneof-slot-duplicate-ids branch from c8185cf to 6bf201d Compare September 8, 2026 18:31
@github-actions github-actions Bot added the cli Affects `cli` code label Sep 8, 2026
@YoofiTT96 YoofiTT96 changed the title fix(shared): report duplicate unique-ids across a slot's candidates fix(shared): resolve prefixItems alternatives in pattern validation Sep 8, 2026
YoofiTT96 and others added 4 commits September 10, 2026 19:07
Interface ids are compared only across node declarations that can appear
in one architecture. Alternatives of one prefixItems entry never appear
together, so they may repeat an interface id. An entry's own properties
is not an alternative of its own oneOf, so those two are still compared.

The two-keyword check becomes a filtered given with the built-in falsy,
which deletes the custom function and its hand-written pointer
conversion. Spectral's xor cannot express it, because an entry may
legitimately declare neither keyword.

is-defined-in-oneof-or-anyof now takes its queries from
declaration-paths, so no rule keeps its own copies of the declaration
sites.

PATTERN-DECISIONS.md records the id contract and the one-level depth
limit on alternatives.

BREAKING CHANGE: calm validate rejects patterns it previously accepted.
Main never read inside oneOf or anyOf, so four checks now apply where
they did not.

Two alternatives of one entry that share a node or relationship
unique-id are rejected. Generate previously emitted both alternatives,
producing an architecture with a duplicate id.

A relationship that names an interface belonging to a sibling
alternative is rejected. Interfaces were pooled across an entry's
alternatives, so the chosen node need not have declared the interface.

An entry that declares both oneOf and anyOf is rejected. An element must
satisfy both, so the entry is unsatisfiable when the id sets are
disjoint and declares unselectable alternatives when they overlap.

An alternative that no relationship and no decision references is
warned about. This fails a build only under --strict.

One further rejection is a deliberate contract rather than a
correction. A unique-id names one kind of thing, so a node id may not
equal an interface id even across alternatives that never appear
together. Both architectures such a pattern can produce are valid.
is-defined-in-oneof-or-anyof read the fixed entry path by destructuring
declaredIdPaths, so reordering that array inverted the rule: it accepted
an id declared only as a fixed entry and rejected one declared inside a
choice. No test covered the function, and the only tests that turned red
under a reorder were unrelated pointer assertions in ids-are-unique.

fixedIdPath and alternativeIdPaths now name what a caller wants, and
declaredIdPaths is composed from them, so the order lives in one place
and nobody reads it by position.

Adds the spec the function never had.
Three queries are collected per id kind, so matches arrived grouped by
query rather than by document position. The rule blames the second
declaration it sees, so it could name the earlier of two duplicates.
Matches are now sorted before detection, padding the indexes so
prefixItems/2 comes before prefixItems/10.

Adds the test for the interface pooling fix in finos#3068, which had none.
Reintroducing pooling passed all 116 spectral tests.

declaration-paths.ts now owns both directions. One keyword list drives
the queries that find declarations and the containingDeclaration and
containingEntry helpers that read the resulting pointers back, so the
two cannot drift. Adding a keyword to that list is picked up by both,
verified. The helpers were pointer-string splits local to
ids-are-unique that only worked for interface pointers.

PATTERN-DECISIONS.md no longer claims an entry's own properties lands in
the architecture alongside its alternatives. calm generate keeps the
selected alternative and discards the entry's own properties, so what it
declares is lost. The duplicate-id error catches the case where the two
halves share an id without naming that fault.
@YoofiTT96
YoofiTT96 marked this pull request as ready for review September 10, 2026 23:47
@YoofiTT96
YoofiTT96 requested a review from a team as a code owner September 10, 2026 23:47
calm validate reads $..connects.source.node and never the destination,
so a destination typo is not reported. The contract claimed both ends.
@YoofiTT96 YoofiTT96 changed the title fix(shared): resolve prefixItems alternatives in pattern validation fix(shared): resolve prefixItems alternatives in pattern validation 1/4 Sep 12, 2026
@YoofiTT96 YoofiTT96 changed the title fix(shared): resolve prefixItems alternatives in pattern validation 1/4 fix(shared): resolve prefixItems alternatives in pattern validation Sep 12, 2026
Comment thread shared/src/spectral/functions/pattern/ids-are-unique.ts Outdated
Comment thread shared/src/spectral/functions/pattern/ids-are-unique.ts Outdated
Comment thread shared/src/spectral/functions/pattern/ids-are-unique.ts Outdated
Comment thread shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts Outdated
Comment thread shared/src/spectral/functions/pattern/ids-are-unique.ts Outdated
Sorting the pointer text put an alternative ahead of the entry that holds
it, because "oneOf" precedes "properties". A node id declared in both was
then blamed on the entry, which is the half an architecture always builds.

The indices of the containing declaration decide the order instead, so a
shorter key means the outer declaration and the padding that capped array
indices at 999,999 goes.
The rule redeclared helper-functions' JSONPathMatch and hand-rolled a
Map-based groupBy beside the lodash import it already had.
Every declaration site was queried before the search began, so a node
declared as a plain prefixItems entry paid for two queries it never
needed. declaredId ran a JSONPath query per node inspected; a lodash
path read off the same ID constant does the same work.

@markscott-ms markscott-ms left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've read through the pattern decisions and performed a review of the code and am satisfied that this seems reasonable.

@YoofiTT96
YoofiTT96 merged commit df09072 into finos:main Sep 17, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Affects `cli` code shared

Projects

None yet

2 participants