@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.
Files changed (36) hide show
  1. package/gate-3.sh +244 -9
  2. package/hooks/gate-3.sh +244 -9
  3. package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
  4. package/hooks/pre-commit +152 -13
  5. package/lib/mattpocock-license-verdict.json +1 -1
  6. package/mock-policy/AGENTS.md +3 -3
  7. package/mutation/AGENTS.md +3 -3
  8. package/package.json +1 -1
  9. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  10. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  11. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  12. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  13. package/plugins/dsh/package.json +1 -1
  14. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  15. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  16. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  17. package/plugins/opencode/package.json +1 -1
  18. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  19. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  20. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  21. package/plugins/qoder/plugin.json +1 -1
  22. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  23. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  24. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  25. package/principles/AGENTS.md +3 -3
  26. package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
  27. package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
  28. package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
  29. package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
  30. package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
  31. package/principles/adapters/cpp.ts +102 -50
  32. package/principles/adapters/typescript.ts +503 -174
  33. package/principles/baseline.ts +58 -70
  34. package/skills/delphi-review/AGENTS.md +3 -3
  35. package/skills/sprint-flow/AGENTS.md +3 -3
  36. 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 c = masked[i];
114
- if (c === '<' || c === '[' || c === '(') {
115
- if (c === '(') { const g = readParenGroup(masked, i); i = g.end - 1; continue; }
116
- depth++;
117
- continue;
118
- }
119
- if (c === '>' || c === ']') { if (depth > 0) depth--; continue; }
120
- if (c === '{') {
121
- if (depth === 0) return i;
122
- depth++;
123
- continue;
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
- let depth = 0;
255
+
256
+ const depth = { value: 0 };
148
257
  let count = 1;
149
- let inSingle = false;
150
- let inDouble = false;
151
- let inTemplate = false;
152
- for (let i = 0; i < trimmed.length; i++) {
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 (inSingle) { if (c === '\\') i++; else if (c === "'") inSingle = false; continue; }
155
- if (inDouble) { if (c === '\\') i++; else if (c === '"') inDouble = false; continue; }
156
- if (inTemplate) { if (c === '\\') i++; else if (c === '`') inTemplate = false; continue; }
157
- if (c === "'") { inSingle = true; continue; }
158
- if (c === '"') { inDouble = true; continue; }
159
- if (c === '`') { inTemplate = true; continue; }
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
- let inSingle = false;
174
- let inDouble = false;
175
- let inTemplate = false;
176
- for (let i = open; i < src.length; i++) {
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 (inSingle) { if (c === '\\') i++; else if (c === "'") inSingle = false; continue; }
179
- if (inDouble) { if (c === '\\') i++; else if (c === '"') inDouble = false; continue; }
180
- if (inTemplate) { if (c === '\\') i++; else if (c === '`') inTemplate = false; continue; }
181
- if (c === "'") { inSingle = true; continue; }
182
- if (c === '"') { inDouble = true; continue; }
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
- let inSingle = false;
198
- let inDouble = false;
199
- let inTemplate = false;
200
- let inLineComment = false;
201
- let inBlockComment = false;
202
- for (let i = 0; i < body.length; i++) {
203
- const c = body[i];
204
- const next = body[i + 1];
205
- if (inLineComment) { if (c === '\n') inLineComment = false; continue; }
206
- if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i++; } continue; }
207
- if (inSingle) { if (c === '\\') i++; else if (c === "'") inSingle = false; continue; }
208
- if (inDouble) { if (c === '\\') i++; else if (c === '"') inDouble = false; continue; }
209
- if (inTemplate) { if (c === '\\') i++; else if (c === '`') inTemplate = false; continue; }
210
- if (c === '/' && next === '/') { inLineComment = true; i++; continue; }
211
- if (c === '/' && next === '*') { inBlockComment = true; i++; continue; }
212
- if (c === "'") { inSingle = true; continue; }
213
- if (c === '"') { inDouble = true; continue; }
214
- if (c === '`') { inTemplate = true; continue; }
215
- if (c === '{') { depth++; if (depth > max) max = depth; }
216
- else if (c === '}') depth--;
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
- while (i < src.length && src[i] !== '\n') { out[i] = ' '; i++; }
570
+ i = maskLineComment(src, out, i);
251
571
  continue;
252
572
  }
253
573
  if (c === '/' && next === '*') {
254
- out[i] = ' '; out[i + 1] = ' '; i += 2;
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
- // Regex literals: `/.../flags`. Only treat `/` as a regex start when the
261
- // previous significant character cannot end an expression.
262
- if (c === '/') {
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
- const quote = c;
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
- // 1. `function name(...)` declarations (incl. export / async / generators).
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
- const key = `${m[1]}@${nameIndex}`;
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
- // 2. Class methods: `name(...) {`. Not anchored to line start so minified
540
- // single-line bodies count too. Control-flow keywords are excluded -- a
541
- // bare `if (a > 0) {` matches the shape but is not a method.
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
- const key = `${m[1]}@${nameIndex}`;
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
- // 3. Arrow consts / assignments: `const name = (...) =>` / `name = x =>`.
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 rest = masked.substring(arrowRe.lastIndex);
563
- const parenOffset = /^\s*\(/.test(rest) ? rest.indexOf('(') : -1;
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
- const braceAfter = masked.indexOf('{', afterParams);
580
- const arrowBodyStart = braceAfter !== -1 && /^\s*(?::[^=]+)?=>\s*\{/.test(masked.substring(afterParams, braceAfter + 1))
581
- ? braceAfter
582
- : -1;
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
- const startLine = lineOf(src, nameIndex);
601
- const key = `${m[1]}@${nameIndex}`;
602
- if (seen.has(key)) continue;
603
- seen.add(key);
604
- found.push({
605
- name: m[1],
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
- return found;
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[] {