Skip to content

Commit a4449ba

Browse files
authored
feat(agent-docs-audit): add diff-scoped PR comments (#3299)
1 parent 2407e93 commit a4449ba

3 files changed

Lines changed: 237 additions & 3 deletions

File tree

.github/scripts/agent-docs-l1.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,10 @@ export function runL1Scan(repoRoot) {
330330
// (so PR-mode reports surface single-finding issues) and as input to the
331331
// L2/L3 gating below.
332332
export function computeFlags(file) {
333-
if (file.isSymlink) return [];
334-
const isRoot = !file.relPath.includes('/');
335333
const reasons = [];
336334
if (file.brokenSymlinkTarget) reasons.push(`broken symlink target: ${file.brokenSymlinkTarget}`);
335+
if (file.isSymlink) return reasons;
336+
const isRoot = !file.relPath.includes('/');
337337
if (isRoot && file.lineCount > CONFIG.budgets.root) reasons.push(`over root budget (${file.lineCount} > ${CONFIG.budgets.root})`);
338338
if (!isRoot && file.lineCount > CONFIG.budgets.nestedWarn) reasons.push(`over nested-warn (${file.lineCount} > ${CONFIG.budgets.nestedWarn})`);
339339
if (file.brokenPathRefs.length > 0) reasons.push(`${file.brokenPathRefs.length} broken path ref(s)`);
@@ -342,7 +342,7 @@ export function computeFlags(file) {
342342
return reasons;
343343
}
344344

345-
function pairFlaggedForReview(pair) {
345+
export function pairFlaggedForReview(pair) {
346346
if (pair.classification === 'linked-inverted') return true;
347347
if (pair.classification === 'unexpected-duplicate') return true;
348348
return false;
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Post a sticky PR comment with diff-scoped L1 findings.
4+
*
5+
* PR runs are deterministic only: no AI, no Bash, no secrets. The comment
6+
* includes only findings for agent-doc files changed by the PR.
7+
*/
8+
9+
import { execFileSync } from 'node:child_process';
10+
import { writeFileSync } from 'node:fs';
11+
import { tmpdir } from 'node:os';
12+
import { dirname, join, relative, resolve } from 'node:path';
13+
import { computeFlags, pairFlaggedForReview, runL1Scan } from './agent-docs-l1.mjs';
14+
15+
const MARKER = '<!-- agent-docs-audit -->';
16+
const PR = process.env.PR_NUMBER;
17+
const REPO = process.env.REPO ?? 'superdoc-dev/superdoc';
18+
const REPO_ROOT = resolve(process.env.REPO_ROOT ?? process.cwd());
19+
const SHA = process.env.GITHUB_SHA ?? 'unknown-sha';
20+
const DRY_RUN = process.argv.includes('--dry-run');
21+
22+
if (!PR && !DRY_RUN) {
23+
console.log('PR_NUMBER not set; not in a PR context. Skipping.');
24+
process.exit(0);
25+
}
26+
27+
function isAgentDocPath(path) {
28+
if (/(?:^|\/)(?:AGENTS|CLAUDE)(?:\.local)?\.md$/.test(path)) return true;
29+
return /(?:^|\/)\.claude\/rules\/.+\.md$/.test(path);
30+
}
31+
32+
function getChangedAgentDocs() {
33+
if (DRY_RUN) {
34+
const filesIdx = process.argv.indexOf('--files');
35+
if (filesIdx < 0 || !process.argv[filesIdx + 1]) return [];
36+
return process.argv[filesIdx + 1].split(',').map((p) => p.trim()).filter(Boolean).filter(isAgentDocPath);
37+
}
38+
39+
try {
40+
const out = execFileSync('gh', ['pr', 'diff', PR, '--repo', REPO, '--name-only'], { encoding: 'utf-8' });
41+
return out.split('\n').map((p) => p.trim()).filter(Boolean).filter(isAgentDocPath);
42+
} catch (err) {
43+
console.log(`Could not list PR changed files: ${err.message}`);
44+
return [];
45+
}
46+
}
47+
48+
function symlinkTargetRel(file) {
49+
if (!file.isSymlink || !file.symlinkTarget) return null;
50+
return relative(REPO_ROOT, file.symlinkTarget).replaceAll('\\', '/');
51+
}
52+
53+
function changedPairDirs(paths) {
54+
const dirs = new Set();
55+
for (const path of paths) {
56+
if (/(?:^|\/)(?:AGENTS|CLAUDE)(?:\.local)?\.md$/.test(path)) {
57+
dirs.add(dirname(path));
58+
}
59+
}
60+
return dirs;
61+
}
62+
63+
function collectFindings(scan, changed) {
64+
const filesByPath = new Map(scan.files.map((file) => [file.relPath, file]));
65+
const findings = [];
66+
67+
for (const requestedPath of changed) {
68+
const requestedFile = filesByPath.get(requestedPath);
69+
if (!requestedFile) continue; // Deleted files are handled through pair findings where applicable.
70+
71+
let file = requestedFile;
72+
const targetRel = symlinkTargetRel(requestedFile);
73+
if (targetRel && filesByPath.has(targetRel) && !requestedFile.brokenSymlinkTarget) {
74+
file = filesByPath.get(targetRel);
75+
}
76+
77+
const reasons = computeFlags(file);
78+
if (reasons.length > 0) {
79+
findings.push({ type: 'file', requestedPath, file, reasons });
80+
}
81+
}
82+
83+
const pairDirs = changedPairDirs(changed);
84+
for (const pair of scan.pairs) {
85+
if (!pairDirs.has(pair.dir)) continue;
86+
if (!pairFlaggedForReview(pair)) continue;
87+
findings.push({ type: 'pair', pair, reasons: [`${pair.classification}: ${pair.detail}`] });
88+
}
89+
90+
return findings;
91+
}
92+
93+
function formatFileFinding(finding) {
94+
const { requestedPath, file, reasons } = finding;
95+
const label = requestedPath === file.relPath
96+
? `\`${file.relPath}\``
97+
: `\`${requestedPath}\` (canonical: \`${file.relPath}\`)`;
98+
const lines = [`### ${label} (${file.lineCount} lines)`, ''];
99+
for (const reason of reasons) lines.push(`- ${reason}`);
100+
101+
if (file.brokenPathRefs.length > 0) {
102+
lines.push('', 'Broken path refs:');
103+
for (const ref of file.brokenPathRefs) lines.push(` - \`${ref}\``);
104+
}
105+
if (file.brokenImports.length > 0) {
106+
lines.push('', 'Broken `@imports`:');
107+
for (const ref of file.brokenImports) lines.push(` - \`${ref}\``);
108+
}
109+
if (file.unresolvedCommands.length > 0) {
110+
lines.push('', 'Unresolved pnpm commands (advisory):');
111+
for (const ref of file.unresolvedCommands) lines.push(` - \`${ref}\``);
112+
}
113+
114+
return lines.join('\n');
115+
}
116+
117+
function formatPairFinding(finding) {
118+
const dir = finding.pair.dir === '.' ? '(root)' : finding.pair.dir;
119+
return [
120+
`### \`${dir}\` pair`,
121+
'',
122+
`- ${finding.pair.classification}: ${finding.pair.detail}`,
123+
].join('\n');
124+
}
125+
126+
function buildFindingsBody(findings) {
127+
const lines = [
128+
MARKER,
129+
'## Agent docs audit',
130+
'',
131+
`Found deterministic findings on ${findings.length} changed agent-doc item(s).`,
132+
'',
133+
];
134+
135+
for (const finding of findings) {
136+
lines.push(finding.type === 'pair' ? formatPairFinding(finding) : formatFileFinding(finding));
137+
lines.push('');
138+
}
139+
140+
lines.push('---');
141+
lines.push('Deterministic L1 only: no AI, no Bash, no secrets. Semantic L2/L3 audit runs weekly on `main`. Policy: `agent-docs-policy.md`.');
142+
return lines.join('\n');
143+
}
144+
145+
function buildResolvedBody(changed) {
146+
const files = changed.map((path) => `\`${path}\``).join(', ');
147+
return [
148+
MARKER,
149+
'## Agent docs audit',
150+
'',
151+
`All changed agent-doc files are clean as of \`${SHA.slice(0, 12)}\`.`,
152+
'',
153+
files ? `Checked: ${files}` : 'No changed agent-doc files detected.',
154+
].join('\n');
155+
}
156+
157+
function getExistingCommentId() {
158+
try {
159+
const out = execFileSync('gh', ['api', `/repos/${REPO}/issues/${PR}/comments`, '--paginate'], { encoding: 'utf-8' });
160+
const comments = JSON.parse(out);
161+
const match = comments.find((comment) => typeof comment.body === 'string' && comment.body.startsWith(MARKER));
162+
return match ? match.id : null;
163+
} catch (err) {
164+
console.log(`Could not list existing comments: ${err.message}`);
165+
return null;
166+
}
167+
}
168+
169+
function upsertComment(body) {
170+
const tmpFile = join(tmpdir(), `agent-docs-comment-${process.pid}.json`);
171+
writeFileSync(tmpFile, JSON.stringify({ body }));
172+
const existing = getExistingCommentId();
173+
174+
try {
175+
if (existing) {
176+
execFileSync('gh', ['api', '-X', 'PATCH', `/repos/${REPO}/issues/comments/${String(existing)}`, '--input', tmpFile], { stdio: 'inherit' });
177+
console.log(`Updated comment ${existing}`);
178+
} else {
179+
execFileSync('gh', ['api', '-X', 'POST', `/repos/${REPO}/issues/${PR}/comments`, '--input', tmpFile], { stdio: 'inherit' });
180+
console.log('Created comment');
181+
}
182+
} catch (err) {
183+
const msg = String(err.message || err);
184+
if (/403|Resource not accessible|forbid/i.test(msg)) {
185+
console.log('No write access (fork PR or read-only token). Skipping comment gracefully.');
186+
process.exit(0);
187+
}
188+
throw err;
189+
}
190+
}
191+
192+
const changed = getChangedAgentDocs();
193+
if (changed.length === 0) {
194+
console.log('No agent-doc files changed in this PR. Skipping comment.');
195+
process.exit(0);
196+
}
197+
198+
console.log(`Changed agent-doc files: ${changed.join(', ')}`);
199+
200+
const scan = runL1Scan(REPO_ROOT);
201+
const findings = collectFindings(scan, changed);
202+
const body = findings.length > 0 ? buildFindingsBody(findings) : buildResolvedBody(changed);
203+
204+
if (DRY_RUN) {
205+
console.log('\n--- comment body ---\n');
206+
console.log(body);
207+
process.exit(0);
208+
}
209+
210+
if (findings.length === 0) {
211+
const existing = getExistingCommentId();
212+
if (!existing) {
213+
console.log('No L1 findings and no previous sticky comment. Skipping comment.');
214+
process.exit(0);
215+
}
216+
}
217+
218+
upsertComment(body);

.github/workflows/agent-docs-audit.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@ on:
3535
- 'agent-docs-policy.md'
3636
- '.github/scripts/agent-docs-audit*'
3737
- '.github/scripts/agent-docs-l1*'
38+
- '.github/scripts/agent-docs-pr-comment*'
3839
- '.github/workflows/agent-docs-audit.yml'
3940

4041
permissions:
4142
contents: read
43+
# Needed for the pull_request sticky comment. This matches existing
44+
# PR-comment workflows such as visual-test.yml.
45+
pull-requests: write
4246

4347
concurrency:
4448
group: agent-docs-audit-${{ github.event.pull_request.number || github.ref }}
@@ -120,3 +124,15 @@ jobs:
120124
/tmp/agent-docs-audit-l1.md
121125
if-no-files-found: warn
122126
retention-days: 30
127+
128+
# Diff-scoped sticky PR comment. Pull_request runs are L1-only; this
129+
# surfaces deterministic findings only for agent-doc files touched by the
130+
# PR. No AI, no Bash, no secrets.
131+
- name: Post sticky PR comment with L1 findings
132+
if: github.event_name == 'pull_request'
133+
env:
134+
PR_NUMBER: ${{ github.event.pull_request.number }}
135+
REPO: ${{ github.repository }}
136+
REPO_ROOT: ${{ github.workspace }}
137+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
138+
run: node .github/scripts/agent-docs-pr-comment.mjs

0 commit comments

Comments
 (0)