Skip to content
Closed
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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,11 @@ answer where the next decision is actually being made.
objections) as Markdown or `QueueReport` v1 JSON; also a managed-issue Action.
- **CI comment** — the `@adrkit/ci` GitHub Action surfaces the governing decisions
on the PRs that touch or explicitly declare them; pattern matches render as `via`
and PR-authored marker claims as `declared by`. It runs with only the default `GITHUB_TOKEN` and
degrades (never fails the job) on a read-only fork token.
and PR-authored marker claims as `declared by`. The comment separately reports
files it could not inspect for markers and markers it inspected but could not
resolve. Both reports are advisory, deterministically capped, and never influence
the Action's verdict. It runs with only the default `GITHUB_TOKEN` and degrades
(never fails the job) on a read-only fork token.
- **MCP server** — let agents retrieve prior decisions, including the rejected
ones, before proposing something already tried.

Expand All @@ -323,6 +326,11 @@ steps:
- uses: mbeacom/adrkit/packages/ci@v0
```

Keep the pull request head checkout at `GITHUB_WORKSPACE`. If `actions/checkout`
uses a different `ref` or a nested `path`, the Action can still resolve
corpus-authored `affects` matchers, but it cannot reliably inspect the changed
files for inbound `@adr` markers and says so in its comment.

## Why not plain MADR — or "Structured MADR"?

adrkit's frontmatter is a strict [MADR](https://adr.github.io/madr/) superset, so
Expand Down
65 changes: 65 additions & 0 deletions packages/ci/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48620,16 +48620,23 @@ var PROPOSALS_HEADING = "#### Active proposals touching this change";
var PROPOSALS_NOTE = "These are not yet ratified and do not bind this change:";
var HISTORY_HEADING = "#### Historical records that once covered this change";
var HISTORY_NOTE = "These no longer bind this change, and are listed for context only:";
var MARKER_SCAN_HEADING = "#### Inbound marker scan incomplete";
var UNRESOLVED_MARKERS_HEADING = "#### Unresolved inbound markers";
var MAX_GOVERNING = 50;
var MAX_DECLARATIONS = 10;
var MAX_MARKER_FINDINGS = 10;
var MAX_COMMENT_CHARS = 65536;
var TRUNCATION_NOTICE = "- …output truncated to fit GitHub’s comment size limit; run `adr check` locally for the complete result.";
var MAX_FINDING_FIELD_CHARS = 256;
var MAX_FINDING_MESSAGE_CHARS = 1024;
var MAX_MARKER_MESSAGE_CHARS = 512;
function changedRecordFindings(outcome) {
const changed = new Set(outcome.changedRecords);
return outcome.findings.filter((finding) => finding.field !== "marker" && finding.path !== undefined && changed.has(finding.path));
}
function unresolvedMarkerFindings(outcome) {
return outcome.findings.filter((finding) => finding.rule === "dangling-marker" || finding.rule === "marker-unresolvable");
}
function code(value) {
const safe = value.replace(/[\u0000-\u001f\u007f]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
let longestRun = 0;
Expand All @@ -48651,6 +48658,56 @@ function renderFindingLine(finding) {
const message = boundedDetail(finding.message, MAX_FINDING_MESSAGE_CHARS, "message");
return `- ${where} — ${code(finding.rule)}${field}: ${message}`;
}
function renderMarkerFindingLine(finding) {
const where = finding.path ? code(finding.path) : "(unknown path)";
const message = boundedDetail(finding.message, MAX_MARKER_MESSAGE_CHARS, "message");
return `- ${where} — ${code(finding.rule)}: ${code(message)}`;
}
function countLabel(count, singular, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
function renderMarkerScanHealth(outcome) {
const report = outcome.markerScan;
if (!report)
return [];
const unavailable = report.counts.absent + report.counts.unreadable + report.counts["out-of-tree"] + report.counts.skipped;
if (unavailable === 0)
return [];
const reasons = [];
if (report.counts.absent > 0) {
reasons.push(countLabel(report.counts.absent, "absent", "absent"));
}
if (report.counts.unreadable > 0) {
reasons.push(countLabel(report.counts.unreadable, "unreadable", "unreadable"));
}
if (report.counts["out-of-tree"] > 0) {
reasons.push(countLabel(report.counts["out-of-tree"], "outside the worktree", "outside the worktree"));
}
if (report.counts.skipped > 0) {
reasons.push(`${countLabel(report.counts.skipped, "skipped", "skipped")} by the ${report.limit}-file scan cap`);
}
return [
MARKER_SCAN_HEADING,
"",
`Could not inspect ${countLabel(unavailable, "changed file")} for inbound \`@adr\` markers: ${reasons.join(", ")}.`,
"Marker-derived governance may be incomplete."
];
}
function renderUnresolvedMarkers(findings2) {
if (findings2.length === 0)
return [];
const lines = [
UNRESOLVED_MARKERS_HEADING,
"",
`${countLabel(findings2.length, "marker claim")} could not be resolved against this decision log:`,
...findings2.slice(0, MAX_MARKER_FINDINGS).map(renderMarkerFindingLine)
];
const remaining = findings2.length - Math.min(findings2.length, MAX_MARKER_FINDINGS);
if (remaining > 0) {
lines.push(`- …and ${countLabel(remaining, "more unresolved marker finding")}; run \`adr check\` locally for the complete result.`);
}
return lines;
}
function renderDecisionLines(decision, withStatus) {
const status = withStatus ? ` _(${decision.status})_` : "";
const successor = decision.supersededBy ? ` — superseded by **${decision.supersededBy}**` : "";
Expand Down Expand Up @@ -48701,13 +48758,21 @@ function renderComment(outcome) {
const findings2 = changedRecordFindings(outcome);
const errors4 = findings2.filter((finding) => finding.severity === "error");
const warnings = findings2.filter((finding) => finding.severity === "warn");
const markerScanHealth = renderMarkerScanHealth(outcome);
const markerFindings = renderUnresolvedMarkers(unresolvedMarkerFindings(outcome));
if (errors4.length > 0) {
lines.push("#### ⚠️ Validation errors on changed records", "");
lines.push("These changed records fail validation and must be fixed:");
for (const finding of errors4)
lines.push(renderFindingLine(finding));
lines.push("");
}
if (markerScanHealth.length > 0) {
lines.push(...markerScanHealth, "");
}
if (markerFindings.length > 0) {
lines.push(...markerFindings, "");
}
if (outcome.governedBy.length === 0) {
lines.push(EMPTY_STATE);
} else if (outcome.governing.length === 0) {
Expand Down
99 changes: 99 additions & 0 deletions packages/ci/src/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const PROPOSALS_HEADING = '#### Active proposals touching this change';
const PROPOSALS_NOTE = 'These are not yet ratified and do not bind this change:';
const HISTORY_HEADING = '#### Historical records that once covered this change';
const HISTORY_NOTE = 'These no longer bind this change, and are listed for context only:';
const MARKER_SCAN_HEADING = '#### Inbound marker scan incomplete';
const UNRESOLVED_MARKERS_HEADING = '#### Unresolved inbound markers';

// Display cap for a pathological governing list. The underlying set is never
// trimmed semantically (R6) — this only shortens what is rendered.
Expand All @@ -34,6 +36,11 @@ const MAX_GOVERNING = 50;
// Bounding what is rendered is what keeps that content out of the body budget below.
const MAX_DECLARATIONS = 10;

// Marker findings are authored by the pull request and can outnumber the useful
// governance detail. Show enough examples to make the problem actionable, then
// collapse the rest into one deterministic count.
const MAX_MARKER_FINDINGS = 10;

/**
* GitHub rejects a comment body over 65,536 characters with a 422. That is not a
* permission error, so it would propagate out of the Action and fail the job — which
Expand All @@ -48,6 +55,7 @@ const TRUNCATION_NOTICE =
// detail must not make that whole line too large for the body limiter to retain.
const MAX_FINDING_FIELD_CHARS = 256;
const MAX_FINDING_MESSAGE_CHARS = 1024;
const MAX_MARKER_MESSAGE_CHARS = 512;

function changedRecordFindings(outcome: CheckOutcome): Finding[] {
const changed = new Set(outcome.changedRecords);
Expand All @@ -57,6 +65,13 @@ function changedRecordFindings(outcome: CheckOutcome): Finding[] {
);
}

function unresolvedMarkerFindings(outcome: CheckOutcome): Finding[] {
return outcome.findings.filter(
(finding) =>
finding.rule === 'dangling-marker' || finding.rule === 'marker-unresolvable',
);
}

/**
* Render a value as an inline code span it cannot escape.
*
Expand Down Expand Up @@ -97,6 +112,79 @@ function renderFindingLine(finding: Finding): string {
return `- ${where} — ${code(finding.rule)}${field}: ${message}`;
}

function renderMarkerFindingLine(finding: Finding): string {
const where = finding.path ? code(finding.path) : '(unknown path)';
const message = boundedDetail(
finding.message,
MAX_MARKER_MESSAGE_CHARS,
'message',
);
return `- ${where} — ${code(finding.rule)}: ${code(message)}`;
}

function countLabel(count: number, singular: string, plural = `${singular}s`): string {
return `${count} ${count === 1 ? singular : plural}`;
}

function renderMarkerScanHealth(outcome: CheckOutcome): string[] {
const report = outcome.markerScan;
if (!report) return [];

const unavailable =
report.counts.absent +
report.counts.unreadable +
report.counts['out-of-tree'] +
report.counts.skipped;
if (unavailable === 0) return [];

const reasons: string[] = [];
if (report.counts.absent > 0) {
reasons.push(countLabel(report.counts.absent, 'absent', 'absent'));
}
if (report.counts.unreadable > 0) {
reasons.push(countLabel(report.counts.unreadable, 'unreadable', 'unreadable'));
}
if (report.counts['out-of-tree'] > 0) {
reasons.push(
countLabel(
report.counts['out-of-tree'],
'outside the worktree',
'outside the worktree',
),
);
}
if (report.counts.skipped > 0) {
reasons.push(
`${countLabel(report.counts.skipped, 'skipped', 'skipped')} by the ${report.limit}-file scan cap`,
);
}

return [
MARKER_SCAN_HEADING,
'',
`Could not inspect ${countLabel(unavailable, 'changed file')} for inbound \`@adr\` markers: ${reasons.join(', ')}.`,
'Marker-derived governance may be incomplete.',
];
}

function renderUnresolvedMarkers(findings: readonly Finding[]): string[] {
if (findings.length === 0) return [];

const lines = [
UNRESOLVED_MARKERS_HEADING,
'',
`${countLabel(findings.length, 'marker claim')} could not be resolved against this decision log:`,
...findings.slice(0, MAX_MARKER_FINDINGS).map(renderMarkerFindingLine),
];
const remaining = findings.length - Math.min(findings.length, MAX_MARKER_FINDINGS);
if (remaining > 0) {
lines.push(
`- …and ${countLabel(remaining, 'more unresolved marker finding')}; run \`adr check\` locally for the complete result.`,
);
}
return lines;
}

/**
* One decision as a bullet, annotated with its status and — for superseded records —
* the successor that replaced it, so a reviewer is never shown a bare record id and
Expand Down Expand Up @@ -169,6 +257,8 @@ export function renderComment(outcome: CheckOutcome): string {
const findings = changedRecordFindings(outcome);
const errors = findings.filter((finding) => finding.severity === 'error');
const warnings = findings.filter((finding) => finding.severity === 'warn');
const markerScanHealth = renderMarkerScanHealth(outcome);
const markerFindings = renderUnresolvedMarkers(unresolvedMarkerFindings(outcome));

// Validation is the blocking result of this Action, so keep it ahead of the
// potentially large governance detail. If the body must be truncated, a reviewer
Expand All @@ -180,6 +270,15 @@ export function renderComment(outcome: CheckOutcome): string {
lines.push('');
}

// These advisory sections are bounded and follow blocking validation so
// pull-request-authored marker content cannot displace changed-record errors.
if (markerScanHealth.length > 0) {
lines.push(...markerScanHealth, '');
}
if (markerFindings.length > 0) {
lines.push(...markerFindings, '');
}

if (outcome.governedBy.length === 0) {
lines.push(EMPTY_STATE);
} else if (outcome.governing.length === 0) {
Expand Down
9 changes: 7 additions & 2 deletions packages/ci/test/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ describe('runAction (end to end with a fake client)', () => {
expect(result.failed).toBe(false);
expect(client.created[0]).toContain('**0001** — Guard marker-owned code');
expect(client.created[0]).toContain('declared by `src/owned.ts:1` (`@adr 0001`)');
expect(client.created[0]).toContain('#### Inbound marker scan incomplete');
expect(client.created[0]).toContain(
'Could not inspect 1 changed file for inbound `@adr` markers: 1 absent.',
);
expect(logger.info.join('\n')).toContain(
'marker scan: 1 scanned, 1 absent, 0 unreadable, 0 out-of-tree, 1 truncated, 0 skipped',
);
Expand Down Expand Up @@ -156,7 +160,7 @@ describe('runAction (end to end with a fake client)', () => {
expect(result.outcome?.governing.map((decision) => decision.recordId)).toEqual(['0001']);
});

test('keeps dangling markers non-failing and out of the focused PR comment', async () => {
test('keeps dangling markers non-failing and reports them in the PR comment', async () => {
const root = await resetTestDir(DIR_NAME);
await mkdir(join(root, 'docs/adr'), { recursive: true });
await writeText(join(root, 'src/dangling.ts'), '// @adr 9999\n');
Expand All @@ -167,7 +171,8 @@ describe('runAction (end to end with a fake client)', () => {
expect(result.failed).toBe(false);
expect(result.outcome?.ok).toBe(true);
expect(result.outcome?.findings.map((finding) => finding.rule)).toContain('dangling-marker');
expect(client.created[0]).not.toContain('dangling-marker');
expect(client.created[0]).toContain('#### Unresolved inbound markers');
expect(client.created[0]).toContain('dangling-marker');
});

test('warns with the exact skipped paths when the marker scan cap is reached', async () => {
Expand Down
Loading
Loading