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.
Files changed (52) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/_register.js +4 -0
  5. package/dist/cli/commands/api-diff-commands.d.ts +16 -0
  6. package/dist/cli/commands/api-diff-commands.js +55 -0
  7. package/dist/cli/commands/audit-commands.d.ts +16 -3
  8. package/dist/cli/commands/audit-commands.js +84 -31
  9. package/dist/cli/commands/job-commands.js +4 -2
  10. package/dist/cli/commands/scan-commands.js +1 -1
  11. package/dist/cli/commands/test-commands.d.ts +60 -3
  12. package/dist/cli/commands/test-commands.js +125 -7
  13. package/dist/services/audit/audit-goal-service.js +38 -3
  14. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.d.ts +65 -0
  15. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.js +186 -0
  16. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  17. package/dist/services/doctor/doctor-service/types.d.ts +20 -0
  18. package/dist/services/hooks/write-gate.js +32 -9
  19. package/dist/services/llm/anthropic-runner.d.ts +87 -0
  20. package/dist/services/llm/anthropic-runner.js +171 -0
  21. package/dist/services/llm/stub-runner.d.ts +11 -0
  22. package/dist/services/llm/stub-runner.js +33 -0
  23. package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
  24. package/dist/services/scan/api-diff-openapi.d.ts +32 -0
  25. package/dist/services/scan/api-diff-openapi.js +359 -0
  26. package/dist/services/scan/api-diff-recorded.d.ts +96 -0
  27. package/dist/services/scan/api-diff-recorded.js +577 -0
  28. package/dist/services/scan/api-diff-service.d.ts +34 -0
  29. package/dist/services/scan/api-diff-service.js +407 -0
  30. package/dist/services/scan/api-diff-types.d.ts +116 -0
  31. package/dist/services/scan/api-diff-types.js +46 -0
  32. package/dist/services/scan/archetype-service.js +27 -1
  33. package/dist/services/scan/existing-system-service.js +17 -4
  34. package/dist/services/scan/hook-convention-service.d.ts +26 -0
  35. package/dist/services/scan/hook-convention-service.js +562 -0
  36. package/dist/services/scan/scan-types.d.ts +47 -0
  37. package/dist/services/session/caller-binding-service.d.ts +28 -0
  38. package/dist/services/session/caller-binding-service.js +10 -2
  39. package/dist/services/session/caller-id-types.d.ts +12 -2
  40. package/dist/services/session/index.d.ts +2 -2
  41. package/dist/services/session/index.js +2 -2
  42. package/dist/services/session/session-binding-bridge.js +11 -6
  43. package/dist/services/session/session-manager.d.ts +33 -1
  44. package/dist/services/session/session-manager.js +84 -25
  45. package/dist/services/skills/skill-presence-service.d.ts +17 -3
  46. package/dist/services/skills/skill-presence-service.js +23 -3
  47. package/package.json +5 -5
  48. package/skills/bee/peaks-rd/SKILL.md +11 -3
  49. package/skills/peaks-code/references/existing-system-extraction.md +5 -1
  50. package/skills/peaks-code/references/frontend-only-mode.md +48 -6
  51. package/skills/peaks-code/references/project-scan-checklist.md +20 -1
  52. 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;