peaks-loop 4.0.42 → 4.0.44
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 +59 -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/codegraph-commands.js +191 -6
- package/dist/cli/commands/final-review-commands.d.ts +34 -10
- package/dist/cli/commands/final-review-commands.js +130 -34
- package/dist/cli/commands/job-commands.js +4 -2
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/share-commands.d.ts +49 -0
- package/dist/cli/commands/share-commands.js +114 -14
- 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/codegraph/codegraph-autorefresh.js +12 -0
- package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
- package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
- package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
- package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
- package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
- package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
- package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
- package/dist/services/codegraph/codegraph-service.d.ts +0 -1
- package/dist/services/codegraph/codegraph-service.js +5 -4
- package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
- package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
- 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 +4 -0
- package/dist/services/doctor/doctor-service/types.d.ts +47 -0
- package/dist/services/final-review/final-review-service.d.ts +154 -0
- package/dist/services/final-review/final-review-service.js +621 -7
- package/dist/services/final-review/index.d.ts +1 -1
- package/dist/services/final-review/index.js +1 -1
- 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/handoff-auto-regen.js +0 -1
- package/dist/services/prd/handoff-service.d.ts +9 -1
- package/dist/services/prd/handoff-service.js +48 -6
- 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 +7 -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
- package/skills/peaks-final-review/SKILL.md +43 -32
|
@@ -0,0 +1,407 @@
|
|
|
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 { readFileSync } from 'node:fs';
|
|
27
|
+
import { resolve } from 'node:path';
|
|
28
|
+
import { loadApiDocument, normalizeType } from './api-diff-openapi.js';
|
|
29
|
+
import { MAX_CANDIDATE_NAMES, findHandoffWithApiMigration, findMockPlan, findRecordedInterfaceFiles, grepCandidateMentions, locationsForRole, normalizeEndpointPath, operationKey, pairingKey, parseHandoffEndpoints, parseRecordedInterfaces, roleOf } from './api-diff-recorded.js';
|
|
30
|
+
import { ABSENT, NOT_DETECTABLE, toDisplayPath } from './api-diff-types.js';
|
|
31
|
+
// The public surface of the whole api-diff feature, so callers (and the CLI)
|
|
32
|
+
// need only ever import this one module.
|
|
33
|
+
export * from './api-diff-openapi.js';
|
|
34
|
+
export * from './api-diff-recorded.js';
|
|
35
|
+
export * from './api-diff-types.js';
|
|
36
|
+
const MAX_LISTED = 20;
|
|
37
|
+
/** Bounded comma list for a note. Notes are read by humans, so they must not be unbounded. */
|
|
38
|
+
function summarizeList(items) {
|
|
39
|
+
if (items.length === 0)
|
|
40
|
+
return '(none)';
|
|
41
|
+
if (items.length <= MAX_LISTED)
|
|
42
|
+
return items.join(', ');
|
|
43
|
+
return `${items.slice(0, MAX_LISTED).join(', ')}, +${items.length - MAX_LISTED} more`;
|
|
44
|
+
}
|
|
45
|
+
/** `GET /api/users/{id} response.200`, the unit a note has to be able to name. */
|
|
46
|
+
function locationLabel(operation, location) {
|
|
47
|
+
return `${operation.method.toUpperCase()} ${operation.path} ${location}`;
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// The diff
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/**
|
|
53
|
+
* Diffs ONE document location against ONE recorded interface.
|
|
54
|
+
*
|
|
55
|
+
* No state is shared between calls: an interface is only ever compared against
|
|
56
|
+
* locations of its own role, so a member consumed at a response location can no
|
|
57
|
+
* longer hide a request field (or vice versa).
|
|
58
|
+
*/
|
|
59
|
+
function compareLocation(operation, location, members, out) {
|
|
60
|
+
const docFields = operation.locations.get(location);
|
|
61
|
+
const recordedNormalized = new Map();
|
|
62
|
+
for (const [name, type] of members)
|
|
63
|
+
recordedNormalized.set(name, normalizeType(type));
|
|
64
|
+
const docOnly = [];
|
|
65
|
+
const recordedOnly = [];
|
|
66
|
+
const consumedDoc = new Set();
|
|
67
|
+
const consumedRecorded = new Set();
|
|
68
|
+
// Pass 1 — same name on both sides.
|
|
69
|
+
for (const [name, type] of docFields) {
|
|
70
|
+
const before = recordedNormalized.get(name);
|
|
71
|
+
if (before === undefined) {
|
|
72
|
+
docOnly.push(name);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
consumedDoc.add(name);
|
|
76
|
+
consumedRecorded.add(name);
|
|
77
|
+
const after = normalizeType(type);
|
|
78
|
+
if (before !== after) {
|
|
79
|
+
out.push({
|
|
80
|
+
confidence: 'exact', kind: 'changed', via: 'type-changed', method: operation.method,
|
|
81
|
+
path: operation.path, location, before: { field: name, type: before }, after: { field: name, type: after }
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const name of recordedNormalized.keys()) {
|
|
86
|
+
if (!docFields.has(name))
|
|
87
|
+
recordedOnly.push(name);
|
|
88
|
+
}
|
|
89
|
+
// Pass 2 — rename pairing for the leftovers, and ONLY when the type is
|
|
90
|
+
// identical and each side offers exactly one such candidate. Ambiguity is
|
|
91
|
+
// left alone: a guessed pairing is invisible, an unpaired field is not.
|
|
92
|
+
for (const docName of docOnly) {
|
|
93
|
+
const docType = normalizeType(docFields.get(docName));
|
|
94
|
+
const docCandidates = docOnly.filter((other) => normalizeType(docFields.get(other)) === docType);
|
|
95
|
+
const recordedCandidates = recordedOnly.filter((other) => recordedNormalized.get(other) === docType);
|
|
96
|
+
if (docCandidates.length !== 1 || recordedCandidates.length !== 1)
|
|
97
|
+
continue;
|
|
98
|
+
const recordedName = recordedCandidates[0];
|
|
99
|
+
consumedDoc.add(docName);
|
|
100
|
+
consumedRecorded.add(recordedName);
|
|
101
|
+
out.push({
|
|
102
|
+
confidence: 'exact', kind: 'changed', via: 'renamed', method: operation.method,
|
|
103
|
+
path: operation.path, location, before: { field: recordedName, type: docType },
|
|
104
|
+
after: { field: docName, type: docType }
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// Pass 3 — genuine one-sided fields are change sites, not silence.
|
|
108
|
+
for (const docName of docOnly) {
|
|
109
|
+
if (consumedDoc.has(docName))
|
|
110
|
+
continue;
|
|
111
|
+
consumedDoc.add(docName);
|
|
112
|
+
out.push({
|
|
113
|
+
confidence: 'exact', kind: 'changed', via: 'added-in-document', method: operation.method,
|
|
114
|
+
path: operation.path, location, before: { field: docName, type: ABSENT },
|
|
115
|
+
after: { field: docName, type: normalizeType(docFields.get(docName)) }
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const recordedName of recordedOnly) {
|
|
119
|
+
if (consumedRecorded.has(recordedName))
|
|
120
|
+
continue;
|
|
121
|
+
consumedRecorded.add(recordedName);
|
|
122
|
+
out.push({
|
|
123
|
+
confidence: 'exact', kind: 'changed', via: 'removed-from-document', method: operation.method,
|
|
124
|
+
path: operation.path, location, before: { field: recordedName, type: recordedNormalized.get(recordedName) },
|
|
125
|
+
after: { field: recordedName, type: ABSENT }
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Endpoint-level diff. Both sides parsed; `{id}` and `:id` are normalised to one spelling first. */
|
|
130
|
+
function diffEndpoints(operations, recorded) {
|
|
131
|
+
const endpoints = [];
|
|
132
|
+
if (recorded.length === 0)
|
|
133
|
+
return endpoints;
|
|
134
|
+
const key = (method, path) => `${method} ${normalizeEndpointPath(path)}`;
|
|
135
|
+
const recordedKeys = new Set(recorded.map((entry) => key(entry.method, entry.path)));
|
|
136
|
+
const documentKeys = new Set();
|
|
137
|
+
for (const operation of operations) {
|
|
138
|
+
const current = key(operation.method, operation.path);
|
|
139
|
+
documentKeys.add(current);
|
|
140
|
+
if (!recordedKeys.has(current)) {
|
|
141
|
+
endpoints.push({ confidence: 'exact', kind: 'added', method: operation.method, path: operation.path });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const entry of recorded) {
|
|
145
|
+
if (!documentKeys.has(key(entry.method, entry.path))) {
|
|
146
|
+
endpoints.push({ confidence: 'exact', kind: 'removed', method: entry.method, path: entry.path });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return endpoints;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Why nothing paired, what the convention actually is, and which link is
|
|
153
|
+
* missing — said with THIS document's own names so the fix is mechanical.
|
|
154
|
+
* Deliberately no path-based or fuzzy fallback: a confident wrong pairing is
|
|
155
|
+
* worse than an honest non-pairing, and the Exact label rests on that.
|
|
156
|
+
*/
|
|
157
|
+
function unpairedNote(operations, interfaces) {
|
|
158
|
+
const operationIds = [
|
|
159
|
+
...new Set(operations.map((operation) => operation.operationId).filter((id) => id !== undefined))
|
|
160
|
+
];
|
|
161
|
+
const exampleId = operationIds[0];
|
|
162
|
+
const convention = exampleId === undefined
|
|
163
|
+
? 'this document declares no `operationId`, so the pairing key falls back to `<method><path>`'
|
|
164
|
+
: `operationId \`${exampleId}\` would pair with an interface named \`${exampleId.charAt(0).toUpperCase()}${exampleId.slice(1)}Response\``;
|
|
165
|
+
return `0 of ${interfaces.length} recorded interface(s) paired with a document operation, so no field-level exact diff was produced. `
|
|
166
|
+
+ `Pairing is name-only — lowercased, punctuation stripped, one trailing Response/Request/Dto/Payload/Body removed — so ${convention}. `
|
|
167
|
+
+ `Document operationIds: ${summarizeList(operationIds)}. `
|
|
168
|
+
+ `Unpaired interfaces: ${summarizeList(interfaces.map((iface) => iface.name))}. `
|
|
169
|
+
+ 'Rename one side so the two names match; this command will not guess a pairing.';
|
|
170
|
+
}
|
|
171
|
+
export function diffApiDocument(input) {
|
|
172
|
+
// The doc path is the user's own argument, so a relative one resolves against
|
|
173
|
+
// the process CWD — the shell convention. `--project` scopes the analysis; it
|
|
174
|
+
// must NOT re-root the argument, which produced doubled paths such as
|
|
175
|
+
// `<project>/<project>/docs/api.json`.
|
|
176
|
+
const projectRoot = resolve(input.projectRoot);
|
|
177
|
+
const docPath = resolve(input.docPath);
|
|
178
|
+
const document = loadApiDocument(docPath);
|
|
179
|
+
const notes = [];
|
|
180
|
+
const mockPlan = findMockPlan(projectRoot);
|
|
181
|
+
if (mockPlan === null) {
|
|
182
|
+
notes.push('no mock-plan.md found under .peaks/_runtime/*/rd/ — mock file paths are unknown.');
|
|
183
|
+
}
|
|
184
|
+
const interfaceFiles = findRecordedInterfaceFiles(projectRoot, mockPlan);
|
|
185
|
+
const interfaces = [];
|
|
186
|
+
for (const file of interfaceFiles) {
|
|
187
|
+
try {
|
|
188
|
+
interfaces.push(...parseRecordedInterfaces(readFileSync(file, 'utf8'), file));
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
notes.push(`could not read recorded interface file ${toDisplayPath(projectRoot, file)}: ${error.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (interfaces.length === 0) {
|
|
195
|
+
notes.push('no recorded interfaces found — exact field diff limited to document-internal changes.');
|
|
196
|
+
}
|
|
197
|
+
const handoff = findHandoffWithApiMigration(projectRoot);
|
|
198
|
+
const recordedEndpoints = handoff === null ? [] : parseHandoffEndpoints(readFileSync(handoff, 'utf8'));
|
|
199
|
+
if (handoff === null) {
|
|
200
|
+
notes.push('no TXT handoff with an `## API Migration` section found under .peaks/_runtime/*/txt/ — no recorded endpoint list to diff endpoints against.');
|
|
201
|
+
}
|
|
202
|
+
else if (recordedEndpoints.length === 0) {
|
|
203
|
+
notes.push(`the \`## API Migration\` section in ${toDisplayPath(projectRoot, handoff)} lists no \`METHOD /path\` endpoint — no recorded endpoint list to diff endpoints against.`);
|
|
204
|
+
}
|
|
205
|
+
// Pairing is by name AND by role. An operation may legitimately pair with two
|
|
206
|
+
// interfaces — one `...Request` and one `...Response` — so a name collision is
|
|
207
|
+
// only ambiguous within a single role.
|
|
208
|
+
const byKey = new Map();
|
|
209
|
+
for (const iface of interfaces) {
|
|
210
|
+
const key = pairingKey(iface.name);
|
|
211
|
+
byKey.set(key, [...(byKey.get(key) ?? []), iface]);
|
|
212
|
+
}
|
|
213
|
+
const fields = [];
|
|
214
|
+
const matchedInterfaces = new Set();
|
|
215
|
+
const coveredLocations = new Set();
|
|
216
|
+
const suppressedInterfaces = new Map();
|
|
217
|
+
const operationsWithRecords = new Set();
|
|
218
|
+
const pairedWithoutLocation = [];
|
|
219
|
+
const incompleteLocations = [];
|
|
220
|
+
const unpairedOperations = [];
|
|
221
|
+
for (const operation of document.operations) {
|
|
222
|
+
const candidates = byKey.get(operationKey(operation)) ?? [];
|
|
223
|
+
if (candidates.length === 0) {
|
|
224
|
+
unpairedOperations.push(`${operation.method.toUpperCase()} ${operation.path}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
for (const candidate of candidates)
|
|
228
|
+
matchedInterfaces.add(candidate);
|
|
229
|
+
const byRole = new Map();
|
|
230
|
+
for (const candidate of candidates) {
|
|
231
|
+
const role = roleOf(candidate.name);
|
|
232
|
+
byRole.set(role, [...(byRole.get(role) ?? []), candidate]);
|
|
233
|
+
}
|
|
234
|
+
const where = `${operation.method.toUpperCase()} ${operation.path}`;
|
|
235
|
+
for (const [role, list] of byRole) {
|
|
236
|
+
if (role === 'unknown') {
|
|
237
|
+
notes.push(`${where}: recorded interface(s) ${list.map((iface) => `\`${iface.name}\``).join(', ')} name neither a request nor a response, so this command cannot tell which part of the operation they describe — pairing refused rather than guessed; their fields are not in the exact diff.`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (list.length > 1) {
|
|
241
|
+
notes.push(`${where}: ${list.length} recorded ${role} interfaces match (${list.map((iface) => `\`${iface.name}\``).join(', ')}) — pairing refused rather than guessed; their fields are not in the exact diff.`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const iface = list[0];
|
|
245
|
+
if (iface.incompleteReason !== null) {
|
|
246
|
+
suppressedInterfaces.set(iface, iface.incompleteReason);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const roleLocations = locationsForRole(operation, role);
|
|
250
|
+
// A response interface's name carries no status, so with more than one
|
|
251
|
+
// JSON response body there is no way to know which one it describes.
|
|
252
|
+
if (role === 'response' && roleLocations.length > 1) {
|
|
253
|
+
notes.push(`${where}: ${roleLocations.length} response statuses have JSON bodies (${roleLocations.map((location) => location.slice('response.'.length)).join(', ')}) and \`${iface.name}\` names no status, so this command cannot tell which one it describes — pairing refused rather than guessed; its fields are not in the exact diff.`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
operationsWithRecords.add(operation);
|
|
257
|
+
if (roleLocations.length === 0) {
|
|
258
|
+
// A pairing that yields no location must still be named, or it is a
|
|
259
|
+
// silent hole: the interface looks consumed and nothing was diffed.
|
|
260
|
+
pairedWithoutLocation.push(`${where} (recorded \`${iface.name}\` describes the ${role === 'request' ? 'requestBody' : 'responses'})`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
for (const location of roleLocations) {
|
|
264
|
+
const label = locationLabel(operation, location);
|
|
265
|
+
coveredLocations.add(label);
|
|
266
|
+
// The doc-side half of the inverted rule: a location the DOC reader
|
|
267
|
+
// could not fully account for is suppressed, because a partial field set
|
|
268
|
+
// is indistinguishable from a complete one.
|
|
269
|
+
const issue = operation.locationIssues.get(location);
|
|
270
|
+
if (issue !== undefined) {
|
|
271
|
+
incompleteLocations.push(`${label} (${issue})`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
compareLocation(operation, location, iface.members, fields);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (interfaces.length > 0) {
|
|
279
|
+
if (matchedInterfaces.size === 0) {
|
|
280
|
+
notes.push(unpairedNote(document.operations, interfaces));
|
|
281
|
+
}
|
|
282
|
+
else if (unpairedOperations.length > 0) {
|
|
283
|
+
// A MIX must never be silent: one paired operation used to be enough for
|
|
284
|
+
// the document to report "(no exact differences)" with no note at all.
|
|
285
|
+
notes.push(`${unpairedOperations.length} document operation(s) matched no recorded interface, so their fields are not in the exact diff: ${summarizeList(unpairedOperations)}. Interfaces pair by name: operationId \`getUser\` pairs with an interface named \`GetUserResponse\`.`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (incompleteLocations.length > 0) {
|
|
289
|
+
notes.push(`${incompleteLocations.length} document location(s) could not be fully read, so every exact line for them is suppressed rather than guessed: ${summarizeList(incompleteLocations)}.`);
|
|
290
|
+
}
|
|
291
|
+
if (suppressedInterfaces.size > 0) {
|
|
292
|
+
// The file is named too: a whole-file structural failure otherwise reads as
|
|
293
|
+
// one stray interface, and the reader has no idea where to look.
|
|
294
|
+
const listed = [...suppressedInterfaces]
|
|
295
|
+
.map(([iface, reason]) => `\`${iface.name}\` in ${toDisplayPath(projectRoot, iface.file)} (${reason})`)
|
|
296
|
+
.join('; ');
|
|
297
|
+
notes.push(`${suppressedInterfaces.size} recorded interface(s) are not fully readable even though the file parsed, so every exact line they would have produced is suppressed rather than guessed: ${summarizeList([listed])}.`);
|
|
298
|
+
}
|
|
299
|
+
if (pairedWithoutLocation.length > 0) {
|
|
300
|
+
notes.push(`${pairedWithoutLocation.length} recorded interface(s) paired with an operation that declares no matching location, so no exact line was produced for them: ${summarizeList(pairedWithoutLocation)}.`);
|
|
301
|
+
}
|
|
302
|
+
if (operationsWithRecords.size > 0) {
|
|
303
|
+
const uncovered = [];
|
|
304
|
+
for (const operation of operationsWithRecords) {
|
|
305
|
+
for (const location of operation.locations.keys()) {
|
|
306
|
+
// Only locations a body interface can actually describe. Path/query
|
|
307
|
+
// params are structurally never recorded, so flagging them would fire
|
|
308
|
+
// on nearly every operation and teach the reader to skip notes.
|
|
309
|
+
if (location !== 'request' && !location.startsWith('response.'))
|
|
310
|
+
continue;
|
|
311
|
+
if (!coveredLocations.has(locationLabel(operation, location))) {
|
|
312
|
+
uncovered.push(locationLabel(operation, location));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (uncovered.length > 0) {
|
|
317
|
+
notes.push(`${uncovered.length} document location(s) have no recorded interface of the matching role, so no exact line was produced for them: ${summarizeList(uncovered)}.`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Candidate names come from the DIFF, never from "everything not verified
|
|
321
|
+
// unchanged": grepping a field that did not change is pure noise, and a
|
|
322
|
+
// section whose every line is noise trains the reader to ignore the section —
|
|
323
|
+
// the exact failure this feature exists to prevent. A changed field
|
|
324
|
+
// contributes BOTH spellings, because the old name is what the current code
|
|
325
|
+
// still says; a one-sided field contributes only the side that exists.
|
|
326
|
+
const candidateNames = new Set();
|
|
327
|
+
for (const entry of fields) {
|
|
328
|
+
if (entry.before.type !== ABSENT)
|
|
329
|
+
candidateNames.add(entry.before.field);
|
|
330
|
+
if (entry.after.type !== ABSENT)
|
|
331
|
+
candidateNames.add(entry.after.field);
|
|
332
|
+
}
|
|
333
|
+
const { mentions, truncated, hitsTruncated } = grepCandidateMentions(projectRoot, [...candidateNames], [docPath]);
|
|
334
|
+
if (truncated) {
|
|
335
|
+
notes.push(`candidate names capped at ${MAX_CANDIDATE_NAMES}; some changed fields were not name-grepped.`);
|
|
336
|
+
}
|
|
337
|
+
if (hitsTruncated) {
|
|
338
|
+
notes.push('candidate hits are capped per name; some change-site lines were not listed, so treat those lists as examples rather than the full set.');
|
|
339
|
+
}
|
|
340
|
+
if (fields.length === 0) {
|
|
341
|
+
// Only claim "nothing was parsed" when that is actually the case — after a
|
|
342
|
+
// suppression the interface WAS parsed, and saying otherwise misdirects.
|
|
343
|
+
notes.push(interfaces.length === 0
|
|
344
|
+
? 'no candidate change-sites: change-site lookup needs a parsed recorded interface to know what changed.'
|
|
345
|
+
: 'no candidate change-sites: no exact field change was produced (see the notes above), so there is nothing to search for.');
|
|
346
|
+
}
|
|
347
|
+
else if (mentions.length === 0) {
|
|
348
|
+
notes.push('name-grep found no mentions of the changed names in this project.');
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
document: {
|
|
352
|
+
file: toDisplayPath(projectRoot, docPath),
|
|
353
|
+
openapi: document.openapi,
|
|
354
|
+
title: document.title ?? null,
|
|
355
|
+
operationCount: document.operations.length
|
|
356
|
+
},
|
|
357
|
+
exact: { endpoints: diffEndpoints(document.operations, recordedEndpoints), fields },
|
|
358
|
+
candidates: mentions,
|
|
359
|
+
sources: {
|
|
360
|
+
mockPlan: mockPlan === null ? null : toDisplayPath(projectRoot, mockPlan),
|
|
361
|
+
interfaceFiles: interfaceFiles.map((file) => toDisplayPath(projectRoot, file)),
|
|
362
|
+
handoff: handoff === null ? null : toDisplayPath(projectRoot, handoff),
|
|
363
|
+
recordedEndpoints: recordedEndpoints.length
|
|
364
|
+
},
|
|
365
|
+
notes,
|
|
366
|
+
notDetectable: NOT_DETECTABLE
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Text rendering
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
function renderFieldLine(entry) {
|
|
373
|
+
const beforeRef = entry.before.field === entry.after.field
|
|
374
|
+
? `${entry.location}.${entry.before.field}`
|
|
375
|
+
: `${entry.location}.${entry.before.field} -> ${entry.location}.${entry.after.field}`;
|
|
376
|
+
return ` ${'CHANGED'.padEnd(9)} ${entry.method.toUpperCase()} ${entry.path} ${beforeRef} ${entry.before.type} -> ${entry.after.type}`;
|
|
377
|
+
}
|
|
378
|
+
export function formatApiDiffText(report) {
|
|
379
|
+
const lines = [];
|
|
380
|
+
// The header names the two parsed sides; the parenthetical fixes the direction
|
|
381
|
+
// of every `before -> after` line, which is otherwise ambiguous.
|
|
382
|
+
lines.push('Exact — document parsed vs recorded interfaces parsed (CHANGED reads recorded -> document)');
|
|
383
|
+
if (report.exact.endpoints.length === 0 && report.exact.fields.length === 0) {
|
|
384
|
+
lines.push(' (no exact differences)');
|
|
385
|
+
}
|
|
386
|
+
for (const entry of report.exact.endpoints) {
|
|
387
|
+
lines.push(` ${(entry.kind === 'added' ? 'ADDED' : 'REMOVED').padEnd(9)} ${entry.method.toUpperCase()} ${entry.path}`);
|
|
388
|
+
}
|
|
389
|
+
for (const entry of report.exact.fields)
|
|
390
|
+
lines.push(renderFieldLine(entry));
|
|
391
|
+
for (const note of report.notes)
|
|
392
|
+
lines.push(` note: ${note}`);
|
|
393
|
+
lines.push('');
|
|
394
|
+
lines.push('Candidate mentions — name-grep, may OVER- and UNDER-report');
|
|
395
|
+
if (report.candidates.length === 0) {
|
|
396
|
+
lines.push(' (none)');
|
|
397
|
+
}
|
|
398
|
+
for (const mention of report.candidates) {
|
|
399
|
+
const where = mention.hits.map((hit) => `${hit.file}:${hit.line}`).join(' ');
|
|
400
|
+
lines.push(` ${mention.name} ${where}`);
|
|
401
|
+
}
|
|
402
|
+
lines.push('');
|
|
403
|
+
lines.push('Not detectable by this command');
|
|
404
|
+
for (const item of report.notDetectable)
|
|
405
|
+
lines.push(` - ${item}`);
|
|
406
|
+
return lines.join('\n');
|
|
407
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 / rid=api-diff-report — shared types for `peaks scan api-diff <doc>`
|
|
3
|
+
* (design `docs/superpowers/specs/2026-09-12-frontend-acl-contract-design.md`
|
|
4
|
+
* §2.1/§2.3).
|
|
5
|
+
*
|
|
6
|
+
* GOVERNING PRINCIPLE (QA repair): exactness requires BOTH sides to be fully
|
|
7
|
+
* known. Any uncertainty on the recorded side SUPPRESSES the exact claim — it
|
|
8
|
+
* is not merely annotated. `confidence: 'exact'` is reserved for lines where
|
|
9
|
+
* both sides were parsed AND the recorded interface was fully readable.
|
|
10
|
+
*/
|
|
11
|
+
/** The honest boundary of the feature. Printed in the output, not just documented. */
|
|
12
|
+
export declare const NOT_DETECTABLE: readonly string[];
|
|
13
|
+
export declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
14
|
+
/** Rendered type text for the side of a one-sided field, i.e. the field is not there at all. */
|
|
15
|
+
export declare const ABSENT = "(absent)";
|
|
16
|
+
export type Method = (typeof HTTP_METHODS)[number];
|
|
17
|
+
/**
|
|
18
|
+
* Which half of an operation a recorded interface describes, derived from the
|
|
19
|
+
* suffix its name carries. A location is only ever diffed against an interface
|
|
20
|
+
* of the matching role — one shared member map compared against every location
|
|
21
|
+
* is what produced a Request interface being consumed at a response location.
|
|
22
|
+
*/
|
|
23
|
+
export type InterfaceRole = 'request' | 'response' | 'unknown';
|
|
24
|
+
export type DocOperation = {
|
|
25
|
+
method: Method;
|
|
26
|
+
path: string;
|
|
27
|
+
operationId?: string;
|
|
28
|
+
/** Ordered `location -> leaf field name -> normalized type text`. */
|
|
29
|
+
locations: Map<string, Map<string, string>>;
|
|
30
|
+
/**
|
|
31
|
+
* `location -> why the DOC reader could not fully account for it`. The same
|
|
32
|
+
* inverted rule the recorded side gets: a partial field set is
|
|
33
|
+
* indistinguishable from a complete one, so an unreadable construct suppresses
|
|
34
|
+
* the location's exact lines instead of being presented as complete.
|
|
35
|
+
*/
|
|
36
|
+
locationIssues: Map<string, string>;
|
|
37
|
+
};
|
|
38
|
+
export type ParsedDocument = {
|
|
39
|
+
openapi: string;
|
|
40
|
+
title?: string;
|
|
41
|
+
operations: DocOperation[];
|
|
42
|
+
};
|
|
43
|
+
export type RecordedInterface = {
|
|
44
|
+
name: string;
|
|
45
|
+
file: string;
|
|
46
|
+
members: Map<string, string>;
|
|
47
|
+
/** Non-null when the extractor could not read the full member set — suppresses every exact line for this interface. */
|
|
48
|
+
incompleteReason: string | null;
|
|
49
|
+
};
|
|
50
|
+
export type RecordedEndpoint = {
|
|
51
|
+
method: Method;
|
|
52
|
+
path: string;
|
|
53
|
+
};
|
|
54
|
+
export type EndpointEntry = {
|
|
55
|
+
confidence: 'exact';
|
|
56
|
+
kind: 'added' | 'removed';
|
|
57
|
+
method: Method;
|
|
58
|
+
path: string;
|
|
59
|
+
};
|
|
60
|
+
export type FieldSide = {
|
|
61
|
+
field: string;
|
|
62
|
+
type: string;
|
|
63
|
+
};
|
|
64
|
+
export type FieldEntry = {
|
|
65
|
+
confidence: 'exact';
|
|
66
|
+
kind: 'changed';
|
|
67
|
+
via: 'type-changed' | 'renamed' | 'added-in-document' | 'removed-from-document';
|
|
68
|
+
method: Method;
|
|
69
|
+
path: string;
|
|
70
|
+
location: string;
|
|
71
|
+
before: FieldSide;
|
|
72
|
+
after: FieldSide;
|
|
73
|
+
};
|
|
74
|
+
export type CandidateMention = {
|
|
75
|
+
confidence: 'candidate';
|
|
76
|
+
name: string;
|
|
77
|
+
hits: readonly {
|
|
78
|
+
file: string;
|
|
79
|
+
line: number;
|
|
80
|
+
}[];
|
|
81
|
+
};
|
|
82
|
+
export type ApiDiffReport = {
|
|
83
|
+
document: {
|
|
84
|
+
file: string;
|
|
85
|
+
openapi: string;
|
|
86
|
+
title: string | null;
|
|
87
|
+
operationCount: number;
|
|
88
|
+
};
|
|
89
|
+
exact: {
|
|
90
|
+
endpoints: readonly EndpointEntry[];
|
|
91
|
+
fields: readonly FieldEntry[];
|
|
92
|
+
};
|
|
93
|
+
candidates: readonly CandidateMention[];
|
|
94
|
+
sources: {
|
|
95
|
+
mockPlan: string | null;
|
|
96
|
+
interfaceFiles: readonly string[];
|
|
97
|
+
handoff: string | null;
|
|
98
|
+
recordedEndpoints: number;
|
|
99
|
+
};
|
|
100
|
+
notes: readonly string[];
|
|
101
|
+
notDetectable: readonly string[];
|
|
102
|
+
};
|
|
103
|
+
/** Raised for a non-OpenAPI-3.x input so the CLI can exit non-zero with no diff output. */
|
|
104
|
+
export declare class ApiDiffInputError extends Error {
|
|
105
|
+
readonly code: string;
|
|
106
|
+
constructor(code: string, message: string);
|
|
107
|
+
}
|
|
108
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
109
|
+
/**
|
|
110
|
+
* Project-relative, forward-slashed path for display.
|
|
111
|
+
*
|
|
112
|
+
* Compares path SEGMENTS, not raw `startsWith`: with a naive prefix test a root
|
|
113
|
+
* of `D:/x` also "contains" `D:/x-archive/docs/api.json`, which then rendered as
|
|
114
|
+
* the misleading `archive/docs/api.json`.
|
|
115
|
+
*/
|
|
116
|
+
export declare function toDisplayPath(projectRoot: string, file: string): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 / rid=api-diff-report — shared types for `peaks scan api-diff <doc>`
|
|
3
|
+
* (design `docs/superpowers/specs/2026-09-12-frontend-acl-contract-design.md`
|
|
4
|
+
* §2.1/§2.3).
|
|
5
|
+
*
|
|
6
|
+
* GOVERNING PRINCIPLE (QA repair): exactness requires BOTH sides to be fully
|
|
7
|
+
* known. Any uncertainty on the recorded side SUPPRESSES the exact claim — it
|
|
8
|
+
* is not merely annotated. `confidence: 'exact'` is reserved for lines where
|
|
9
|
+
* both sides were parsed AND the recorded interface was fully readable.
|
|
10
|
+
*/
|
|
11
|
+
import { resolve, sep } from 'node:path';
|
|
12
|
+
/** The honest boundary of the feature. Printed in the output, not just documented. */
|
|
13
|
+
export const NOT_DETECTABLE = [
|
|
14
|
+
'a field whose type is unchanged but whose meaning changed',
|
|
15
|
+
'a removed endpoint that nothing calls',
|
|
16
|
+
'drift where this document is stale: types cannot check doc against server'
|
|
17
|
+
];
|
|
18
|
+
export const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
19
|
+
/** Rendered type text for the side of a one-sided field, i.e. the field is not there at all. */
|
|
20
|
+
export const ABSENT = '(absent)';
|
|
21
|
+
/** Raised for a non-OpenAPI-3.x input so the CLI can exit non-zero with no diff output. */
|
|
22
|
+
export class ApiDiffInputError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'ApiDiffInputError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function isRecord(value) {
|
|
31
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Project-relative, forward-slashed path for display.
|
|
35
|
+
*
|
|
36
|
+
* Compares path SEGMENTS, not raw `startsWith`: with a naive prefix test a root
|
|
37
|
+
* of `D:/x` also "contains" `D:/x-archive/docs/api.json`, which then rendered as
|
|
38
|
+
* the misleading `archive/docs/api.json`.
|
|
39
|
+
*/
|
|
40
|
+
export function toDisplayPath(projectRoot, file) {
|
|
41
|
+
const rootParts = resolve(projectRoot).split(sep);
|
|
42
|
+
const fileParts = resolve(file).split(sep);
|
|
43
|
+
const inside = fileParts.length > rootParts.length
|
|
44
|
+
&& rootParts.every((part, index) => part === fileParts[index]);
|
|
45
|
+
return (inside ? fileParts.slice(rootParts.length) : fileParts).join('/');
|
|
46
|
+
}
|
|
@@ -235,6 +235,25 @@ function decideFrontendOnly(report) {
|
|
|
235
235
|
}
|
|
236
236
|
return { frontendOnly: false, reason: 'swagger-or-proto-present' };
|
|
237
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Three frontend integration scenarios, from the signals `detected`
|
|
240
|
+
* already holds. Backend presence uses the SAME triple as
|
|
241
|
+
* `decideArchetype`'s `hasBackend` (framework OR next API routes OR
|
|
242
|
+
* backend dirs) rather than `hasBackendFramework` alone: `next` is
|
|
243
|
+
* deliberately excluded from `backendFrameworks` (:77), so a Next
|
|
244
|
+
* project with `pages/api` is `legacy-fullstack` there and must not be
|
|
245
|
+
* `prd-only` here — one report cannot contradict itself.
|
|
246
|
+
*/
|
|
247
|
+
function decideIntegrationMode(report) {
|
|
248
|
+
const hasBackend = report.detected.hasBackendFramework || report.detected.hasNextApiRoutes || report.detected.backendDirsPresent.length > 0;
|
|
249
|
+
if (hasBackend) {
|
|
250
|
+
return { integrationMode: 'full-stack', reason: 'backend-detected' };
|
|
251
|
+
}
|
|
252
|
+
if (report.detected.hasSwaggerOrProto) {
|
|
253
|
+
return { integrationMode: 'prd-plus-interface-doc', reason: 'interface-doc-present' };
|
|
254
|
+
}
|
|
255
|
+
return { integrationMode: 'prd-only', reason: 'no-backend-no-interface-doc' };
|
|
256
|
+
}
|
|
238
257
|
export async function scanArchetype(options) {
|
|
239
258
|
const { projectRoot } = options;
|
|
240
259
|
const { exists: hasPackageJson, deps } = await readPackageJsonDeps(projectRoot);
|
|
@@ -262,5 +281,12 @@ export async function scanArchetype(options) {
|
|
|
262
281
|
const { archetype, confidence, signals } = decideArchetype(detected);
|
|
263
282
|
const base = { archetype, confidence, signals, detected };
|
|
264
283
|
const { frontendOnly, reason } = decideFrontendOnly(base);
|
|
265
|
-
|
|
284
|
+
const { integrationMode, reason: integrationReason } = decideIntegrationMode(base);
|
|
285
|
+
return {
|
|
286
|
+
...base,
|
|
287
|
+
frontendOnly,
|
|
288
|
+
frontendOnlyReason: reason,
|
|
289
|
+
integrationMode,
|
|
290
|
+
integrationModeReason: integrationReason
|
|
291
|
+
};
|
|
266
292
|
}
|
|
@@ -2,6 +2,7 @@ import { readdir, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, join, relative } from 'node:path';
|
|
3
3
|
import { isDirectory, pathExists, readText } from 'peaks-loop-shared/fs';
|
|
4
4
|
import { scanArchetype } from './archetype-service.js';
|
|
5
|
+
import { scanHookConvention } from './hook-convention-service.js';
|
|
5
6
|
const DEFAULT_MAX_TOKENS = 40;
|
|
6
7
|
const DEFAULT_SAMPLES = 5;
|
|
7
8
|
const COLOR_KEYWORDS = ['color', 'primary', 'success', 'warning', 'error', 'danger', 'info', 'bg', 'background', 'border', 'text'];
|
|
@@ -205,14 +206,25 @@ export async function scanExistingSystem(options) {
|
|
|
205
206
|
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
206
207
|
const maxSamples = options.maxSamplesPerKind ?? DEFAULT_SAMPLES;
|
|
207
208
|
const archetypeReport = await scanArchetype({ projectRoot });
|
|
209
|
+
// Deliberately NOT behind the legacy gate below: the hook convention is read
|
|
210
|
+
// from file contents whenever a hook directory exists, so a greenfield or
|
|
211
|
+
// monorepo project adopting the ACL convention still gets it reported.
|
|
212
|
+
const hookConvention = await scanHookConvention({ projectRoot, hookDirs: HOOK_DIRS });
|
|
208
213
|
if (archetypeReport.archetype === 'greenfield' || archetypeReport.archetype === 'unknown') {
|
|
209
214
|
return {
|
|
210
215
|
archetype: archetypeReport.archetype,
|
|
211
216
|
scanned: false,
|
|
212
217
|
scanSkippedReason: `archetype=${archetypeReport.archetype} — extraction only runs on legacy projects`,
|
|
213
218
|
visualTokens: { colors: [], spacing: [], typography: [], radii: [], sources: [] },
|
|
214
|
-
conventions: {
|
|
215
|
-
|
|
219
|
+
conventions: {
|
|
220
|
+
componentNaming: 'unknown',
|
|
221
|
+
componentDir: null,
|
|
222
|
+
serviceDir: null,
|
|
223
|
+
hookDir: null,
|
|
224
|
+
samples: [],
|
|
225
|
+
hookConvention
|
|
226
|
+
},
|
|
227
|
+
inconsistencies: hookConvention.inconsistencies
|
|
216
228
|
};
|
|
217
229
|
}
|
|
218
230
|
const sources = [];
|
|
@@ -293,8 +305,9 @@ export async function scanExistingSystem(options) {
|
|
|
293
305
|
componentDir,
|
|
294
306
|
serviceDir,
|
|
295
307
|
hookDir,
|
|
296
|
-
samples
|
|
308
|
+
samples,
|
|
309
|
+
hookConvention
|
|
297
310
|
},
|
|
298
|
-
inconsistencies: findInconsistencies(rawTokens)
|
|
311
|
+
inconsistencies: [...findInconsistencies(rawTokens), ...hookConvention.inconsistencies]
|
|
299
312
|
};
|
|
300
313
|
}
|