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,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 / rid=api-diff-report — OpenAPI 3.x parsing for `peaks scan api-diff`.
|
|
3
|
+
*
|
|
4
|
+
* `.json` via `JSON.parse`; `.yaml` / `.yml` via the `yaml` runtime dependency.
|
|
5
|
+
* A document that is not OpenAPI 3.x, or that declares no operations, raises
|
|
6
|
+
* `ApiDiffInputError` — the command must never print an empty-but-successful
|
|
7
|
+
* diff.
|
|
8
|
+
*
|
|
9
|
+
* THE SAME INVERTED COMPLETENESS RULE THE RECORDED SIDE GETS APPLIES HERE
|
|
10
|
+
* (QA final gate): `collectFields` returns a *partial* field set just as easily
|
|
11
|
+
* as a complete one, and the two are indistinguishable downstream. Any schema
|
|
12
|
+
* construct this reader cannot fully account for marks its LOCATION as
|
|
13
|
+
* incomplete, which suppresses every exact line for that location. Partial is
|
|
14
|
+
* never presented as complete.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { parse as parseYaml } from 'yaml';
|
|
18
|
+
import { ApiDiffInputError, HTTP_METHODS, isRecord } from './api-diff-types.js';
|
|
19
|
+
const MAX_REF_DEPTH = 4;
|
|
20
|
+
/**
|
|
21
|
+
* OpenAPI scalar types mapped to their TypeScript spelling. `integer` is NOT a
|
|
22
|
+
* TypeScript type — an unmapped `integer` produced a false `number -> integer`
|
|
23
|
+
* exact line against a recorded `id: number`.
|
|
24
|
+
*/
|
|
25
|
+
const SCALAR_TYPES = {
|
|
26
|
+
string: 'string',
|
|
27
|
+
number: 'number',
|
|
28
|
+
integer: 'number',
|
|
29
|
+
boolean: 'boolean'
|
|
30
|
+
};
|
|
31
|
+
const COMPOSITION_KEYS = ['allOf', 'oneOf', 'anyOf'];
|
|
32
|
+
function refName(ref) {
|
|
33
|
+
const tail = ref.slice(ref.lastIndexOf('/') + 1);
|
|
34
|
+
return tail.length > 0 ? tail : ref;
|
|
35
|
+
}
|
|
36
|
+
/** Resolves a `$ref` chain. Callers that only need the NAME must not use this — see `schemaToTypeString`. */
|
|
37
|
+
function deref(schema, doc, depth) {
|
|
38
|
+
if (!isRecord(schema))
|
|
39
|
+
return null;
|
|
40
|
+
const ref = schema['$ref'];
|
|
41
|
+
if (typeof ref === 'string' && depth < MAX_REF_DEPTH) {
|
|
42
|
+
let node = doc;
|
|
43
|
+
for (const part of ref.replace(/^#\//, '').split('/')) {
|
|
44
|
+
if (!isRecord(node))
|
|
45
|
+
return null;
|
|
46
|
+
node = node[part];
|
|
47
|
+
}
|
|
48
|
+
return deref(node, doc, depth + 1);
|
|
49
|
+
}
|
|
50
|
+
return schema;
|
|
51
|
+
}
|
|
52
|
+
/** The composition/open-shape construct a schema node carries, if any — those cannot be flattened without a compiler. */
|
|
53
|
+
function unreadableConstruct(node) {
|
|
54
|
+
for (const key of COMPOSITION_KEYS) {
|
|
55
|
+
const value = node[key];
|
|
56
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
57
|
+
return `it composes schemas with \`${key}\`, which cannot be flattened here`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const additional = node['additionalProperties'];
|
|
61
|
+
if (additional !== undefined && additional !== false) {
|
|
62
|
+
return 'it declares `additionalProperties`, so it may carry fields it does not list';
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Renders an OpenAPI schema as a TypeScript-ish type text. Arrays render as
|
|
68
|
+
* `Array<X>` (not `X[]`) on purpose: `normalizeType` rewrites both sides
|
|
69
|
+
* identically, so a recorded `X[]` and a document `Array<X>` still compare equal.
|
|
70
|
+
*
|
|
71
|
+
* A `$ref` is answered with its referenced NAME and never dereferenced. Checking
|
|
72
|
+
* after `deref()` made `refName` unreachable for every ref inside
|
|
73
|
+
* `MAX_REF_DEPTH`, so a nested `owner: {$ref: User}` was reported as `object`
|
|
74
|
+
* against a recorded `owner: User` — an exact line for a field that did not
|
|
75
|
+
* change. The name is the faithful reading, and it is what a recorded interface
|
|
76
|
+
* writes.
|
|
77
|
+
*/
|
|
78
|
+
function schemaToTypeString(schema, doc, depth) {
|
|
79
|
+
if (isRecord(schema) && typeof schema['$ref'] === 'string') {
|
|
80
|
+
const named = refName(schema['$ref']);
|
|
81
|
+
return schema['nullable'] === true ? `${named} | null` : named;
|
|
82
|
+
}
|
|
83
|
+
const node = deref(schema, doc, depth);
|
|
84
|
+
if (!node)
|
|
85
|
+
return 'unknown';
|
|
86
|
+
const nullable = node['nullable'] === true;
|
|
87
|
+
const enumValues = node['enum'];
|
|
88
|
+
let base;
|
|
89
|
+
if (Array.isArray(enumValues) && enumValues.length > 0) {
|
|
90
|
+
base = enumValues.map((value) => (typeof value === 'string' ? JSON.stringify(value) : String(value))).join(' | ');
|
|
91
|
+
}
|
|
92
|
+
else if (COMPOSITION_KEYS.some((key) => Array.isArray(node[key]) && node[key].length > 0)) {
|
|
93
|
+
const key = COMPOSITION_KEYS.find((candidate) => Array.isArray(node[candidate]));
|
|
94
|
+
const variants = node[key];
|
|
95
|
+
const glue = key === 'allOf' ? ' & ' : ' | ';
|
|
96
|
+
base = variants.map((variant) => schemaToTypeString(variant, doc, depth + 1)).join(glue);
|
|
97
|
+
}
|
|
98
|
+
else if (Array.isArray(node['type'])) {
|
|
99
|
+
// OpenAPI 3.1 spells nullability as `type: ['string', 'null']`. The same
|
|
100
|
+
// SCALAR_TYPES mapping must apply here — joining with bare `String(entry)`
|
|
101
|
+
// leaked an unmapped `integer`, which this file's own comment records as a
|
|
102
|
+
// source of false `number -> integer` exact lines.
|
|
103
|
+
base = node['type']
|
|
104
|
+
.map((entry) => (typeof entry === 'string' && SCALAR_TYPES[entry] !== undefined ? SCALAR_TYPES[entry] : String(entry)))
|
|
105
|
+
.join(' | ');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const type = node['type'];
|
|
109
|
+
if (type === 'array') {
|
|
110
|
+
const items = node['items'];
|
|
111
|
+
base = `Array<${items === undefined ? 'unknown' : schemaToTypeString(items, doc, depth + 1)}>`;
|
|
112
|
+
}
|
|
113
|
+
else if (type === 'object') {
|
|
114
|
+
base = 'object';
|
|
115
|
+
}
|
|
116
|
+
else if (typeof type === 'string' && SCALAR_TYPES[type] !== undefined) {
|
|
117
|
+
base = SCALAR_TYPES[type];
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
base = 'unknown';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return nullable ? `${base} | null` : base;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Flat leaf fields of a body/response schema. Arrays descend one level into
|
|
127
|
+
* `items`. The ROOT `$ref` is resolved on purpose — a top-level `$ref` response
|
|
128
|
+
* IS the interface — while nested refs stay named (see `schemaToTypeString`).
|
|
129
|
+
*
|
|
130
|
+
* A node carrying `allOf`/`oneOf`/`anyOf` or `additionalProperties` alongside its
|
|
131
|
+
* own `properties` is INCOMPLETE: the sibling properties made the location look
|
|
132
|
+
* non-empty, so there was neither suppression nor a note, and a field declared
|
|
133
|
+
* under `allOf` was reported as an exact `-> (absent)`.
|
|
134
|
+
*/
|
|
135
|
+
const UNRESOLVED_REF = 'its schema is a `$ref` chain deeper than the reader resolves';
|
|
136
|
+
const NO_PROPERTIES = 'its schema lists no `properties`, so no field set could be read from it';
|
|
137
|
+
function collectFields(schema, doc) {
|
|
138
|
+
const fields = new Map();
|
|
139
|
+
const node = deref(schema, doc, 0);
|
|
140
|
+
if (!node)
|
|
141
|
+
return { fields, incompleteReason: 'its schema could not be read' };
|
|
142
|
+
if (typeof node['$ref'] === 'string')
|
|
143
|
+
return { fields, incompleteReason: UNRESOLVED_REF };
|
|
144
|
+
const rootIssue = unreadableConstruct(node);
|
|
145
|
+
if (rootIssue !== null)
|
|
146
|
+
return { fields, incompleteReason: rootIssue };
|
|
147
|
+
let target = node;
|
|
148
|
+
if (target['type'] === 'array' || target['items'] !== undefined) {
|
|
149
|
+
const items = deref(target['items'], doc, 0);
|
|
150
|
+
if (!items)
|
|
151
|
+
return { fields, incompleteReason: 'its array items schema could not be read' };
|
|
152
|
+
if (typeof items['$ref'] === 'string')
|
|
153
|
+
return { fields, incompleteReason: UNRESOLVED_REF };
|
|
154
|
+
const itemsIssue = unreadableConstruct(items);
|
|
155
|
+
if (itemsIssue !== null)
|
|
156
|
+
return { fields, incompleteReason: itemsIssue };
|
|
157
|
+
target = items;
|
|
158
|
+
}
|
|
159
|
+
const properties = target['properties'];
|
|
160
|
+
// An ABSENT `properties` is not an empty field set: it is a schema this reader
|
|
161
|
+
// cannot produce a field set from at all. Returning `{fields: {}, null}` here
|
|
162
|
+
// dropped the whole location, which is the "empty result treated as a
|
|
163
|
+
// successful one" failure the inverted rule exists to prevent — and dropping
|
|
164
|
+
// a readable `200` also let a `*Response` interface be compared against a
|
|
165
|
+
// surviving `404`, defeating the multi-status guard.
|
|
166
|
+
if (!isRecord(properties))
|
|
167
|
+
return { fields, incompleteReason: NO_PROPERTIES };
|
|
168
|
+
// Per OpenAPI, OMITTING `required` means every property is optional — which is
|
|
169
|
+
// how `openapi-typescript` renders it (`id?: T`). Treating an absent
|
|
170
|
+
// `required` as "everything is required" produced false
|
|
171
|
+
// `string | undefined -> string` lines against generated recordings.
|
|
172
|
+
const requiredRaw = target['required'];
|
|
173
|
+
const required = Array.isArray(requiredRaw)
|
|
174
|
+
? new Set(requiredRaw.filter((entry) => typeof entry === 'string'))
|
|
175
|
+
: new Set();
|
|
176
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
177
|
+
const type = schemaToTypeString(propSchema, doc, 1);
|
|
178
|
+
fields.set(key, required.has(key) ? type : `${type} | undefined`);
|
|
179
|
+
}
|
|
180
|
+
return { fields, incompleteReason: null };
|
|
181
|
+
}
|
|
182
|
+
function jsonContent(content) {
|
|
183
|
+
if (!isRecord(content))
|
|
184
|
+
return undefined;
|
|
185
|
+
if (content['application/json'] !== undefined)
|
|
186
|
+
return content['application/json'];
|
|
187
|
+
return Object.values(content)[0];
|
|
188
|
+
}
|
|
189
|
+
function schemaOf(media) {
|
|
190
|
+
return isRecord(media) ? media['schema'] : undefined;
|
|
191
|
+
}
|
|
192
|
+
function collectParameters(parameters, locations) {
|
|
193
|
+
if (!Array.isArray(parameters))
|
|
194
|
+
return;
|
|
195
|
+
for (const raw of parameters) {
|
|
196
|
+
if (!isRecord(raw))
|
|
197
|
+
continue;
|
|
198
|
+
const where = raw['in'];
|
|
199
|
+
const name = raw['name'];
|
|
200
|
+
if (where !== 'path' && where !== 'query')
|
|
201
|
+
continue;
|
|
202
|
+
if (typeof name !== 'string')
|
|
203
|
+
continue;
|
|
204
|
+
const key = where === 'path' ? 'pathParams' : 'queryParams';
|
|
205
|
+
const bucket = locations.get(key) ?? new Map();
|
|
206
|
+
bucket.set(name, schemaToTypeString(raw['schema'], {}, 0));
|
|
207
|
+
locations.set(key, bucket);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function parseOpenApiDocument(file, doc) {
|
|
211
|
+
const version = doc['openapi'];
|
|
212
|
+
if (typeof version !== 'string' || !/^3\.\d+/.test(version.trim())) {
|
|
213
|
+
throw new ApiDiffInputError('NOT_OPENAPI_3', `${file} is not an OpenAPI 3.x document (expected a top-level \`openapi: 3.x\` string, found ${version === undefined ? 'nothing' : JSON.stringify(version)})`);
|
|
214
|
+
}
|
|
215
|
+
const paths = doc['paths'];
|
|
216
|
+
if (!isRecord(paths)) {
|
|
217
|
+
throw new ApiDiffInputError('NO_PATHS', `${file} declares openapi ${version} but has no \`paths\` object — nothing to diff`);
|
|
218
|
+
}
|
|
219
|
+
const operations = [];
|
|
220
|
+
for (const [path, pathItemRaw] of Object.entries(paths)) {
|
|
221
|
+
const pathItem = deref(pathItemRaw, doc, 0);
|
|
222
|
+
if (!pathItem)
|
|
223
|
+
continue;
|
|
224
|
+
for (const method of HTTP_METHODS) {
|
|
225
|
+
const opRaw = pathItem[method];
|
|
226
|
+
if (!isRecord(opRaw))
|
|
227
|
+
continue;
|
|
228
|
+
const locations = new Map();
|
|
229
|
+
const locationIssues = new Map();
|
|
230
|
+
const record = (location, read) => {
|
|
231
|
+
if (read.fields.size === 0 && read.incompleteReason === null)
|
|
232
|
+
return;
|
|
233
|
+
locations.set(location, read.fields);
|
|
234
|
+
if (read.incompleteReason !== null)
|
|
235
|
+
locationIssues.set(location, read.incompleteReason);
|
|
236
|
+
};
|
|
237
|
+
const shared = Array.isArray(pathItem['parameters']) ? pathItem['parameters'] : [];
|
|
238
|
+
const own = Array.isArray(opRaw['parameters']) ? opRaw['parameters'] : [];
|
|
239
|
+
collectParameters([...shared, ...own], locations);
|
|
240
|
+
const requestBody = opRaw['requestBody'];
|
|
241
|
+
const bodySchema = schemaOf(jsonContent(isRecord(requestBody) ? requestBody['content'] : undefined));
|
|
242
|
+
if (bodySchema !== undefined)
|
|
243
|
+
record('request', collectFields(bodySchema, doc));
|
|
244
|
+
const responses = opRaw['responses'];
|
|
245
|
+
if (isRecord(responses)) {
|
|
246
|
+
for (const [status, responseRaw] of Object.entries(responses)) {
|
|
247
|
+
if (!isRecord(responseRaw))
|
|
248
|
+
continue;
|
|
249
|
+
const responseSchema = schemaOf(jsonContent(responseRaw['content']));
|
|
250
|
+
if (responseSchema === undefined)
|
|
251
|
+
continue;
|
|
252
|
+
record(`response.${status}`, collectFields(responseSchema, doc));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const operationId = typeof opRaw['operationId'] === 'string' ? opRaw['operationId'] : undefined;
|
|
256
|
+
operations.push(operationId === undefined
|
|
257
|
+
? { method, path, locations, locationIssues }
|
|
258
|
+
: { method, path, operationId, locations, locationIssues });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (operations.length === 0) {
|
|
262
|
+
throw new ApiDiffInputError('NO_OPERATIONS', `${file} is an OpenAPI ${version} document but declares no operations — refusing to print an empty diff`);
|
|
263
|
+
}
|
|
264
|
+
const info = doc['info'];
|
|
265
|
+
const title = isRecord(info) && typeof info['title'] === 'string' ? info['title'] : undefined;
|
|
266
|
+
return title === undefined ? { openapi: version, operations } : { openapi: version, title, operations };
|
|
267
|
+
}
|
|
268
|
+
/** Reads and parses `.json` / `.yaml` / `.yml`. Throws `ApiDiffInputError` on anything else. */
|
|
269
|
+
export function loadApiDocument(file) {
|
|
270
|
+
let raw;
|
|
271
|
+
try {
|
|
272
|
+
raw = readFileSync(file, 'utf8');
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
throw new ApiDiffInputError('UNREADABLE', `cannot read ${file}: ${error.message}`);
|
|
276
|
+
}
|
|
277
|
+
const lower = file.toLowerCase();
|
|
278
|
+
let parsed;
|
|
279
|
+
try {
|
|
280
|
+
if (lower.endsWith('.yaml') || lower.endsWith('.yml'))
|
|
281
|
+
parsed = parseYaml(raw);
|
|
282
|
+
else if (lower.endsWith('.json') || raw.trimStart().startsWith('{'))
|
|
283
|
+
parsed = JSON.parse(raw);
|
|
284
|
+
else
|
|
285
|
+
parsed = parseYaml(raw);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
throw new ApiDiffInputError('PARSE_FAILED', `cannot parse ${file} as JSON or YAML: ${error.message}`);
|
|
289
|
+
}
|
|
290
|
+
if (!isRecord(parsed)) {
|
|
291
|
+
throw new ApiDiffInputError('NOT_AN_OBJECT', `${file} did not parse to an object`);
|
|
292
|
+
}
|
|
293
|
+
return parseOpenApiDocument(file, parsed);
|
|
294
|
+
}
|
|
295
|
+
/** Splits on a separator that sits at bracket depth 0, leaving `("a" | "b")[]` and `Record<string, number>` intact. */
|
|
296
|
+
function splitTopLevel(text, separator) {
|
|
297
|
+
const parts = [];
|
|
298
|
+
let current = '';
|
|
299
|
+
let inString = null;
|
|
300
|
+
let depth = 0;
|
|
301
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
302
|
+
const ch = text[i];
|
|
303
|
+
if (inString !== null) {
|
|
304
|
+
if (ch === '\\') {
|
|
305
|
+
current += ch + (text[i + 1] ?? '');
|
|
306
|
+
i += 1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ch === inString)
|
|
310
|
+
inString = null;
|
|
311
|
+
current += ch;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
315
|
+
inString = ch;
|
|
316
|
+
current += ch;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (ch === '<' || ch === '(' || ch === '[' || ch === '{')
|
|
320
|
+
depth += 1;
|
|
321
|
+
else if (ch === '>' || ch === ')' || ch === ']' || ch === '}')
|
|
322
|
+
depth = Math.max(0, depth - 1);
|
|
323
|
+
if (depth === 0 && text.startsWith(separator, i)) {
|
|
324
|
+
parts.push(current);
|
|
325
|
+
current = '';
|
|
326
|
+
i += separator.length - 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
current += ch;
|
|
330
|
+
}
|
|
331
|
+
parts.push(current);
|
|
332
|
+
return parts;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Normalizes type text so `Array<string>`, `string[]`, `string | null` and
|
|
336
|
+
* `string|null` compare equal.
|
|
337
|
+
*
|
|
338
|
+
* Quote style is unified because it is a RENDERING artifact, not a change: the
|
|
339
|
+
* document renders enums via `JSON.stringify` (`"a"`) while hand-written
|
|
340
|
+
* recorded types use single quotes (`'a'`), so the same project written with
|
|
341
|
+
* double quotes emitted nothing and with single quotes emitted an exact
|
|
342
|
+
* CHANGED. Duplicate union members are collapsed for the same reason — a
|
|
343
|
+
* recorded `a?: X` normalises to `X | undefined`, which against a document's
|
|
344
|
+
* `X | undefined` used to become `X | undefined | undefined`.
|
|
345
|
+
*/
|
|
346
|
+
export function normalizeType(text) {
|
|
347
|
+
let out = text.trim().replace(/[;,]\s*$/, '').replace(/\s+/g, ' ');
|
|
348
|
+
out = out.replace(/'([^'\\]*)'/g, '"$1"');
|
|
349
|
+
out = out.replace(/\s*\|\s*/g, ' | ').replace(/\s*&\s*/g, ' & ');
|
|
350
|
+
const union = splitTopLevel(out, ' | ');
|
|
351
|
+
if (union.length > 1)
|
|
352
|
+
out = [...new Set(union)].join(' | ');
|
|
353
|
+
out = out.replace(/\s*\[\s*\]/g, '[]');
|
|
354
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
355
|
+
out = out.replace(/Array<([^<>]*)>/g, (_all, inner) => (/[|&]/.test(inner) ? `(${inner})[]` : `${inner}[]`));
|
|
356
|
+
}
|
|
357
|
+
out = out.replace(/\s*<\s*/g, '<').replace(/\s*>\s*/g, '>');
|
|
358
|
+
return out.trim();
|
|
359
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { type CandidateMention, type DocOperation, type InterfaceRole, type RecordedEndpoint, type RecordedInterface } from './api-diff-types.js';
|
|
15
|
+
export declare const MAX_CANDIDATE_NAMES = 100;
|
|
16
|
+
/**
|
|
17
|
+
* Line-based extractor for recorded `*-api.types.ts` interfaces.
|
|
18
|
+
*
|
|
19
|
+
* INVERTED DEFAULT (QA round 2): a line-based reader cannot tell "this line is
|
|
20
|
+
* not a member" from "I failed to read this member", so the model is not
|
|
21
|
+
* "exact unless I found a reason to suppress". It is the opposite — **an
|
|
22
|
+
* interface is COMPLETE only while every depth-1 line in its body is
|
|
23
|
+
* classified**. The first line the extractor cannot classify makes the whole
|
|
24
|
+
* interface INCOMPLETE, which suppresses every exact line for it and emits one
|
|
25
|
+
* note naming it and the reason.
|
|
26
|
+
*
|
|
27
|
+
* That is what catches, without needing to enumerate them in advance: quoted
|
|
28
|
+
* keys, index signatures, multi-line unions (the continuation line is the
|
|
29
|
+
* unclassifiable one), intersections and mapped types (which never reach a body
|
|
30
|
+
* at all — see `DECLARATION_LIKE`), and nested inline objects.
|
|
31
|
+
*
|
|
32
|
+
* KNOWN LIMITS, stated rather than papered over:
|
|
33
|
+
* - members are matched at depth 1 only; a nested shape is `object` and makes
|
|
34
|
+
* the interface incomplete rather than being half-read;
|
|
35
|
+
* - `extends` is unresolvable without a compiler, so such an interface is
|
|
36
|
+
* incomplete by construction;
|
|
37
|
+
* - a regex literal containing a brace would still mis-count (`braceBalance`).
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseRecordedInterfaces(source: string, file: string): RecordedInterface[];
|
|
40
|
+
/**
|
|
41
|
+
* Which half of an operation an interface describes, from the suffix its name
|
|
42
|
+
* carries. `unknown` is a refusal, not a default: diffing an interface against
|
|
43
|
+
* a location it does not describe is how a request field got reported at a
|
|
44
|
+
* response location, and how a response-shaped `*Dto` got reported at the
|
|
45
|
+
* requestBody.
|
|
46
|
+
*
|
|
47
|
+
* Only suffixes that actually name a side are accepted. `Dto` names neither
|
|
48
|
+
* side (a `UserDto` is as likely to be a response), and a bare `Body` does not
|
|
49
|
+
* say which side it belongs to either — `GetThingResponseBody` ends in `Body`.
|
|
50
|
+
* `...RequestBody` still classifies as a request via the `request` suffix. A
|
|
51
|
+
* suffix that carries no information must not receive LESS caution than no
|
|
52
|
+
* suffix at all, so both become `unknown` and are refused with a note.
|
|
53
|
+
*/
|
|
54
|
+
export declare function roleOf(name: string): InterfaceRole;
|
|
55
|
+
/** Locations of `operation` that `role` is allowed to describe. */
|
|
56
|
+
export declare function locationsForRole(operation: DocOperation, role: InterfaceRole): string[];
|
|
57
|
+
/** Source 1: the most recent `.peaks/_runtime/<sid>/rd/mock-plan.md`. */
|
|
58
|
+
export declare function findMockPlan(projectRoot: string): string | null;
|
|
59
|
+
/** Pulls the file paths a mock plan records (backticked or whitespace-delimited). */
|
|
60
|
+
export declare function extractMockPlanPaths(source: string): string[];
|
|
61
|
+
/** Source 2: recorded interface files — mock-plan-named plus the prescribed `src/services/types/*-api.types.ts` layout. */
|
|
62
|
+
export declare function findRecordedInterfaceFiles(projectRoot: string, mockPlan: string | null): string[];
|
|
63
|
+
/** Source 3: the newest TXT handoff that actually carries an `## API Migration` section. */
|
|
64
|
+
export declare function findHandoffWithApiMigration(projectRoot: string): string | null;
|
|
65
|
+
/** Parses the `## API Migration` body for `<METHOD> <path>` pairs. */
|
|
66
|
+
export declare function parseHandoffEndpoints(source: string): RecordedEndpoint[];
|
|
67
|
+
/**
|
|
68
|
+
* Key used to pair a document operation with a recorded interface. Deliberately
|
|
69
|
+
* conservative: only lowercasing, punctuation removal, and one trailing
|
|
70
|
+
* `Response`/`Request`/`Dto`/`Payload`/`Body` strip. Verb stripping (`getUser`
|
|
71
|
+
* -> `user`) was considered and rejected — a false pairing produces a
|
|
72
|
+
* confidently wrong diff, which is worse than the visible false negative of an
|
|
73
|
+
* unpaired interface (design §2.3).
|
|
74
|
+
*/
|
|
75
|
+
export declare function pairingKey(name: string): string;
|
|
76
|
+
export declare function operationKey(operation: DocOperation): string;
|
|
77
|
+
/**
|
|
78
|
+
* Greps every candidate name across the consumer's source and non-TS assets.
|
|
79
|
+
* Over-reports (test fixtures, unrelated identifiers) and under-reports (i18n
|
|
80
|
+
* keys, AntD `columns` arrays, monorepo barrels) — the report says so.
|
|
81
|
+
*
|
|
82
|
+
* `exclude` carries the OpenAPI document itself: it is the input, not a change
|
|
83
|
+
* site, and every field name would otherwise match on its own definition line.
|
|
84
|
+
*/
|
|
85
|
+
export declare function grepCandidateMentions(projectRoot: string, names: readonly string[], exclude?: readonly string[]): {
|
|
86
|
+
mentions: CandidateMention[];
|
|
87
|
+
truncated: boolean;
|
|
88
|
+
hitsTruncated: boolean;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Canonical endpoint path for comparison: `{id}` and `:id` are the same
|
|
92
|
+
* endpoint spelled two ways, and raw equality reported them as an exact
|
|
93
|
+
* ADDED + REMOVED pair. Parameter NAMES are collapsed too — `/users/{id}` and
|
|
94
|
+
* `/users/{userId}` are one endpoint.
|
|
95
|
+
*/
|
|
96
|
+
export declare function normalizeEndpointPath(path: string): string;
|