@andares/pi-toolkits 0.9.8 → 0.11.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 +117 -5
- package/README.zh-CN.md +102 -5
- package/package.json +8 -3
- package/src/features/init/analyze.ts +162 -0
- package/src/features/init/apply.test.ts +264 -0
- package/src/features/init/apply.ts +693 -0
- package/src/features/init/audit.test.ts +292 -0
- package/src/features/init/audit.ts +586 -0
- package/src/features/init/coverage.test.ts +93 -0
- package/src/features/init/coverage.ts +163 -0
- package/src/features/init/detect.test.ts +237 -0
- package/src/features/init/detect.ts +808 -0
- package/src/features/init/dispatch.ts +135 -0
- package/src/features/init/index.test.ts +331 -0
- package/src/features/init/index.ts +569 -0
- package/src/features/init/plan.test.ts +261 -0
- package/src/features/init/plan.ts +867 -0
- package/src/features/init/render.ts +131 -0
- package/src/features/init/spec.ts +429 -0
- package/src/features/init/templates/agents-skeleton.md +44 -0
- package/src/features/init/templates/memory-readme.md +99 -0
- package/src/features/init/templates/signpost.md +15 -0
- package/src/features/init/templates/state-gitignore +6 -0
- package/src/features/init/templates.test.ts +58 -0
- package/src/features/init/templates.ts +79 -0
- package/src/features/init/test-helpers.ts +89 -0
- package/src/features/init/tool.ts +72 -0
- package/src/features/init/types.ts +284 -0
- package/src/features/init/ui.ts +160 -0
- package/src/features/permission/audit.test.ts +159 -0
- package/src/features/permission/audit.ts +145 -0
- package/src/features/permission/index.test.ts +138 -3
- package/src/features/permission/index.ts +108 -12
- package/src/features/permission/policy.ts +39 -3
- package/src/index.ts +9 -2
- package/src/lib/approval-log.test.ts +289 -0
- package/src/lib/approval-log.ts +724 -0
- package/src/lib/gates.audit.test.ts +343 -0
- package/src/lib/gates.ts +291 -10
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init feature — the audit.
|
|
3
|
+
*
|
|
4
|
+
* The audit is what keeps the framework from decaying the way hand-maintained
|
|
5
|
+
* memory does: budgets drift, sections get duplicated, appendices stop being
|
|
6
|
+
* last, `todos/` outlives the work it described. Every finding has a stable code
|
|
7
|
+
* so a report can be compared across runs and the model can fix by code.
|
|
8
|
+
*
|
|
9
|
+
* It is read-only and never "fixes" anything itself.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
13
|
+
import { join, posix } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
AUDIT_CODES,
|
|
16
|
+
BUDGETS,
|
|
17
|
+
CONTRACT_FILE,
|
|
18
|
+
FRAMEWORK_VERSION,
|
|
19
|
+
IMPLEMENTATIONS_DIR,
|
|
20
|
+
MEMORY_DIR,
|
|
21
|
+
OVERRIDE_FILE,
|
|
22
|
+
PLAN_FILE,
|
|
23
|
+
REPORTS_DIR,
|
|
24
|
+
SPECS_DIR,
|
|
25
|
+
} from "./spec.js";
|
|
26
|
+
import {
|
|
27
|
+
duplicateHeadings,
|
|
28
|
+
extractFrameworkPaths,
|
|
29
|
+
largestSections,
|
|
30
|
+
sectionsAfterAppendix,
|
|
31
|
+
} from "./analyze.js";
|
|
32
|
+
import { parseCoverage } from "./coverage.js";
|
|
33
|
+
import { staleRoots } from "./plan.js";
|
|
34
|
+
import type { DocInfo, Facts, Finding, Severity } from "./types.js";
|
|
35
|
+
|
|
36
|
+
export interface AuditOptions {
|
|
37
|
+
/** Coverage report to read; defaults to the newest one in reports/. */
|
|
38
|
+
readonly coveragePath?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function finding(
|
|
42
|
+
code: string,
|
|
43
|
+
severity: Severity,
|
|
44
|
+
message: string,
|
|
45
|
+
path?: string,
|
|
46
|
+
line?: number,
|
|
47
|
+
): Finding {
|
|
48
|
+
return { code, severity, message, path, line };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function git(root: string, args: string[]): string | undefined {
|
|
52
|
+
try {
|
|
53
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* -------------------------------------------------------------------------- */
|
|
63
|
+
/* Coverage report lookup */
|
|
64
|
+
/* -------------------------------------------------------------------------- */
|
|
65
|
+
|
|
66
|
+
export function latestCoverageReport(root: string): string | undefined {
|
|
67
|
+
let names: string[];
|
|
68
|
+
try {
|
|
69
|
+
names = readdirSync(join(root, REPORTS_DIR)).sort();
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const matching = names.filter((name) => /^coverage-.*\.md$/.test(name));
|
|
74
|
+
if (matching.length === 0) return undefined;
|
|
75
|
+
return posix.join(REPORTS_DIR, matching[matching.length - 1]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* -------------------------------------------------------------------------- */
|
|
79
|
+
/* Checks */
|
|
80
|
+
/* -------------------------------------------------------------------------- */
|
|
81
|
+
|
|
82
|
+
function checkStructure(facts: Facts): Finding[] {
|
|
83
|
+
const out: Finding[] = [];
|
|
84
|
+
if (
|
|
85
|
+
!facts.state &&
|
|
86
|
+
!facts.dirs[MEMORY_DIR] &&
|
|
87
|
+
!facts.deprecated.memory &&
|
|
88
|
+
facts.memory.length === 0
|
|
89
|
+
) {
|
|
90
|
+
out.push(
|
|
91
|
+
finding(
|
|
92
|
+
AUDIT_CODES.notInstalled,
|
|
93
|
+
"info",
|
|
94
|
+
"framework not installed (no .agents/memory, no state): run /ask-init",
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (facts.context.override) {
|
|
99
|
+
out.push(
|
|
100
|
+
finding(
|
|
101
|
+
AUDIT_CODES.overrideContext,
|
|
102
|
+
"warning",
|
|
103
|
+
`${OVERRIDE_FILE} replaces ${"AGENTS.md"} in this directory: pi never loads the framework entry`,
|
|
104
|
+
OVERRIDE_FILE,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const memoryExists = facts.memory.length > 0 || facts.dirs[MEMORY_DIR];
|
|
109
|
+
if (memoryExists && !existsSync(join(facts.root, CONTRACT_FILE))) {
|
|
110
|
+
out.push(
|
|
111
|
+
finding(
|
|
112
|
+
AUDIT_CODES.contractMissing,
|
|
113
|
+
"error",
|
|
114
|
+
`${CONTRACT_FILE} is missing: nothing states the write rules or budgets`,
|
|
115
|
+
CONTRACT_FILE,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (facts.state && facts.state.version !== FRAMEWORK_VERSION) {
|
|
120
|
+
out.push(
|
|
121
|
+
finding(
|
|
122
|
+
AUDIT_CODES.stateVersion,
|
|
123
|
+
facts.state.version > FRAMEWORK_VERSION ? "error" : "warning",
|
|
124
|
+
`state says framework v${facts.state.version}, this tool implements v${FRAMEWORK_VERSION}`,
|
|
125
|
+
".agents/framework/state.toml",
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (!facts.context.agents && facts.memory.length > 0) {
|
|
130
|
+
out.push(
|
|
131
|
+
finding(
|
|
132
|
+
AUDIT_CODES.indexMismatch,
|
|
133
|
+
"error",
|
|
134
|
+
"memory exists but AGENTS.md is missing: the memory cannot be reached from the entry point",
|
|
135
|
+
"AGENTS.md",
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkIndex(facts: Facts): Finding[] {
|
|
143
|
+
const agents = facts.context.agents;
|
|
144
|
+
if (!agents) return [];
|
|
145
|
+
const out: Finding[] = [];
|
|
146
|
+
const missing = facts.memory.filter((doc) => {
|
|
147
|
+
const name = doc.rel.replace(`${MEMORY_DIR}/`, "");
|
|
148
|
+
return !agents.text.includes(doc.rel) && !agents.text.includes(name);
|
|
149
|
+
});
|
|
150
|
+
if (!agents.text.includes(PLAN_FILE) && !agents.text.includes(SPECS_DIR)) {
|
|
151
|
+
out.push(
|
|
152
|
+
finding(
|
|
153
|
+
AUDIT_CODES.indexMismatch,
|
|
154
|
+
"error",
|
|
155
|
+
`AGENTS.md has no plan/memory index (neither \`${PLAN_FILE}\` nor \`${SPECS_DIR}/\` is mentioned)`,
|
|
156
|
+
"AGENTS.md",
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (missing.length > 0) {
|
|
161
|
+
out.push(
|
|
162
|
+
finding(
|
|
163
|
+
AUDIT_CODES.indexMismatch,
|
|
164
|
+
"warning",
|
|
165
|
+
`memory files not referenced from AGENTS.md: ${missing
|
|
166
|
+
.map((doc) => doc.rel)
|
|
167
|
+
.join(", ")}`,
|
|
168
|
+
"AGENTS.md",
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function checkLinks(facts: Facts): Finding[] {
|
|
176
|
+
const docs: DocInfo[] = [
|
|
177
|
+
...(facts.context.agents ? [facts.context.agents] : []),
|
|
178
|
+
...facts.memory,
|
|
179
|
+
...facts.legacyMemory,
|
|
180
|
+
...facts.specs,
|
|
181
|
+
];
|
|
182
|
+
const out: Finding[] = [];
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
for (const doc of docs) {
|
|
185
|
+
for (const rel of extractFrameworkPaths(doc.text)) {
|
|
186
|
+
if (seen.has(rel)) continue;
|
|
187
|
+
seen.add(rel);
|
|
188
|
+
const target = rel.replace(/^memory\//, `${MEMORY_DIR}/`);
|
|
189
|
+
if (existsSync(join(facts.root, target))) continue;
|
|
190
|
+
out.push(
|
|
191
|
+
finding(
|
|
192
|
+
AUDIT_CODES.brokenLink,
|
|
193
|
+
"warning",
|
|
194
|
+
`reference points at a missing file: ${rel}`,
|
|
195
|
+
doc.rel,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function checkRefs(facts: Facts): Finding[] {
|
|
204
|
+
if (facts.refs.hits === 0) return [];
|
|
205
|
+
const areas = Object.entries(facts.refs.byArea)
|
|
206
|
+
.sort((a, b) => b[1].hits - a[1].hits)
|
|
207
|
+
.slice(0, 6)
|
|
208
|
+
.map(([area, bucket]) => `${area} (${bucket.files} files / ${bucket.hits})`);
|
|
209
|
+
const example = facts.refs.examples[0];
|
|
210
|
+
return [
|
|
211
|
+
finding(
|
|
212
|
+
AUDIT_CODES.pathBareRef,
|
|
213
|
+
"error",
|
|
214
|
+
`${facts.refs.hits} bare path references in ${facts.refs.paths.length} files would break on a move: ${areas.join(", ")}${example ? `; e.g. ${example.path}:${example.line}` : ""}`,
|
|
215
|
+
example?.path,
|
|
216
|
+
example?.line,
|
|
217
|
+
),
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function checkTracked(facts: Facts): Finding[] {
|
|
222
|
+
if (!facts.isGit || facts.memory.length === 0) return [];
|
|
223
|
+
const out: Finding[] = [];
|
|
224
|
+
// `check-ignore -q` exits 0 and prints nothing when the path IS ignored, and
|
|
225
|
+
// exits 1 when it is not; git() maps a non-zero exit to undefined, so an
|
|
226
|
+
// empty string means "ignored".
|
|
227
|
+
const ignored = git(facts.root, ["check-ignore", "-q", `${MEMORY_DIR}/README.md`]);
|
|
228
|
+
if (ignored === "") {
|
|
229
|
+
out.push(
|
|
230
|
+
finding(
|
|
231
|
+
AUDIT_CODES.stateUntracked,
|
|
232
|
+
"error",
|
|
233
|
+
`${MEMORY_DIR}/ is gitignored: memory would never be committed`,
|
|
234
|
+
MEMORY_DIR,
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
const tracked = git(facts.root, ["ls-files", "--", MEMORY_DIR]);
|
|
240
|
+
if (tracked !== undefined && tracked.trim().length === 0) {
|
|
241
|
+
// Right after a fresh install this is the normal state, so it must not be
|
|
242
|
+
// an E-* finding: the work order asks the model to fix every error, and
|
|
243
|
+
// "commit the project" is not a fix the model may choose on its own.
|
|
244
|
+
out.push(
|
|
245
|
+
finding(
|
|
246
|
+
AUDIT_CODES.stateUncommitted,
|
|
247
|
+
"warning",
|
|
248
|
+
`${MEMORY_DIR}/ has no tracked files yet: commit them (this is expected immediately after an install)`,
|
|
249
|
+
MEMORY_DIR,
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function checkRoot(facts: Facts): Finding[] {
|
|
257
|
+
const out: Finding[] = [];
|
|
258
|
+
const legacy: string[] = [];
|
|
259
|
+
if (facts.deprecated.memory) legacy.push("memory/");
|
|
260
|
+
if (facts.deprecated.implementations) legacy.push("implementations/");
|
|
261
|
+
if (facts.deprecated.prompts) legacy.push("prompts/");
|
|
262
|
+
if (legacy.length > 0) {
|
|
263
|
+
out.push(
|
|
264
|
+
finding(
|
|
265
|
+
AUDIT_CODES.rootAgentDir,
|
|
266
|
+
"warning",
|
|
267
|
+
`agent-only directories at the repository root (move under .agents/): ${legacy.join(", ")}`,
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (facts.retiredTodos) {
|
|
272
|
+
out.push(
|
|
273
|
+
finding(
|
|
274
|
+
AUDIT_CODES.todosRemain,
|
|
275
|
+
"warning",
|
|
276
|
+
"todos/ is retired: requirement wording belongs in specs/<unit>.md",
|
|
277
|
+
"todos/",
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (facts.rootEmptyFiles.length > 0) {
|
|
282
|
+
out.push(
|
|
283
|
+
finding(
|
|
284
|
+
AUDIT_CODES.rootEmptyFile,
|
|
285
|
+
"warning",
|
|
286
|
+
`zero-byte files at the root: ${facts.rootEmptyFiles.slice(0, 8).join(", ")}${
|
|
287
|
+
facts.rootEmptyFiles.length > 8 ? " …" : ""
|
|
288
|
+
}`,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
for (const root of staleRoots(facts)) {
|
|
293
|
+
out.push(
|
|
294
|
+
finding(
|
|
295
|
+
AUDIT_CODES.rootMissing,
|
|
296
|
+
"warning",
|
|
297
|
+
`registered workspace root is missing: ${root.label} (${root.path})`,
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
for (const root of facts.roots.filter((entry) => entry.exists)) {
|
|
302
|
+
try {
|
|
303
|
+
const card = readFileSync(join(root.absolute, "AGENTS.md"), "utf8");
|
|
304
|
+
const lines = card.split("\n").length;
|
|
305
|
+
if (lines > 40 && !card.includes("agent-framework")) {
|
|
306
|
+
out.push(
|
|
307
|
+
finding(
|
|
308
|
+
AUDIT_CODES.rootCardTooBig,
|
|
309
|
+
"warning",
|
|
310
|
+
`${root.label}/AGENTS.md is ${lines} lines: pi-add-dir injects it verbatim into every request — keep a scope card of at most 30 lines`,
|
|
311
|
+
root.path,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// No card at all is the normal case for a code-only root.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (existsSync(join(facts.root, ".agents/skills"))) {
|
|
320
|
+
out.push(
|
|
321
|
+
finding(
|
|
322
|
+
AUDIT_CODES.agentsSkills,
|
|
323
|
+
"info",
|
|
324
|
+
".agents/skills exists: pi treats it as project resources and will require project trust",
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function checkBudgets(facts: Facts): Finding[] {
|
|
332
|
+
const out: Finding[] = [];
|
|
333
|
+
const agents = facts.context.agents;
|
|
334
|
+
if (agents) {
|
|
335
|
+
if (agents.bytes > BUDGETS.agentsMdHardChars) {
|
|
336
|
+
const largest = largestSections(agents, 3)
|
|
337
|
+
.map(({ heading, chars }) => `${heading.text} (${chars})`)
|
|
338
|
+
.join(", ");
|
|
339
|
+
out.push(
|
|
340
|
+
finding(
|
|
341
|
+
AUDIT_CODES.budgetAgents,
|
|
342
|
+
"warning",
|
|
343
|
+
`AGENTS.md is ${agents.bytes} chars (soft ${BUDGETS.agentsMdSoftChars}, hard ${BUDGETS.agentsMdHardChars}); largest sections: ${largest}`,
|
|
344
|
+
"AGENTS.md",
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
} else if (agents.bytes > BUDGETS.agentsMdSoftChars) {
|
|
348
|
+
out.push(
|
|
349
|
+
finding(
|
|
350
|
+
AUDIT_CODES.budgetAgents,
|
|
351
|
+
"info",
|
|
352
|
+
`AGENTS.md is ${agents.bytes} chars, over the soft budget ${BUDGETS.agentsMdSoftChars}`,
|
|
353
|
+
"AGENTS.md",
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const doc of facts.memory) {
|
|
359
|
+
if (doc.bytes <= BUDGETS.memoryFileChars) continue;
|
|
360
|
+
out.push(
|
|
361
|
+
finding(
|
|
362
|
+
AUDIT_CODES.budgetMemory,
|
|
363
|
+
"warning",
|
|
364
|
+
`${doc.rel} is ${doc.bytes} chars (budget ${BUDGETS.memoryFileChars}): split or archive`,
|
|
365
|
+
doc.rel,
|
|
366
|
+
),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
if (facts.implementations.length > BUDGETS.implementationsFiles) {
|
|
370
|
+
out.push(
|
|
371
|
+
finding(
|
|
372
|
+
AUDIT_CODES.budgetMemory,
|
|
373
|
+
"info",
|
|
374
|
+
`${facts.implementations.length} change records: roll up per milestone and archive the originals`,
|
|
375
|
+
IMPLEMENTATIONS_DIR,
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function checkDuplication(facts: Facts): Finding[] {
|
|
383
|
+
const docs = [
|
|
384
|
+
...(facts.context.agents ? [facts.context.agents] : []),
|
|
385
|
+
...facts.memory,
|
|
386
|
+
...facts.legacyMemory,
|
|
387
|
+
];
|
|
388
|
+
const groups = duplicateHeadings(docs).filter(
|
|
389
|
+
(group) => new Set(group.occurrences.map((occurrence) => occurrence.path)).size > 1,
|
|
390
|
+
);
|
|
391
|
+
return groups.slice(0, 6).map((group) =>
|
|
392
|
+
finding(
|
|
393
|
+
AUDIT_CODES.duplicateFact,
|
|
394
|
+
"warning",
|
|
395
|
+
`same section in several files: "${group.occurrences[0].heading}" → ${group.occurrences
|
|
396
|
+
.map((occurrence) => `${occurrence.path}:${occurrence.line}`)
|
|
397
|
+
.join(", ")}`,
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function checkOrder(facts: Facts): Finding[] {
|
|
403
|
+
const docs = [
|
|
404
|
+
...(facts.context.agents ? [facts.context.agents] : []),
|
|
405
|
+
...facts.memory,
|
|
406
|
+
...facts.legacyMemory,
|
|
407
|
+
];
|
|
408
|
+
const out: Finding[] = [];
|
|
409
|
+
for (const doc of docs) {
|
|
410
|
+
const after = sectionsAfterAppendix(doc);
|
|
411
|
+
if (after.length === 0) continue;
|
|
412
|
+
out.push(
|
|
413
|
+
finding(
|
|
414
|
+
AUDIT_CODES.appendixOrder,
|
|
415
|
+
"warning",
|
|
416
|
+
`sections appended after the appendix: ${after.map((heading) => heading.text).join(", ")}`,
|
|
417
|
+
doc.rel,
|
|
418
|
+
after[0].line,
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function checkMeta(facts: Facts): Finding[] {
|
|
426
|
+
const out: Finding[] = [];
|
|
427
|
+
const future = facts.memory.find((doc) => doc.rel.endsWith("/FUTURE.md"));
|
|
428
|
+
if (future && !/触发|trigger/i.test(future.text)) {
|
|
429
|
+
out.push(
|
|
430
|
+
finding(
|
|
431
|
+
AUDIT_CODES.futureNoTrigger,
|
|
432
|
+
"warning",
|
|
433
|
+
"FUTURE.md has no trigger wording: a deferred item without a revisit condition is a graveyard entry",
|
|
434
|
+
future.rel,
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
const finished = facts.memory.find((doc) => doc.rel.endsWith("/FINISHED.md"));
|
|
439
|
+
if (finished) {
|
|
440
|
+
const sections = finished.text.split(/^##\s+/m).slice(1);
|
|
441
|
+
const without = sections
|
|
442
|
+
// A placeholder or a prose-only intro has no entries to prove. Only
|
|
443
|
+
// sections with a list or a table can be "finished work without evidence".
|
|
444
|
+
.filter((section) => /^\s*(?:[-*+]|\|)/m.test(section))
|
|
445
|
+
.filter((section) => !/implementations\//.test(section) && !/specs\//.test(section))
|
|
446
|
+
.map((section) => section.split("\n")[0].trim())
|
|
447
|
+
.filter((title) => title.length > 0 && !/^(附录|appendix)/i.test(title));
|
|
448
|
+
if (without.length > 0) {
|
|
449
|
+
out.push(
|
|
450
|
+
finding(
|
|
451
|
+
AUDIT_CODES.finishedNoEvidence,
|
|
452
|
+
"warning",
|
|
453
|
+
`FINISHED.md sections with no evidence pointer: ${without.slice(0, 5).join(", ")}${
|
|
454
|
+
without.length > 5 ? " …" : ""
|
|
455
|
+
}`,
|
|
456
|
+
finished.rel,
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function checkPlanDrift(facts: Facts): Finding[] {
|
|
465
|
+
const plan = facts.specs.find((doc) => doc.rel === PLAN_FILE);
|
|
466
|
+
if (!plan) return [];
|
|
467
|
+
const out: Finding[] = [];
|
|
468
|
+
const closed = facts.specs.filter((doc) => {
|
|
469
|
+
if (doc.rel === PLAN_FILE) return false;
|
|
470
|
+
const boxes = doc.text.match(/^- \[[ xX]\]/gm) ?? [];
|
|
471
|
+
const open = boxes.filter((box) => /\[ \]/.test(box)).length;
|
|
472
|
+
return boxes.length > 0 && open === 0 && plan.text.includes(doc.rel.replace(`${SPECS_DIR}/`, ""));
|
|
473
|
+
});
|
|
474
|
+
for (const doc of closed.slice(0, 5)) {
|
|
475
|
+
out.push(
|
|
476
|
+
finding(
|
|
477
|
+
AUDIT_CODES.planSpecDrift,
|
|
478
|
+
"warning",
|
|
479
|
+
`${doc.rel} has every acceptance box checked but is still listed in ${PLAN_FILE}: close it and move the summary to FINISHED.md`,
|
|
480
|
+
PLAN_FILE,
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function checkCoverage(facts: Facts, coveragePath: string | undefined): Finding[] {
|
|
488
|
+
const rel = coveragePath ?? latestCoverageReport(facts.root);
|
|
489
|
+
if (!rel) return [];
|
|
490
|
+
const absolute = join(facts.root, rel);
|
|
491
|
+
if (!existsSync(absolute)) return [];
|
|
492
|
+
const report = parseCoverage(readFileSync(absolute, "utf8"));
|
|
493
|
+
if (report.open === 0) return [];
|
|
494
|
+
return [
|
|
495
|
+
finding(
|
|
496
|
+
AUDIT_CODES.coverageOpen,
|
|
497
|
+
"warning",
|
|
498
|
+
`${report.open} source sections have no destination or reason in ${rel}`,
|
|
499
|
+
rel,
|
|
500
|
+
),
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function checkScaffold(facts: Facts): Finding[] {
|
|
505
|
+
const docs = [
|
|
506
|
+
...(facts.context.agents ? [facts.context.agents] : []),
|
|
507
|
+
...facts.memory,
|
|
508
|
+
...facts.specs,
|
|
509
|
+
];
|
|
510
|
+
let count = 0;
|
|
511
|
+
for (const doc of docs) count += (doc.text.match(/TODO\(ask-init\)/g) ?? []).length;
|
|
512
|
+
if (count === 0) return [];
|
|
513
|
+
return [
|
|
514
|
+
finding(
|
|
515
|
+
AUDIT_CODES.scaffoldLeftover,
|
|
516
|
+
"warning",
|
|
517
|
+
`${count} TODO(ask-init) markers remain: the scaffold was not filled in`,
|
|
518
|
+
),
|
|
519
|
+
];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/* -------------------------------------------------------------------------- */
|
|
523
|
+
/* Entry points */
|
|
524
|
+
/* -------------------------------------------------------------------------- */
|
|
525
|
+
|
|
526
|
+
export function audit(facts: Facts, options: AuditOptions = {}): Finding[] {
|
|
527
|
+
const findings = [
|
|
528
|
+
...checkStructure(facts),
|
|
529
|
+
...checkIndex(facts),
|
|
530
|
+
...checkRefs(facts),
|
|
531
|
+
...checkTracked(facts),
|
|
532
|
+
...checkRoot(facts),
|
|
533
|
+
...checkLinks(facts),
|
|
534
|
+
...checkBudgets(facts),
|
|
535
|
+
...checkDuplication(facts),
|
|
536
|
+
...checkOrder(facts),
|
|
537
|
+
...checkMeta(facts),
|
|
538
|
+
...checkPlanDrift(facts),
|
|
539
|
+
...checkCoverage(facts, options.coveragePath),
|
|
540
|
+
...checkScaffold(facts),
|
|
541
|
+
];
|
|
542
|
+
const rank: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
|
|
543
|
+
return findings.sort((a, b) => rank[a.severity] - rank[b.severity] || a.code.localeCompare(b.code));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function countFindings(findings: readonly Finding[]): {
|
|
547
|
+
errors: number;
|
|
548
|
+
warnings: number;
|
|
549
|
+
infos: number;
|
|
550
|
+
} {
|
|
551
|
+
return {
|
|
552
|
+
errors: findings.filter((item) => item.severity === "error").length,
|
|
553
|
+
warnings: findings.filter((item) => item.severity === "warning").length,
|
|
554
|
+
infos: findings.filter((item) => item.severity === "info").length,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Text for humans and for the model's self-check loop. */
|
|
559
|
+
export function renderFindings(
|
|
560
|
+
findings: readonly Finding[],
|
|
561
|
+
context: { root: string; frameworkVersion: number },
|
|
562
|
+
): string {
|
|
563
|
+
const counts = countFindings(findings);
|
|
564
|
+
const lines = [
|
|
565
|
+
`agent-framework v${context.frameworkVersion} — audit`,
|
|
566
|
+
`repo: ${context.root}`,
|
|
567
|
+
`result: ${counts.errors} errors, ${counts.warnings} warnings, ${counts.infos} info`,
|
|
568
|
+
"",
|
|
569
|
+
];
|
|
570
|
+
if (findings.length === 0) {
|
|
571
|
+
lines.push("No findings. The layout, budgets and references check out.");
|
|
572
|
+
return lines.join("\n");
|
|
573
|
+
}
|
|
574
|
+
for (const severity of ["error", "warning", "info"] as const) {
|
|
575
|
+
const group = findings.filter((item) => item.severity === severity);
|
|
576
|
+
if (group.length === 0) continue;
|
|
577
|
+
lines.push(`## ${severity}s`);
|
|
578
|
+
for (const item of group) {
|
|
579
|
+
const location = item.path ? ` [${item.path}${item.line ? `:${item.line}` : ""}]` : "";
|
|
580
|
+
lines.push(`- ${item.code}${location} ${item.message}`);
|
|
581
|
+
}
|
|
582
|
+
lines.push("");
|
|
583
|
+
}
|
|
584
|
+
return lines.join("\n");
|
|
585
|
+
}
|
|
586
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseHeadings } from "./detect.js";
|
|
3
|
+
import { buildCoverage, parseCoverage, renderCoverage } from "./coverage.js";
|
|
4
|
+
import type { DocInfo } from "./types.js";
|
|
5
|
+
|
|
6
|
+
function doc(rel: string, text: string): DocInfo {
|
|
7
|
+
return {
|
|
8
|
+
rel,
|
|
9
|
+
text,
|
|
10
|
+
bytes: text.length,
|
|
11
|
+
lines: text.split("\n").length,
|
|
12
|
+
mtimeMs: 0,
|
|
13
|
+
headings: parseHeadings(text),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SOURCE = doc(
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
[
|
|
20
|
+
"# demo",
|
|
21
|
+
"",
|
|
22
|
+
"## 定位",
|
|
23
|
+
"",
|
|
24
|
+
"这是一个相片管理软件。",
|
|
25
|
+
"",
|
|
26
|
+
"## 硬规则",
|
|
27
|
+
"",
|
|
28
|
+
"不要猜事实。",
|
|
29
|
+
"",
|
|
30
|
+
"## 术语",
|
|
31
|
+
"",
|
|
32
|
+
"勾选与选中是两回事。",
|
|
33
|
+
"",
|
|
34
|
+
].join("\n"),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const TARGET = doc(
|
|
38
|
+
".agents/memory/ARCHITECTURE.md",
|
|
39
|
+
["# Architecture", "", "## 硬规则", "", "不要猜事实。", ""].join("\n"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
describe("coverage", () => {
|
|
43
|
+
it("marks a section hit when the destination carries the same heading", () => {
|
|
44
|
+
const report = buildCoverage([SOURCE], [TARGET]);
|
|
45
|
+
const row = report.rows.find((item) => item.heading === "硬规则");
|
|
46
|
+
expect(row?.status).toBe("hit");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("counts declared and open sections separately", () => {
|
|
50
|
+
const mapping = new Map([
|
|
51
|
+
["AGENTS.md#定位", { destination: ".agents/memory/ARCHITECTURE.md (intro)" }],
|
|
52
|
+
]);
|
|
53
|
+
const report = buildCoverage([SOURCE], [TARGET], mapping);
|
|
54
|
+
expect(report.hit).toBe(1);
|
|
55
|
+
expect(report.mapped).toBe(1);
|
|
56
|
+
expect(report.open).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("finds a section by its opening paragraph when the heading changed", () => {
|
|
60
|
+
const renamed = doc(
|
|
61
|
+
".agents/memory/TERMS.md",
|
|
62
|
+
["# Terms", "", "## 选择语义", "", "勾选与选中是两回事。", ""].join("\n"),
|
|
63
|
+
);
|
|
64
|
+
const report = buildCoverage([SOURCE], [renamed]);
|
|
65
|
+
const row = report.rows.find((item) => item.heading === "术语");
|
|
66
|
+
expect(row?.status).toBe("hit");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders a table and reads it back", () => {
|
|
70
|
+
const mapping = new Map([
|
|
71
|
+
["AGENTS.md#定位", { destination: "somewhere", reason: "merged" }],
|
|
72
|
+
]);
|
|
73
|
+
const report = buildCoverage([SOURCE], [TARGET], mapping);
|
|
74
|
+
const text = renderCoverage(report, {
|
|
75
|
+
createdAt: "2026-10-26T10:00:00+08:00",
|
|
76
|
+
sources: ["AGENTS.md"],
|
|
77
|
+
targets: [".agents/memory/ARCHITECTURE.md"],
|
|
78
|
+
});
|
|
79
|
+
expect(text).toContain("| AGENTS.md | 硬规则 | found |");
|
|
80
|
+
const parsed = parseCoverage(text);
|
|
81
|
+
expect(parsed.hit).toBe(report.hit);
|
|
82
|
+
expect(parsed.mapped).toBe(report.mapped);
|
|
83
|
+
expect(parsed.open).toBe(report.open);
|
|
84
|
+
expect(parsed.rows.find((row) => row.heading === "定位")?.destination).toBe("somewhere");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("treats an unknown status as open so a malformed row cannot pass", () => {
|
|
88
|
+
const parsed = parseCoverage(
|
|
89
|
+
["| Source | Section | Status | Destination | Reason |", "| --- | --- | --- | --- | --- |", "| AGENTS.md | 定位 | declared | | |"].join("\n"),
|
|
90
|
+
);
|
|
91
|
+
expect(parsed.open).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
});
|