@boyingliu01/xp-gate 0.31.2 → 0.31.4
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/gate-3.sh +244 -9
- package/hooks/gate-3.sh +244 -9
- package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
- package/hooks/pre-commit +152 -13
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
- package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
- package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
- package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
- package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
- package/principles/adapters/cpp.ts +102 -50
- package/principles/adapters/typescript.ts +503 -174
- package/principles/baseline.ts +58 -70
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -102,6 +102,56 @@ function findBlockEnd(masked: string, open: number): number {
|
|
|
102
102
|
return masked.length;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** Keywords that begin a new top-level declaration. */
|
|
106
|
+
const DECLARATION_KEYWORDS = /^(?:export|import|interface|class|const|let|var|function|type|declare|@)/;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* True when a newline at `index` ends a declaration that has no body: the next
|
|
110
|
+
* line starts another declaration (or a decorator). Without this the scan runs
|
|
111
|
+
* into the following declaration and reports ITS brace as this one's body.
|
|
112
|
+
*/
|
|
113
|
+
function startsNewDeclaration(masked: string, index: number): boolean {
|
|
114
|
+
const rest = masked.substring(index + 1).replace(/^[ \t]*/, '');
|
|
115
|
+
return DECLARATION_KEYWORDS.test(rest);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What the declaration scan should do with the character it just read. */
|
|
119
|
+
type ScanAction =
|
|
120
|
+
| { kind: 'advance' }
|
|
121
|
+
| { kind: 'jump'; to: number }
|
|
122
|
+
| { kind: 'depth'; delta: 1 | -1 }
|
|
123
|
+
| { kind: 'body'; at: number }
|
|
124
|
+
| { kind: 'no-body' };
|
|
125
|
+
|
|
126
|
+
/** Characters that open a generic/bracket group in a declaration header. */
|
|
127
|
+
const HEADER_OPENERS = new Set(['<', '[']);
|
|
128
|
+
/** Characters that close one. */
|
|
129
|
+
const HEADER_CLOSERS = new Set(['>', ']']);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decide what the declaration scan does at `i`.
|
|
133
|
+
*
|
|
134
|
+
* Table-driven rather than an `if` ladder (#620): the interesting cases are
|
|
135
|
+
* "this character means something", and a per-character decision keeps that list
|
|
136
|
+
* readable and each rule independently testable.
|
|
137
|
+
*/
|
|
138
|
+
function declarationScanAction(masked: string, i: number, depth: number): ScanAction {
|
|
139
|
+
const c = masked[i];
|
|
140
|
+
|
|
141
|
+
// A parenthesised group is skipped whole: parens only ever appear inside a
|
|
142
|
+
// header (`class X extends Y<() => void>`), never as body structure.
|
|
143
|
+
if (c === '(') return { kind: 'jump', to: readParenGroup(masked, i).end - 1 };
|
|
144
|
+
if (HEADER_OPENERS.has(c)) return { kind: 'depth', delta: 1 };
|
|
145
|
+
if (HEADER_CLOSERS.has(c)) return { kind: 'depth', delta: -1 };
|
|
146
|
+
|
|
147
|
+
if (c === '{') return depth === 0 ? { kind: 'body', at: i } : { kind: 'depth', delta: 1 };
|
|
148
|
+
if (c === '}') return { kind: 'depth', delta: -1 };
|
|
149
|
+
|
|
150
|
+
if (c === ';' && depth === 0) return { kind: 'no-body' };
|
|
151
|
+
if (c === '\n' && depth === 0 && startsNewDeclaration(masked, i)) return { kind: 'no-body' };
|
|
152
|
+
return { kind: 'advance' };
|
|
153
|
+
}
|
|
154
|
+
|
|
105
155
|
/**
|
|
106
156
|
* Body brace for declarations that have no parameter list (`class`, `interface`).
|
|
107
157
|
* Scans forward from the end of the declaration header; the first `{` at generic
|
|
@@ -110,27 +160,17 @@ function findBlockEnd(masked: string, open: number): number {
|
|
|
110
160
|
function findDeclarationBrace(masked: string, from: number): number {
|
|
111
161
|
let depth = 0;
|
|
112
162
|
for (let i = from; i < masked.length; i++) {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
if (c === '}') { if (depth > 0) depth--; continue; }
|
|
126
|
-
if (c === ';' && depth === 0) return -1;
|
|
127
|
-
// Stop at a blank line / a new top-level keyword so we do not run into the
|
|
128
|
-
// next declaration when this one has no body.
|
|
129
|
-
if (c === '\n') {
|
|
130
|
-
const rest = masked.substring(i + 1).replace(/^[ \t]*/, '');
|
|
131
|
-
if (/^(?:export|import|interface|class|const|let|var|function|type|declare|@)/.test(rest) && depth === 0) {
|
|
132
|
-
return -1;
|
|
133
|
-
}
|
|
163
|
+
const action = declarationScanAction(masked, i, depth);
|
|
164
|
+
switch (action.kind) {
|
|
165
|
+
case 'body': return action.at;
|
|
166
|
+
case 'no-body': return -1;
|
|
167
|
+
case 'jump': i = action.to; break;
|
|
168
|
+
case 'depth':
|
|
169
|
+
// Depth never goes negative: a stray closing bracket in a header must
|
|
170
|
+
// not look like the start of a new group.
|
|
171
|
+
depth = Math.max(0, depth + action.delta);
|
|
172
|
+
break;
|
|
173
|
+
case 'advance': break;
|
|
134
174
|
}
|
|
135
175
|
}
|
|
136
176
|
return -1;
|
|
@@ -140,26 +180,95 @@ function lineOf(src: string, index: number): number {
|
|
|
140
180
|
return src.substring(0, index).split('\n').length;
|
|
141
181
|
}
|
|
142
182
|
|
|
183
|
+
/** The three quoted states shared by the parameter/paren scanners. */
|
|
184
|
+
interface QuoteState {
|
|
185
|
+
inSingle: boolean;
|
|
186
|
+
inDouble: boolean;
|
|
187
|
+
inTemplate: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function newQuoteState(): QuoteState {
|
|
191
|
+
return { inSingle: false, inDouble: false, inTemplate: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function inQuotedState(state: QuoteState): boolean {
|
|
195
|
+
return state.inSingle || state.inDouble || state.inTemplate;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Advance one character of quote tracking.
|
|
200
|
+
*
|
|
201
|
+
* Extracted because `countParams` and `readParenGroup` carried the same
|
|
202
|
+
* six-branch block verbatim (#602): both must ignore delimiters that appear
|
|
203
|
+
* inside a string, and neither may treat an escape as a closing quote.
|
|
204
|
+
*
|
|
205
|
+
* @returns the number of characters consumed (2 when the next char is escaped).
|
|
206
|
+
*/
|
|
207
|
+
function advanceQuoteState(src: string, i: number, state: QuoteState): number {
|
|
208
|
+
const c = src[i];
|
|
209
|
+
if (state.inSingle) {
|
|
210
|
+
if (c === '\\') return 2;
|
|
211
|
+
if (c === "'") state.inSingle = false;
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
if (state.inDouble) {
|
|
215
|
+
if (c === '\\') return 2;
|
|
216
|
+
if (c === '"') state.inDouble = false;
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
if (state.inTemplate) {
|
|
220
|
+
if (c === '\\') return 2;
|
|
221
|
+
if (c === '`') state.inTemplate = false;
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
if (c === "'") state.inSingle = true;
|
|
225
|
+
else if (c === '"') state.inDouble = true;
|
|
226
|
+
else if (c === '`') state.inTemplate = true;
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Opening delimiters that raise the generic/bracket depth in a param list. */
|
|
231
|
+
const DEPTH_OPENERS = new Set(['(', '[', '{', '<']);
|
|
232
|
+
/** Closing delimiters that lower it. */
|
|
233
|
+
const DEPTH_CLOSERS = new Set([')', ']', '}', '>']);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Adjust `depth` for one delimiter.
|
|
237
|
+
* @returns true when the character was a delimiter (so it cannot also be a
|
|
238
|
+
* separator), false otherwise.
|
|
239
|
+
*/
|
|
240
|
+
function applyDelimiter(c: string, depth: { value: number }): boolean {
|
|
241
|
+
if (DEPTH_OPENERS.has(c)) { depth.value++; return true; }
|
|
242
|
+
if (DEPTH_CLOSERS.has(c)) { depth.value--; return true; }
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** True when the character opens a quote/template the scanners must skip. */
|
|
247
|
+
function isQuote(c: string): boolean {
|
|
248
|
+
return c === "'" || c === '"' || c === '`';
|
|
249
|
+
}
|
|
250
|
+
|
|
143
251
|
/** Count top-level, comma-separated parameters in a raw parameter list. */
|
|
144
252
|
function countParams(rawList: string): number {
|
|
145
253
|
const trimmed = rawList.trim();
|
|
146
254
|
if (trimmed === '') return 0;
|
|
147
|
-
|
|
255
|
+
|
|
256
|
+
const depth = { value: 0 };
|
|
148
257
|
let count = 1;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
let
|
|
152
|
-
|
|
258
|
+
const state = newQuoteState();
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < trimmed.length;) {
|
|
261
|
+
if (inQuotedState(state)) {
|
|
262
|
+
i += advanceQuoteState(trimmed, i, state);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
153
265
|
const c = trimmed[i];
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (c === '(' || c === '[' || c === '{' || c === '<') depth++;
|
|
161
|
-
else if (c === ')' || c === ']' || c === '}' || c === '>') depth--;
|
|
162
|
-
else if (c === ',' && depth === 0) count++;
|
|
266
|
+
if (isQuote(c)) {
|
|
267
|
+
advanceQuoteState(trimmed, i, state);
|
|
268
|
+
} else if (!applyDelimiter(c, depth) && c === ',' && depth.value === 0) {
|
|
269
|
+
count++;
|
|
270
|
+
}
|
|
271
|
+
i++;
|
|
163
272
|
}
|
|
164
273
|
return count;
|
|
165
274
|
}
|
|
@@ -170,50 +279,152 @@ function countParams(rawList: string): number {
|
|
|
170
279
|
*/
|
|
171
280
|
function readParenGroup(src: string, open: number): { inner: string; end: number } {
|
|
172
281
|
let depth = 0;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
let
|
|
176
|
-
|
|
282
|
+
const state = newQuoteState();
|
|
283
|
+
|
|
284
|
+
for (let i = open; i < src.length;) {
|
|
285
|
+
if (inQuotedState(state)) {
|
|
286
|
+
i += advanceQuoteState(src, i, state);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
177
289
|
const c = src[i];
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (c === '
|
|
183
|
-
if (c === '`') { inTemplate = true; continue; }
|
|
184
|
-
if (c === '(') depth++;
|
|
185
|
-
else if (c === ')') {
|
|
290
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
291
|
+
advanceQuoteState(src, i, state);
|
|
292
|
+
} else if (c === '(') {
|
|
293
|
+
depth++;
|
|
294
|
+
} else if (c === ')') {
|
|
186
295
|
depth--;
|
|
187
296
|
if (depth === 0) return { inner: src.substring(open + 1, i), end: i + 1 };
|
|
188
297
|
}
|
|
298
|
+
i++;
|
|
189
299
|
}
|
|
190
300
|
return { inner: src.substring(open + 1), end: src.length };
|
|
191
301
|
}
|
|
192
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The lexical states `maxNesting` tracks while counting braces. Braces inside
|
|
305
|
+
* any of these are data, not structure, so they must not change the count.
|
|
306
|
+
*/
|
|
307
|
+
interface NestingScanState {
|
|
308
|
+
inSingle: boolean;
|
|
309
|
+
inDouble: boolean;
|
|
310
|
+
inTemplate: boolean;
|
|
311
|
+
inLineComment: boolean;
|
|
312
|
+
inBlockComment: boolean;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* One handler per lexical state. Each returns how many characters were
|
|
317
|
+
* consumed, and mutates `state` only when the construct actually ends.
|
|
318
|
+
*
|
|
319
|
+
* Split out of a single 22-CCN dispatcher (#602): every state's exit condition
|
|
320
|
+
* is independent, so keeping them in one body bought nothing but branch count.
|
|
321
|
+
*/
|
|
322
|
+
type StateAdvance = (body: string, i: number, state: NestingScanState) => number;
|
|
323
|
+
|
|
324
|
+
const advanceLineComment: StateAdvance = (body, i, state) => {
|
|
325
|
+
if (body[i] === '\n') state.inLineComment = false;
|
|
326
|
+
return 1;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const advanceBlockComment: StateAdvance = (body, i, state) => {
|
|
330
|
+
if (body[i] === '*' && body[i + 1] === '/') {
|
|
331
|
+
state.inBlockComment = false;
|
|
332
|
+
return 2;
|
|
333
|
+
}
|
|
334
|
+
return 1;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** Shared shape of the three quoted states: honour `\`, close on the delimiter. */
|
|
338
|
+
const advanceSingleQuote: StateAdvance = (body, i, state) => {
|
|
339
|
+
if (body[i] === '\\') return 2;
|
|
340
|
+
if (body[i] === "'") state.inSingle = false;
|
|
341
|
+
return 1;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const advanceDoubleQuote: StateAdvance = (body, i, state) => {
|
|
345
|
+
if (body[i] === '\\') return 2;
|
|
346
|
+
if (body[i] === '"') state.inDouble = false;
|
|
347
|
+
return 1;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const advanceTemplate: StateAdvance = (body, i, state) => {
|
|
351
|
+
if (body[i] === '\\') return 2;
|
|
352
|
+
if (body[i] === '`') state.inTemplate = false;
|
|
353
|
+
return 1;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Enter a construct when the current character opens one.
|
|
358
|
+
* @returns characters consumed, or 0 when nothing was opened.
|
|
359
|
+
*/
|
|
360
|
+
function openConstruct(body: string, i: number, state: NestingScanState): number {
|
|
361
|
+
const c = body[i];
|
|
362
|
+
const next = body[i + 1];
|
|
363
|
+
if (c === '/' && next === '/') { state.inLineComment = true; return 2; }
|
|
364
|
+
if (c === '/' && next === '*') { state.inBlockComment = true; return 2; }
|
|
365
|
+
if (c === "'") { state.inSingle = true; return 1; }
|
|
366
|
+
if (c === '"') { state.inDouble = true; return 1; }
|
|
367
|
+
if (c === '`') { state.inTemplate = true; return 1; }
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Advance the quoting/commenting state machine by one character.
|
|
373
|
+
*
|
|
374
|
+
* Pure with respect to depth: it only updates `state` and reports how many
|
|
375
|
+
* characters were consumed. Extracted from `maxNesting` (#602) so the brace
|
|
376
|
+
* counting and the lexing can be reasoned about separately -- the combined body
|
|
377
|
+
* carried every branch of both.
|
|
378
|
+
*
|
|
379
|
+
* @returns the number of characters consumed (1, or 2 for the escape/close
|
|
380
|
+
* pairs that swallow the next character).
|
|
381
|
+
*/
|
|
382
|
+
function advanceNestingState(
|
|
383
|
+
body: string,
|
|
384
|
+
i: number,
|
|
385
|
+
state: NestingScanState,
|
|
386
|
+
): number {
|
|
387
|
+
if (state.inLineComment) return advanceLineComment(body, i, state);
|
|
388
|
+
if (state.inBlockComment) return advanceBlockComment(body, i, state);
|
|
389
|
+
if (state.inSingle) return advanceSingleQuote(body, i, state);
|
|
390
|
+
if (state.inDouble) return advanceDoubleQuote(body, i, state);
|
|
391
|
+
if (state.inTemplate) return advanceTemplate(body, i, state);
|
|
392
|
+
const opened = openConstruct(body, i, state);
|
|
393
|
+
return opened === 0 ? 1 : opened;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** True when the character at `i` is structural (not inside a literal/comment). */
|
|
397
|
+
function isStructuralAt(state: NestingScanState): boolean {
|
|
398
|
+
return !state.inSingle && !state.inDouble && !state.inTemplate &&
|
|
399
|
+
!state.inLineComment && !state.inBlockComment;
|
|
400
|
+
}
|
|
401
|
+
|
|
193
402
|
/** Max brace nesting depth inside a function body (1 = body itself). */
|
|
194
403
|
function maxNesting(body: string): number {
|
|
195
404
|
let depth = 0;
|
|
196
405
|
let max = 0;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
406
|
+
const state: NestingScanState = {
|
|
407
|
+
inSingle: false,
|
|
408
|
+
inDouble: false,
|
|
409
|
+
inTemplate: false,
|
|
410
|
+
inLineComment: false,
|
|
411
|
+
inBlockComment: false,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
for (let i = 0; i < body.length;) {
|
|
415
|
+
const consumed = advanceNestingState(body, i, state);
|
|
416
|
+
if (consumed === 2) {
|
|
417
|
+
// The pair (escape, comment close, comment open) is consumed whole. A
|
|
418
|
+
// comment opener must not also be read as a brace-less character below.
|
|
419
|
+
i += 2;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (isStructuralAt(state)) {
|
|
423
|
+
const c = body[i];
|
|
424
|
+
if (c === '{') { depth++; if (depth > max) max = depth; }
|
|
425
|
+
else if (c === '}') depth--;
|
|
426
|
+
}
|
|
427
|
+
i += 1;
|
|
217
428
|
}
|
|
218
429
|
return max;
|
|
219
430
|
}
|
|
@@ -232,12 +443,121 @@ function ioOperationsIn(body: string): string[] {
|
|
|
232
443
|
return [...found];
|
|
233
444
|
}
|
|
234
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Blank a `//` line comment starting at `start`.
|
|
448
|
+
* Stops AT the newline so line numbering survives (the newline is the caller's
|
|
449
|
+
* to consume as an ordinary character).
|
|
450
|
+
* @returns the index to resume scanning from.
|
|
451
|
+
*/
|
|
452
|
+
function maskLineComment(src: string, out: string[], start: number): number {
|
|
453
|
+
let i = start;
|
|
454
|
+
while (i < src.length && src[i] !== '\n') { out[i] = ' '; i++; }
|
|
455
|
+
return i;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Blank a block comment starting at `start` (which holds `/`).
|
|
460
|
+
*
|
|
461
|
+
* #602: newlines inside the comment must survive. Blanking them shifts every
|
|
462
|
+
* subsequent line number, so `lineOf()` and every line-numbered violation after
|
|
463
|
+
* a multi-line comment would point at the wrong line -- exactly the defect the
|
|
464
|
+
* line-comment branch never had, because it stops AT the newline.
|
|
465
|
+
*
|
|
466
|
+
* @returns the index to resume scanning from.
|
|
467
|
+
*/
|
|
468
|
+
function maskBlockComment(src: string, out: string[], start: number): number {
|
|
469
|
+
let i = start;
|
|
470
|
+
out[i] = ' '; out[i + 1] = ' '; i += 2;
|
|
471
|
+
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) {
|
|
472
|
+
if (src[i] !== '\n') out[i] = ' ';
|
|
473
|
+
i++;
|
|
474
|
+
}
|
|
475
|
+
if (i < src.length) { out[i] = ' '; out[i + 1] = ' '; i += 2; }
|
|
476
|
+
return i;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Decide whether the `/` at `index` opens a regex literal rather than being
|
|
481
|
+
* division. A regex may only start where an expression may start: at the very
|
|
482
|
+
* beginning, after an operator/punctuation, or after a keyword that expects an
|
|
483
|
+
* expression (`return /re/`, `typeof /re/`, ...).
|
|
484
|
+
*/
|
|
485
|
+
function opensRegexLiteral(src: string, index: number): boolean {
|
|
486
|
+
const before = src.substring(0, index).replace(/\s+$/, '');
|
|
487
|
+
const prev = before[before.length - 1];
|
|
488
|
+
if (prev === undefined) return true;
|
|
489
|
+
if (/[=(,:[!&|?{};+\-*%^~<>]/.test(prev)) return true;
|
|
490
|
+
return /(?:^|[^\w$])(return|typeof|case|in|of|new|delete|void|instanceof)$/.test(before);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Find where a regex literal starting at `start` closes, honouring `\` escapes
|
|
495
|
+
* and character classes (`[/]` must not close the literal).
|
|
496
|
+
* @returns the index of the closing `/`, or -1 when the line ends first (so the
|
|
497
|
+
* slash really was division).
|
|
498
|
+
*/
|
|
499
|
+
function findRegexClose(src: string, start: number): number {
|
|
500
|
+
let inClass = false;
|
|
501
|
+
for (let j = start + 1; j < src.length; j++) {
|
|
502
|
+
const d = src[j];
|
|
503
|
+
if (d === '\\') { j++; continue; }
|
|
504
|
+
if (d === '\n') return -1;
|
|
505
|
+
if (d === '[') { inClass = true; continue; }
|
|
506
|
+
if (d === ']') { inClass = false; continue; }
|
|
507
|
+
if (d === '/' && !inClass) return j;
|
|
508
|
+
}
|
|
509
|
+
return -1;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Blank a regex literal (and its trailing flags) starting at `start`.
|
|
514
|
+
* @returns the index to resume scanning from.
|
|
515
|
+
*/
|
|
516
|
+
function maskRegexLiteral(src: string, out: string[], start: number): number {
|
|
517
|
+
const close = findRegexClose(src, start);
|
|
518
|
+
if (close < 0) return start;
|
|
519
|
+
for (let k = start; k <= close; k++) out[k] = ' ';
|
|
520
|
+
let i = close + 1;
|
|
521
|
+
while (i < src.length && /[a-z]/i.test(src[i])) { out[i] = ' '; i++; }
|
|
522
|
+
return i;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Blank a quoted or template literal starting at `start`.
|
|
527
|
+
* A backtick runs to its closing backtick (interpolations included); a single
|
|
528
|
+
* or double quote stops at an unescaped newline, matching a syntax error.
|
|
529
|
+
* @returns the index to resume scanning from.
|
|
530
|
+
*/
|
|
531
|
+
function maskStringLiteral(src: string, out: string[], start: number): number {
|
|
532
|
+
const quote = src[start];
|
|
533
|
+
out[start] = ' ';
|
|
534
|
+
let i = start + 1;
|
|
535
|
+
while (i < src.length) {
|
|
536
|
+
const d = src[i];
|
|
537
|
+
if (d === '\\') {
|
|
538
|
+
out[i] = ' ';
|
|
539
|
+
if (i + 1 < src.length) out[i + 1] = ' ';
|
|
540
|
+
i += 2;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (d === quote) { out[i] = ' '; i++; break; }
|
|
544
|
+
if (d === '\n' && quote !== '`') break;
|
|
545
|
+
out[i] = ' ';
|
|
546
|
+
i++;
|
|
547
|
+
}
|
|
548
|
+
return i;
|
|
549
|
+
}
|
|
550
|
+
|
|
235
551
|
/**
|
|
236
552
|
* Blank out comments and string/template literals, preserving offsets.
|
|
237
553
|
* A single left-to-right scan is used rather than chained `.replace()` calls:
|
|
238
554
|
* sequential regex replacement re-scans text that earlier passes produced and
|
|
239
555
|
* can pair a quote inside a regex literal with a quote in a later comment,
|
|
240
556
|
* swallowing hundreds of lines (observed as a 302-line phantom function).
|
|
557
|
+
*
|
|
558
|
+
* The scan dispatches to one masking routine per construct and otherwise copies
|
|
559
|
+
* the character through, so the loop's own branching stays flat (#602: split
|
|
560
|
+
* from a single 36-CCN body into five routines the tests can pin separately).
|
|
241
561
|
*/
|
|
242
562
|
function maskLiterals(src: string): string {
|
|
243
563
|
const out = src.split('');
|
|
@@ -247,56 +567,20 @@ function maskLiterals(src: string): string {
|
|
|
247
567
|
const next = src[i + 1];
|
|
248
568
|
|
|
249
569
|
if (c === '/' && next === '/') {
|
|
250
|
-
|
|
570
|
+
i = maskLineComment(src, out, i);
|
|
251
571
|
continue;
|
|
252
572
|
}
|
|
253
573
|
if (c === '/' && next === '*') {
|
|
254
|
-
|
|
255
|
-
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) { out[i] = ' '; i++; }
|
|
256
|
-
if (i < src.length) { out[i] = ' '; out[i + 1] = ' '; i += 2; }
|
|
574
|
+
i = maskBlockComment(src, out, i);
|
|
257
575
|
continue;
|
|
258
576
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const before = src.substring(0, i).replace(/\s+$/, '');
|
|
264
|
-
const prev = before[before.length - 1];
|
|
265
|
-
const prevWord = /(?:^|[^\w$])(return|typeof|case|in|of|new|delete|void|instanceof)$/.test(before);
|
|
266
|
-
if (prev === undefined || /[=(,:[!&|?{};+\-*%^~<>]/.test(prev) || prevWord) {
|
|
267
|
-
let j = i + 1;
|
|
268
|
-
let inClass = false;
|
|
269
|
-
let closed = false;
|
|
270
|
-
while (j < src.length) {
|
|
271
|
-
const d = src[j];
|
|
272
|
-
if (d === '\\') { j += 2; continue; }
|
|
273
|
-
if (d === '\n') break;
|
|
274
|
-
if (d === '[') inClass = true;
|
|
275
|
-
else if (d === ']') inClass = false;
|
|
276
|
-
else if (d === '/' && !inClass) { closed = true; break; }
|
|
277
|
-
j++;
|
|
278
|
-
}
|
|
279
|
-
if (closed) {
|
|
280
|
-
for (let k = i; k <= j; k++) out[k] = ' ';
|
|
281
|
-
i = j + 1;
|
|
282
|
-
while (i < src.length && /[a-z]/i.test(src[i])) { out[i] = ' '; i++; }
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
577
|
+
if (c === '/' && opensRegexLiteral(src, i)) {
|
|
578
|
+
const resumed = maskRegexLiteral(src, out, i);
|
|
579
|
+
// -1/findRegexClose failure keeps start, so `i` only advances on a match.
|
|
580
|
+
if (resumed !== i) { i = resumed; continue; }
|
|
286
581
|
}
|
|
287
|
-
|
|
288
582
|
if (c === "'" || c === '"' || c === '`') {
|
|
289
|
-
|
|
290
|
-
out[i] = ' ';
|
|
291
|
-
i++;
|
|
292
|
-
while (i < src.length) {
|
|
293
|
-
const d = src[i];
|
|
294
|
-
if (d === '\\') { out[i] = ' '; if (i + 1 < src.length) out[i + 1] = ' '; i += 2; continue; }
|
|
295
|
-
if (d === quote) { out[i] = ' '; i++; break; }
|
|
296
|
-
if (d === '\n' && quote !== '`') break;
|
|
297
|
-
out[i] = ' ';
|
|
298
|
-
i++;
|
|
299
|
-
}
|
|
583
|
+
i = maskStringLiteral(src, out, i);
|
|
300
584
|
continue;
|
|
301
585
|
}
|
|
302
586
|
|
|
@@ -513,15 +797,31 @@ export class TypeScriptAdapter extends BaseAdapter implements Adapter {
|
|
|
513
797
|
}
|
|
514
798
|
|
|
515
799
|
extractFunctions(): unknown[] {
|
|
516
|
-
const src = this.fileContent;
|
|
517
800
|
// Match declarations against the masked source so identifiers that appear
|
|
518
801
|
// only in comments or strings are never mistaken for functions, while
|
|
519
802
|
// bodies/positions still come from the original text (same offsets).
|
|
520
|
-
const masked = this.maskedSource;
|
|
521
|
-
const found: FunctionMetrics[] = [];
|
|
522
803
|
const seen = new Set<string>();
|
|
804
|
+
const found: FunctionMetrics[] = [];
|
|
805
|
+
// Three independent syntactic shapes, one collector each. Splitting them out
|
|
806
|
+
// of the single CCN-20 body also keeps each shape's quirks (see the comments
|
|
807
|
+
// on the collectors) next to the code they explain.
|
|
808
|
+
this.collectFunctionDecls(seen, found);
|
|
809
|
+
this.collectClassMethods(seen, found);
|
|
810
|
+
this.collectArrowFunctions(seen, found);
|
|
811
|
+
return found;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Claim a `name@offset` key once, so overlapping shapes cannot double-count. */
|
|
815
|
+
private claim(seen: Set<string>, name: string, nameIndex: number): boolean {
|
|
816
|
+
const key = `${name}@${nameIndex}`;
|
|
817
|
+
if (seen.has(key)) return false;
|
|
818
|
+
seen.add(key);
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
523
821
|
|
|
524
|
-
|
|
822
|
+
/** `function name(...)` declarations, including export / async / generators. */
|
|
823
|
+
private collectFunctionDecls(seen: Set<string>, found: FunctionMetrics[]): void {
|
|
824
|
+
const masked = this.maskedSource;
|
|
525
825
|
const declRe = /(?:^|[^\w$.])(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*/g;
|
|
526
826
|
let m: RegExpExecArray | null;
|
|
527
827
|
while ((m = declRe.exec(masked)) !== null) {
|
|
@@ -530,16 +830,20 @@ export class TypeScriptAdapter extends BaseAdapter implements Adapter {
|
|
|
530
830
|
if (parenIndex === -1) continue;
|
|
531
831
|
const bodyOpen = findBodyBrace(masked, parenIndex);
|
|
532
832
|
if (bodyOpen === -1) continue;
|
|
533
|
-
|
|
534
|
-
if (seen.has(key)) continue;
|
|
535
|
-
seen.add(key);
|
|
833
|
+
if (!this.claim(seen, m[1], nameIndex)) continue;
|
|
536
834
|
found.push(this.buildFunction(m[1], 'function', nameIndex, parenIndex, bodyOpen));
|
|
537
835
|
}
|
|
836
|
+
}
|
|
538
837
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
838
|
+
/**
|
|
839
|
+
* Class methods: `name(...) {`. Not anchored to line start so minified
|
|
840
|
+
* single-line bodies count too. Control-flow keywords are excluded -- a bare
|
|
841
|
+
* `if (a > 0) {` matches the shape but is not a method.
|
|
842
|
+
*/
|
|
843
|
+
private collectClassMethods(seen: Set<string>, found: FunctionMetrics[]): void {
|
|
844
|
+
const masked = this.maskedSource;
|
|
542
845
|
const memberRe = /(?<![\w$.])(?:public\s+|private\s+|protected\s+|static\s+|async\s+|readonly\s+|get\s+|set\s+|\*\s*)*(\w+)\s*\(/g;
|
|
846
|
+
let m: RegExpExecArray | null;
|
|
543
847
|
while ((m = memberRe.exec(masked)) !== null) {
|
|
544
848
|
if (CONTROL_KEYWORDS.has(m[1])) continue;
|
|
545
849
|
// The pattern ends with `\(`, so the match's last character IS the opening
|
|
@@ -550,73 +854,98 @@ export class TypeScriptAdapter extends BaseAdapter implements Adapter {
|
|
|
550
854
|
// Only treat as a method when a body brace follows the parameter list.
|
|
551
855
|
const bodyOpen = findBodyBrace(masked, parenIndex);
|
|
552
856
|
if (bodyOpen === -1) continue;
|
|
553
|
-
|
|
554
|
-
if (seen.has(key)) continue;
|
|
555
|
-
seen.add(key);
|
|
857
|
+
if (!this.claim(seen, m[1], nameIndex)) continue;
|
|
556
858
|
found.push(this.buildFunction(m[1], 'method', nameIndex, parenIndex, bodyOpen));
|
|
557
859
|
}
|
|
860
|
+
}
|
|
558
861
|
|
|
559
|
-
|
|
862
|
+
/** Arrow consts / assignments: `const name = (...) =>` / `name = x =>`. */
|
|
863
|
+
private collectArrowFunctions(seen: Set<string>, found: FunctionMetrics[]): void {
|
|
864
|
+
const masked = this.maskedSource;
|
|
560
865
|
const arrowRe = /(?:const|let|var)\s+(\w+)\s*(?::[^=]+)?=\s*(?:async\s*)?/g;
|
|
866
|
+
let m: RegExpExecArray | null;
|
|
561
867
|
while ((m = arrowRe.exec(masked)) !== null) {
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
let parenIndex: number;
|
|
565
|
-
if (parenOffset !== -1) {
|
|
566
|
-
parenIndex = arrowRe.lastIndex + parenOffset;
|
|
567
|
-
} else {
|
|
568
|
-
// Single-identifier param: `x => ...`
|
|
569
|
-
const simple = /^\s*(\w+)\s*=>/.exec(rest);
|
|
570
|
-
if (!simple) continue;
|
|
571
|
-
parenIndex = -1;
|
|
572
|
-
}
|
|
573
|
-
const afterParams = parenIndex === -1
|
|
574
|
-
? arrowRe.lastIndex + (/^\s*(\w+)/.exec(rest) as RegExpExecArray)[0].length
|
|
575
|
-
: readParenGroup(src, parenIndex).end;
|
|
576
|
-
if (!/^\s*(?::[^=]+)?=>/.test(masked.substring(afterParams))) continue;
|
|
577
|
-
|
|
868
|
+
const arrow = this.parseArrowAt(m, arrowRe.lastIndex);
|
|
869
|
+
if (!arrow) continue;
|
|
578
870
|
const nameIndex = m.index + m[0].length - m[1].length;
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
let length = 1;
|
|
585
|
-
let nestingDepth = 0;
|
|
586
|
-
let hasTryCatch = false;
|
|
587
|
-
let ioOperations: string[] = [];
|
|
588
|
-
let code = '';
|
|
589
|
-
if (arrowBodyStart !== -1) {
|
|
590
|
-
const bodyEnd = findBlockEnd(masked, arrowBodyStart);
|
|
591
|
-
code = src.substring(arrowBodyStart, bodyEnd);
|
|
592
|
-
const startLine = lineOf(src, nameIndex);
|
|
593
|
-
const endLine = lineOf(src, bodyEnd);
|
|
594
|
-
length = Math.max(1, endLine - startLine + 1);
|
|
595
|
-
nestingDepth = maxNesting(code);
|
|
596
|
-
hasTryCatch = /\btry\s*\{/.test(code);
|
|
597
|
-
ioOperations = ioOperationsIn(code);
|
|
598
|
-
}
|
|
871
|
+
if (!this.claim(seen, m[1], nameIndex)) continue;
|
|
872
|
+
found.push(this.buildArrowMetrics(m[1], nameIndex, arrow));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
599
875
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
876
|
+
/**
|
|
877
|
+
* Resolve an arrow assignment's parameter list and body brace.
|
|
878
|
+
* @returns null when the text after the assignment is not an arrow function.
|
|
879
|
+
*/
|
|
880
|
+
private parseArrowAt(
|
|
881
|
+
m: RegExpExecArray,
|
|
882
|
+
scanFrom: number,
|
|
883
|
+
): { parenIndex: number; afterParams: number; bodyOpen: number } | null {
|
|
884
|
+
const masked = this.maskedSource;
|
|
885
|
+
const rest = masked.substring(scanFrom);
|
|
886
|
+
const parenOffset = /^\s*\(/.test(rest) ? rest.indexOf('(') : -1;
|
|
887
|
+
let parenIndex: number;
|
|
888
|
+
if (parenOffset !== -1) {
|
|
889
|
+
parenIndex = scanFrom + parenOffset;
|
|
890
|
+
} else {
|
|
891
|
+
// Single-identifier param: `x => ...`
|
|
892
|
+
if (!/^\s*(\w+)\s*=>/.test(rest)) return null;
|
|
893
|
+
parenIndex = -1;
|
|
894
|
+
}
|
|
895
|
+
const afterParams = parenIndex === -1
|
|
896
|
+
? scanFrom + (/^\s*(\w+)/.exec(rest) as RegExpExecArray)[0].length
|
|
897
|
+
: readParenGroup(this.fileContent, parenIndex).end;
|
|
898
|
+
if (!/^\s*(?::[^=]+)?=>/.test(masked.substring(afterParams))) return null;
|
|
899
|
+
|
|
900
|
+
const braceAfter = masked.indexOf('{', afterParams);
|
|
901
|
+
const isBlockBody = braceAfter !== -1 &&
|
|
902
|
+
/^\s*(?::[^=]+)?=>\s*\{/.test(masked.substring(afterParams, braceAfter + 1));
|
|
903
|
+
return { parenIndex, afterParams, bodyOpen: isBlockBody ? braceAfter : -1 };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Derive the metrics for one arrow function, whether or not it has a block body. */
|
|
907
|
+
private buildArrowMetrics(
|
|
908
|
+
name: string,
|
|
909
|
+
nameIndex: number,
|
|
910
|
+
arrow: { parenIndex: number; afterParams: number; bodyOpen: number },
|
|
911
|
+
): FunctionMetrics {
|
|
912
|
+
const src = this.fileContent;
|
|
913
|
+
const startLine = lineOf(src, nameIndex);
|
|
914
|
+
|
|
915
|
+
// A concise body (`() => x + 1`) has no block, so it carries no measurable
|
|
916
|
+
// span: length 1, no nesting, no io. Only a block body is inspected.
|
|
917
|
+
if (arrow.bodyOpen === -1) {
|
|
918
|
+
return {
|
|
919
|
+
name,
|
|
606
920
|
type: 'arrow',
|
|
607
921
|
line: startLine,
|
|
608
922
|
startLine,
|
|
609
923
|
endLine: startLine,
|
|
610
|
-
length,
|
|
611
|
-
paramCount: parenIndex === -1 ? 1 : countParams(readParenGroup(src, parenIndex).inner),
|
|
612
|
-
nestingDepth,
|
|
613
|
-
hasTryCatch,
|
|
614
|
-
ioOperations,
|
|
615
|
-
code,
|
|
616
|
-
}
|
|
924
|
+
length: 1,
|
|
925
|
+
paramCount: arrow.parenIndex === -1 ? 1 : countParams(readParenGroup(src, arrow.parenIndex).inner),
|
|
926
|
+
nestingDepth: 0,
|
|
927
|
+
hasTryCatch: false,
|
|
928
|
+
ioOperations: [],
|
|
929
|
+
code: '',
|
|
930
|
+
};
|
|
617
931
|
}
|
|
618
932
|
|
|
619
|
-
|
|
933
|
+
const bodyEnd = findBlockEnd(this.maskedSource, arrow.bodyOpen);
|
|
934
|
+
const code = src.substring(arrow.bodyOpen, bodyEnd);
|
|
935
|
+
const endLine = lineOf(src, bodyEnd);
|
|
936
|
+
return {
|
|
937
|
+
name,
|
|
938
|
+
type: 'arrow',
|
|
939
|
+
line: startLine,
|
|
940
|
+
startLine,
|
|
941
|
+
endLine: startLine,
|
|
942
|
+
length: Math.max(1, endLine - startLine + 1),
|
|
943
|
+
paramCount: arrow.parenIndex === -1 ? 1 : countParams(readParenGroup(src, arrow.parenIndex).inner),
|
|
944
|
+
nestingDepth: maxNesting(code),
|
|
945
|
+
hasTryCatch: /\btry\s*\{/.test(code),
|
|
946
|
+
ioOperations: ioOperationsIn(code),
|
|
947
|
+
code,
|
|
948
|
+
};
|
|
620
949
|
}
|
|
621
950
|
|
|
622
951
|
extractClasses(): unknown[] {
|