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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ Until `1.0.0`, minor releases may include breaking changes
`governing` matches `affects` only: it never reads the file at the path, so
it has no `@adr` marker evidence.

### Fixed

- **A `repo`-qualified `affects` matcher no longer vanishes silently
([#300](https://github.com/mbeacom/adrkit/issues/300)).** A matcher such as
`{ type: path, pattern: "src/**", repo: "payments" }` names another
repository, so in a single-repository corpus it still never matches. Until
now it was also skipped with no finding, so `adr check src/a.ts` returned
nothing and said nothing. `adr check` and `adr explain` now report each
skipped matcher as an `info` finding, `affects-repo-not-applicable`, and
`adr lint` reports one per record, so a corpus-wide run surfaces it without
a path. This repository's ADR-0056, whose `resource` matchers name
`mbeacom/adrkit-azure`, is the live example. The finding is `info`, as
[ADR-0059](docs/adr/0059-aggregate-decision-corpora-across-an-organization-s-repositories-as-a-pinned-rea.md)
clause 11 names it, and changes no exit code; `adr lint --json` and
`adr check --json` output grow additively. In `@adrkit/core`, the unused
`log` input on `resolveAffects` and `checkChanges`, which no caller set,
is replaced by an explicit `member: { log, repo }` identity, and
`matcherAppliesToMember` applies a qualified matcher when it equals either
name. No surface passes a member yet; that is ADR-0059's federation work.

## [0.19.0] - 2026-10-10

This release ships the decision portal: a third, build-only nested Action,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@
* `packages/core/src/schema/adr.schema.ts` and `schema/adr.schema.json`: the
* schema shape did change, by design, when zod 4.5 made `date-time` fields
* require seconds. The published v0.1.0 bytes are retained under
* `schema/versions/` and pinned by `site/scripts/sync-schema.test.ts`.
* `schema/versions/` and pinned by `site/scripts/sync-schema.test.ts`. The
* separately authorized #300 fix re-pinned only `affects/index.ts`: a
* `repo`-qualified matcher still never matches in a single-repository run, and
* now reports an `info` `affects-repo-not-applicable` finding instead of being
* skipped silently; the dead `log` input became an explicit `member` identity
* (ADR-0059 clause 11). The catalog types and the schema are untouched.
*
* ## Why the pin table lives in a JSON sidecar
*
Expand Down
2 changes: 1 addition & 1 deletion packages/catalog-envelope/test/protected-surfaces.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
{
"path": "packages/core/src/affects/index.ts",
"sha256": "dec53795d47490d67451f5bff8130f613c7944e9bf3f96a41e0e2d62eeefe0c1",
"sha256": "e49904da651b666e366a9f0f4e4f5379da5a9b2c1a8f039a23ebbfe4470fc438",
"why": "the affects resolution entry point — FR-004's matcher semantics"
},
{
Expand Down
198 changes: 116 additions & 82 deletions packages/ci/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39473,83 +39473,6 @@ function validateImportIncomplete(records) {
}
return findings;
}
// ../core/src/validate/index.ts
import { stat as stat3 } from "node:fs/promises";
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
function parseErrorFinding(error, path) {
if (error instanceof FrontmatterError) {
return {
rule: error.code === "invalid-yaml" ? "frontmatter-parse" : "frontmatter-fence",
severity: "error",
message: error.message,
path,
field: "frontmatter"
};
}
return {
rule: "file-read",
severity: "error",
message: error instanceof Error ? error.message : String(error),
path
};
}
function skippedFileFinding(skipped, cwd) {
const message = skipped.reason === "nested" ? "Markdown file is in a subdirectory of the corpus, and discovery reads only the top level of the corpus directory; move it to the corpus root as <id>-<slug>.md for it to be linted and enforced" : "Markdown file in the corpus directory is not a discoverable ADR record and was skipped; rename it to <id>-<slug>.md (four or more leading digits) for it to be linted and enforced";
return {
rule: "corpus-file-skipped",
severity: "warn",
message,
path: normalizeDisplayPath(skipped.path, cwd)
};
}
async function isDirectory2(path) {
return stat3(path).then((info) => info.isDirectory(), () => false);
}
async function scannedDirectories(paths, dir, cwd) {
if (!paths || paths.length === 0)
return [dir];
const directories = new Set;
for (const path of paths) {
const absolutePath = isAbsolute2(path) ? path : resolve2(cwd, path);
if (await isDirectory2(absolutePath))
directories.add(absolutePath);
}
return [...directories].sort((a, b) => compareByDisplayPath(a, b, cwd));
}
async function lintCorpus(options = {}) {
const cwd = options.cwd ?? process.cwd();
const dir = options.dir ?? "docs/adr";
const files = await expandRecordInputs(options.paths, dir, cwd);
const records = [];
const findings = [];
for (const file of files) {
const displayPath = normalizeDisplayPath(file, cwd);
try {
const parsed = await parseAdrFile(file, cwd);
const result = validateParsedAdr(parsed);
findings.push(...result.findings);
if (result.record) {
records.push(result.record);
}
} catch (error) {
findings.push(parseErrorFinding(error, displayPath));
}
}
const checkedPaths = new Set(files);
for (const scanned of await scannedDirectories(options.paths, dir, cwd)) {
for (const skipped of await discoverSkippedMarkdownFiles(scanned, cwd)) {
if (!checkedPaths.has(skipped.path))
findings.push(skippedFileFinding(skipped, cwd));
}
}
findings.push(...validateImportIncomplete(records));
findings.push(...validateCorpusInvariants(records));
return {
checked: files.length,
findings: sortFindings(findings),
records: [...records].sort((a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id))
};
}
// ../core/src/affects/inert.ts
var import_picomatch = __toESM(require_picomatch2(), 1);
function compilePattern(pattern) {
Expand Down Expand Up @@ -39691,6 +39614,7 @@ function matchPathPattern(pattern, changedFiles) {
}

// ../core/src/affects/index.ts
var AFFECTS_REPO_NOT_APPLICABLE = "affects-repo-not-applicable";
function compareFiredMatcher(a, b) {
return compareCodeUnits(a.type, b.type) || compareCodeUnits(a.pattern, b.pattern);
}
Expand All @@ -39712,8 +39636,15 @@ function affectsFinding(record, matcher, rule, severity, message) {
pattern: matcher.pattern
};
}
function matcherAppliesToLog(matcher, log) {
return !matcher.repo || matcher.repo === log;
function matcherAppliesToMember(matcher, member) {
if (!matcher.repo)
return true;
if (member === undefined)
return false;
return matcher.repo === member.repo || matcher.repo === member.log;
}
function repoNotApplicableFinding(record, matcher) {
return affectsFinding(record, matcher, AFFECTS_REPO_NOT_APPLICABLE, "info", `${matcher.type} matcher "${matcher.pattern}" is qualified with repo "${matcher.repo}" and cannot apply in a single-repository corpus; it was skipped, and is kept for a federation that includes that repository.`);
}
function evaluateMatcher(record, matcher, changedFiles, snapshots) {
if (matcher.type === "path") {
Expand Down Expand Up @@ -39782,8 +39713,11 @@ function resolveAffects(input) {
const firedMatchers = [];
let suppressed = false;
for (const matcher of record.frontmatter.affects ?? []) {
if (!matcherAppliesToLog(matcher, input.log))
if (!matcherAppliesToMember(matcher, input.member)) {
if (input.member === undefined)
findings.push(repoNotApplicableFinding(record, matcher));
continue;
}
const result = evaluateMatcher(record, matcher, input.changedFiles, input.snapshots);
findings.push(...result.findings);
if (!result.matched)
Expand All @@ -39806,6 +39740,106 @@ function resolveAffects(input) {
findings: sortFindings(findings)
};
}

// ../core/src/validate/affects-repo-qualifier.ts
function validateAffectsRepoQualifiers(records) {
const findings = [];
for (const record of records) {
const qualified = (record.frontmatter.affects ?? []).filter((matcher) => Boolean(matcher.repo));
if (qualified.length === 0)
continue;
const repos = [...new Set(qualified.map((matcher) => matcher.repo))].sort(compareCodeUnits);
const count = qualified.length;
const noun = count === 1 ? "affects matcher is" : "affects matchers are";
findings.push({
rule: AFFECTS_REPO_NOT_APPLICABLE,
severity: "info",
message: `${count} ${noun} qualified with another repository (repo ${repos.map((repo) => `"${repo}"`).join(", ")}) ` + "and cannot apply in a single-repository corpus; adr check and adr explain skip them, " + "and they are kept for a federation that includes that repository.",
path: record.path,
id: record.frontmatter.id,
field: "affects"
});
}
return findings;
}
// ../core/src/validate/index.ts
import { stat as stat3 } from "node:fs/promises";
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
function parseErrorFinding(error, path) {
if (error instanceof FrontmatterError) {
return {
rule: error.code === "invalid-yaml" ? "frontmatter-parse" : "frontmatter-fence",
severity: "error",
message: error.message,
path,
field: "frontmatter"
};
}
return {
rule: "file-read",
severity: "error",
message: error instanceof Error ? error.message : String(error),
path
};
}
function skippedFileFinding(skipped, cwd) {
const message = skipped.reason === "nested" ? "Markdown file is in a subdirectory of the corpus, and discovery reads only the top level of the corpus directory; move it to the corpus root as <id>-<slug>.md for it to be linted and enforced" : "Markdown file in the corpus directory is not a discoverable ADR record and was skipped; rename it to <id>-<slug>.md (four or more leading digits) for it to be linted and enforced";
return {
rule: "corpus-file-skipped",
severity: "warn",
message,
path: normalizeDisplayPath(skipped.path, cwd)
};
}
async function isDirectory2(path) {
return stat3(path).then((info) => info.isDirectory(), () => false);
}
async function scannedDirectories(paths, dir, cwd) {
if (!paths || paths.length === 0)
return [dir];
const directories = new Set;
for (const path of paths) {
const absolutePath = isAbsolute2(path) ? path : resolve2(cwd, path);
if (await isDirectory2(absolutePath))
directories.add(absolutePath);
}
return [...directories].sort((a, b) => compareByDisplayPath(a, b, cwd));
}
async function lintCorpus(options = {}) {
const cwd = options.cwd ?? process.cwd();
const dir = options.dir ?? "docs/adr";
const files = await expandRecordInputs(options.paths, dir, cwd);
const records = [];
const findings = [];
for (const file of files) {
const displayPath = normalizeDisplayPath(file, cwd);
try {
const parsed = await parseAdrFile(file, cwd);
const result = validateParsedAdr(parsed);
findings.push(...result.findings);
if (result.record) {
records.push(result.record);
}
} catch (error) {
findings.push(parseErrorFinding(error, displayPath));
}
}
const checkedPaths = new Set(files);
for (const scanned of await scannedDirectories(options.paths, dir, cwd)) {
for (const skipped of await discoverSkippedMarkdownFiles(scanned, cwd)) {
if (!checkedPaths.has(skipped.path))
findings.push(skippedFileFinding(skipped, cwd));
}
}
findings.push(...validateImportIncomplete(records));
findings.push(...validateAffectsRepoQualifiers(records));
findings.push(...validateCorpusInvariants(records));
return {
checked: files.length,
findings: sortFindings(findings),
records: [...records].sort((a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id))
};
}
// ../core/src/markers/scan.ts
var MARKER_HEADER_WINDOW_BYTES = 8192;
var MARKER_DECLARATION_FILE_CAP = 64;
Expand Down Expand Up @@ -40349,15 +40383,15 @@ function checkChanges(input) {
records: input.lint.records,
changedFiles,
snapshots: input.snapshots,
log: input.log
...input.member ? { member: input.member } : {}
});
const markerResolution = input.markerScans ? resolveSourceMarkers({
records: input.lint.records,
markers: input.markerScans.scans.flatMap((scan) => scan.markers)
}) : { matches: [], findings: [] };
const governedBy = mergeSourceDeclarations(toGoverningDecisions(input.lint.records, resolution.matches), input.lint.records, markerResolution.matches);
const buckets = bucketDecisions(governedBy);
const changedRecordFindings = input.lint.findings.filter((finding) => finding.path !== undefined && changedRecordSet.has(finding.path));
const changedRecordFindings = input.lint.findings.filter((finding) => finding.path !== undefined && changedRecordSet.has(finding.path) && !(input.member !== undefined && finding.rule === AFFECTS_REPO_NOT_APPLICABLE));
const markerScan = input.markerScans ? markerScanReport(input.markerScans) : undefined;
const capped = markerScan ? cappedScanFinding(markerScan) : undefined;
const cappedDeclarations = markerScan ? cappedDeclarationsFinding(markerScan) : undefined;
Expand Down
Loading
Loading