Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,35 @@ Until `1.0.0`, minor releases may include breaking changes
([ADR-0054](docs/adr/0054-publish-a-static-decision-portal-for-github-pages-built-outside-the-cli-from-the.md)
action item 8, #294).

- **Behavior change: `adr lint` no longer fails on a log-qualified `relatesTo`
or `conflictsWith`.** A reference such as `relatesTo: ["payments:0012"]` names
a record in another decision log. The schema has always accepted it, but the
corpus gate reported it as `dangling-relatesTo` (`error`, exit `1`) or
`dangling-conflictsWith` (`warn`), while the evaluator, the marker resolver,
and the MCP server already treated it as inert. `adr lint` and `adr lint
--json` now report it as `federated-ref-unresolved` at `info`, so the
surfaces agree ([#299](https://github.com/mbeacom/adrkit/issues/299)). This is
the one non-additive change
[ADR-0059](docs/adr/0059-aggregate-decision-corpora-across-an-organization-s-repositories-as-a-pinned-rea.md)
clause 5 makes, and it amends
[ADR-0031](docs/adr/0031-publish-a-narrow-consumer-sdk-as-the-contract-and-document-the-cli-json-as-its-s.md)
clause 5's additive-only CLI JSON contract for exactly this case: the finding
is renamed and its severity lowered. Only the `relatesTo` case lowers an exit
code, from `1` to `0`; a dangling `conflictsWith` was already a warning.
Measured on a one-record fixture, that moves `adr lint` and `adr explain`
from exit `1` to `0`; `adr check` and `adr queue` already exited `0` there.
Everything that reads lint findings, the Actions included, sees the new code.
A qualified reference never resolves
against a local record with the same bare id. An unqualified dangling
reference is unchanged, and a log-qualified `supersedes` or `supersededBy`
stays a `dangling-*` error, now with a message saying cross-repository
supersession is not supported (ADR-0059 clause 4). `adr evaluate` now agrees:
a log-qualified `supersedes` fails `no-orphan-refs` as
`no-orphan-refs.dangling-supersedes` even when a snapshot of that log is
supplied (before, it was inert without a snapshot and passed when the snapshot
listed the id), and a log-qualified `supersededBy` names clause 4 in its
existing failure.

## [0.19.0] - 2026-10-10

This release ships the decision portal: a third, build-only nested Action,
Expand Down
10 changes: 7 additions & 3 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,13 @@ The public surface is:
Patch releases must be backward compatible: no removals, renames, exit-code
reclassification, or incompatible output/type-shape changes. Minor releases may
carry breaking changes before `1.0.0`, but the release notes must call them out
explicitly. Adding a runtime export is not breaking, but it intentionally expands
the API: update the matching surface test and mention the addition in the
release notes. Any `exports` map or runtime named-export change that is not
explicitly. The CLI JSON is additive-only under ADR-0031 clause 5, with one
amendment: under ADR-0059 clause 5, `adr lint --json` reports a log-qualified
`relatesTo` or `conflictsWith` (`payments:0012`) as `federated-ref-unresolved`
at `info`, not `dangling-relatesTo` (`error`) or `dangling-conflictsWith`
(`warn`), so only the qualified `relatesTo` case lowers an exit code. Adding a
runtime export is not breaking, but it intentionally expands the API: update
the matching surface test and mention the addition in the release notes. Any `exports` map or runtime named-export change that is not
accompanied by a surface-test update is a release blocker.

`QueueReport.totalItems` carries an obligation the surface tests cannot see.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ rung 3 is absent.
1. [ ] Define the manifest in Zod, emit `schema/federation.schema.json`, and
add the manifest validator (unique `log` and `repo`, required
`visibility`, no unknown keys).
2. [ ] Change `validateCorpusInvariants` per clause 5, with a CHANGELOG
2. [x] Change `validateCorpusInvariants` per clause 5, with a CHANGELOG
behavior-change entry and a test observed failing first.
3. [ ] Add `packages/ci/federation/action.yml`: fetch per member, write the
tree aside, write the sidecar last, swap, write the lock, and check
Expand Down
24 changes: 21 additions & 3 deletions packages/ci/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39374,15 +39374,27 @@ function validateParsedAdr(parsed) {
}
// ../core/src/validate/corpus-invariants.ts
function addReferenceFinding(findings, record, field, ref, severity) {
const crossLog = parseAdrRef(ref).log !== undefined;
findings.push({
rule: `dangling-${field}`,
severity,
message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
message: crossLog ? `Reference "${ref}" in ${field} names another decision log; cross-repository supersession is not supported` : `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
path: record.path,
id: record.frontmatter.id,
field
});
}
function addFederatedReferenceFinding(findings, record, field, ref, log) {
findings.push({
rule: "federated-ref-unresolved",
severity: "info",
message: `Reference "${ref}" in ${field} names another decision log ("${log}") and is inert against this corpus`,
path: record.path,
id: record.frontmatter.id,
field,
pattern: ref
});
}
function validateCorpusInvariants(records) {
const findings = [];
const byId = new Map;
Expand Down Expand Up @@ -39416,11 +39428,17 @@ function validateCorpusInvariants(records) {
addReferenceFinding(findings, record, "supersededBy", record.frontmatter.supersededBy, "error");
}
for (const ref of record.frontmatter.relatesTo) {
if (!ids.has(ref))
const { log } = parseAdrRef(ref);
if (log !== undefined)
addFederatedReferenceFinding(findings, record, "relatesTo", ref, log);
else if (!ids.has(ref))
addReferenceFinding(findings, record, "relatesTo", ref, "error");
}
for (const ref of record.frontmatter.conflictsWith) {
if (!ids.has(ref)) {
const { log } = parseAdrRef(ref);
if (log !== undefined) {
addFederatedReferenceFinding(findings, record, "conflictsWith", ref, log);
} else if (!ids.has(ref)) {
addReferenceFinding(findings, record, "conflictsWith", ref, "warn");
} else if (record.frontmatter.status === "accepted") {
findings.push({
Expand Down
24 changes: 21 additions & 3 deletions packages/ci/dist/portal-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -41128,15 +41128,27 @@ function validateParsedAdr(parsed) {
}
// ../core/src/validate/corpus-invariants.ts
function addReferenceFinding(findings, record, field, ref, severity) {
const crossLog = parseAdrRef(ref).log !== undefined;
findings.push({
rule: `dangling-${field}`,
severity,
message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
message: crossLog ? `Reference "${ref}" in ${field} names another decision log; cross-repository supersession is not supported` : `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
path: record.path,
id: record.frontmatter.id,
field
});
}
function addFederatedReferenceFinding(findings, record, field, ref, log) {
findings.push({
rule: "federated-ref-unresolved",
severity: "info",
message: `Reference "${ref}" in ${field} names another decision log ("${log}") and is inert against this corpus`,
path: record.path,
id: record.frontmatter.id,
field,
pattern: ref
});
}
function validateCorpusInvariants(records) {
const findings = [];
const byId = new Map;
Expand Down Expand Up @@ -41170,11 +41182,17 @@ function validateCorpusInvariants(records) {
addReferenceFinding(findings, record, "supersededBy", record.frontmatter.supersededBy, "error");
}
for (const ref of record.frontmatter.relatesTo) {
if (!ids.has(ref))
const { log } = parseAdrRef(ref);
if (log !== undefined)
addFederatedReferenceFinding(findings, record, "relatesTo", ref, log);
else if (!ids.has(ref))
addReferenceFinding(findings, record, "relatesTo", ref, "error");
}
for (const ref of record.frontmatter.conflictsWith) {
if (!ids.has(ref)) {
const { log } = parseAdrRef(ref);
if (log !== undefined) {
addFederatedReferenceFinding(findings, record, "conflictsWith", ref, log);
} else if (!ids.has(ref)) {
addReferenceFinding(findings, record, "conflictsWith", ref, "warn");
} else if (record.frontmatter.status === "accepted") {
findings.push({
Expand Down
31 changes: 28 additions & 3 deletions packages/ci/dist/queue-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -37408,6 +37408,13 @@ var AdrFrontmatter = strictObject2({
});
// ../core/src/schema/emit.ts
var ADR_SCHEMA_ID = `https://adrkit.dev/schema/adr/v${SCHEMA_VERSION}/adr.schema.json`;
// ../core/src/schema/ref.ts
function parseAdrRef(ref) {
const idx = ref.indexOf(":");
if (idx <= 0)
return { id: ref };
return { log: ref.slice(0, idx), id: ref.slice(idx + 1) };
}
// ../../node_modules/.bun/yaml@2.9.1/node_modules/yaml/dist/index.js
var composer = require_composer();
var Document = require_Document();
Expand Down Expand Up @@ -37696,15 +37703,27 @@ function validateParsedAdr(parsed) {
}
// ../core/src/validate/corpus-invariants.ts
function addReferenceFinding(findings, record, field, ref, severity) {
const crossLog = parseAdrRef(ref).log !== undefined;
findings.push({
rule: `dangling-${field}`,
severity,
message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
message: crossLog ? `Reference "${ref}" in ${field} names another decision log; cross-repository supersession is not supported` : `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
path: record.path,
id: record.frontmatter.id,
field
});
}
function addFederatedReferenceFinding(findings, record, field, ref, log) {
findings.push({
rule: "federated-ref-unresolved",
severity: "info",
message: `Reference "${ref}" in ${field} names another decision log ("${log}") and is inert against this corpus`,
path: record.path,
id: record.frontmatter.id,
field,
pattern: ref
});
}
function validateCorpusInvariants(records) {
const findings = [];
const byId = new Map;
Expand Down Expand Up @@ -37738,11 +37757,17 @@ function validateCorpusInvariants(records) {
addReferenceFinding(findings, record, "supersededBy", record.frontmatter.supersededBy, "error");
}
for (const ref of record.frontmatter.relatesTo) {
if (!ids.has(ref))
const { log } = parseAdrRef(ref);
if (log !== undefined)
addFederatedReferenceFinding(findings, record, "relatesTo", ref, log);
else if (!ids.has(ref))
addReferenceFinding(findings, record, "relatesTo", ref, "error");
}
for (const ref of record.frontmatter.conflictsWith) {
if (!ids.has(ref)) {
const { log } = parseAdrRef(ref);
if (log !== undefined) {
addFederatedReferenceFinding(findings, record, "conflictsWith", ref, log);
} else if (!ids.has(ref)) {
addReferenceFinding(findings, record, "conflictsWith", ref, "warn");
} else if (record.frontmatter.status === "accepted") {
findings.push({
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/test/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,71 @@ describe('adr lint CLI', () => {
expect(result.stderr).toContain('superseded-requires-supersededBy');
});

// ADR-0059 clause 5, amending ADR-0031 clause 5: a log-qualified relatesTo or
// conflictsWith is inert against a single corpus, so it no longer fails the gate.
test('exits 0 on a qualified relatesTo and reports it at info in --json', async () => {
const root = await resetTestDir(DIR_NAME);
await writeText(
join(root, 'docs/adr/0001-federated.md'),
recordMarkdown('0001').replace('relatesTo: []', 'relatesTo: ["payments:0012"]'),
);

const human = await runAdr(['lint', '--dir', join(root, 'docs/adr')]);
expect(human.exitCode).toBe(0);
expect(human.stdout).toContain('checked 1 records, 0 errors, 0 warnings');
expect(human.stderr).toContain('federated-ref-unresolved');
expect(human.stderr).not.toContain('dangling-relatesTo');

const json = await runAdr(['lint', '--json', '--dir', join(root, 'docs/adr')]);
expect(json.exitCode).toBe(0);
const report = JSON.parse(json.stdout) as { findings: Array<Record<string, unknown>> };
expect(report.findings).toMatchObject([
{ rule: 'federated-ref-unresolved', severity: 'info', id: '0001', field: 'relatesTo', pattern: 'payments:0012' },
]);
});

test('reports a qualified conflictsWith at info instead of warn', async () => {
const root = await resetTestDir(DIR_NAME);
await writeText(
join(root, 'docs/adr/0001-federated.md'),
recordMarkdown('0001', undefined, 'conflictsWith: ["payments:0012"]\n'),
);

const result = await runAdr(['lint', '--json', '--dir', join(root, 'docs/adr')]);
expect(result.exitCode).toBe(0);
const report = JSON.parse(result.stdout) as { findings: Array<Record<string, unknown>> };
expect(report.findings).toMatchObject([
{ rule: 'federated-ref-unresolved', severity: 'info', field: 'conflictsWith' },
]);
});

test('still exits 1 on a qualified supersedes, which clause 4 keeps out', async () => {
const root = await resetTestDir(DIR_NAME);
await writeText(
join(root, 'docs/adr/0001-federated.md'),
recordMarkdown('0001', undefined, 'supersedes: ["payments:0012"]\n'),
);

const result = await runAdr(['lint', '--dir', join(root, 'docs/adr')]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain('checked 1 records, 1 errors');
expect(result.stderr).toContain('dangling-supersedes');
expect(result.stderr).toContain('cross-repository supersession is not supported');
});

test('still exits 1 on an unqualified dangling relatesTo', async () => {
const root = await resetTestDir(DIR_NAME);
await writeText(
join(root, 'docs/adr/0001-local.md'),
recordMarkdown('0001').replace('relatesTo: []', 'relatesTo: ["0012"]'),
);

const result = await runAdr(['lint', '--json', '--dir', join(root, 'docs/adr')]);
expect(result.exitCode).toBe(1);
const report = JSON.parse(result.stdout) as { findings: Array<Record<string, unknown>> };
expect(report.findings).toMatchObject([{ rule: 'dangling-relatesTo', severity: 'error' }]);
});

test('reports a missing explicit path as a finding instead of crashing', async () => {
const root = await resetTestDir(DIR_NAME);

Expand Down
37 changes: 34 additions & 3 deletions packages/core/src/validate/corpus-invariants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Adr } from '../schema/adr.schema.ts';
import { parseAdrRef } from '../schema/ref.ts';
import type { Finding, FindingSeverity } from './findings.ts';

function addReferenceFinding(
Expand All @@ -8,16 +9,41 @@ function addReferenceFinding(
ref: string,
severity: FindingSeverity,
): void {
// ADR-0059 clause 4: a log-qualified supersession target stays a dangling error, and the
// message says why, so nobody reads it as a typo to fix in this corpus.
const crossLog = parseAdrRef(ref).log !== undefined;
findings.push({
rule: `dangling-${field}`,
severity,
message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
message: crossLog
? `Reference "${ref}" in ${field} names another decision log; cross-repository supersession is not supported`
: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
path: record.path,
id: record.frontmatter.id,
field,
});
}

/**
* A log-qualified `relatesTo` or `conflictsWith` (`payments:0012`) names a record in another
* decision log, which a single corpus cannot judge. ADR-0059 clause 5 (amending ADR-0031
* clause 5) reports it at `info`, as the marker resolver's `marker-unresolvable`, the
* evaluator's `no-orphan-refs.federated-log-absent`, and MCP's `federated-log-unavailable`
* already treat the same input: inert here, not broken. It never resolves against a local
* record with the same bare id.
*/
function addFederatedReferenceFinding(findings: Finding[], record: Adr, field: string, ref: string, log: string): void {
findings.push({
rule: 'federated-ref-unresolved',
severity: 'info',
message: `Reference "${ref}" in ${field} names another decision log ("${log}") and is inert against this corpus`,
path: record.path,
id: record.frontmatter.id,
field,
pattern: ref,
});
}

export function validateCorpusInvariants(records: readonly Adr[]): Finding[] {
const findings: Finding[] = [];
const byId = new Map<string, Adr[]>();
Expand Down Expand Up @@ -54,11 +80,16 @@ export function validateCorpusInvariants(records: readonly Adr[]): Finding[] {
}

for (const ref of record.frontmatter.relatesTo) {
if (!ids.has(ref)) addReferenceFinding(findings, record, 'relatesTo', ref, 'error');
const { log } = parseAdrRef(ref);
if (log !== undefined) addFederatedReferenceFinding(findings, record, 'relatesTo', ref, log);
else if (!ids.has(ref)) addReferenceFinding(findings, record, 'relatesTo', ref, 'error');
}

for (const ref of record.frontmatter.conflictsWith) {
if (!ids.has(ref)) {
const { log } = parseAdrRef(ref);
if (log !== undefined) {
addFederatedReferenceFinding(findings, record, 'conflictsWith', ref, log);
} else if (!ids.has(ref)) {
addReferenceFinding(findings, record, 'conflictsWith', ref, 'warn');
} else if (record.frontmatter.status === 'accepted') {
findings.push({
Expand Down
Loading
Loading