peaks-loop 4.0.41 → 4.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/_register.js +4 -0
- package/dist/cli/commands/api-diff-commands.d.ts +16 -0
- package/dist/cli/commands/api-diff-commands.js +55 -0
- package/dist/cli/commands/audit-commands.d.ts +16 -3
- package/dist/cli/commands/audit-commands.js +84 -31
- package/dist/cli/commands/job-commands.js +4 -2
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/test-commands.d.ts +60 -3
- package/dist/cli/commands/test-commands.js +125 -7
- package/dist/services/audit/audit-goal-service.js +38 -3
- package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.d.ts +65 -0
- package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.js +186 -0
- package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
- package/dist/services/doctor/doctor-service/types.d.ts +20 -0
- package/dist/services/hooks/write-gate.js +32 -9
- package/dist/services/llm/anthropic-runner.d.ts +87 -0
- package/dist/services/llm/anthropic-runner.js +171 -0
- package/dist/services/llm/stub-runner.d.ts +11 -0
- package/dist/services/llm/stub-runner.js +33 -0
- package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
- package/dist/services/scan/api-diff-openapi.d.ts +32 -0
- package/dist/services/scan/api-diff-openapi.js +359 -0
- package/dist/services/scan/api-diff-recorded.d.ts +96 -0
- package/dist/services/scan/api-diff-recorded.js +577 -0
- package/dist/services/scan/api-diff-service.d.ts +34 -0
- package/dist/services/scan/api-diff-service.js +407 -0
- package/dist/services/scan/api-diff-types.d.ts +116 -0
- package/dist/services/scan/api-diff-types.js +46 -0
- package/dist/services/scan/archetype-service.js +27 -1
- package/dist/services/scan/existing-system-service.js +17 -4
- package/dist/services/scan/hook-convention-service.d.ts +26 -0
- package/dist/services/scan/hook-convention-service.js +562 -0
- package/dist/services/scan/scan-types.d.ts +47 -0
- package/dist/services/session/caller-binding-service.d.ts +28 -0
- package/dist/services/session/caller-binding-service.js +10 -2
- package/dist/services/session/caller-id-types.d.ts +12 -2
- package/dist/services/session/index.d.ts +2 -2
- package/dist/services/session/index.js +2 -2
- package/dist/services/session/session-binding-bridge.js +11 -6
- package/dist/services/session/session-manager.d.ts +33 -1
- package/dist/services/session/session-manager.js +84 -25
- package/dist/services/skills/skill-presence-service.d.ts +17 -3
- package/dist/services/skills/skill-presence-service.js +23 -3
- package/package.json +5 -5
- package/skills/bee/peaks-rd/SKILL.md +11 -3
- package/skills/peaks-code/references/existing-system-extraction.md +5 -1
- package/skills/peaks-code/references/frontend-only-mode.md +48 -6
- package/skills/peaks-code/references/project-scan-checklist.md +20 -1
- package/skills/peaks-doctor/references/doctor-check-catalog.md +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HookConventionReport } from './scan-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reads the CONTENTS of a consumer project's hook directories and reports the
|
|
4
|
+
* OBSERVED convention: per exported function its name and return shape, per
|
|
5
|
+
* directory the dominant naming pattern / return shape / mapper delegation,
|
|
6
|
+
* and the deviations from those dominants.
|
|
7
|
+
*
|
|
8
|
+
* What this deliberately is NOT: a type-flow checker. "This hook consumes a
|
|
9
|
+
* `*DTO` directly" is a property of a VALUE'S TYPE, not of its text — a regex
|
|
10
|
+
* for it fires on the one legal mapper import and misses the real violation.
|
|
11
|
+
* Every field below is therefore labelled observed, and anything the text
|
|
12
|
+
* cannot answer is reported as null rather than guessed.
|
|
13
|
+
*
|
|
14
|
+
* Known limits of a text scan without a compiler: string literals and comments
|
|
15
|
+
* are masked out first, so a commented-out hook is never counted; a return
|
|
16
|
+
* whose shape is produced by a call or held in an identifier cannot be
|
|
17
|
+
* classified; a generic parameter list with nested angle brackets is not
|
|
18
|
+
* matched; `.vue`/`.tsx` script blocks are not read (the extension set matches
|
|
19
|
+
* the existing hook sampler).
|
|
20
|
+
*/
|
|
21
|
+
export type HookConventionScanOptions = {
|
|
22
|
+
projectRoot: string;
|
|
23
|
+
/** Candidate hook directories relative to `projectRoot`, in report order. */
|
|
24
|
+
hookDirs: string[];
|
|
25
|
+
};
|
|
26
|
+
export declare function scanHookConvention(options: HookConventionScanOptions): Promise<HookConventionReport>;
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { isDirectory, readText } from 'peaks-loop-shared/fs';
|
|
4
|
+
/** Mirrors the extension set the existing-system hook sampler already uses. */
|
|
5
|
+
const HOOK_FILE_EXTS = /\.(ts|js)$/i;
|
|
6
|
+
const USE_PREFIX_RE = /^use[A-Z0-9_]/;
|
|
7
|
+
const MAPPER_PATH_RE = /mapper/i;
|
|
8
|
+
/** Keeps inconsistency strings short — the full list lives in `hooks[]`. */
|
|
9
|
+
const MAX_DEVIANTS_LISTED = 5;
|
|
10
|
+
const EXPORTED_DECL_PATTERNS = [
|
|
11
|
+
{ pattern: /export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g, form: 'function' },
|
|
12
|
+
{ pattern: /export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g, form: 'function' },
|
|
13
|
+
{
|
|
14
|
+
pattern: /export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|(?:<[^<>]*>\s*)?\()/g,
|
|
15
|
+
form: 'paren'
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
pattern: /export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?use[A-Z0-9_][\w$]*\s*\(/g,
|
|
19
|
+
form: 'wrapper'
|
|
20
|
+
}
|
|
21
|
+
];
|
|
22
|
+
const IMPORT_SPECIFIER_RE = /^([ \t]*)import\s+(?:[\s\S]{0,500}?\sfrom\s+)?['"]([^'"]+)['"]/gm;
|
|
23
|
+
const IDENTIFIER_RE = /[A-Za-z_$][\w$]*/y;
|
|
24
|
+
// ---------- text scanning ------------------------------------------------
|
|
25
|
+
function skipQuoted(text, start) {
|
|
26
|
+
const quote = text[start] ?? '';
|
|
27
|
+
let i = start + 1;
|
|
28
|
+
while (i < text.length) {
|
|
29
|
+
const ch = text[i] ?? '';
|
|
30
|
+
if (ch === '\\') {
|
|
31
|
+
i += 2;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === quote)
|
|
35
|
+
return i + 1;
|
|
36
|
+
i += 1;
|
|
37
|
+
}
|
|
38
|
+
return text.length;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* If `i` begins a string literal or a comment, return the index just past it;
|
|
42
|
+
* otherwise return `i` unchanged. Every scanner below uses this so braces and
|
|
43
|
+
* `return` keywords inside strings/comments are never counted as code.
|
|
44
|
+
*/
|
|
45
|
+
function skipNonCode(text, i, limit) {
|
|
46
|
+
const ch = text[i] ?? '';
|
|
47
|
+
if (ch === '"' || ch === "'" || ch === '`')
|
|
48
|
+
return Math.min(skipQuoted(text, i), limit);
|
|
49
|
+
if (ch !== '/')
|
|
50
|
+
return i;
|
|
51
|
+
if (text[i + 1] === '/') {
|
|
52
|
+
const newline = text.indexOf('\n', i);
|
|
53
|
+
return newline === -1 || newline >= limit ? limit : newline + 1;
|
|
54
|
+
}
|
|
55
|
+
if (text[i + 1] === '*') {
|
|
56
|
+
const end = text.indexOf('*/', i);
|
|
57
|
+
return end === -1 ? limit : Math.min(end + 2, limit);
|
|
58
|
+
}
|
|
59
|
+
return i;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Blank out every string literal and comment, preserving length and line
|
|
63
|
+
* breaks so all indices still address the original source. Declaration
|
|
64
|
+
* discovery runs on the masked text, so a commented-out hook is not a hook.
|
|
65
|
+
*/
|
|
66
|
+
function maskNonCode(text) {
|
|
67
|
+
const chars = text.split('');
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < text.length) {
|
|
70
|
+
const next = skipNonCode(text, i, text.length);
|
|
71
|
+
if (next !== i) {
|
|
72
|
+
for (let j = i; j < next; j += 1) {
|
|
73
|
+
if (chars[j] !== '\n')
|
|
74
|
+
chars[j] = ' ';
|
|
75
|
+
}
|
|
76
|
+
i = next;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
i += 1;
|
|
80
|
+
}
|
|
81
|
+
return chars.join('');
|
|
82
|
+
}
|
|
83
|
+
function skipSpace(text, from, limit) {
|
|
84
|
+
let i = from;
|
|
85
|
+
while (i < limit && /\s/.test(text[i] ?? ''))
|
|
86
|
+
i += 1;
|
|
87
|
+
return i;
|
|
88
|
+
}
|
|
89
|
+
function matchParen(text, open, limit) {
|
|
90
|
+
let depth = 0;
|
|
91
|
+
let i = open;
|
|
92
|
+
while (i < limit) {
|
|
93
|
+
const nonCode = skipNonCode(text, i, limit);
|
|
94
|
+
if (nonCode !== i) {
|
|
95
|
+
i = nonCode;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const ch = text[i] ?? '';
|
|
99
|
+
if (ch === '(')
|
|
100
|
+
depth += 1;
|
|
101
|
+
else if (ch === ')') {
|
|
102
|
+
depth -= 1;
|
|
103
|
+
if (depth === 0)
|
|
104
|
+
return i;
|
|
105
|
+
}
|
|
106
|
+
i += 1;
|
|
107
|
+
}
|
|
108
|
+
return -1;
|
|
109
|
+
}
|
|
110
|
+
/** Index of the `{` opening a block body after a parameter list, or -1. */
|
|
111
|
+
function findBlockBody(text, from, limit) {
|
|
112
|
+
let i = skipSpace(text, from, limit);
|
|
113
|
+
if (text[i] === ':') {
|
|
114
|
+
// Return-type annotation: advance to the first `{`, `=>`, `;` or newline.
|
|
115
|
+
let j = i + 1;
|
|
116
|
+
while (j < limit && !'{=>;\n'.includes(text[j] ?? ''))
|
|
117
|
+
j += 1;
|
|
118
|
+
i = skipSpace(text, j, limit);
|
|
119
|
+
}
|
|
120
|
+
if (text.startsWith('=>', i))
|
|
121
|
+
i = skipSpace(text, i + 2, limit);
|
|
122
|
+
return text[i] === '{' ? i : -1;
|
|
123
|
+
}
|
|
124
|
+
function isReturnAt(text, i) {
|
|
125
|
+
if (!text.startsWith('return', i))
|
|
126
|
+
return false;
|
|
127
|
+
const before = text[i - 1] ?? ' ';
|
|
128
|
+
const after = text[i + 6] ?? ' ';
|
|
129
|
+
return !/[\w$]/.test(before) && !/[\w$]/.test(after);
|
|
130
|
+
}
|
|
131
|
+
/** Reads a JS expression from `from`, stopping at a depth-0 `;` or newline. */
|
|
132
|
+
function readExpression(text, from, limit) {
|
|
133
|
+
let depth = 0;
|
|
134
|
+
let i = from;
|
|
135
|
+
while (i < limit) {
|
|
136
|
+
const nonCode = skipNonCode(text, i, limit);
|
|
137
|
+
if (nonCode !== i) {
|
|
138
|
+
i = nonCode;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const ch = text[i] ?? '';
|
|
142
|
+
if (ch === '{' || ch === '[' || ch === '(') {
|
|
143
|
+
depth += 1;
|
|
144
|
+
i += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === '}' || ch === ']' || ch === ')') {
|
|
148
|
+
if (depth === 0)
|
|
149
|
+
break;
|
|
150
|
+
depth -= 1;
|
|
151
|
+
i += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (depth === 0 && (ch === ';' || ch === '\n'))
|
|
155
|
+
break;
|
|
156
|
+
i += 1;
|
|
157
|
+
}
|
|
158
|
+
return { text: text.slice(from, i), next: i };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Raw text of every `return` written directly in the block opened by
|
|
162
|
+
* `openBrace`. Returns nested inside an `if` / `switch` / `for` / callback
|
|
163
|
+
* body sit at a deeper brace depth and are deliberately not collected: the
|
|
164
|
+
* hook's own return is the one that describes its shape.
|
|
165
|
+
*/
|
|
166
|
+
function scanBlockBody(text, openBrace) {
|
|
167
|
+
const returns = [];
|
|
168
|
+
const limit = text.length;
|
|
169
|
+
let depth = 0;
|
|
170
|
+
let i = openBrace;
|
|
171
|
+
while (i < limit) {
|
|
172
|
+
const nonCode = skipNonCode(text, i, limit);
|
|
173
|
+
if (nonCode !== i) {
|
|
174
|
+
i = nonCode;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const ch = text[i] ?? '';
|
|
178
|
+
if (ch === '{' || ch === '[' || ch === '(') {
|
|
179
|
+
depth += 1;
|
|
180
|
+
i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (ch === '}' || ch === ']' || ch === ')') {
|
|
184
|
+
depth -= 1;
|
|
185
|
+
if (depth === 0 && ch === '}')
|
|
186
|
+
return returns;
|
|
187
|
+
i += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (depth === 1 && isReturnAt(text, i)) {
|
|
191
|
+
const expression = readExpression(text, i + 'return'.length, limit);
|
|
192
|
+
returns.push(expression.text);
|
|
193
|
+
i = expression.next;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
i += 1;
|
|
197
|
+
}
|
|
198
|
+
return returns;
|
|
199
|
+
}
|
|
200
|
+
// ---------- return-shape classification -----------------------------------
|
|
201
|
+
function stripOuterParens(text) {
|
|
202
|
+
let current = text.trim();
|
|
203
|
+
while (current.startsWith('(') && current.endsWith(')')) {
|
|
204
|
+
if (matchParen(current, 0, current.length) !== current.length - 1)
|
|
205
|
+
break;
|
|
206
|
+
current = current.slice(1, -1).trim();
|
|
207
|
+
}
|
|
208
|
+
return current;
|
|
209
|
+
}
|
|
210
|
+
/** Sorted top-level key list of an object-literal expression, or null. */
|
|
211
|
+
function objectSignature(text) {
|
|
212
|
+
const keys = [];
|
|
213
|
+
const limit = text.length;
|
|
214
|
+
let depth = 0;
|
|
215
|
+
let previous = '';
|
|
216
|
+
let i = 0;
|
|
217
|
+
while (i < limit) {
|
|
218
|
+
const nonCode = skipNonCode(text, i, limit);
|
|
219
|
+
if (nonCode !== i) {
|
|
220
|
+
i = nonCode;
|
|
221
|
+
previous = '"';
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const ch = text[i] ?? '';
|
|
225
|
+
if (ch === '{' || ch === '[' || ch === '(') {
|
|
226
|
+
depth += 1;
|
|
227
|
+
previous = ch;
|
|
228
|
+
i += 1;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (ch === '}' || ch === ']' || ch === ')') {
|
|
232
|
+
depth -= 1;
|
|
233
|
+
previous = ch;
|
|
234
|
+
i += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (depth === 1 && (previous === '{' || previous === ',')) {
|
|
238
|
+
IDENTIFIER_RE.lastIndex = i;
|
|
239
|
+
const key = IDENTIFIER_RE.exec(text);
|
|
240
|
+
if (key !== null) {
|
|
241
|
+
keys.push(key[0]);
|
|
242
|
+
previous = key[0];
|
|
243
|
+
i += key[0].length;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!/\s/.test(ch))
|
|
248
|
+
previous = ch;
|
|
249
|
+
i += 1;
|
|
250
|
+
}
|
|
251
|
+
if (keys.length === 0)
|
|
252
|
+
return null;
|
|
253
|
+
return `{ ${[...new Set(keys)].sort().join(', ')} }`;
|
|
254
|
+
}
|
|
255
|
+
function classifyReturn(expression) {
|
|
256
|
+
const text = stripOuterParens(expression);
|
|
257
|
+
if (text.startsWith('{'))
|
|
258
|
+
return { shape: 'object', signature: objectSignature(text) };
|
|
259
|
+
if (text.startsWith('['))
|
|
260
|
+
return { shape: 'tuple', signature: null };
|
|
261
|
+
return { shape: 'other', signature: null };
|
|
262
|
+
}
|
|
263
|
+
function describeShape(shape, signature) {
|
|
264
|
+
if (shape === 'object') {
|
|
265
|
+
return signature === null ? 'an object whose keys could not be read' : `an object ${signature}`;
|
|
266
|
+
}
|
|
267
|
+
if (shape === 'tuple')
|
|
268
|
+
return 'a tuple';
|
|
269
|
+
return 'no classifiable return expression';
|
|
270
|
+
}
|
|
271
|
+
// ---------- declaration discovery -----------------------------------------
|
|
272
|
+
function findExportedDeclarations(masked) {
|
|
273
|
+
const heads = [];
|
|
274
|
+
for (const entry of EXPORTED_DECL_PATTERNS) {
|
|
275
|
+
entry.pattern.lastIndex = 0;
|
|
276
|
+
let match;
|
|
277
|
+
while ((match = entry.pattern.exec(masked)) !== null) {
|
|
278
|
+
const name = match[1];
|
|
279
|
+
if (name !== undefined) {
|
|
280
|
+
const raw = match[0];
|
|
281
|
+
heads.push({
|
|
282
|
+
name,
|
|
283
|
+
start: match.index,
|
|
284
|
+
headEnd: match.index + raw.length,
|
|
285
|
+
// `export const x = function (…)` ends on the keyword, not on a `(`.
|
|
286
|
+
form: raw.endsWith('function') ? 'function' : entry.form
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
heads.sort((a, b) => a.start - b.start);
|
|
292
|
+
const declarations = [];
|
|
293
|
+
for (const [index, head] of heads.entries()) {
|
|
294
|
+
const next = heads[index + 1];
|
|
295
|
+
declarations.push({
|
|
296
|
+
name: head.name,
|
|
297
|
+
form: head.form,
|
|
298
|
+
headEnd: head.headEnd,
|
|
299
|
+
limit: next === undefined ? masked.length : next.start
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return declarations;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Body of a declaration, located from its PARAMETER LIST — never by scanning
|
|
306
|
+
* for the next `(` — so an `if (`, `for (`, `switch (` or nested arrow inside
|
|
307
|
+
* the body cannot be mistaken for the hook's own signature.
|
|
308
|
+
*/
|
|
309
|
+
function locateBody(text, declaration) {
|
|
310
|
+
const { form, headEnd, limit } = declaration;
|
|
311
|
+
let paramsOpen;
|
|
312
|
+
if (form === 'paren') {
|
|
313
|
+
paramsOpen = headEnd - 1; // the head ends on the params' opening paren
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
// `function name(…)` / `= function (…)`: the next `(` is the params.
|
|
317
|
+
// Wrapper form `= useX(…)`: the next `(` is the callback's params.
|
|
318
|
+
const found = text.indexOf('(', headEnd);
|
|
319
|
+
if (found === -1 || found >= limit)
|
|
320
|
+
return null;
|
|
321
|
+
paramsOpen = found;
|
|
322
|
+
}
|
|
323
|
+
const paramsClose = matchParen(text, paramsOpen, limit);
|
|
324
|
+
if (paramsClose === -1)
|
|
325
|
+
return null;
|
|
326
|
+
const block = findBlockBody(text, paramsClose + 1, limit);
|
|
327
|
+
if (block !== -1)
|
|
328
|
+
return { kind: 'block', openBrace: block };
|
|
329
|
+
const afterParams = skipSpace(text, paramsClose + 1, limit);
|
|
330
|
+
if (text.startsWith('=>', afterParams)) {
|
|
331
|
+
return { kind: 'expression', from: skipSpace(text, afterParams + 2, limit) };
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
function readReturnShape(masked, declaration) {
|
|
336
|
+
const body = locateBody(masked, declaration);
|
|
337
|
+
if (body === null)
|
|
338
|
+
return { shape: 'other', signature: null };
|
|
339
|
+
const expressions = body.kind === 'expression'
|
|
340
|
+
? [masked.slice(body.from, readExpression(masked, body.from, declaration.limit).next)]
|
|
341
|
+
: scanBlockBody(masked, body.openBrace);
|
|
342
|
+
if (expressions.length === 0)
|
|
343
|
+
return { shape: 'other', signature: null };
|
|
344
|
+
const classified = expressions.map(classifyReturn);
|
|
345
|
+
const shape = strictDominantOf(classified.map((entry) => entry.shape)) ?? 'other';
|
|
346
|
+
if (shape !== 'object')
|
|
347
|
+
return { shape, signature: null };
|
|
348
|
+
const signatures = [];
|
|
349
|
+
for (const entry of classified) {
|
|
350
|
+
if (entry.shape === 'object' && entry.signature !== null)
|
|
351
|
+
signatures.push(entry.signature);
|
|
352
|
+
}
|
|
353
|
+
return { shape, signature: strictDominantOf(signatures) };
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* True when the file imports a module whose PATH matches /mapper/i. Reads the
|
|
357
|
+
* RAW text (a specifier is itself a string literal, which the mask would
|
|
358
|
+
* blank) and cross-checks against the masked text so a commented-out import
|
|
359
|
+
* does not count: the mask leaves spaces where a real `import` keyword was.
|
|
360
|
+
*/
|
|
361
|
+
function fileImportsMapper(raw, masked) {
|
|
362
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
363
|
+
let match;
|
|
364
|
+
while ((match = IMPORT_SPECIFIER_RE.exec(raw)) !== null) {
|
|
365
|
+
const indentation = match[1];
|
|
366
|
+
const specifier = match[2];
|
|
367
|
+
if (indentation === undefined || specifier === undefined)
|
|
368
|
+
continue;
|
|
369
|
+
if (!MAPPER_PATH_RE.test(specifier))
|
|
370
|
+
continue;
|
|
371
|
+
if (masked.startsWith('import', match.index + indentation.length))
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
function readHooksFromMasked(masked, file) {
|
|
377
|
+
return findExportedDeclarations(masked).map((declaration) => {
|
|
378
|
+
const classified = readReturnShape(masked, declaration);
|
|
379
|
+
return {
|
|
380
|
+
file,
|
|
381
|
+
name: declaration.name,
|
|
382
|
+
usePrefix: USE_PREFIX_RE.test(declaration.name),
|
|
383
|
+
returnShape: classified.shape,
|
|
384
|
+
returnSignature: classified.signature
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
// ---------- aggregation ----------------------------------------------------
|
|
389
|
+
/**
|
|
390
|
+
* Most frequent value, or null when nothing is STRICTLY most frequent. A tie
|
|
391
|
+
* has no dominant, and saying so beats picking one arbitrarily.
|
|
392
|
+
*/
|
|
393
|
+
function strictDominantOf(values) {
|
|
394
|
+
const counts = new Map();
|
|
395
|
+
for (const value of values)
|
|
396
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
397
|
+
let best = null;
|
|
398
|
+
let bestCount = 0;
|
|
399
|
+
let tied = false;
|
|
400
|
+
for (const [value, count] of counts) {
|
|
401
|
+
if (count > bestCount) {
|
|
402
|
+
best = value;
|
|
403
|
+
bestCount = count;
|
|
404
|
+
tied = false;
|
|
405
|
+
}
|
|
406
|
+
else if (count === bestCount) {
|
|
407
|
+
tied = true;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return tied ? null : best;
|
|
411
|
+
}
|
|
412
|
+
function namingPatternOf(count, prefixed) {
|
|
413
|
+
if (count === 0)
|
|
414
|
+
return 'unknown';
|
|
415
|
+
if (prefixed === count)
|
|
416
|
+
return 'use<X>';
|
|
417
|
+
if (prefixed === 0)
|
|
418
|
+
return 'no-use-prefix';
|
|
419
|
+
return 'mixed';
|
|
420
|
+
}
|
|
421
|
+
function formatShapeCounts(hooks) {
|
|
422
|
+
const counts = new Map();
|
|
423
|
+
for (const hook of hooks)
|
|
424
|
+
counts.set(hook.returnShape, (counts.get(hook.returnShape) ?? 0) + 1);
|
|
425
|
+
return [...counts.entries()].map(([shape, count]) => `${shape} ×${count}`).join(', ');
|
|
426
|
+
}
|
|
427
|
+
function listedNames(names) {
|
|
428
|
+
const listed = names.slice(0, MAX_DEVIANTS_LISTED).join(', ');
|
|
429
|
+
const extra = names.length - MAX_DEVIANTS_LISTED;
|
|
430
|
+
return extra > 0 ? `${listed} (+${extra} more)` : listed;
|
|
431
|
+
}
|
|
432
|
+
function summarizeDirectory(dir, hooks, files) {
|
|
433
|
+
const dominantReturnShape = strictDominantOf(hooks.map((hook) => hook.returnShape));
|
|
434
|
+
const signatures = [];
|
|
435
|
+
if (dominantReturnShape === 'object') {
|
|
436
|
+
for (const hook of hooks) {
|
|
437
|
+
if (hook.returnShape === 'object' && hook.returnSignature !== null)
|
|
438
|
+
signatures.push(hook.returnSignature);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// A tie inside the object class has no dominant key set — null, not a pick.
|
|
442
|
+
const dominantReturnSignature = strictDominantOf(signatures);
|
|
443
|
+
// Deviants are compared by shape CLASS. A different key set is key-level
|
|
444
|
+
// detail, reported separately, never as "an object deviating from objects".
|
|
445
|
+
const offShape = dominantReturnShape === null
|
|
446
|
+
? []
|
|
447
|
+
: hooks.filter((hook) => hook.returnShape !== dominantReturnShape);
|
|
448
|
+
const prefixed = hooks.filter((hook) => hook.usePrefix).length;
|
|
449
|
+
const mapperFiles = files.filter((entry) => entry.importsMapper).map((entry) => entry.file);
|
|
450
|
+
const convention = {
|
|
451
|
+
dir,
|
|
452
|
+
hookCount: hooks.length,
|
|
453
|
+
hookFileCount: files.length,
|
|
454
|
+
namingPattern: namingPatternOf(hooks.length, prefixed),
|
|
455
|
+
dominantReturnShape,
|
|
456
|
+
dominantReturnSignature,
|
|
457
|
+
offShapeCount: offShape.length,
|
|
458
|
+
offShapeHooks: offShape.map((hook) => hook.name),
|
|
459
|
+
mapperFiles,
|
|
460
|
+
hooks
|
|
461
|
+
};
|
|
462
|
+
const inconsistencies = [];
|
|
463
|
+
if (hooks.length > 0 && dominantReturnShape === null) {
|
|
464
|
+
inconsistencies.push(`hook return shape: no dominant shape among the ${hooks.length} hooks in ${dir} ` +
|
|
465
|
+
`(${formatShapeCounts(hooks)}); no deviant set is reported`);
|
|
466
|
+
}
|
|
467
|
+
else if (dominantReturnShape !== null && offShape.length > 0) {
|
|
468
|
+
const deviants = offShape.map((hook) => `${hook.name} (${describeShape(hook.returnShape, hook.returnSignature)})`);
|
|
469
|
+
inconsistencies.push(`hook return shape: ${hooks.length - offShape.length} of ${hooks.length} hooks in ${dir} return ` +
|
|
470
|
+
`${describeShape(dominantReturnShape, dominantReturnSignature)}; deviating: ${listedNames(deviants)}`);
|
|
471
|
+
}
|
|
472
|
+
if (dominantReturnShape === 'object') {
|
|
473
|
+
const objectHooks = hooks.filter((hook) => hook.returnShape === 'object');
|
|
474
|
+
const distinctKeys = new Set(signatures);
|
|
475
|
+
if (distinctKeys.size >= 2) {
|
|
476
|
+
const differing = objectHooks.filter((hook) => hook.returnSignature !== dominantReturnSignature);
|
|
477
|
+
const dominantKeys = dominantReturnSignature === null
|
|
478
|
+
? 'no single dominant key set'
|
|
479
|
+
: `an object ${dominantReturnSignature}`;
|
|
480
|
+
inconsistencies.push(`hook return keys: ${objectHooks.length - differing.length} of ${objectHooks.length} object-returning hooks ` +
|
|
481
|
+
`in ${dir} return ${dominantKeys}; differing keys: ` +
|
|
482
|
+
listedNames(differing.map((hook) => `${hook.name} (${hook.returnSignature ?? 'keys not read'})`)));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (convention.namingPattern === 'mixed') {
|
|
486
|
+
const unprefixed = hooks.filter((hook) => !hook.usePrefix).map((hook) => hook.name);
|
|
487
|
+
inconsistencies.push(`hook naming: ${prefixed} of ${hooks.length} exported functions in ${dir} use the "use" prefix; ` +
|
|
488
|
+
`deviating: ${listedNames(unprefixed)}`);
|
|
489
|
+
}
|
|
490
|
+
if (mapperFiles.length > 0 && mapperFiles.length < files.length) {
|
|
491
|
+
inconsistencies.push(`mapper delegation (observed from import paths only): ${mapperFiles.length} of ${files.length} hook files in ` +
|
|
492
|
+
`${dir} import a module whose path matches /mapper/i; the remaining ${files.length - mapperFiles.length} ` +
|
|
493
|
+
'import none — that is the absence of an observation, not evidence that they map inline');
|
|
494
|
+
}
|
|
495
|
+
return { convention, inconsistencies };
|
|
496
|
+
}
|
|
497
|
+
// ---------- filesystem -----------------------------------------------------
|
|
498
|
+
async function listHookFiles(dir) {
|
|
499
|
+
const found = [];
|
|
500
|
+
const queue = [dir];
|
|
501
|
+
while (queue.length > 0) {
|
|
502
|
+
const current = queue.shift();
|
|
503
|
+
if (current === undefined)
|
|
504
|
+
break;
|
|
505
|
+
let entries;
|
|
506
|
+
try {
|
|
507
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
514
|
+
continue;
|
|
515
|
+
const full = join(current, entry.name);
|
|
516
|
+
if (entry.isDirectory())
|
|
517
|
+
queue.push(full);
|
|
518
|
+
else if (HOOK_FILE_EXTS.test(entry.name))
|
|
519
|
+
found.push(full);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return found;
|
|
523
|
+
}
|
|
524
|
+
async function readHooksInDirectory(absoluteDir, projectRoot) {
|
|
525
|
+
const paths = await listHookFiles(absoluteDir);
|
|
526
|
+
paths.sort();
|
|
527
|
+
const hooks = [];
|
|
528
|
+
const files = [];
|
|
529
|
+
for (const path of paths) {
|
|
530
|
+
let text;
|
|
531
|
+
try {
|
|
532
|
+
text = await readText(path);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
continue; // unreadable file — this scan is read-only and best-effort
|
|
536
|
+
}
|
|
537
|
+
const masked = maskNonCode(text);
|
|
538
|
+
const declared = readHooksFromMasked(masked, relative(projectRoot, path).split(/[\\/]/).join('/'));
|
|
539
|
+
if (declared.length === 0)
|
|
540
|
+
continue;
|
|
541
|
+
hooks.push(...declared);
|
|
542
|
+
// Mapper delegation is observed per FILE: an import belongs to a module,
|
|
543
|
+
// so attributing it to each hook that module exports would over-claim.
|
|
544
|
+
files.push({ file: declared[0]?.file ?? '', importsMapper: fileImportsMapper(text, masked) });
|
|
545
|
+
}
|
|
546
|
+
return { hooks, files };
|
|
547
|
+
}
|
|
548
|
+
export async function scanHookConvention(options) {
|
|
549
|
+
const { projectRoot, hookDirs } = options;
|
|
550
|
+
const directories = [];
|
|
551
|
+
const inconsistencies = [];
|
|
552
|
+
for (const dir of hookDirs) {
|
|
553
|
+
const absolute = join(projectRoot, dir);
|
|
554
|
+
if (!(await isDirectory(absolute)))
|
|
555
|
+
continue; // absent directory is not an error
|
|
556
|
+
const { hooks, files } = await readHooksInDirectory(absolute, projectRoot);
|
|
557
|
+
const summary = summarizeDirectory(dir, hooks, files);
|
|
558
|
+
directories.push(summary.convention);
|
|
559
|
+
inconsistencies.push(...summary.inconsistencies);
|
|
560
|
+
}
|
|
561
|
+
return { directories, inconsistencies };
|
|
562
|
+
}
|
|
@@ -4,11 +4,19 @@ export type ArchetypeSignal = {
|
|
|
4
4
|
matched: boolean;
|
|
5
5
|
detail?: string;
|
|
6
6
|
};
|
|
7
|
+
/**
|
|
8
|
+
* Which of the three frontend integration scenarios the consumer project
|
|
9
|
+
* is in. Derived from signals `ArchetypeReport.detected` already carries —
|
|
10
|
+
* no new probes. `frontendOnly` stays for back-compat; this is additive.
|
|
11
|
+
*/
|
|
12
|
+
export type IntegrationMode = 'full-stack' | 'prd-plus-interface-doc' | 'prd-only';
|
|
7
13
|
export type ArchetypeReport = {
|
|
8
14
|
archetype: ProjectArchetype;
|
|
9
15
|
confidence: 'high' | 'medium' | 'low';
|
|
10
16
|
frontendOnly: boolean;
|
|
11
17
|
frontendOnlyReason: string;
|
|
18
|
+
integrationMode: IntegrationMode;
|
|
19
|
+
integrationModeReason: string;
|
|
12
20
|
signals: ArchetypeSignal[];
|
|
13
21
|
detected: {
|
|
14
22
|
hasPackageJson: boolean;
|
|
@@ -37,6 +45,44 @@ export type ConventionSample = {
|
|
|
37
45
|
path: string;
|
|
38
46
|
kind: 'component' | 'service' | 'hook' | 'page';
|
|
39
47
|
};
|
|
48
|
+
export type HookReturnShape = 'object' | 'tuple' | 'other';
|
|
49
|
+
/**
|
|
50
|
+
* One exported function found in a hook directory, read from file text only.
|
|
51
|
+
* Every field is OBSERVED, not verified: `returnShape` comes from the leading
|
|
52
|
+
* token of the returned expression. It is not a type-flow check — a shape
|
|
53
|
+
* produced by a call or held in an identifier is reported as `other`, and
|
|
54
|
+
* `returnSignature` is null when the keys could not be read.
|
|
55
|
+
*/
|
|
56
|
+
export type HookObservation = {
|
|
57
|
+
file: string;
|
|
58
|
+
name: string;
|
|
59
|
+
usePrefix: boolean;
|
|
60
|
+
returnShape: HookReturnShape;
|
|
61
|
+
returnSignature: string | null;
|
|
62
|
+
};
|
|
63
|
+
export type HookDirectoryConvention = {
|
|
64
|
+
dir: string;
|
|
65
|
+
hookCount: number;
|
|
66
|
+
hookFileCount: number;
|
|
67
|
+
namingPattern: 'use<X>' | 'mixed' | 'no-use-prefix' | 'unknown';
|
|
68
|
+
/** Null when no class is strictly dominant (a tie), not an arbitrary pick. */
|
|
69
|
+
dominantReturnShape: HookReturnShape | null;
|
|
70
|
+
dominantReturnSignature: string | null;
|
|
71
|
+
/** Hooks whose shape CLASS differs from the dominant one. Key-level drift
|
|
72
|
+
* is separate detail and never makes an object deviate from objects. */
|
|
73
|
+
offShapeCount: number;
|
|
74
|
+
offShapeHooks: string[];
|
|
75
|
+
/** Files whose import specifiers match /mapper/i — a FILE-level observation:
|
|
76
|
+
* an import belongs to a module, so it is not attributed to each hook that
|
|
77
|
+
* module exports. An absent entry is the absence of an observation, NOT
|
|
78
|
+
* evidence that a hook maps data inline (that would need a value's type). */
|
|
79
|
+
mapperFiles: string[];
|
|
80
|
+
hooks: HookObservation[];
|
|
81
|
+
};
|
|
82
|
+
export type HookConventionReport = {
|
|
83
|
+
directories: HookDirectoryConvention[];
|
|
84
|
+
inconsistencies: string[];
|
|
85
|
+
};
|
|
40
86
|
export type ExistingSystemReport = {
|
|
41
87
|
archetype: ProjectArchetype;
|
|
42
88
|
scanned: boolean;
|
|
@@ -54,6 +100,7 @@ export type ExistingSystemReport = {
|
|
|
54
100
|
serviceDir: string | null;
|
|
55
101
|
hookDir: string | null;
|
|
56
102
|
samples: ConventionSample[];
|
|
103
|
+
hookConvention: HookConventionReport;
|
|
57
104
|
};
|
|
58
105
|
inconsistencies: string[];
|
|
59
106
|
};
|
|
@@ -54,6 +54,34 @@ export declare function synthesiseLegacyCallerId(input: string): string;
|
|
|
54
54
|
* absent).
|
|
55
55
|
*/
|
|
56
56
|
export declare function getCallerBinding(projectRoot: string, callerId: string): CallerBinding | null;
|
|
57
|
+
/**
|
|
58
|
+
* Resolution-time read of a per-caller binding.
|
|
59
|
+
*
|
|
60
|
+
* `getCallerBinding` answers a SHAPE question: "is there a well-formed
|
|
61
|
+
* binding file for this caller?". Session resolution needs a second
|
|
62
|
+
* answer: "is that binding still USABLE?". Slice 2026-09-12
|
|
63
|
+
* (rid=caller-binding-staleness) found it is not when the bound session's
|
|
64
|
+
* directory is gone — the binding was still trusted, so a command
|
|
65
|
+
* resolved the dead session id and `mkdir`'d a fresh tree under
|
|
66
|
+
* `.peaks/_runtime/<gone-sid>/...` for a session that no longer exists.
|
|
67
|
+
*
|
|
68
|
+
* Returns a tagged result rather than `null` so the fall-through is
|
|
69
|
+
* observable to the caller instead of silent:
|
|
70
|
+
* - `bound` — usable; resolve to `binding.peakSessionId`.
|
|
71
|
+
* - `stale` — the file is well-formed but its session directory is
|
|
72
|
+
* gone; callers fall through the chain and may report it.
|
|
73
|
+
* - `absent` — no binding file (or a malformed one).
|
|
74
|
+
*/
|
|
75
|
+
export type CallerBindingResolution = {
|
|
76
|
+
readonly status: 'bound';
|
|
77
|
+
readonly binding: CallerBinding;
|
|
78
|
+
} | {
|
|
79
|
+
readonly status: 'stale';
|
|
80
|
+
readonly binding: CallerBinding;
|
|
81
|
+
} | {
|
|
82
|
+
readonly status: 'absent';
|
|
83
|
+
};
|
|
84
|
+
export declare function resolveCallerBinding(projectRoot: string, callerId: string): CallerBindingResolution;
|
|
57
85
|
/**
|
|
58
86
|
* Write or update a per-caller binding file. The caller is responsible
|
|
59
87
|
* for the binding object (callerId must match the file stem, peakSessionId
|