loopgen 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/cli.js +103 -0
- package/dist/core/audit.js +5 -5
- package/dist/core/governance-report.js +111 -0
- package/dist/core/governance.js +127 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -181,6 +181,32 @@ npm run loopgen -- run test-repair . --mode driven --adapter ollama --ollama-mod
|
|
|
181
181
|
- 需要干净的 git 工作区(`--allow-dirty` 可跳过);`--dry-run` 只预览第一轮提议、不写文件。
|
|
182
182
|
- 诚实说明:**有界 + 强制 + 验证 + 留证 —— 不是沙箱。** 模型仍会提议,loopgen 负责框住、限制、验证、留证。
|
|
183
183
|
|
|
184
|
+
#### 治理 —— 把审计账本变成团队证据(`loopgen audit`)
|
|
185
|
+
|
|
186
|
+
每次 `loopgen run` 都会往本仓库的、带哈希链的 `.loopgen/audit.jsonl` 追加一条。`audit` 命令族把这些账本
|
|
187
|
+
变成团队级、可用于合规的证据,以及一个 CI 闸门 —— local-first、无需服务器:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
npm run loopgen -- audit verify # 校验哈希链是否完好(防篡改)
|
|
191
|
+
npm run loopgen -- audit summary # 单仓库:通过率、按 loop、违规数
|
|
192
|
+
npm run loopgen -- audit aggregate ../repos --html gov.html --report gov.md # 聚合多个仓库/开发者
|
|
193
|
+
npm run loopgen -- audit check --require test-repair --require-no-violations --require-chain # CI 闸门
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`aggregate` 会在给定的文件/目录里找 `audit.jsonl`,合并成一份汇总,并可生成一个**自包含的 HTML 治理看板**
|
|
197
|
+
(直接打开即可,无需服务器)和一份 markdown 报告。`check` 是**合并闸门**:当某个必需 loop 没有通过记录、
|
|
198
|
+
有改动碰了禁止路径、或链断了,就以非 0 退出 —— 接进 CI 即可在「证据缺失/不足」时挡住合并。
|
|
199
|
+
|
|
200
|
+
也有现成的 GitHub Action(会把治理汇总写进 job summary,并在策略违规时让 job 失败):
|
|
201
|
+
|
|
202
|
+
```yaml
|
|
203
|
+
- uses: Nagiici/Loopgen/.github/actions/audit-check@v0.6.0
|
|
204
|
+
with:
|
|
205
|
+
require: test-repair,ci-failure-repair
|
|
206
|
+
require-no-violations: "true"
|
|
207
|
+
require-chain: "true"
|
|
208
|
+
```
|
|
209
|
+
|
|
184
210
|
可用 adapter:
|
|
185
211
|
|
|
186
212
|
- `agents-md`:通用 `AGENTS.md`,可被 Claude Code、Codex、Cursor、Copilot、Gemini CLI、Aider 等读取
|
|
@@ -436,6 +462,34 @@ audit + a proof report with the full iteration history (including every blocked
|
|
|
436
462
|
- Honest scope: **bounded + enforced + verified + proven — not a sandbox.** The model still proposes; loopgen
|
|
437
463
|
bounds, confines, verifies, and proves.
|
|
438
464
|
|
|
465
|
+
#### Governance — turn the ledgers into team evidence (`loopgen audit`)
|
|
466
|
+
|
|
467
|
+
Every `loopgen run` appends to a per-repo, hash-chained `.loopgen/audit.jsonl`. The `audit` commands turn
|
|
468
|
+
those ledgers into team-level, compliance-ready evidence and a CI gate — local-first, no server:
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
npm run loopgen -- audit verify # prove the hash chain is intact (tamper check)
|
|
472
|
+
npm run loopgen -- audit summary # one repo: pass rate, by loop, violations
|
|
473
|
+
npm run loopgen -- audit aggregate ../repos --html gov.html --report gov.md # roll up many repos/devs
|
|
474
|
+
npm run loopgen -- audit check --require test-repair --require-no-violations --require-chain # CI gate
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`aggregate` scans the given files/directories for `audit.jsonl`, merges them into one rollup, and can write
|
|
478
|
+
a self-contained **HTML governance dashboard** (just open it — no server) plus a markdown report. `check` is
|
|
479
|
+
the **merge gate**: it exits non-zero if a required loop has no passing run, a run touched forbidden paths, or
|
|
480
|
+
the chain is broken — wire it into CI to block merges on missing or insufficient proof.
|
|
481
|
+
|
|
482
|
+
There's a ready-made GitHub Action (it renders the governance rollup into the job summary and fails the job
|
|
483
|
+
on policy violations):
|
|
484
|
+
|
|
485
|
+
```yaml
|
|
486
|
+
- uses: Nagiici/Loopgen/.github/actions/audit-check@v0.6.0
|
|
487
|
+
with:
|
|
488
|
+
require: test-repair,ci-failure-repair
|
|
489
|
+
require-no-violations: "true"
|
|
490
|
+
require-chain: "true"
|
|
491
|
+
```
|
|
492
|
+
|
|
439
493
|
Available adapters:
|
|
440
494
|
|
|
441
495
|
- `agents-md` — one `AGENTS.md` read by Claude Code, Codex, Cursor, Copilot, Gemini CLI, Aider, and more
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,10 @@ import { TEMPLATE_DEFINITIONS } from "./core/templates.js";
|
|
|
13
13
|
import { startLoopgenServer } from "./server.js";
|
|
14
14
|
import { DEFAULT_ADAPTER_IDS, parseAdapterIds } from "./core/adapters.js";
|
|
15
15
|
import { runLoop } from "./core/runner.js";
|
|
16
|
+
import { readAuditLog, verifyAuditChain } from "./core/audit.js";
|
|
17
|
+
import { buildSummary, collectAuditFiles, evaluatePolicy } from "./core/governance.js";
|
|
18
|
+
import { renderGovernanceHtml, renderGovernanceMarkdown } from "./core/governance-report.js";
|
|
19
|
+
import { promises as fsp } from "node:fs";
|
|
16
20
|
const PROJECT_MANIFESTS = [
|
|
17
21
|
"package.json",
|
|
18
22
|
"pyproject.toml",
|
|
@@ -191,6 +195,90 @@ program
|
|
|
191
195
|
}
|
|
192
196
|
process.exitCode = result.passed ? 0 : 1;
|
|
193
197
|
});
|
|
198
|
+
const audit = program.command("audit").description("Inspect, aggregate, and gate on loopgen audit logs (governance).");
|
|
199
|
+
audit
|
|
200
|
+
.command("verify")
|
|
201
|
+
.argument("[project]", "project directory", ".")
|
|
202
|
+
.description("Verify the audit hash chain is intact (tamper check).")
|
|
203
|
+
.action(async (project) => {
|
|
204
|
+
const entries = await readAuditLog(path.resolve(project));
|
|
205
|
+
const chain = verifyAuditChain(entries);
|
|
206
|
+
if (chain.valid) {
|
|
207
|
+
console.log(`Audit chain valid (${entries.length} entries).`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
console.log(`Audit chain BROKEN at entry ${chain.brokenAt}.`);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
audit
|
|
215
|
+
.command("summary")
|
|
216
|
+
.argument("[project]", "project directory", ".")
|
|
217
|
+
.option("--json", "print the summary as JSON")
|
|
218
|
+
.description("Summarize one repo's audit log (pass rate, by loop, violations).")
|
|
219
|
+
.action(async (project, options) => {
|
|
220
|
+
const root = path.resolve(project);
|
|
221
|
+
const { summary } = await buildSummary([{ label: ".", filePath: path.join(root, ".loopgen", "audit.jsonl") }]);
|
|
222
|
+
if (options.json)
|
|
223
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
224
|
+
else
|
|
225
|
+
printSummary(summary);
|
|
226
|
+
});
|
|
227
|
+
audit
|
|
228
|
+
.command("aggregate")
|
|
229
|
+
.argument("<paths...>", "audit.jsonl files or directories to scan")
|
|
230
|
+
.option("--json", "print the rollup as JSON")
|
|
231
|
+
.option("--report <file>", "write a markdown governance report")
|
|
232
|
+
.option("--html <file>", "write a self-contained HTML governance dashboard")
|
|
233
|
+
.description("Aggregate many devs'/repos' audit logs into one team governance rollup.")
|
|
234
|
+
.action(async (paths, options) => {
|
|
235
|
+
const files = await collectAuditFiles(paths);
|
|
236
|
+
if (!files.length) {
|
|
237
|
+
console.error("No audit.jsonl files found in the given paths.");
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const { summary } = await buildSummary(files.map((file) => ({ label: relLabel(file), filePath: file })));
|
|
242
|
+
if (options.report) {
|
|
243
|
+
await fsp.writeFile(options.report, renderGovernanceMarkdown(summary), "utf8");
|
|
244
|
+
console.log(`Wrote ${options.report}`);
|
|
245
|
+
}
|
|
246
|
+
if (options.html) {
|
|
247
|
+
await fsp.writeFile(options.html, renderGovernanceHtml(summary), "utf8");
|
|
248
|
+
console.log(`Wrote ${options.html}`);
|
|
249
|
+
}
|
|
250
|
+
if (options.json)
|
|
251
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
252
|
+
else
|
|
253
|
+
printSummary(summary);
|
|
254
|
+
});
|
|
255
|
+
audit
|
|
256
|
+
.command("check")
|
|
257
|
+
.argument("[project]", "project directory", ".")
|
|
258
|
+
.option("--require <loops>", "comma-separated loop ids that must have a passing run")
|
|
259
|
+
.option("--since <iso>", "only consider runs at/after this ISO timestamp")
|
|
260
|
+
.option("--require-no-violations", "fail if any run modified forbidden paths")
|
|
261
|
+
.option("--require-chain", "fail if the audit chain is broken")
|
|
262
|
+
.description("Gate CI/merge on the audit log; exits 1 if the policy is not satisfied.")
|
|
263
|
+
.action(async (project, options) => {
|
|
264
|
+
const entries = await readAuditLog(path.resolve(project));
|
|
265
|
+
const policy = {
|
|
266
|
+
requireLoops: options.require ? options.require.split(",").map((item) => item.trim()).filter(Boolean) : undefined,
|
|
267
|
+
since: options.since,
|
|
268
|
+
requireNoViolations: options.requireNoViolations,
|
|
269
|
+
requireChainValid: options.requireChain
|
|
270
|
+
};
|
|
271
|
+
const result = evaluatePolicy(entries, policy);
|
|
272
|
+
if (result.ok) {
|
|
273
|
+
console.log(`Policy satisfied (${result.checked} run(s) checked).`);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
console.log("Policy FAILED:");
|
|
277
|
+
for (const failure of result.failures)
|
|
278
|
+
console.log(` - ${failure}`);
|
|
279
|
+
process.exitCode = 1;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
194
282
|
program.parseAsync(process.argv).catch((error) => {
|
|
195
283
|
console.error(error instanceof Error ? error.message : String(error));
|
|
196
284
|
process.exitCode = 1;
|
|
@@ -265,6 +353,21 @@ function printRunResult(result) {
|
|
|
265
353
|
if (!result.dryRun)
|
|
266
354
|
console.log(` audit: .loopgen/audit.jsonl (${result.entry.hash.slice(0, 12)}…)`);
|
|
267
355
|
}
|
|
356
|
+
function printSummary(summary) {
|
|
357
|
+
console.log(`Runs: ${summary.total} (${summary.passed} pass / ${summary.failed} fail) — ${Math.round(summary.passRate * 100)}%`);
|
|
358
|
+
console.log(`Modes: referee ${summary.byMode.referee}, driven ${summary.byMode.driven}`);
|
|
359
|
+
console.log(`Chain: ${summary.chain.valid ? "valid" : `BROKEN at ${summary.chain.brokenAt}`}`);
|
|
360
|
+
console.log(`Forbidden violations: ${summary.forbiddenViolationRuns} run(s); blocked attempts (prevented): ${summary.blockedAttempts}`);
|
|
361
|
+
for (const [loop, stat] of Object.entries(summary.byLoop).sort((a, b) => b[1].total - a[1].total)) {
|
|
362
|
+
console.log(` ${loop}: ${stat.passed}/${stat.total} passed`);
|
|
363
|
+
}
|
|
364
|
+
if (summary.sources.length > 1) {
|
|
365
|
+
console.log(`Sources: ${summary.sources.length} (${summary.sources.filter((source) => source.chainValid).length} with a valid chain)`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function relLabel(file) {
|
|
369
|
+
return path.relative(process.cwd(), file) || file;
|
|
370
|
+
}
|
|
268
371
|
function formatCommands(commands) {
|
|
269
372
|
const entries = Object.entries(commands).filter(([, command]) => command);
|
|
270
373
|
return entries.length ? entries.map(([name, command]) => `${name}=${command}`).join(", ") : "none inferred";
|
package/dist/core/audit.js
CHANGED
|
@@ -20,11 +20,8 @@ function canonicalize(value) {
|
|
|
20
20
|
export function hashEntry(input, prevHash) {
|
|
21
21
|
return createHash("sha256").update(canonicalize({ ...input, prevHash })).digest("hex");
|
|
22
22
|
}
|
|
23
|
-
async function
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
export async function readAuditLog(projectRoot) {
|
|
27
|
-
const raw = await readRaw(projectRoot);
|
|
23
|
+
export async function readAuditFile(filePath) {
|
|
24
|
+
const raw = await fs.readFile(filePath, "utf8").catch(() => undefined);
|
|
28
25
|
if (!raw)
|
|
29
26
|
return [];
|
|
30
27
|
return raw
|
|
@@ -33,6 +30,9 @@ export async function readAuditLog(projectRoot) {
|
|
|
33
30
|
.filter(Boolean)
|
|
34
31
|
.map((line) => JSON.parse(line));
|
|
35
32
|
}
|
|
33
|
+
export async function readAuditLog(projectRoot) {
|
|
34
|
+
return readAuditFile(path.join(projectRoot, AUDIT_FILE));
|
|
35
|
+
}
|
|
36
36
|
export async function appendAuditEntry(projectRoot, input) {
|
|
37
37
|
const existing = await readAuditLog(projectRoot);
|
|
38
38
|
const prevHash = existing.length ? existing[existing.length - 1].hash : null;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const pct = (value) => `${Math.round(value * 100)}%`;
|
|
2
|
+
export function renderGovernanceMarkdown(summary) {
|
|
3
|
+
const loopRows = Object.entries(summary.byLoop)
|
|
4
|
+
.sort((a, b) => b[1].total - a[1].total)
|
|
5
|
+
.map(([loop, stat]) => `| \`${loop}\` | ${stat.total} | ${stat.passed} | ${pct(rate(stat))} |`)
|
|
6
|
+
.join("\n");
|
|
7
|
+
const actorRows = Object.entries(summary.byActor)
|
|
8
|
+
.sort((a, b) => b[1].total - a[1].total)
|
|
9
|
+
.map(([actor, stat]) => `| ${actor} | ${stat.total} | ${stat.passed} | ${pct(rate(stat))} |`)
|
|
10
|
+
.join("\n");
|
|
11
|
+
const sourceRows = summary.sources
|
|
12
|
+
.map((source) => `| ${source.label} | ${source.entries} | ${source.chainValid ? "valid" : "BROKEN"} |`)
|
|
13
|
+
.join("\n");
|
|
14
|
+
return `# loopgen governance report
|
|
15
|
+
|
|
16
|
+
- Runs: **${summary.total}** (${summary.passed} passed / ${summary.failed} failed) — pass rate **${pct(summary.passRate)}**
|
|
17
|
+
- Window: ${summary.firstAt ?? "—"} → ${summary.lastAt ?? "—"}
|
|
18
|
+
- Modes: referee ${summary.byMode.referee} · driven ${summary.byMode.driven}
|
|
19
|
+
- Chain integrity: ${summary.chain.valid ? "**valid**" : `**BROKEN** (entry ${summary.chain.brokenAt})`}
|
|
20
|
+
- Forbidden-path violations: **${summary.forbiddenViolationRuns}** run(s)
|
|
21
|
+
- Blocked attempts (driven, prevented at apply time): **${summary.blockedAttempts}**
|
|
22
|
+
|
|
23
|
+
## By loop
|
|
24
|
+
|
|
25
|
+
| loop | runs | passed | pass rate |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
${loopRows || "| — | 0 | 0 | 0% |"}
|
|
28
|
+
|
|
29
|
+
## By actor
|
|
30
|
+
|
|
31
|
+
| actor | runs | passed | pass rate |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
${actorRows || "| — | 0 | 0 | 0% |"}
|
|
34
|
+
|
|
35
|
+
## Sources
|
|
36
|
+
|
|
37
|
+
| source | entries | chain |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
${sourceRows || "| — | 0 | — |"}
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
export function renderGovernanceHtml(summary) {
|
|
43
|
+
const loopRows = Object.entries(summary.byLoop)
|
|
44
|
+
.sort((a, b) => b[1].total - a[1].total)
|
|
45
|
+
.map(([loop, stat]) => row(loop, stat.total, stat.passed))
|
|
46
|
+
.join("");
|
|
47
|
+
const actorRows = Object.entries(summary.byActor)
|
|
48
|
+
.sort((a, b) => b[1].total - a[1].total)
|
|
49
|
+
.map(([actor, stat]) => row(actor, stat.total, stat.passed))
|
|
50
|
+
.join("");
|
|
51
|
+
const sourceRows = summary.sources
|
|
52
|
+
.map((source) => `<tr><td>${esc(source.label)}</td><td>${source.entries}</td><td>${source.chainValid ? "valid" : '<b class="bad">BROKEN</b>'}</td></tr>`)
|
|
53
|
+
.join("");
|
|
54
|
+
return `<!doctype html>
|
|
55
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
56
|
+
<title>loopgen governance</title>
|
|
57
|
+
<style>
|
|
58
|
+
:root{--paper:#f4efe4;--ink:#1a1714;--muted:#7a7064;--line:#d8cfbd;--rule:#b9ad96;--ok:#4a6b3f;--bad:#c2362b;--surface:#fbf7ee}
|
|
59
|
+
@media(prefers-color-scheme:dark){:root{--paper:#17150f;--ink:#ece4d4;--muted:#948b78;--line:#322d22;--rule:#4a4334;--ok:#8fb079;--bad:#e0524a;--surface:#221e16}}
|
|
60
|
+
*{box-sizing:border-box}
|
|
61
|
+
body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,sans-serif}
|
|
62
|
+
.wrap{max-width:920px;margin:0 auto;padding:40px 24px}
|
|
63
|
+
h1{font:500 30px/1.1 Georgia,"Times New Roman",serif;margin:0}
|
|
64
|
+
.sub{color:var(--muted);font-size:12px;letter-spacing:.16em;text-transform:uppercase;margin-top:6px}
|
|
65
|
+
hr{border:0;border-top:2px solid var(--ink);margin:16px 0 28px}
|
|
66
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0;border:1px solid var(--line);margin-bottom:28px}
|
|
67
|
+
.card{padding:16px 18px;border-right:1px solid var(--line)}
|
|
68
|
+
.card:last-child{border-right:0}
|
|
69
|
+
.card .k{color:var(--muted);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
|
|
70
|
+
.card .v{font:500 26px/1.1 Georgia,serif;margin-top:6px}
|
|
71
|
+
.bar{height:6px;background:var(--line);margin-top:10px}
|
|
72
|
+
.bar>span{display:block;height:6px;background:var(--ok)}
|
|
73
|
+
h2{font:500 18px/1.1 Georgia,serif;margin:28px 0 10px;padding-bottom:8px;border-bottom:1px solid var(--line)}
|
|
74
|
+
table{width:100%;border-collapse:collapse;font-size:14px}
|
|
75
|
+
th{text-align:left;color:var(--muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500;padding:8px 6px;border-bottom:1px solid var(--rule)}
|
|
76
|
+
td{padding:9px 6px;border-bottom:1px solid var(--line)}
|
|
77
|
+
code{font-family:"IBM Plex Mono",monospace;font-size:13px}
|
|
78
|
+
.good{color:var(--ok)}.bad{color:var(--bad)}
|
|
79
|
+
.note{color:var(--muted);font-size:12px;margin-top:28px;border-top:1px solid var(--line);padding-top:14px}
|
|
80
|
+
</style></head>
|
|
81
|
+
<body><div class="wrap">
|
|
82
|
+
<h1>loopgen governance</h1>
|
|
83
|
+
<div class="sub">audit rollup · ${esc(summary.firstAt ?? "—")} → ${esc(summary.lastAt ?? "—")}</div>
|
|
84
|
+
<hr>
|
|
85
|
+
<div class="cards">
|
|
86
|
+
<div class="card"><div class="k">Runs</div><div class="v">${summary.total}</div></div>
|
|
87
|
+
<div class="card"><div class="k">Pass rate</div><div class="v">${pct(summary.passRate)}</div><div class="bar"><span style="width:${pct(summary.passRate)}"></span></div></div>
|
|
88
|
+
<div class="card"><div class="k">Forbidden violations</div><div class="v ${summary.forbiddenViolationRuns ? "bad" : "good"}">${summary.forbiddenViolationRuns}</div></div>
|
|
89
|
+
<div class="card"><div class="k">Blocked (prevented)</div><div class="v">${summary.blockedAttempts}</div></div>
|
|
90
|
+
<div class="card"><div class="k">Chain</div><div class="v ${summary.chain.valid ? "good" : "bad"}">${summary.chain.valid ? "valid" : "BROKEN"}</div></div>
|
|
91
|
+
</div>
|
|
92
|
+
<h2>By loop</h2>
|
|
93
|
+
<table><thead><tr><th>Loop</th><th>Runs</th><th>Passed</th><th>Pass rate</th></tr></thead><tbody>${loopRows || '<tr><td colspan="4">No runs.</td></tr>'}</tbody></table>
|
|
94
|
+
<h2>By actor</h2>
|
|
95
|
+
<table><thead><tr><th>Actor</th><th>Runs</th><th>Passed</th><th>Pass rate</th></tr></thead><tbody>${actorRows || '<tr><td colspan="4">No runs.</td></tr>'}</tbody></table>
|
|
96
|
+
<h2>Sources</h2>
|
|
97
|
+
<table><thead><tr><th>Source</th><th>Entries</th><th>Chain</th></tr></thead><tbody>${sourceRows || '<tr><td colspan="3">No sources.</td></tr>'}</tbody></table>
|
|
98
|
+
<div class="note">Generated by loopgen from hash-chained <code>.loopgen/audit.jsonl</code> ledgers. Local-first — open this file directly; no server. A broken chain means a ledger was edited in place.</div>
|
|
99
|
+
</div></body></html>
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
function row(label, total, passed) {
|
|
103
|
+
const r = rate({ total, passed });
|
|
104
|
+
return `<tr><td><code>${esc(label)}</code></td><td>${total}</td><td>${passed}</td><td><span class="${r === 1 ? "good" : r < 0.5 ? "bad" : ""}">${pct(r)}</span></td></tr>`;
|
|
105
|
+
}
|
|
106
|
+
function rate(stat) {
|
|
107
|
+
return stat.total ? stat.passed / stat.total : 0;
|
|
108
|
+
}
|
|
109
|
+
function esc(value) {
|
|
110
|
+
return value.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[char] ?? char);
|
|
111
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readAuditFile, verifyAuditChain } from "./audit.js";
|
|
4
|
+
// Resolve CLI inputs (files or directories) into a deduped list of audit.jsonl paths.
|
|
5
|
+
export async function collectAuditFiles(inputs) {
|
|
6
|
+
const found = [];
|
|
7
|
+
for (const input of inputs) {
|
|
8
|
+
const stat = await fs.stat(input).catch(() => undefined);
|
|
9
|
+
if (!stat)
|
|
10
|
+
continue;
|
|
11
|
+
if (stat.isFile()) {
|
|
12
|
+
found.push(path.resolve(input));
|
|
13
|
+
}
|
|
14
|
+
else if (stat.isDirectory()) {
|
|
15
|
+
await walk(path.resolve(input), found, 0);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return [...new Set(found)];
|
|
19
|
+
}
|
|
20
|
+
async function walk(dir, found, depth) {
|
|
21
|
+
if (depth > 6)
|
|
22
|
+
return;
|
|
23
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (entry.name === "node_modules" || entry.name === ".git")
|
|
26
|
+
continue;
|
|
27
|
+
const full = path.join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
await walk(full, found, depth + 1);
|
|
30
|
+
}
|
|
31
|
+
else if (entry.name === "audit.jsonl") {
|
|
32
|
+
found.push(full);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function buildSummary(sources) {
|
|
37
|
+
const all = [];
|
|
38
|
+
const sourceMeta = [];
|
|
39
|
+
let allChainsValid = true;
|
|
40
|
+
let brokenAt;
|
|
41
|
+
for (const source of sources) {
|
|
42
|
+
const entries = await readAuditFile(source.filePath);
|
|
43
|
+
const chain = verifyAuditChain(entries);
|
|
44
|
+
if (!chain.valid) {
|
|
45
|
+
allChainsValid = false;
|
|
46
|
+
if (brokenAt === undefined)
|
|
47
|
+
brokenAt = chain.brokenAt;
|
|
48
|
+
}
|
|
49
|
+
sourceMeta.push({ label: source.label, entries: entries.length, chainValid: chain.valid });
|
|
50
|
+
all.push(...entries);
|
|
51
|
+
}
|
|
52
|
+
return { summary: summarizeEntries(all, sourceMeta, { valid: allChainsValid, brokenAt }), entries: all };
|
|
53
|
+
}
|
|
54
|
+
function summarizeEntries(entries, sources, chain) {
|
|
55
|
+
const byLoop = {};
|
|
56
|
+
const byActor = {};
|
|
57
|
+
const byMode = { referee: 0, driven: 0 };
|
|
58
|
+
let passed = 0;
|
|
59
|
+
let blockedAttempts = 0;
|
|
60
|
+
let forbiddenViolationRuns = 0;
|
|
61
|
+
let firstAt;
|
|
62
|
+
let lastAt;
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (entry.passed)
|
|
65
|
+
passed += 1;
|
|
66
|
+
if (entry.mode === "driven")
|
|
67
|
+
byMode.driven += 1;
|
|
68
|
+
else
|
|
69
|
+
byMode.referee += 1;
|
|
70
|
+
byLoop[entry.loopId] ??= { total: 0, passed: 0 };
|
|
71
|
+
byLoop[entry.loopId].total += 1;
|
|
72
|
+
if (entry.passed)
|
|
73
|
+
byLoop[entry.loopId].passed += 1;
|
|
74
|
+
const actor = entry.actor.user ?? "unknown";
|
|
75
|
+
byActor[actor] ??= { total: 0, passed: 0 };
|
|
76
|
+
byActor[actor].total += 1;
|
|
77
|
+
if (entry.passed)
|
|
78
|
+
byActor[actor].passed += 1;
|
|
79
|
+
if (!entry.forbidden.ok)
|
|
80
|
+
forbiddenViolationRuns += 1;
|
|
81
|
+
if (entry.driven) {
|
|
82
|
+
for (const attempt of entry.driven.attempts)
|
|
83
|
+
blockedAttempts += attempt.blocked.length;
|
|
84
|
+
}
|
|
85
|
+
if (!firstAt || entry.timestamp < firstAt)
|
|
86
|
+
firstAt = entry.timestamp;
|
|
87
|
+
if (!lastAt || entry.timestamp > lastAt)
|
|
88
|
+
lastAt = entry.timestamp;
|
|
89
|
+
}
|
|
90
|
+
const total = entries.length;
|
|
91
|
+
return {
|
|
92
|
+
total,
|
|
93
|
+
passed,
|
|
94
|
+
failed: total - passed,
|
|
95
|
+
passRate: total ? passed / total : 0,
|
|
96
|
+
byLoop,
|
|
97
|
+
byMode,
|
|
98
|
+
byActor,
|
|
99
|
+
blockedAttempts,
|
|
100
|
+
forbiddenViolationRuns,
|
|
101
|
+
firstAt,
|
|
102
|
+
lastAt,
|
|
103
|
+
chain,
|
|
104
|
+
sources
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// Evaluate a team policy against a repo's audit entries — the CI / merge gate.
|
|
108
|
+
export function evaluatePolicy(entries, policy) {
|
|
109
|
+
const failures = [];
|
|
110
|
+
const scoped = policy.since ? entries.filter((entry) => entry.timestamp >= policy.since) : entries;
|
|
111
|
+
if (policy.requireChainValid) {
|
|
112
|
+
const chain = verifyAuditChain(entries);
|
|
113
|
+
if (!chain.valid)
|
|
114
|
+
failures.push(`audit chain is broken at entry ${chain.brokenAt}`);
|
|
115
|
+
}
|
|
116
|
+
if (policy.requireNoViolations) {
|
|
117
|
+
const violations = scoped.filter((entry) => !entry.forbidden.ok);
|
|
118
|
+
if (violations.length)
|
|
119
|
+
failures.push(`${violations.length} run(s) modified forbidden paths`);
|
|
120
|
+
}
|
|
121
|
+
for (const loopId of policy.requireLoops ?? []) {
|
|
122
|
+
const ok = scoped.some((entry) => entry.loopId === loopId && entry.passed);
|
|
123
|
+
if (!ok)
|
|
124
|
+
failures.push(`no passing run found for required loop "${loopId}"`);
|
|
125
|
+
}
|
|
126
|
+
return { ok: failures.length === 0, failures, checked: scoped.length };
|
|
127
|
+
}
|
package/package.json
CHANGED