diff --git a/CHANGELOG.md b/CHANGELOG.md index 8366581e..59b813be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/packages/catalog-envelope/test/no-core-schema-change.test.ts b/packages/catalog-envelope/test/no-core-schema-change.test.ts index 269e6c8e..1725e27b 100644 --- a/packages/catalog-envelope/test/no-core-schema-change.test.ts +++ b/packages/catalog-envelope/test/no-core-schema-change.test.ts @@ -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 * diff --git a/packages/catalog-envelope/test/protected-surfaces.json b/packages/catalog-envelope/test/protected-surfaces.json index 2521dbab..5272b8cb 100644 --- a/packages/catalog-envelope/test/protected-surfaces.json +++ b/packages/catalog-envelope/test/protected-surfaces.json @@ -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" }, { diff --git a/packages/ci/dist/index.js b/packages/ci/dist/index.js index 8950feb0..8a351cf1 100644 --- a/packages/ci/dist/index.js +++ b/packages/ci/dist/index.js @@ -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 -.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 -.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) { @@ -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); } @@ -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") { @@ -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) @@ -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 -.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 -.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; @@ -40349,7 +40383,7 @@ 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, @@ -40357,7 +40391,7 @@ function checkChanges(input) { }) : { 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; diff --git a/packages/ci/dist/portal-action.js b/packages/ci/dist/portal-action.js index 47df156d..137c5514 100644 --- a/packages/ci/dist/portal-action.js +++ b/packages/ci/dist/portal-action.js @@ -41227,123 +41227,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 resolve3 } 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 -.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 -.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 : resolve3(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/graph/build.ts -function edgeKey(edge) { - return `${edge.from}\x00${edge.to}\x00${edge.kind}`; -} -function pushEdge(edges, ids, edge) { - if (!ids.has(edge.from) || !ids.has(edge.to)) - return; - edges.set(edgeKey(edge), edge); -} -function buildAdrGraph(records) { - const sortedRecords = [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id)); - const ids = new Set(sortedRecords.map((record) => record.frontmatter.id)); - const edges = new Map; - for (const record of sortedRecords) { - for (const superseded of record.frontmatter.supersedes) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: superseded, kind: "supersedes" }); - } - if (record.frontmatter.supersededBy) { - pushEdge(edges, ids, { - from: record.frontmatter.supersededBy, - to: record.frontmatter.id, - kind: "supersedes" - }); - } - for (const related of record.frontmatter.relatesTo) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: related, kind: "relatesTo" }); - } - for (const conflict of record.frontmatter.conflictsWith) { - pushEdge(edges, ids, { from: record.frontmatter.id, to: conflict, kind: "conflictsWith" }); - } - } - return { - nodes: sortedRecords.map((record) => ({ - id: record.frontmatter.id, - title: record.frontmatter.title, - status: record.frontmatter.status - })), - edges: [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)) - }; -} // ../core/src/affects/inert.ts var import_picomatch = __toESM(require_picomatch2(), 1); function compilePattern(pattern) { @@ -41448,6 +41331,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); } @@ -41469,8 +41353,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") { @@ -41539,8 +41430,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) @@ -41563,6 +41457,146 @@ 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 resolve3 } 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 -.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 -.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 : resolve3(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/graph/build.ts +function edgeKey(edge) { + return `${edge.from}\x00${edge.to}\x00${edge.kind}`; +} +function pushEdge(edges, ids, edge) { + if (!ids.has(edge.from) || !ids.has(edge.to)) + return; + edges.set(edgeKey(edge), edge); +} +function buildAdrGraph(records) { + const sortedRecords = [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id)); + const ids = new Set(sortedRecords.map((record) => record.frontmatter.id)); + const edges = new Map; + for (const record of sortedRecords) { + for (const superseded of record.frontmatter.supersedes) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: superseded, kind: "supersedes" }); + } + if (record.frontmatter.supersededBy) { + pushEdge(edges, ids, { + from: record.frontmatter.supersededBy, + to: record.frontmatter.id, + kind: "supersedes" + }); + } + for (const related of record.frontmatter.relatesTo) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: related, kind: "relatesTo" }); + } + for (const conflict of record.frontmatter.conflictsWith) { + pushEdge(edges, ids, { from: record.frontmatter.id, to: conflict, kind: "conflictsWith" }); + } + } + return { + nodes: sortedRecords.map((record) => ({ + id: record.frontmatter.id, + title: record.frontmatter.title, + status: record.frontmatter.status + })), + edges: [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind)) + }; +} // ../core/src/markers/scan.ts var MARKER_HEADER_WINDOW_BYTES = 8192; var MARKER_DECLARATION_FILE_CAP = 64; diff --git a/packages/ci/dist/queue-action.js b/packages/ci/dist/queue-action.js index aa7f5526..1c4ed5ab 100644 --- a/packages/ci/dist/queue-action.js +++ b/packages/ci/dist/queue-action.js @@ -37802,6 +37802,33 @@ function validateImportIncomplete(records) { } return findings; } +// ../core/src/affects/matchers/package.ts +var import_semver = __toESM(require_semver2(), 1); + +// ../core/src/affects/index.ts +var AFFECTS_REPO_NOT_APPLICABLE = "affects-repo-not-applicable"; + +// ../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"; @@ -37872,6 +37899,7 @@ async function lintCorpus(options = {}) { } } findings.push(...validateImportIncomplete(records)); + findings.push(...validateAffectsRepoQualifiers(records)); findings.push(...validateCorpusInvariants(records)); return { checked: files.length, @@ -37879,8 +37907,6 @@ async function lintCorpus(options = {}) { records: [...records].sort((a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id)) }; } -// ../core/src/affects/matchers/package.ts -var import_semver = __toESM(require_semver2(), 1); // ../core/src/import/status.ts var MADR_RECOGNIZED_STATUSES = [ "draft", diff --git a/packages/cli/test/lint.test.ts b/packages/cli/test/lint.test.ts index 2c12b589..c1edc85f 100644 --- a/packages/cli/test/lint.test.ts +++ b/packages/cli/test/lint.test.ts @@ -42,7 +42,9 @@ describe('adr lint CLI', () => { const result = await runAdr(['lint']); expect(result.exitCode).toBe(0); expect(result.stdout).toContain(`checked ${expectedRecords} records, 0 errors, 0 warnings`); - expect(result.stderr).toBe(''); + // Advisory `info` findings may appear (ADR-0056's repo-qualified matchers, #300); + // nothing at `error` or `warn` may. + expect(result.stderr).not.toMatch(/^\s+(error|warn)\s/m); }); test('exits 1 on a fixture corpus containing an error', async () => { diff --git a/packages/cli/test/repo-qualified-matchers.test.ts b/packages/cli/test/repo-qualified-matchers.test.ts new file mode 100644 index 00000000..23395773 --- /dev/null +++ b/packages/cli/test/repo-qualified-matchers.test.ts @@ -0,0 +1,108 @@ +/** + * #300 through the CLI: a `repo`-qualified `affects` matcher in a single-repository + * corpus never matches, and `adr lint`, `adr check`, and `adr explain` each say so with + * an `info` finding instead of dropping it silently. `info` has no exit-code authority + * (ADR-0022), so every exit code here is the one the corpus would get without it. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { join, resolve } from 'node:path'; +import { acceptedRecordMarkdown, cleanupTestDir, resetTestDir, writeText } from '../../core/test/helpers.ts'; + +const CLI_PATH = resolve(process.cwd(), 'packages/cli/src/index.ts'); +const DIR_NAME = 'cli-repo-qualified-matchers'; +const RULE = 'affects-repo-not-applicable'; + +async function runAdr(args: string[], cwd = process.cwd()) { + const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +} + +interface JsonFinding { + rule: string; + severity: string; + id?: string; + field?: string; + pattern?: string; +} + +/** The issue's own reproduction: a record whose only matcher is repo-qualified. */ +async function seedCorpus(): Promise { + const root = await resetTestDir(DIR_NAME); + await writeText( + join(root, 'docs/adr/0001-payments.md'), + acceptedRecordMarkdown('0001', 'Use payments paths').replace( + 'affects: []', + ['affects:', ' - type: path', ' pattern: "src/**"', ' repo: "payments"'].join('\n'), + ), + ); + return root; +} + +afterEach(async () => { + await cleanupTestDir(DIR_NAME); +}); + +describe('repo-qualified affects matchers (#300)', () => { + test('adr lint reports one info finding per record and still exits 0', async () => { + const root = await seedCorpus(); + + const json = await runAdr(['lint', '--dir', 'docs/adr', '--json'], root); + expect(json.exitCode).toBe(0); + const findings = (JSON.parse(json.stdout) as { findings: JsonFinding[] }).findings; + expect(findings.map(({ rule, severity, id, field }) => ({ rule, severity, id, field }))).toEqual([ + { rule: RULE, severity: 'info', id: '0001', field: 'affects' }, + ]); + + const human = await runAdr(['lint', '--dir', 'docs/adr'], root); + expect(human.exitCode).toBe(0); + expect(`${human.stdout}${human.stderr}`).toContain(RULE); + }); + + test('adr check does not match the path, reports the skipped matcher, and exits 0', async () => { + const root = await seedCorpus(); + + const json = await runAdr(['check', 'src/a.ts', '--dir', 'docs/adr', '--json'], root); + expect(json.exitCode).toBe(0); + const outcome = JSON.parse(json.stdout) as { governing: unknown[]; findings: JsonFinding[]; ok: boolean }; + expect(outcome.governing).toEqual([]); + expect(outcome.ok).toBe(true); + expect(outcome.findings.map(({ rule, severity, id, pattern }) => ({ rule, severity, id, pattern }))).toEqual([ + { rule: RULE, severity: 'info', id: '0001', pattern: 'src/**' }, + ]); + + const human = await runAdr(['check', 'src/a.ts', '--dir', 'docs/adr'], root); + expect(human.exitCode).toBe(0); + expect(`${human.stdout}${human.stderr}`).toContain(RULE); + }); + + test('adr explain does not match the path, reports the skipped matcher, and exits 0', async () => { + const root = await seedCorpus(); + + const json = await runAdr(['explain', 'src/a.ts', '--dir', 'docs/adr', '--json'], root); + expect(json.exitCode).toBe(0); + const explained = JSON.parse(json.stdout) as { governing: unknown[]; findings: JsonFinding[] }; + expect(explained.governing).toEqual([]); + expect(explained.findings.map(({ rule, severity, id, pattern }) => ({ rule, severity, id, pattern }))).toEqual([ + { rule: RULE, severity: 'info', id: '0001', pattern: 'src/**' }, + ]); + + const human = await runAdr(['explain', 'src/a.ts', '--dir', 'docs/adr'], root); + expect(human.exitCode).toBe(0); + expect(human.stdout).toContain('No decision governs'); + expect(human.stdout).toContain(RULE); + }); + + test("this repository's ADR-0056 resource matchers produce the lint advisory", async () => { + const result = await runAdr(['lint', '--json']); + expect(result.exitCode).toBe(0); + const findings = (JSON.parse(result.stdout) as { findings: JsonFinding[] }).findings; + expect(findings.filter((finding) => finding.rule === RULE && finding.id === '0056')).toEqual([ + expect.objectContaining({ severity: 'info', field: 'affects' }), + ]); + }); +}); diff --git a/packages/core/src/affects/index.ts b/packages/core/src/affects/index.ts index 1de79317..ec06f626 100644 --- a/packages/core/src/affects/index.ts +++ b/packages/core/src/affects/index.ts @@ -25,13 +25,37 @@ export interface ResolutionSnapshots { catalog?: CatalogSnapshot; } +/** + * The federation member whose working tree a resolution is for (ADR-0059 clause 11). + * + * A federation manifest names each member by both a `log` (the slug an `AdrRef` prefix + * uses, as in `payments:0012`) and a `repo` (`owner/name`). A `repo`-qualified `affects` + * matcher applies to a member when it equals either name, which is how ADR-0056's + * `repo: "mbeacom/adrkit-azure"` and a slug-style `repo: "payments"` both stay valid. + * + * `undefined` is a single-repository run, the only kind any surface performs today. No + * caller passes a member yet; the federation's `governing(, path)` will. + * + * This is deliberately not `Adr.log`. That field names the log a *record* was loaded + * from; the member names the *repository being checked*. A record sourced from + * `payments` can carry a matcher for `billing`. + */ +export interface MemberIdentity { + readonly log: string; + readonly repo: string; +} + export interface ResolveAffectsInput { records: readonly Adr[]; changedFiles: readonly string[]; snapshots?: ResolutionSnapshots; - log?: string; + /** The federation member being resolved for; omit for a single-repository run. */ + member?: MemberIdentity; } +/** Rule id for a `repo`-qualified matcher that cannot apply in this run (#300). */ +export const AFFECTS_REPO_NOT_APPLICABLE = 'affects-repo-not-applicable'; + export interface FiredMatcher { type: string; pattern: string; @@ -89,8 +113,35 @@ function affectsFinding( }; } -function matcherAppliesToLog(matcher: Matcher, log: string | undefined): boolean { - return !matcher.repo || matcher.repo === log; +/** + * Whether an `affects` matcher applies to the repository being resolved. + * + * An unqualified matcher always applies. A `repo`-qualified one applies only to a + * federation member named by that `repo`, as either its `repo` or its `log` (ADR-0059 + * clause 11). With no member — a single-repository run — a qualified matcher never + * applies; `resolveAffects` reports that with an `affects-repo-not-applicable` finding + * rather than skipping it silently (ADR-0016). + */ +export function matcherAppliesToMember( + matcher: { readonly repo?: string }, + member: MemberIdentity | undefined, +): boolean { + if (!matcher.repo) return true; + if (member === undefined) return false; + return matcher.repo === member.repo || matcher.repo === member.log; +} + +function repoNotApplicableFinding(record: Adr, matcher: Matcher): Finding { + return affectsFinding( + record, + matcher, + AFFECTS_REPO_NOT_APPLICABLE, + // `info`, as ADR-0059 clause 11 names it: the matcher is not malformed, only inert in + // this run, which is what `affects-unresolvable` already reports at `info`. ADR-0022: + // no exit code reads an `info` finding. + '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( @@ -213,7 +264,12 @@ export function resolveAffects(input: ResolveAffectsInput): ResolveAffectsResult let suppressed = false; for (const matcher of (record.frontmatter.affects ?? []) as readonly Matcher[]) { - if (!matcherAppliesToLog(matcher, input.log)) continue; + if (!matcherAppliesToMember(matcher, input.member)) { + // Inside a federation, a matcher naming another member applies there, so its + // skip here is expected and says nothing. Outside one it can apply nowhere. + if (input.member === undefined) findings.push(repoNotApplicableFinding(record, matcher)); + continue; + } const result = evaluateMatcher(record, matcher, input.changedFiles, input.snapshots); findings.push(...result.findings); diff --git a/packages/core/src/check/index.ts b/packages/core/src/check/index.ts index e4c45239..daf6d850 100644 --- a/packages/core/src/check/index.ts +++ b/packages/core/src/check/index.ts @@ -5,7 +5,12 @@ // file the corpus reaches by no pattern at all. import { sep } from 'node:path'; import type { Adr } from '../schema/adr.schema.ts'; -import { resolveAffects, type ResolutionSnapshots } from '../affects/index.ts'; +import { + AFFECTS_REPO_NOT_APPLICABLE, + resolveAffects, + type MemberIdentity, + type ResolutionSnapshots, +} from '../affects/index.ts'; import { mergeSourceDeclarations, resolveSourceMarkers } from '../markers/resolve.ts'; import { MARKER_DECLARATION_BATCH_CAP, @@ -89,8 +94,12 @@ export interface CheckChangesInput { /** Corpus directory (default `docs/adr`) used to identify changed records. */ dir?: string; snapshots?: ResolutionSnapshots; - /** Optional current-repo identity for scoped (`repo`-qualified) matchers. */ - log?: string; + /** + * The federation member being checked, for `repo`-qualified matchers (ADR-0059 + * clause 11). Omit for a single-repository run, where such a matcher never applies and + * is reported as `affects-repo-not-applicable`. + */ + member?: MemberIdentity; /** Marker I/O performed by the caller; `checkChanges` only resolves these values. */ markerScans?: SourceMarkerBatchScan; } @@ -238,7 +247,7 @@ export function checkChanges(input: CheckChangesInput): CheckOutcome { records: input.lint.records, changedFiles, snapshots: input.snapshots, - log: input.log, + ...(input.member ? { member: input.member } : {}), }); const markerResolution = input.markerScans @@ -257,8 +266,18 @@ export function checkChanges(input: CheckChangesInput): CheckOutcome { // Findings kept only for files lint attributes to a changed record. Errors on // unchanged records (A5) and corpus-level findings without a record path do not // fail the check. + // + // `lintCorpus` always reads a single-repository corpus, so its per-record + // `affects-repo-not-applicable` advisory asserts that the record's qualified matchers + // cannot apply. With a member, that claim is false: the resolver above either applied + // each matcher or skipped it because it names another member, which says nothing + // (ADR-0059 clause 11). Drop the lint advisory then, so a check never both applies a + // matcher and reports that it cannot apply. It is `info`, so `ok` is unaffected. const changedRecordFindings = input.lint.findings.filter( - (finding) => finding.path !== undefined && changedRecordSet.has(finding.path), + (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; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd92b27e..d78361f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export * from './validate/findings.ts'; export * from './validate/contract.ts'; export * from './validate/corpus-invariants.ts'; export * from './validate/import-incomplete.ts'; +export * from './validate/affects-repo-qualifier.ts'; export * from './validate/index.ts'; export * from './scaffold/new.ts'; export * from './graph/build.ts'; diff --git a/packages/core/src/validate/affects-repo-qualifier.ts b/packages/core/src/validate/affects-repo-qualifier.ts new file mode 100644 index 00000000..43693ba4 --- /dev/null +++ b/packages/core/src/validate/affects-repo-qualifier.ts @@ -0,0 +1,43 @@ +import { AFFECTS_REPO_NOT_APPLICABLE } from '../affects/index.ts'; +import { compareCodeUnits } from '../ordering/index.ts'; +import type { Adr } from '../schema/adr.schema.ts'; +import type { Finding } from './findings.ts'; + +/** + * `adr lint`'s half of #300: one advisory per record whose `affects` carries a + * `repo`-qualified matcher. + * + * `adr lint` always reads a single-repository corpus, where such a matcher can never + * apply (ADR-0059 clause 11). `adr check` and `adr explain` already report each skipped + * matcher where they resolve a path; this finding lets a corpus-wide run surface the + * same fact without one. It is per record rather than per matcher, carries no + * `pattern`, and is `info`, so it never changes an exit code (ADR-0022). + * + * The rule id is shared with the resolver's per-matcher finding. The two can meet only + * in `adr check` on a change to the record itself, and `pattern` tells them apart. + */ +export function validateAffectsRepoQualifiers(records: readonly Adr[]): Finding[] { + const findings: Finding[] = []; + + 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 as string))].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; +} diff --git a/packages/core/src/validate/index.ts b/packages/core/src/validate/index.ts index 00ff9c78..d250934a 100644 --- a/packages/core/src/validate/index.ts +++ b/packages/core/src/validate/index.ts @@ -14,6 +14,7 @@ import { compareCodeUnits } from '../ordering/index.ts'; import { validateParsedAdr } from './contract.ts'; import { validateCorpusInvariants } from './corpus-invariants.ts'; import { validateImportIncomplete } from './import-incomplete.ts'; +import { validateAffectsRepoQualifiers } from './affects-repo-qualifier.ts'; import { sortFindings, type Finding } from './findings.ts'; export interface LintCorpusOptions { @@ -126,6 +127,7 @@ export async function lintCorpus(options: LintCorpusOptions = {}): Promise[], status = 'accepted'): Adr { + return { + frontmatter: AdrFrontmatter.parse({ + schemaVersion: '0.1.0', + id, + title: `Use repo-qualified record ${id}`, + status, + date: '2026-10-10', + deciders: ['@mbeacom'], + tags: [], + scope: 'component', + reversibility: 'unknown', + blastRadius: 'component', + affects, + provenance: { authoredBy: 'human' }, + }), + body: '', + path: `docs/adr/${id}-repo.md`, + }; +} + +describe('repo-qualified affects matchers in a single-repository corpus', () => { + test('a repo-qualified path matcher does not match and emits one info finding', () => { + const result = resolveAffects({ + records: [record('0001', [{ type: 'path', pattern: 'src/**', repo: 'payments' }])], + changedFiles: ['src/a.ts'], + }); + + expect(result.matches).toEqual([]); + expect(result.findings).toEqual([ + { + rule: 'affects-repo-not-applicable', + severity: 'info', + message: + 'path matcher "src/**" is qualified with repo "payments" and cannot apply in a single-repository corpus; it was skipped, and is kept for a federation that includes that repository.', + id: '0001', + path: 'docs/adr/0001-repo.md', + field: 'affects.path', + pattern: 'src/**', + }, + ]); + expect(exitCodeForFindings(result.findings)).toBe(0); + }); + + test('the finding fires regardless of the changed path, as affects-unresolvable does', () => { + const result = resolveAffects({ + records: [record('0001', [{ type: 'path', pattern: 'src/**', repo: 'payments' }])], + changedFiles: ['unrelated/file.md'], + }); + expect(result.findings.map((finding) => finding.rule)).toEqual(['affects-repo-not-applicable']); + }); + + test('a skipped repo-qualified matcher is not also evaluated (one finding per matcher)', () => { + // ADR-0056's live shape: a `resource` matcher qualified with an owner/name repo. + const result = resolveAffects({ + records: [ + record('0056', [ + { type: 'path', pattern: 'packages/server/**' }, + { type: 'resource', pattern: 'azurerm_api_management', repo: 'mbeacom/adrkit-azure' }, + ]), + ], + changedFiles: ['packages/server/src/index.ts'], + }); + + expect(result.matches).toEqual([ + { recordId: '0056', firedMatchers: [{ type: 'path', pattern: 'packages/server/**' }] }, + ]); + expect(result.findings.map((finding) => [finding.rule, finding.pattern])).toEqual([ + ['affects-repo-not-applicable', 'azurerm_api_management'], + ]); + }); + + test('a repo-qualified negation cannot suppress a local match', () => { + const result = resolveAffects({ + records: [ + record('0001', [ + { type: 'path', pattern: 'src/**' }, + { type: 'path', pattern: 'src/**', repo: 'payments', negate: true }, + ]), + ], + changedFiles: ['src/a.ts'], + }); + expect(result.matches.map((match) => match.recordId)).toEqual(['0001']); + expect(result.findings.map((finding) => finding.rule)).toEqual(['affects-repo-not-applicable']); + }); + + test('an unqualified matcher is unaffected and emits nothing', () => { + const result = resolveAffects({ + records: [record('0001', [{ type: 'path', pattern: 'src/**' }])], + changedFiles: ['src/a.ts'], + }); + expect(result.matches.map((match) => match.recordId)).toEqual(['0001']); + expect(result.findings).toEqual([]); + }); +}); + +describe('the federation hook: an explicit member identity (ADR-0059 clause 11)', () => { + const member = { log: 'payments', repo: 'acme/payments' }; + + test('matcherAppliesToMember: unqualified always applies; qualified needs a member', () => { + expect(matcherAppliesToMember({}, undefined)).toBe(true); + expect(matcherAppliesToMember({}, member)).toBe(true); + expect(matcherAppliesToMember({ repo: 'payments' }, undefined)).toBe(false); + expect(matcherAppliesToMember({ repo: 'payments' }, member)).toBe(true); + expect(matcherAppliesToMember({ repo: 'acme/payments' }, member)).toBe(true); + expect(matcherAppliesToMember({ repo: 'acme/billing' }, member)).toBe(false); + }); + + test('with a member, a matcher naming its repo or its log applies and emits nothing', () => { + for (const repo of ['payments', 'acme/payments']) { + const result = resolveAffects({ + records: [record('0001', [{ type: 'path', pattern: 'src/**', repo }])], + changedFiles: ['src/a.ts'], + member, + }); + expect(result.matches.map((match) => match.recordId)).toEqual(['0001']); + expect(result.findings).toEqual([]); + } + }); + + test('with a member, a matcher naming another member is skipped silently: it applies there', () => { + const result = resolveAffects({ + records: [record('0001', [{ type: 'path', pattern: 'src/**', repo: 'acme/billing' }])], + changedFiles: ['src/a.ts'], + member, + }); + expect(result.matches).toEqual([]); + expect(result.findings).toEqual([]); + }); + + test("Adr.log is not consulted: a record's source log does not make its matcher apply", () => { + const sourced: Adr = { ...record('0001', [{ type: 'path', pattern: 'src/**', repo: 'payments' }]), log: 'payments' }; + const result = resolveAffects({ records: [sourced], changedFiles: ['src/a.ts'] }); + expect(result.matches).toEqual([]); + expect(result.findings.map((finding) => finding.rule)).toEqual(['affects-repo-not-applicable']); + }); +}); + +describe('validateAffectsRepoQualifiers (adr lint, once per record)', () => { + test('one info finding per record with repo-qualified matchers, naming count and repos', () => { + const findings = validateAffectsRepoQualifiers([ + record('0056', [ + { type: 'path', pattern: 'packages/server/**' }, + { type: 'resource', pattern: 'azurerm_api_management', repo: 'mbeacom/adrkit-azure' }, + { type: 'resource', pattern: 'azapi_resource', repo: 'mbeacom/adrkit-azure' }, + { type: 'path', pattern: 'src/**', repo: 'payments' }, + ]), + record('0057', [{ type: 'path', pattern: 'src/**' }]), + ]); + + expect(findings).toEqual([ + { + rule: 'affects-repo-not-applicable', + severity: 'info', + message: + '3 affects matchers are qualified with another repository (repo "mbeacom/adrkit-azure", "payments") 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: 'docs/adr/0056-repo.md', + id: '0056', + field: 'affects', + }, + ]); + expect(exitCodeForFindings(findings)).toBe(0); + }); + + test('a single qualified matcher reads in the singular', () => { + const [finding] = validateAffectsRepoQualifiers([ + record('0001', [{ type: 'path', pattern: 'src/**', repo: 'payments' }]), + ]); + expect(finding?.message).toStartWith('1 affects matcher is qualified with another repository (repo "payments")'); + }); + + test('records without repo-qualified matchers produce nothing', () => { + expect(validateAffectsRepoQualifiers([record('0001', [{ type: 'path', pattern: 'src/**' }])])).toEqual([]); + }); +}); + +describe('checkChanges passes the member through', () => { + test('no member: the advisory, no governing record, ok; a member naming the repo: governed', async () => { + const { checkChanges } = await import('../src/check/index.ts'); + const records = [record('0001', [{ type: 'path', pattern: 'src/**', repo: 'payments' }])]; + const lint = { checked: 1, findings: [], records }; + + const single = checkChanges({ lint, changedFiles: ['src/a.ts'] }); + expect(single.governing).toEqual([]); + expect(single.ok).toBe(true); + expect(single.findings.map((finding) => finding.rule)).toEqual(['affects-repo-not-applicable']); + + const federated = checkChanges({ lint, changedFiles: ['src/a.ts'], member: { log: 'payments', repo: 'acme/payments' } }); + expect(federated.governing.map((decision) => decision.recordId)).toEqual(['0001']); + expect(federated.findings).toEqual([]); + }); +}); + +describe('lintCorpus + checkChanges with a member and the qualified record itself changed (PR #308 review)', () => { + const DIR_NAME = 'core-affects-repo-member-check'; + + async function seed(repo: string): Promise { + const { acceptedRecordMarkdown, resetTestDir, writeText } = await import('./helpers.ts'); + const root = await resetTestDir(DIR_NAME); + await writeText( + `${root}/docs/adr/0001-payments.md`, + acceptedRecordMarkdown('0001', 'Use payments paths').replace( + 'affects: []', + ['affects:', ' - type: path', ' pattern: "src/**"', ` repo: "${repo}"`].join('\n'), + ), + ); + return root; + } + + async function run(repo: string, member?: { log: string; repo: string }) { + const { lintCorpus } = await import('../src/validate/index.ts'); + const { checkChanges } = await import('../src/check/index.ts'); + const { cleanupTestDir } = await import('./helpers.ts'); + const root = await seed(repo); + try { + const lint = await lintCorpus({ cwd: root }); + expect(lint.findings.map((finding) => finding.rule)).toEqual(['affects-repo-not-applicable']); + return checkChanges({ + lint, + changedFiles: ['src/a.ts', 'docs/adr/0001-payments.md'], + ...(member ? { member } : {}), + }); + } finally { + await cleanupTestDir(DIR_NAME); + } + } + + test('a member the matcher names: governed, and no finding says the matcher cannot apply', async () => { + const outcome = await run('payments', { log: 'payments', repo: 'acme/payments' }); + expect(outcome.governing.map((decision) => decision.recordId)).toEqual(['0001']); + expect(outcome.findings.filter((finding) => finding.rule === 'affects-repo-not-applicable')).toEqual([]); + }); + + test('a member the matcher does not name: skipped silently, as the resolver does', async () => { + const outcome = await run('acme/billing', { log: 'payments', repo: 'acme/payments' }); + expect(outcome.governing).toEqual([]); + expect(outcome.findings.filter((finding) => finding.rule === 'affects-repo-not-applicable')).toEqual([]); + }); + + test('no member: both the per-matcher and the per-record advisory still appear', async () => { + const outcome = await run('payments'); + expect(outcome.governing).toEqual([]); + expect( + outcome.findings + .filter((finding) => finding.rule === 'affects-repo-not-applicable') + .map((finding) => finding.field), + ).toEqual(['affects', 'affects.path']); + }); +}); diff --git a/packages/core/test/surface.test.ts b/packages/core/test/surface.test.ts index ffb09814..f93cfaf4 100644 --- a/packages/core/test/surface.test.ts +++ b/packages/core/test/surface.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; const PUBLIC_RUNTIME_EXPORTS = [ 'ADR_SCHEMA_ID', + 'AFFECTS_REPO_NOT_APPLICABLE', 'AdrFrontmatter', 'AdrRef', 'AffectsMatcher', @@ -78,6 +79,7 @@ const PUBLIC_RUNTIME_EXPORTS = [ 'mapMadrStatus', 'matchPackagePattern', 'matchPathPattern', + 'matcherAppliesToMember', 'mergeMadr', 'mergeSourceDeclarations', 'migrateMadr', @@ -116,6 +118,7 @@ const PUBLIC_RUNTIME_EXPORTS = [ 'stringifyJsonSchema', 'toGoverningDecisions', 'validateAdrFrontmatter', + 'validateAffectsRepoQualifiers', 'validateCorpusInvariants', 'validateImportIncomplete', 'validateParsedAdr',