iterate-plugin 3.5.2 → 3.5.4
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/session-hooks.js +48 -4
- package/dist/skill-prompt.js +63 -29
- package/dist/tools/checkpoint.js +39 -5
- package/dist/tools/config.js +11 -2
- package/dist/tools/context.js +5 -1
- package/dist/tools/defense-events.js +48 -9
- package/dist/tools/defense-store.js +63 -5
- package/dist/tools/experience-bank.js +14 -11
- package/dist/tools/experience-store.js +79 -14
- package/dist/tools/fix.js +30 -10
- package/dist/tools/history.js +5 -3
- package/dist/tools/prune.js +197 -29
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +8 -2
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +72 -15
- package/dist/tools/triage.js +35 -2
- package/dist/transcript.js +30 -7
- package/lib/client.js +89 -21
- package/lib/parse.js +27 -2
- package/package.json +9 -7
- package/src/client/index.ts +53 -8
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +63 -29
- package/src/tools/checkpoint.ts +45 -6
- package/src/tools/config.ts +11 -2
- package/src/tools/context.ts +5 -1
- package/src/tools/defense-events.ts +50 -9
- package/src/tools/defense-store.ts +64 -8
- package/src/tools/experience-bank.ts +14 -11
- package/src/tools/experience-store.ts +88 -23
- package/src/tools/fix.ts +32 -12
- package/src/tools/history.ts +5 -3
- package/src/tools/prune.ts +204 -28
- package/src/tools/quality-gate.ts +4 -0
- package/src/tools/quality-store.ts +7 -2
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +71 -14
- package/src/tools/triage.ts +36 -2
- package/src/transcript.ts +32 -7
- package/src/types.ts +2 -0
|
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { iterateDir } from "../paths.js";
|
|
10
10
|
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
11
|
+
import { hashString } from "./fix.js";
|
|
11
12
|
const DEFENSE_EVENTS_FILE = 'defense-events.json';
|
|
12
13
|
/** Valid defense event types (must stay in sync with DefenseEventType). */
|
|
13
14
|
const VALID_EVENT_TYPES = new Set([
|
|
@@ -39,11 +40,45 @@ function emptyStream() {
|
|
|
39
40
|
},
|
|
40
41
|
};
|
|
41
42
|
}
|
|
43
|
+
/** Valid severity values (kept in sync with DefenseEvent). */
|
|
44
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
45
|
+
/**
|
|
46
|
+
* Normalize one persisted event. Hand-edited files can carry events missing
|
|
47
|
+
* `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
|
|
48
|
+
* (list sort by timestamp, render label selection) must never crash or emit
|
|
49
|
+
* NaN for those. Returns null when the entry is not an object or has no usable
|
|
50
|
+
* `type`; otherwise fills every required field with a safe default. An event
|
|
51
|
+
* missing its `id` gets a DETERMINISTIC id derived from its content and
|
|
52
|
+
* position (never a random one) so a read→write cycle cannot silently churn
|
|
53
|
+
* ids on every mutation.
|
|
54
|
+
*/
|
|
55
|
+
function normalizeEvent(raw, index) {
|
|
56
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
57
|
+
return null;
|
|
58
|
+
const e = raw;
|
|
59
|
+
if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type))
|
|
60
|
+
return null;
|
|
61
|
+
const derivedId = `def-${hashString(`${index}|${String(e.timestamp ?? '')}|${e.type}|${String(e.description ?? '')}|${String(e.defense ?? '')}`)}`;
|
|
62
|
+
return {
|
|
63
|
+
id: typeof e.id === 'string' && e.id ? e.id : derivedId,
|
|
64
|
+
timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
|
|
65
|
+
round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
|
|
66
|
+
type: e.type,
|
|
67
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
68
|
+
defense: typeof e.defense === 'string' ? e.defense : '',
|
|
69
|
+
outcome: typeof e.outcome === 'string' ? e.outcome : '',
|
|
70
|
+
...(typeof e.file === 'string' && e.file.length > 0 ? { file: e.file } : {}),
|
|
71
|
+
...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
|
|
72
|
+
severity: VALID_SEVERITIES.has(String(e.severity)) ? e.severity : 'low',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
42
75
|
/**
|
|
43
76
|
* Read the defense events stream from disk.
|
|
44
77
|
* Normalizes the persisted stream so a hand-edited / partial file can never
|
|
45
78
|
* produce NaN counts: `counts` is recomputed from the events when missing or
|
|
46
|
-
* malformed,
|
|
79
|
+
* malformed, every type key is guaranteed present, and every surviving event is
|
|
80
|
+
* shape-normalized so consumers (timestamp sort, render label selection) cannot
|
|
81
|
+
* throw on missing fields.
|
|
47
82
|
*/
|
|
48
83
|
export function readDefenseEvents(projectRoot) {
|
|
49
84
|
const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE);
|
|
@@ -51,7 +86,9 @@ export function readDefenseEvents(projectRoot) {
|
|
|
51
86
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
52
87
|
const parsed = JSON.parse(content);
|
|
53
88
|
if (parsed && Array.isArray(parsed.events)) {
|
|
54
|
-
const events = parsed.events
|
|
89
|
+
const events = parsed.events
|
|
90
|
+
.map((raw, i) => normalizeEvent(raw, i))
|
|
91
|
+
.filter((e) => e !== null);
|
|
55
92
|
const counts = computeCounts(events);
|
|
56
93
|
return {
|
|
57
94
|
events,
|
|
@@ -87,13 +124,34 @@ export function writeDefenseEvents(projectRoot, stream) {
|
|
|
87
124
|
}
|
|
88
125
|
return { ok: true };
|
|
89
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Clear the persisted defense-event stream (`.iterate/defense-events.json`).
|
|
129
|
+
* Mirrors `clearQualityGate` in quality-store.ts: a stale event stream from a
|
|
130
|
+
* previous iteration must be resettable before a fresh run. Returns whether a
|
|
131
|
+
* file existed and was removed, or a structured error when removal fails.
|
|
132
|
+
*/
|
|
133
|
+
export function clearDefenseEvents(projectRoot) {
|
|
134
|
+
const filePath = path.join(iterateDir(projectRoot), DEFENSE_EVENTS_FILE);
|
|
135
|
+
const existed = fs.existsSync(filePath);
|
|
136
|
+
if (!existed)
|
|
137
|
+
return { ok: true, existed: false };
|
|
138
|
+
try {
|
|
139
|
+
fs.rmSync(filePath, { force: true });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` };
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, existed: true };
|
|
145
|
+
}
|
|
90
146
|
/** Add a defense event to the stream. */
|
|
91
147
|
export function addDefenseEvent(stream, event) {
|
|
92
|
-
|
|
148
|
+
// Spread the caller-shaped event FIRST so the store-generated `id` and
|
|
149
|
+
// `timestamp` always win — a caller-supplied id/timestamp must never
|
|
150
|
+
// override the stream's own identity fields.
|
|
93
151
|
const newEvent = {
|
|
94
|
-
id,
|
|
95
|
-
timestamp: new Date().toISOString(),
|
|
96
152
|
...event,
|
|
153
|
+
id: `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
154
|
+
timestamp: new Date().toISOString(),
|
|
97
155
|
};
|
|
98
156
|
// Always recompute from the events array instead of mutating a possibly
|
|
99
157
|
// stale/malformed persisted `counts` object — guarantees the stream counts
|
|
@@ -67,9 +67,12 @@ function normalizeExperienceInput(raw) {
|
|
|
67
67
|
export function registerExperienceBankTool(ctx) {
|
|
68
68
|
ctx.tools.register(defineTool({
|
|
69
69
|
name: 'iterate_experience',
|
|
70
|
-
// List/search/get never write;
|
|
71
|
-
// join a parallel dispatch group.
|
|
72
|
-
isConcurrencySafe: (args) =>
|
|
70
|
+
// List/search/get never write; `add` upserts the bank and `remove`
|
|
71
|
+
// rewrites it → only the read shapes may join a parallel dispatch group.
|
|
72
|
+
isConcurrencySafe: (args) => {
|
|
73
|
+
const op = args.operation;
|
|
74
|
+
return op !== 'add' && op !== 'remove';
|
|
75
|
+
},
|
|
73
76
|
description: 'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
|
|
74
77
|
'or record a new verified fix (operation:"add"). ' +
|
|
75
78
|
'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
|
|
@@ -144,8 +147,8 @@ export function registerExperienceBankTool(ctx) {
|
|
|
144
147
|
`Dimension: ${entry.dimension}`,
|
|
145
148
|
`Description: ${entry.description}`,
|
|
146
149
|
`Fix: ${entry.verifiedFix}`,
|
|
147
|
-
`Files: ${entry.files.join(', ')}`,
|
|
148
|
-
`Tags: ${entry.tags.join(', ')}`,
|
|
150
|
+
`Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
|
|
151
|
+
`Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
|
|
149
152
|
].join('\n') }];
|
|
150
153
|
}
|
|
151
154
|
if (value.operation === 'remove') {
|
|
@@ -158,9 +161,9 @@ export function registerExperienceBankTool(ctx) {
|
|
|
158
161
|
`Pattern: ${entry.pattern}`,
|
|
159
162
|
`Description: ${entry.description}`,
|
|
160
163
|
`Fix: ${entry.verifiedFix}`,
|
|
161
|
-
`Files: ${entry.files.join(', ')}`,
|
|
164
|
+
`Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
|
|
162
165
|
`Hits: ${entry.hitCount}`,
|
|
163
|
-
`Tags: ${entry.tags.join(', ')}`,
|
|
166
|
+
`Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
|
|
164
167
|
].join('\n') }];
|
|
165
168
|
}
|
|
166
169
|
const entries = value.entries ?? [];
|
|
@@ -252,7 +255,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
252
255
|
operation: 'get',
|
|
253
256
|
count: 1,
|
|
254
257
|
entry: entry,
|
|
255
|
-
totalHits: bank.totalHits,
|
|
258
|
+
totalHits: bank.totalHits ?? 0,
|
|
256
259
|
};
|
|
257
260
|
}
|
|
258
261
|
if (operation === 'search' && typeof args.query === 'string') {
|
|
@@ -266,7 +269,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
266
269
|
operation: 'search',
|
|
267
270
|
count: entries.length,
|
|
268
271
|
entries: entries,
|
|
269
|
-
totalHits: bank.totalHits,
|
|
272
|
+
totalHits: bank.totalHits ?? 0,
|
|
270
273
|
};
|
|
271
274
|
}
|
|
272
275
|
// Default: list with optional filters
|
|
@@ -275,7 +278,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
275
278
|
entries = entries.filter((e) => e.dimension === args.dimension);
|
|
276
279
|
}
|
|
277
280
|
if (Array.isArray(args.tags) && args.tags.length > 0) {
|
|
278
|
-
entries = entries.filter((e) => args.tags.every((t) => e.tags.includes(t)));
|
|
281
|
+
entries = entries.filter((e) => args.tags.every((t) => Array.isArray(e.tags) && e.tags.includes(t)));
|
|
279
282
|
}
|
|
280
283
|
return {
|
|
281
284
|
ok: true,
|
|
@@ -283,7 +286,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
283
286
|
operation: 'list',
|
|
284
287
|
count: Math.min(entries.length, limit),
|
|
285
288
|
entries: entries.slice(0, limit),
|
|
286
|
-
totalHits: bank.totalHits,
|
|
289
|
+
totalHits: bank.totalHits ?? 0,
|
|
287
290
|
};
|
|
288
291
|
},
|
|
289
292
|
}));
|
|
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { iterateDir } from "../paths.js";
|
|
10
10
|
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
11
|
+
import { hashString } from "./fix.js";
|
|
11
12
|
const EXPERIENCE_FILE = 'experience.json';
|
|
12
13
|
/** Default empty experience bank. */
|
|
13
14
|
function emptyBank() {
|
|
@@ -17,6 +18,49 @@ function emptyBank() {
|
|
|
17
18
|
totalHits: 0,
|
|
18
19
|
};
|
|
19
20
|
}
|
|
21
|
+
/** Valid severity values (kept in sync with ExperienceEntry). */
|
|
22
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
23
|
+
/** String array guard for fields that must be arrays (`files`, `tags`). */
|
|
24
|
+
function stringArray(v) {
|
|
25
|
+
if (!Array.isArray(v))
|
|
26
|
+
return [];
|
|
27
|
+
return v.filter((x) => typeof x === 'string');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Normalize one persisted experience entry. A hand-edited bank entry can be
|
|
31
|
+
* missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
|
|
32
|
+
* entries (`render` `.join(', ')`, `searchExperienceEntries` spread) must never
|
|
33
|
+
* throw or emit NaN. Returns null for non-object entries; every required field
|
|
34
|
+
* gets a safe default.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeEntry(raw, index) {
|
|
37
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
38
|
+
return null;
|
|
39
|
+
const e = raw;
|
|
40
|
+
const pattern = typeof e.pattern === 'string' ? e.pattern : '';
|
|
41
|
+
const dimension = typeof e.dimension === 'string' ? e.dimension : '';
|
|
42
|
+
// Deterministic id (content + position based) so a read→write cycle never
|
|
43
|
+
// churns random ids on entries that were hand-edited without one.
|
|
44
|
+
const derivedId = `exp-${hashString(`${index}|${pattern}|${dimension}`)}`;
|
|
45
|
+
const id = typeof e.id === 'string' && e.id ? e.id : derivedId;
|
|
46
|
+
if (!pattern && !dimension)
|
|
47
|
+
return null;
|
|
48
|
+
const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0;
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
|
|
52
|
+
dimension,
|
|
53
|
+
pattern,
|
|
54
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
55
|
+
verifiedFix: typeof e.verifiedFix === 'string' ? e.verifiedFix : '',
|
|
56
|
+
findingSummary: typeof e.findingSummary === 'string' ? e.findingSummary : '',
|
|
57
|
+
files: stringArray(e.files),
|
|
58
|
+
hitCount,
|
|
59
|
+
...(typeof e.lastHitAt === 'string' ? { lastHitAt: e.lastHitAt } : {}),
|
|
60
|
+
tags: stringArray(e.tags),
|
|
61
|
+
severity: VALID_SEVERITIES.has(String(e.severity)) ? e.severity : 'low',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
20
64
|
/** Read the experience bank from disk. Returns empty bank if not found. */
|
|
21
65
|
export function readExperienceBank(projectRoot) {
|
|
22
66
|
const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE);
|
|
@@ -24,7 +68,14 @@ export function readExperienceBank(projectRoot) {
|
|
|
24
68
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
25
69
|
const parsed = JSON.parse(content);
|
|
26
70
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
27
|
-
|
|
71
|
+
const entries = parsed.entries
|
|
72
|
+
.map((raw, i) => normalizeEntry(raw, i))
|
|
73
|
+
.filter((e) => e !== null);
|
|
74
|
+
return {
|
|
75
|
+
entries,
|
|
76
|
+
lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
|
|
77
|
+
totalHits: typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
|
|
78
|
+
};
|
|
28
79
|
}
|
|
29
80
|
}
|
|
30
81
|
catch {
|
|
@@ -58,17 +109,23 @@ export function writeExperienceBank(projectRoot, bank) {
|
|
|
58
109
|
export function searchExperienceEntries(entries, query, opts = {}) {
|
|
59
110
|
const lowerQuery = query.toLowerCase();
|
|
60
111
|
return entries.filter((entry) => {
|
|
112
|
+
if (!entry || typeof entry !== 'object')
|
|
113
|
+
return false;
|
|
114
|
+
const rawEntry = entry;
|
|
115
|
+
const tags = stringArray(rawEntry.tags);
|
|
61
116
|
// Dimension filter
|
|
62
117
|
if (opts.dimension && entry.dimension !== opts.dimension) {
|
|
63
118
|
return false;
|
|
64
119
|
}
|
|
65
120
|
// Tags filter (AND logic)
|
|
66
121
|
if (opts.tags && opts.tags.length > 0) {
|
|
67
|
-
if (!opts.tags.every((t) =>
|
|
122
|
+
if (!opts.tags.every((t) => tags.includes(t))) {
|
|
68
123
|
return false;
|
|
69
124
|
}
|
|
70
125
|
}
|
|
71
|
-
// Text search across multiple fields
|
|
126
|
+
// Text search across multiple fields. Guards against a hand-edited bank entry
|
|
127
|
+
// whose `files`/`tags` are missing or non-array (the spread below would
|
|
128
|
+
// otherwise throw a TypeError on a non-iterable).
|
|
72
129
|
if (query) {
|
|
73
130
|
const searchableText = [
|
|
74
131
|
entry.pattern,
|
|
@@ -76,8 +133,8 @@ export function searchExperienceEntries(entries, query, opts = {}) {
|
|
|
76
133
|
entry.verifiedFix,
|
|
77
134
|
entry.findingSummary,
|
|
78
135
|
entry.dimension,
|
|
79
|
-
...entry.files,
|
|
80
|
-
...entry.tags,
|
|
136
|
+
...stringArray(entry.files),
|
|
137
|
+
...stringArray(entry.tags),
|
|
81
138
|
].join(' ').toLowerCase();
|
|
82
139
|
if (!searchableText.includes(lowerQuery)) {
|
|
83
140
|
return false;
|
|
@@ -89,8 +146,11 @@ export function searchExperienceEntries(entries, query, opts = {}) {
|
|
|
89
146
|
/**
|
|
90
147
|
* Add or update an experience entry.
|
|
91
148
|
*
|
|
92
|
-
* An entry with an `id` that already exists
|
|
93
|
-
*
|
|
149
|
+
* An entry with an `id` that already exists is treated as an UPDATE + HIT:
|
|
150
|
+
* the caller-supplied fields replace the stored ones and the hitCount is
|
|
151
|
+
* incremented (lastHitAt refreshed) — this fulfills the documented
|
|
152
|
+
* "update a specific entry via add" contract. A NEW entry whose
|
|
153
|
+
* `pattern`+`dimension` pair matches an existing entry is treated as a HIT:
|
|
94
154
|
* the matching entry's hitCount is incremented (lastHitAt refreshed) so
|
|
95
155
|
* repeated encounters of the same pattern do not create duplicates. Otherwise
|
|
96
156
|
* a fresh entry is appended with hitCount 1. Never mutates the input bank.
|
|
@@ -104,11 +164,14 @@ export function upsertExperience(bank, entry) {
|
|
|
104
164
|
? bank.entries.find((e) => e.id === entry.id)
|
|
105
165
|
: bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension);
|
|
106
166
|
if (existing) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
167
|
+
// Explicit-id updates REPLACE the editable fields (fulfilling the
|
|
168
|
+
// documented "update a specific entry via add" contract); a pattern+
|
|
169
|
+
// dimension HIT only bumps the hit metadata so a repeat encounter never
|
|
170
|
+
// overwrites the curated entry.
|
|
171
|
+
const byId = typeof entry.id === 'string' && entry.id.length > 0;
|
|
172
|
+
const updated = byId
|
|
173
|
+
? { ...existing, ...entry, id: existing.id, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
|
|
174
|
+
: { ...existing, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated };
|
|
112
175
|
return {
|
|
113
176
|
bank: {
|
|
114
177
|
...bank,
|
|
@@ -120,14 +183,16 @@ export function upsertExperience(bank, entry) {
|
|
|
120
183
|
entryId: existing.id,
|
|
121
184
|
};
|
|
122
185
|
}
|
|
123
|
-
// Add new entry
|
|
186
|
+
// Add new entry. Spread the caller input FIRST so the store-generated
|
|
187
|
+
// `id`/`timestamp`/`hitCount`/`lastHitAt` always win — a hostile or
|
|
188
|
+
// malformed input can never forge its own identity or hit count.
|
|
124
189
|
const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
125
190
|
const newEntry = {
|
|
191
|
+
...entry,
|
|
126
192
|
id,
|
|
127
193
|
timestamp: lastUpdated,
|
|
128
194
|
hitCount: 1,
|
|
129
195
|
lastHitAt: lastUpdated,
|
|
130
|
-
...entry,
|
|
131
196
|
};
|
|
132
197
|
return {
|
|
133
198
|
bank: {
|
package/dist/tools/fix.js
CHANGED
|
@@ -120,9 +120,22 @@ export function readRegistry(projectRoot) {
|
|
|
120
120
|
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
121
121
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds))
|
|
122
122
|
return emptyRegistry();
|
|
123
|
-
// Defensive: a hand-edited or partially-written registry may
|
|
124
|
-
// round without a `records` array
|
|
125
|
-
|
|
123
|
+
// Defensive normalization: a hand-edited or partially-written registry may
|
|
124
|
+
// contain a round without a `records` array, or records that are missing
|
|
125
|
+
// their id / finding object — all of which would make readers
|
|
126
|
+
// (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
|
|
127
|
+
parsed.rounds = parsed.rounds
|
|
128
|
+
.filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
|
|
129
|
+
.map((r) => ({
|
|
130
|
+
...r,
|
|
131
|
+
round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
|
|
132
|
+
records: r.records.filter((rec) => !!rec &&
|
|
133
|
+
typeof rec === 'object' &&
|
|
134
|
+
typeof rec.id === 'string' &&
|
|
135
|
+
!!rec.finding &&
|
|
136
|
+
typeof rec.finding === 'object'),
|
|
137
|
+
}))
|
|
138
|
+
.filter((r) => typeof r.round === 'number' && Number.isFinite(r.round));
|
|
126
139
|
return parsed;
|
|
127
140
|
}
|
|
128
141
|
catch {
|
|
@@ -611,20 +624,27 @@ export function registerDiffTool(ctx) {
|
|
|
611
624
|
for (const r of round.records) {
|
|
612
625
|
if (!r.success)
|
|
613
626
|
continue;
|
|
614
|
-
const
|
|
627
|
+
const file = typeof r.finding.file === 'string' ? r.finding.file : '';
|
|
628
|
+
if (!file)
|
|
629
|
+
continue;
|
|
630
|
+
// Coerce defensively: a hand-edited registry record missing the
|
|
631
|
+
// numeric fields must not produce NaN in the accumulated summary.
|
|
632
|
+
const added = Number(r.linesAdded) || 0;
|
|
633
|
+
const removed = Number(r.linesRemoved) || 0;
|
|
634
|
+
const existing = files.find((f) => f.file === file);
|
|
615
635
|
if (existing) {
|
|
616
|
-
existing.linesAdded +=
|
|
617
|
-
existing.linesRemoved +=
|
|
636
|
+
existing.linesAdded += added;
|
|
637
|
+
existing.linesRemoved += removed;
|
|
618
638
|
// Recompute the summary from the summed counts so a multi-fix
|
|
619
639
|
// file's text does not contradict its accumulated numbers.
|
|
620
640
|
existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`;
|
|
621
641
|
}
|
|
622
642
|
else {
|
|
623
643
|
files.push({
|
|
624
|
-
file
|
|
625
|
-
diffSummary: r.diffSummary
|
|
626
|
-
linesAdded:
|
|
627
|
-
linesRemoved:
|
|
644
|
+
file,
|
|
645
|
+
diffSummary: typeof r.diffSummary === 'string' ? r.diffSummary : `+${added}/-${removed} lines`,
|
|
646
|
+
linesAdded: added,
|
|
647
|
+
linesRemoved: removed,
|
|
628
648
|
});
|
|
629
649
|
}
|
|
630
650
|
}
|
package/dist/tools/history.js
CHANGED
|
@@ -46,8 +46,10 @@ export function filterDecisionEntries(entries, opts) {
|
|
|
46
46
|
export function summarizeFixRegistry(registry) {
|
|
47
47
|
const rounds = (registry.rounds ?? []).map((r) => ({
|
|
48
48
|
round: r.round,
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Coerce defensively so a hand-edited registry round missing either count
|
|
50
|
+
// can never propagate NaN into the integer output fields.
|
|
51
|
+
fixedCount: Number(r.fixedCount) || 0,
|
|
52
|
+
failedCount: Number(r.failedCount) || 0,
|
|
51
53
|
}));
|
|
52
54
|
return {
|
|
53
55
|
totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
|
|
@@ -74,7 +76,7 @@ export function registerHistoryTool(ctx) {
|
|
|
74
76
|
type: {
|
|
75
77
|
type: 'string',
|
|
76
78
|
description: 'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
|
|
77
|
-
'revert, validation, decision, report.',
|
|
79
|
+
'revert, round_failed, validation, decision, report, resume.',
|
|
78
80
|
},
|
|
79
81
|
since: {
|
|
80
82
|
type: 'string',
|