peaks-loop 4.0.41 → 4.0.43
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/CHANGELOG.md +44 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/_register.js +4 -0
- package/dist/cli/commands/api-diff-commands.d.ts +16 -0
- package/dist/cli/commands/api-diff-commands.js +55 -0
- package/dist/cli/commands/audit-commands.d.ts +16 -3
- package/dist/cli/commands/audit-commands.js +84 -31
- package/dist/cli/commands/job-commands.js +4 -2
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/test-commands.d.ts +60 -3
- package/dist/cli/commands/test-commands.js +125 -7
- package/dist/services/audit/audit-goal-service.js +38 -3
- package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.d.ts +65 -0
- package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.js +186 -0
- package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
- package/dist/services/doctor/doctor-service/types.d.ts +20 -0
- package/dist/services/hooks/write-gate.js +32 -9
- package/dist/services/llm/anthropic-runner.d.ts +87 -0
- package/dist/services/llm/anthropic-runner.js +171 -0
- package/dist/services/llm/stub-runner.d.ts +11 -0
- package/dist/services/llm/stub-runner.js +33 -0
- package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
- package/dist/services/scan/api-diff-openapi.d.ts +32 -0
- package/dist/services/scan/api-diff-openapi.js +359 -0
- package/dist/services/scan/api-diff-recorded.d.ts +96 -0
- package/dist/services/scan/api-diff-recorded.js +577 -0
- package/dist/services/scan/api-diff-service.d.ts +34 -0
- package/dist/services/scan/api-diff-service.js +407 -0
- package/dist/services/scan/api-diff-types.d.ts +116 -0
- package/dist/services/scan/api-diff-types.js +46 -0
- package/dist/services/scan/archetype-service.js +27 -1
- package/dist/services/scan/existing-system-service.js +17 -4
- package/dist/services/scan/hook-convention-service.d.ts +26 -0
- package/dist/services/scan/hook-convention-service.js +562 -0
- package/dist/services/scan/scan-types.d.ts +47 -0
- package/dist/services/session/caller-binding-service.d.ts +28 -0
- package/dist/services/session/caller-binding-service.js +10 -2
- package/dist/services/session/caller-id-types.d.ts +12 -2
- package/dist/services/session/index.d.ts +2 -2
- package/dist/services/session/index.js +2 -2
- package/dist/services/session/session-binding-bridge.js +11 -6
- package/dist/services/session/session-manager.d.ts +33 -1
- package/dist/services/session/session-manager.js +84 -25
- package/dist/services/skills/skill-presence-service.d.ts +17 -3
- package/dist/services/skills/skill-presence-service.js +23 -3
- package/package.json +5 -5
- package/skills/bee/peaks-rd/SKILL.md +11 -3
- package/skills/peaks-code/references/existing-system-extraction.md +5 -1
- package/skills/peaks-code/references/frontend-only-mode.md +48 -6
- package/skills/peaks-code/references/project-scan-checklist.md +20 -1
- package/skills/peaks-doctor/references/doctor-check-catalog.md +1 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 / rid=api-diff-report — the three RECORDED sources for `peaks scan api-diff`,
|
|
3
|
+
* plus the candidate name-grep.
|
|
4
|
+
*
|
|
5
|
+
* 1. `.peaks/_runtime/<sid>/rd/mock-plan.md` (most recent by mtime)
|
|
6
|
+
* 2. recorded `*-api.types.ts` interfaces (from (1) + `src/services/types`)
|
|
7
|
+
* 3. the `## API Migration` endpoint list in `.peaks/_runtime/<sid>/txt/*.md`
|
|
8
|
+
*
|
|
9
|
+
* All three are OPTIONAL: absence is reported in the report's `notes`, never
|
|
10
|
+
* silently. `typescript` is a devDependency and is NOT available at runtime, so
|
|
11
|
+
* recorded interfaces are read by the line-based extractor below — its limits
|
|
12
|
+
* are documented on the function and exercised by tests.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { toDisplayPath } from './api-diff-types.js';
|
|
17
|
+
export const MAX_CANDIDATE_NAMES = 100;
|
|
18
|
+
const MAX_HITS_PER_NAME = 5;
|
|
19
|
+
const MAX_SCAN_FILE_BYTES = 2 * 1024 * 1024;
|
|
20
|
+
const SCAN_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.json', '.html'];
|
|
21
|
+
const SKIP_DIRS = new Set([
|
|
22
|
+
'node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt', '.output',
|
|
23
|
+
'.peaks', '.turbo', '.cache', '__pycache__'
|
|
24
|
+
]);
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Recorded interface extraction (NO TypeScript compiler)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/** Removes `//` and block comments while preserving string literals. Regex literals are NOT modelled (documented limit). */
|
|
29
|
+
function stripComments(source) {
|
|
30
|
+
let out = '';
|
|
31
|
+
let i = 0;
|
|
32
|
+
let inString = null;
|
|
33
|
+
while (i < source.length) {
|
|
34
|
+
const ch = source[i];
|
|
35
|
+
const next = source[i + 1];
|
|
36
|
+
if (inString !== null) {
|
|
37
|
+
if (ch === '\\') {
|
|
38
|
+
out += ch + (next ?? '');
|
|
39
|
+
i += 2;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (ch === inString)
|
|
43
|
+
inString = null;
|
|
44
|
+
out += ch;
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
49
|
+
inString = ch;
|
|
50
|
+
out += ch;
|
|
51
|
+
i += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === '/' && next === '/') {
|
|
55
|
+
while (i < source.length && source[i] !== '\n')
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '/' && next === '*') {
|
|
60
|
+
i += 2;
|
|
61
|
+
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/'))
|
|
62
|
+
i += 1;
|
|
63
|
+
i += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
out += ch;
|
|
67
|
+
i += 1;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Brace balance, counting neither braces inside string literals nor escapes.
|
|
73
|
+
* Without the string-literal awareness a member like `tpl: '{';` pushed the
|
|
74
|
+
* depth to 2, hid every later member, and swallowed every later interface in
|
|
75
|
+
* the file. Multi-line template literals are tracked across lines.
|
|
76
|
+
*
|
|
77
|
+
* LIMIT: a regex literal containing a brace is still mis-counted; recorded
|
|
78
|
+
* `*-api.types.ts` interfaces do not contain regex literals in practice.
|
|
79
|
+
*/
|
|
80
|
+
function braceBalance(text) {
|
|
81
|
+
let delta = 0;
|
|
82
|
+
let inString = null;
|
|
83
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
84
|
+
const ch = text[i];
|
|
85
|
+
if (inString !== null) {
|
|
86
|
+
if (ch === '\\') {
|
|
87
|
+
i += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (ch === inString)
|
|
91
|
+
inString = null;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
95
|
+
inString = ch;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === '{')
|
|
99
|
+
delta += 1;
|
|
100
|
+
else if (ch === '}')
|
|
101
|
+
delta -= 1;
|
|
102
|
+
}
|
|
103
|
+
return delta;
|
|
104
|
+
}
|
|
105
|
+
const DECLARATION = /^\s*(?:export\s+)?(?:declare\s+)?(?:interface|type)\s+([A-Za-z_$][\w$]*)\s*(?:<[^{>]*>)?\s*(?:=\s*)?(?:(extends)\s+[^{]+)?\{/;
|
|
106
|
+
/**
|
|
107
|
+
* The fail-safe, anchored on the DECLARATION KEYWORD rather than on the shape
|
|
108
|
+
* of what follows. `<[^{>}]*>` cannot span a nested generic, so
|
|
109
|
+
* `interface X<T extends Record<string, unknown>> {` matched nothing at all and
|
|
110
|
+
* was dropped in total silence — no interface, no suppression, no note. A
|
|
111
|
+
* declaration we cannot read must still be RECORDED as incomplete.
|
|
112
|
+
*/
|
|
113
|
+
const DECLARATION_KEYWORD = /^\s*(?:export\s+)?(?:declare\s+)?(?:interface|type)\s+([A-Za-z_$][\w$]*)/;
|
|
114
|
+
const MEMBER = /^\s*(?:readonly\s+)?([A-Za-z_$][\w$]*)\s*(\??)\s*:\s*(.+?)\s*;?\s*$/;
|
|
115
|
+
/**
|
|
116
|
+
* Top-level skeleton of a type text: string literals blanked, bracketed
|
|
117
|
+
* sub-terms collapsed, bracket depth tracked. What remains is the part of the
|
|
118
|
+
* text that sits at depth 0 — where a stray member separator can only have come
|
|
119
|
+
* from a SECOND member the line-based reader swallowed into the type.
|
|
120
|
+
*/
|
|
121
|
+
function typeSkeleton(typeText) {
|
|
122
|
+
let out = '';
|
|
123
|
+
let inString = null;
|
|
124
|
+
let depth = 0;
|
|
125
|
+
for (let i = 0; i < typeText.length; i += 1) {
|
|
126
|
+
const ch = typeText[i];
|
|
127
|
+
if (inString !== null) {
|
|
128
|
+
if (ch === '\\') {
|
|
129
|
+
i += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (ch === inString)
|
|
133
|
+
inString = null;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
137
|
+
inString = ch;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (ch === '<' || ch === '(' || ch === '[' || ch === '{') {
|
|
141
|
+
depth += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === '>' || ch === ')' || ch === ']' || ch === '}') {
|
|
145
|
+
depth = Math.max(0, depth - 1);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (depth === 0)
|
|
149
|
+
out += ch;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* True when a captured member type absorbed a second member.
|
|
155
|
+
*
|
|
156
|
+
* `MEMBER` anchors its type capture to `$`, so `id: string; name: string;` on
|
|
157
|
+
* one line was classified cleanly with type `string; name: string` — the member
|
|
158
|
+
* `name` vanished, the type was garbage, and nothing was reported. A member
|
|
159
|
+
* line counts only if it is FULLY consumed: no top-level `;`, and no second
|
|
160
|
+
* `identifier:` at depth 0. A function type like `(a: string) => void` keeps its
|
|
161
|
+
* colon inside parentheses, so it is not flagged.
|
|
162
|
+
*/
|
|
163
|
+
function absorbedExtraMember(typeText) {
|
|
164
|
+
const skeleton = typeSkeleton(typeText);
|
|
165
|
+
if (skeleton.includes(';'))
|
|
166
|
+
return true;
|
|
167
|
+
return /[A-Za-z_$][\w$]*\s*\??\s*:/.test(skeleton);
|
|
168
|
+
}
|
|
169
|
+
/** True when a type text contains an object literal opener OUTSIDE a string literal (`Array<{ a: string }>` counts; `'{'` does not). */
|
|
170
|
+
function hasInlineObject(typeText) {
|
|
171
|
+
let inString = null;
|
|
172
|
+
for (let i = 0; i < typeText.length; i += 1) {
|
|
173
|
+
const ch = typeText[i];
|
|
174
|
+
if (inString !== null) {
|
|
175
|
+
if (ch === '\\') {
|
|
176
|
+
i += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === inString)
|
|
180
|
+
inString = null;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
184
|
+
inString = ch;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (ch === '{')
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
function truncate(text, max = 40) {
|
|
193
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Line-based extractor for recorded `*-api.types.ts` interfaces.
|
|
197
|
+
*
|
|
198
|
+
* INVERTED DEFAULT (QA round 2): a line-based reader cannot tell "this line is
|
|
199
|
+
* not a member" from "I failed to read this member", so the model is not
|
|
200
|
+
* "exact unless I found a reason to suppress". It is the opposite — **an
|
|
201
|
+
* interface is COMPLETE only while every depth-1 line in its body is
|
|
202
|
+
* classified**. The first line the extractor cannot classify makes the whole
|
|
203
|
+
* interface INCOMPLETE, which suppresses every exact line for it and emits one
|
|
204
|
+
* note naming it and the reason.
|
|
205
|
+
*
|
|
206
|
+
* That is what catches, without needing to enumerate them in advance: quoted
|
|
207
|
+
* keys, index signatures, multi-line unions (the continuation line is the
|
|
208
|
+
* unclassifiable one), intersections and mapped types (which never reach a body
|
|
209
|
+
* at all — see `DECLARATION_LIKE`), and nested inline objects.
|
|
210
|
+
*
|
|
211
|
+
* KNOWN LIMITS, stated rather than papered over:
|
|
212
|
+
* - members are matched at depth 1 only; a nested shape is `object` and makes
|
|
213
|
+
* the interface incomplete rather than being half-read;
|
|
214
|
+
* - `extends` is unresolvable without a compiler, so such an interface is
|
|
215
|
+
* incomplete by construction;
|
|
216
|
+
* - a regex literal containing a brace would still mis-count (`braceBalance`).
|
|
217
|
+
*/
|
|
218
|
+
export function parseRecordedInterfaces(source, file) {
|
|
219
|
+
const lines = stripComments(source).split(/\r?\n/);
|
|
220
|
+
const fileBalanced = braceBalance(lines.join('\n')) === 0;
|
|
221
|
+
const fileReason = fileBalanced ? null : 'the file has unbalanced braces, so its structure could not be read';
|
|
222
|
+
const found = [];
|
|
223
|
+
let i = 0;
|
|
224
|
+
while (i < lines.length) {
|
|
225
|
+
const line = lines[i] ?? '';
|
|
226
|
+
const match = DECLARATION.exec(line);
|
|
227
|
+
if (!match) {
|
|
228
|
+
const keyword = DECLARATION_KEYWORD.exec(line);
|
|
229
|
+
if (keyword) {
|
|
230
|
+
// A declaration we cannot read must still be RECORDED as incomplete:
|
|
231
|
+
// ignoring it silently produces no suppression, no note and no line.
|
|
232
|
+
found.push({
|
|
233
|
+
name: keyword[1],
|
|
234
|
+
file,
|
|
235
|
+
members: new Map(),
|
|
236
|
+
incompleteReason: 'its declaration could not be fully parsed (a nested generic, an intersection, an alias, or a brace on a following line)'
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
i += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const name = match[1];
|
|
243
|
+
const members = new Map();
|
|
244
|
+
const reasons = [];
|
|
245
|
+
if (match[2] !== undefined) {
|
|
246
|
+
reasons.push('it extends a base type, whose inherited members cannot be resolved without a compiler');
|
|
247
|
+
}
|
|
248
|
+
let depth = braceBalance(line);
|
|
249
|
+
if (depth === 0) {
|
|
250
|
+
reasons.push('its body is written on a single line, which the line-based extractor cannot read');
|
|
251
|
+
i += 1;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
i += 1;
|
|
255
|
+
while (i < lines.length && depth > 0) {
|
|
256
|
+
const bodyLine = lines[i] ?? '';
|
|
257
|
+
if (depth === 1) {
|
|
258
|
+
const trimmed = bodyLine.trim();
|
|
259
|
+
if (trimmed !== '' && !/^[};,]+$/.test(trimmed)) {
|
|
260
|
+
const member = MEMBER.exec(bodyLine);
|
|
261
|
+
if (member === null) {
|
|
262
|
+
reasons.push(`it has a body line the extractor cannot classify (\`${truncate(trimmed)}\`), which may be a member it failed to read`);
|
|
263
|
+
}
|
|
264
|
+
else if (absorbedExtraMember(member[3])) {
|
|
265
|
+
reasons.push(`it has a body line carrying more than one member (\`${truncate(trimmed)}\`), which the line-based extractor cannot split`);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const optional = member[2] === '?';
|
|
269
|
+
const raw = member[3];
|
|
270
|
+
if (hasInlineObject(raw)) {
|
|
271
|
+
reasons.push('it has a nested inline object member, whose inner fields are not visible');
|
|
272
|
+
members.set(member[1], optional ? 'object | undefined' : 'object');
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
members.set(member[1], optional ? `${raw} | undefined` : raw);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
depth += braceBalance(bodyLine);
|
|
281
|
+
i += 1;
|
|
282
|
+
}
|
|
283
|
+
if (depth > 0)
|
|
284
|
+
reasons.push('its body never closes, so the file structure collapsed');
|
|
285
|
+
}
|
|
286
|
+
found.push({
|
|
287
|
+
name,
|
|
288
|
+
file,
|
|
289
|
+
members,
|
|
290
|
+
incompleteReason: fileReason ?? (reasons.length > 0 ? reasons.join('; ') : null)
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return found;
|
|
294
|
+
}
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Interface role
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
/**
|
|
299
|
+
* Which half of an operation an interface describes, from the suffix its name
|
|
300
|
+
* carries. `unknown` is a refusal, not a default: diffing an interface against
|
|
301
|
+
* a location it does not describe is how a request field got reported at a
|
|
302
|
+
* response location, and how a response-shaped `*Dto` got reported at the
|
|
303
|
+
* requestBody.
|
|
304
|
+
*
|
|
305
|
+
* Only suffixes that actually name a side are accepted. `Dto` names neither
|
|
306
|
+
* side (a `UserDto` is as likely to be a response), and a bare `Body` does not
|
|
307
|
+
* say which side it belongs to either — `GetThingResponseBody` ends in `Body`.
|
|
308
|
+
* `...RequestBody` still classifies as a request via the `request` suffix. A
|
|
309
|
+
* suffix that carries no information must not receive LESS caution than no
|
|
310
|
+
* suffix at all, so both become `unknown` and are refused with a note.
|
|
311
|
+
*/
|
|
312
|
+
export function roleOf(name) {
|
|
313
|
+
const key = name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
314
|
+
if (key.endsWith('response'))
|
|
315
|
+
return 'response';
|
|
316
|
+
for (const suffix of ['request', 'payload']) {
|
|
317
|
+
if (key.endsWith(suffix))
|
|
318
|
+
return 'request';
|
|
319
|
+
}
|
|
320
|
+
return 'unknown';
|
|
321
|
+
}
|
|
322
|
+
/** Locations of `operation` that `role` is allowed to describe. */
|
|
323
|
+
export function locationsForRole(operation, role) {
|
|
324
|
+
const keys = [...operation.locations.keys()];
|
|
325
|
+
if (role === 'request')
|
|
326
|
+
return keys.includes('request') ? ['request'] : [];
|
|
327
|
+
if (role === 'response')
|
|
328
|
+
return keys.filter((key) => key.startsWith('response.')).sort();
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Source discovery
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
function listDirs(dir) {
|
|
335
|
+
try {
|
|
336
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
337
|
+
.filter((entry) => entry.isDirectory())
|
|
338
|
+
.map((entry) => entry.name);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function listFiles(dir, suffix) {
|
|
345
|
+
try {
|
|
346
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
347
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(suffix))
|
|
348
|
+
.map((entry) => join(dir, entry.name));
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function mtimeOf(file) {
|
|
355
|
+
try {
|
|
356
|
+
return statSync(file).mtimeMs;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return -1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function readTextOr(file, fallback) {
|
|
363
|
+
try {
|
|
364
|
+
return readFileSync(file, 'utf8');
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return fallback;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function newest(files) {
|
|
371
|
+
let best = null;
|
|
372
|
+
let bestMtime = -1;
|
|
373
|
+
for (const file of files) {
|
|
374
|
+
const mtime = mtimeOf(file);
|
|
375
|
+
if (mtime > bestMtime) {
|
|
376
|
+
bestMtime = mtime;
|
|
377
|
+
best = file;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return best;
|
|
381
|
+
}
|
|
382
|
+
/** Source 1: the most recent `.peaks/_runtime/<sid>/rd/mock-plan.md`. */
|
|
383
|
+
export function findMockPlan(projectRoot) {
|
|
384
|
+
const runtime = join(projectRoot, '.peaks', '_runtime');
|
|
385
|
+
const candidates = listDirs(runtime)
|
|
386
|
+
.map((sid) => join(runtime, sid, 'rd', 'mock-plan.md'))
|
|
387
|
+
.filter((file) => mtimeOf(file) >= 0);
|
|
388
|
+
return newest(candidates);
|
|
389
|
+
}
|
|
390
|
+
/** Pulls the file paths a mock plan records (backticked or whitespace-delimited). */
|
|
391
|
+
export function extractMockPlanPaths(source) {
|
|
392
|
+
const paths = new Set();
|
|
393
|
+
const pattern = /`([^`\s]+\.(?:ts|tsx|js|jsx))`|(?:^|[\s(])([\w./@-]+\.(?:ts|tsx|js|jsx))/gm;
|
|
394
|
+
for (const match of source.matchAll(pattern)) {
|
|
395
|
+
const value = match[1] ?? match[2];
|
|
396
|
+
if (value !== undefined)
|
|
397
|
+
paths.add(value);
|
|
398
|
+
}
|
|
399
|
+
return [...paths];
|
|
400
|
+
}
|
|
401
|
+
/** Source 2: recorded interface files — mock-plan-named plus the prescribed `src/services/types/*-api.types.ts` layout. */
|
|
402
|
+
export function findRecordedInterfaceFiles(projectRoot, mockPlan) {
|
|
403
|
+
const files = new Set();
|
|
404
|
+
if (mockPlan !== null) {
|
|
405
|
+
for (const raw of extractMockPlanPaths(readTextOr(mockPlan, ''))) {
|
|
406
|
+
if (!raw.endsWith('-api.types.ts'))
|
|
407
|
+
continue;
|
|
408
|
+
const absolute = resolve(projectRoot, raw);
|
|
409
|
+
if (mtimeOf(absolute) >= 0)
|
|
410
|
+
files.add(absolute);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const file of listFiles(join(projectRoot, 'src', 'services', 'types'), '-api.types.ts'))
|
|
414
|
+
files.add(file);
|
|
415
|
+
return [...files].sort();
|
|
416
|
+
}
|
|
417
|
+
/** Source 3: the newest TXT handoff that actually carries an `## API Migration` section. */
|
|
418
|
+
export function findHandoffWithApiMigration(projectRoot) {
|
|
419
|
+
const runtime = join(projectRoot, '.peaks', '_runtime');
|
|
420
|
+
const candidates = [];
|
|
421
|
+
for (const sid of listDirs(runtime))
|
|
422
|
+
candidates.push(...listFiles(join(runtime, sid, 'txt'), '.md'));
|
|
423
|
+
const withSection = candidates.filter((file) => /^##\s+API Migration\s*$/m.test(readTextOr(file, '')));
|
|
424
|
+
return newest(withSection);
|
|
425
|
+
}
|
|
426
|
+
/** Parses the `## API Migration` body for `<METHOD> <path>` pairs. */
|
|
427
|
+
export function parseHandoffEndpoints(source) {
|
|
428
|
+
const heading = /^##\s+API Migration\s*$/m.exec(source);
|
|
429
|
+
if (!heading)
|
|
430
|
+
return [];
|
|
431
|
+
const rest = source.slice(heading.index + heading[0].length);
|
|
432
|
+
const nextHeading = /^##\s+/m.exec(rest);
|
|
433
|
+
const body = nextHeading ? rest.slice(0, nextHeading.index) : rest;
|
|
434
|
+
const seen = new Set();
|
|
435
|
+
const endpoints = [];
|
|
436
|
+
const pattern = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/[^\s`)\]|,;]*)/gi;
|
|
437
|
+
for (const match of body.matchAll(pattern)) {
|
|
438
|
+
const method = match[1].toLowerCase();
|
|
439
|
+
const path = match[2].replace(/[.,;:]+$/, '').replace(/\/+$/, '') || '/';
|
|
440
|
+
const key = `${method} ${path}`;
|
|
441
|
+
if (seen.has(key))
|
|
442
|
+
continue;
|
|
443
|
+
seen.add(key);
|
|
444
|
+
endpoints.push({ method, path });
|
|
445
|
+
}
|
|
446
|
+
return endpoints;
|
|
447
|
+
}
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Name pairing
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
/**
|
|
452
|
+
* Key used to pair a document operation with a recorded interface. Deliberately
|
|
453
|
+
* conservative: only lowercasing, punctuation removal, and one trailing
|
|
454
|
+
* `Response`/`Request`/`Dto`/`Payload`/`Body` strip. Verb stripping (`getUser`
|
|
455
|
+
* -> `user`) was considered and rejected — a false pairing produces a
|
|
456
|
+
* confidently wrong diff, which is worse than the visible false negative of an
|
|
457
|
+
* unpaired interface (design §2.3).
|
|
458
|
+
*/
|
|
459
|
+
export function pairingKey(name) {
|
|
460
|
+
let key = name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
461
|
+
for (const suffix of ['response', 'request', 'payload', 'dto', 'body']) {
|
|
462
|
+
if (key.length > suffix.length && key.endsWith(suffix)) {
|
|
463
|
+
key = key.slice(0, -suffix.length);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return key;
|
|
468
|
+
}
|
|
469
|
+
export function operationKey(operation) {
|
|
470
|
+
if (operation.operationId !== undefined)
|
|
471
|
+
return pairingKey(operation.operationId);
|
|
472
|
+
return pairingKey(`${operation.method}${operation.path.replace(/[{}]/g, '')}`);
|
|
473
|
+
}
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Candidate name-grep
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
function escapeRegex(text) {
|
|
478
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
479
|
+
}
|
|
480
|
+
function collectScanFiles(root, out, depth) {
|
|
481
|
+
if (depth > 12)
|
|
482
|
+
return;
|
|
483
|
+
for (const entry of (() => {
|
|
484
|
+
try {
|
|
485
|
+
return readdirSync(root, { withFileTypes: true });
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
})()) {
|
|
491
|
+
if (entry.isDirectory()) {
|
|
492
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
493
|
+
collectScanFiles(join(root, entry.name), out, depth + 1);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (!entry.isFile())
|
|
497
|
+
continue;
|
|
498
|
+
if (!SCAN_EXTENSIONS.some((ext) => entry.name.toLowerCase().endsWith(ext)))
|
|
499
|
+
continue;
|
|
500
|
+
const file = join(root, entry.name);
|
|
501
|
+
let size = Number.MAX_SAFE_INTEGER;
|
|
502
|
+
try {
|
|
503
|
+
size = statSync(file).size;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Unreadable files are skipped by the size guard below, not by throwing.
|
|
507
|
+
}
|
|
508
|
+
if (size <= MAX_SCAN_FILE_BYTES)
|
|
509
|
+
out.push(file);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Greps every candidate name across the consumer's source and non-TS assets.
|
|
514
|
+
* Over-reports (test fixtures, unrelated identifiers) and under-reports (i18n
|
|
515
|
+
* keys, AntD `columns` arrays, monorepo barrels) — the report says so.
|
|
516
|
+
*
|
|
517
|
+
* `exclude` carries the OpenAPI document itself: it is the input, not a change
|
|
518
|
+
* site, and every field name would otherwise match on its own definition line.
|
|
519
|
+
*/
|
|
520
|
+
export function grepCandidateMentions(projectRoot, names, exclude = []) {
|
|
521
|
+
const ordered = [...new Set(names)].sort((a, b) => b.length - a.length);
|
|
522
|
+
const truncated = ordered.length > MAX_CANDIDATE_NAMES;
|
|
523
|
+
const capped = ordered.slice(0, MAX_CANDIDATE_NAMES);
|
|
524
|
+
let hitsTruncated = false;
|
|
525
|
+
if (capped.length === 0)
|
|
526
|
+
return { mentions: [], truncated: false, hitsTruncated: false };
|
|
527
|
+
const skip = new Set(exclude.map((file) => resolve(file)));
|
|
528
|
+
const hits = new Map();
|
|
529
|
+
const pattern = new RegExp(`\\b(${capped.map(escapeRegex).join('|')})\\b`, 'g');
|
|
530
|
+
const files = [];
|
|
531
|
+
collectScanFiles(projectRoot, files, 0);
|
|
532
|
+
for (const file of files) {
|
|
533
|
+
if (skip.has(resolve(file)))
|
|
534
|
+
continue;
|
|
535
|
+
const text = readTextOr(file, '');
|
|
536
|
+
if (text === '')
|
|
537
|
+
continue;
|
|
538
|
+
const display = toDisplayPath(projectRoot, file);
|
|
539
|
+
const lines = text.split(/\r?\n/);
|
|
540
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
541
|
+
const line = lines[index];
|
|
542
|
+
pattern.lastIndex = 0;
|
|
543
|
+
let match = pattern.exec(line);
|
|
544
|
+
while (match !== null) {
|
|
545
|
+
const name = match[1];
|
|
546
|
+
const bucket = hits.get(name) ?? [];
|
|
547
|
+
const lineNumber = index + 1;
|
|
548
|
+
const already = bucket.some((hit) => hit.file === display && hit.line === lineNumber);
|
|
549
|
+
if (!already) {
|
|
550
|
+
if (bucket.length < MAX_HITS_PER_NAME) {
|
|
551
|
+
bucket.push({ file: display, line: lineNumber });
|
|
552
|
+
hits.set(name, bucket);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
// A truncated list must say so, or it reads as the complete set.
|
|
556
|
+
hitsTruncated = true;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
match = pattern.exec(line);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const mentions = capped
|
|
564
|
+
.filter((name) => hits.has(name))
|
|
565
|
+
.map((name) => ({ confidence: 'candidate', name, hits: hits.get(name) }));
|
|
566
|
+
return { mentions, truncated, hitsTruncated };
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Canonical endpoint path for comparison: `{id}` and `:id` are the same
|
|
570
|
+
* endpoint spelled two ways, and raw equality reported them as an exact
|
|
571
|
+
* ADDED + REMOVED pair. Parameter NAMES are collapsed too — `/users/{id}` and
|
|
572
|
+
* `/users/{userId}` are one endpoint.
|
|
573
|
+
*/
|
|
574
|
+
export function normalizeEndpointPath(path) {
|
|
575
|
+
const collapsed = path.replace(/\{[^/}]*\}/g, '{}').replace(/:[^/]+/g, '{}');
|
|
576
|
+
return collapsed.replace(/\/+$/, '') || '/';
|
|
577
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 / rid=api-diff-report — `peaks scan api-diff <doc>` (design
|
|
3
|
+
* `docs/superpowers/specs/2026-09-12-frontend-acl-contract-design.md` §2.1/§2.3).
|
|
4
|
+
*
|
|
5
|
+
* Read-only. Parses an OpenAPI 3.x document (JSON or YAML — `yaml` is already a
|
|
6
|
+
* runtime dependency) and diffs it against the three sources a consumer project
|
|
7
|
+
* already produces: `mock-plan.md`, the recorded `*-api.types.ts` interfaces,
|
|
8
|
+
* and the TXT handoff's `## API Migration` endpoint list.
|
|
9
|
+
*
|
|
10
|
+
* TWO RULES GOVERN EVERY LINE BELOW (QA repair, rid=api-diff-report):
|
|
11
|
+
*
|
|
12
|
+
* 1. Each document LOCATION is diffed against the recorded interface that
|
|
13
|
+
* actually describes it. An operation has independent locations (path /
|
|
14
|
+
* query / requestBody / per-status response) and a `...Request` interface
|
|
15
|
+
* must never be compared against a response. No shared consumption across
|
|
16
|
+
* locations.
|
|
17
|
+
* 2. Exactness requires BOTH sides to be fully known. Any uncertainty on the
|
|
18
|
+
* recorded side SUPPRESSES the exact claim and says so — it is never
|
|
19
|
+
* merely annotated. A half-readable interface silently invents
|
|
20
|
+
* `added-in-document` / `removed-from-document` lines.
|
|
21
|
+
*
|
|
22
|
+
* This is the public entrypoint; parsing (`api-diff-openapi`), the recorded
|
|
23
|
+
* sources (`api-diff-recorded`) and the shared types (`api-diff-types`) live in
|
|
24
|
+
* sibling modules and are re-exported here.
|
|
25
|
+
*/
|
|
26
|
+
import { type ApiDiffReport } from './api-diff-types.js';
|
|
27
|
+
export * from './api-diff-openapi.js';
|
|
28
|
+
export * from './api-diff-recorded.js';
|
|
29
|
+
export * from './api-diff-types.js';
|
|
30
|
+
export declare function diffApiDocument(input: {
|
|
31
|
+
projectRoot: string;
|
|
32
|
+
docPath: string;
|
|
33
|
+
}): ApiDiffReport;
|
|
34
|
+
export declare function formatApiDiffText(report: ApiDiffReport): string;
|