@boyingliu01/xp-gate 0.31.3 → 0.32.0

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 (151) hide show
  1. package/adapters/java.sh +256 -4
  2. package/adapters/powershell.sh +45 -6
  3. package/bin/xp-gate.js +8 -2
  4. package/build-integrity/__tests__/gate-10.test.ts +88 -1
  5. package/build-integrity/gate-10.ts +51 -16
  6. package/dsh.bundle.patch.yml +15 -0
  7. package/gate-13-compiler-strictness.sh +1031 -0
  8. package/gate-3.sh +48 -13
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +48 -13
  11. package/hooks/lib/coverage-total.sh +18 -2
  12. package/hooks/lib/java-test-selection.sh +251 -0
  13. package/hooks/lib/jscpd-run.sh +9 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +269 -13
  16. package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
  17. package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
  18. package/lib/__tests__/gate-runner.test.js +29 -2
  19. package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
  20. package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
  21. package/lib/__tests__/shared-utils.test.js +21 -0
  22. package/lib/check.js +6 -2
  23. package/lib/detect-deps.js +42 -10
  24. package/lib/doctor-tui.js +111 -21
  25. package/lib/gate-runner.js +111 -37
  26. package/lib/mattpocock-license-verdict.json +1 -1
  27. package/lib/principles.js +48 -6
  28. package/lib/shared-utils.js +8 -0
  29. package/mock-policy/AGENTS.md +3 -3
  30. package/mock-policy/gate-m3.ts +57 -23
  31. package/mutation/AGENTS.md +3 -3
  32. package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
  33. package/mutation/__tests__/test-filter-parity.test.ts +5 -2
  34. package/mutation/gate-m.ts +30 -24
  35. package/mutation/runners/mutmut-runner.ts +80 -30
  36. package/mutation/runners/pitest-runner.ts +60 -42
  37. package/package.json +14 -2
  38. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  39. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/dsh/lib/command.d.ts +38 -0
  43. package/plugins/dsh/lib/command.d.ts.map +1 -0
  44. package/plugins/dsh/lib/command.js +87 -0
  45. package/plugins/dsh/lib/credentials.d.ts +39 -0
  46. package/plugins/dsh/lib/credentials.d.ts.map +1 -0
  47. package/plugins/dsh/lib/credentials.js +57 -0
  48. package/plugins/dsh/lib/delphi-config.d.ts +129 -0
  49. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  50. package/plugins/dsh/lib/delphi-config.js +369 -0
  51. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  52. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  53. package/plugins/dsh/lib/delphi-run.js +113 -0
  54. package/plugins/dsh/lib/delphi.d.ts +94 -0
  55. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  56. package/plugins/dsh/lib/delphi.js +207 -0
  57. package/plugins/dsh/lib/dialect.d.ts +24 -0
  58. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  59. package/plugins/dsh/lib/dialect.js +36 -0
  60. package/plugins/dsh/lib/evidence.d.ts +70 -0
  61. package/plugins/dsh/lib/evidence.d.ts.map +1 -0
  62. package/plugins/dsh/lib/evidence.js +92 -0
  63. package/plugins/dsh/lib/gate-runner.d.ts +49 -0
  64. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  65. package/plugins/dsh/lib/gate-runner.js +95 -0
  66. package/plugins/dsh/lib/git-context.d.ts +26 -0
  67. package/plugins/dsh/lib/git-context.d.ts.map +1 -0
  68. package/plugins/dsh/lib/git-context.js +30 -0
  69. package/plugins/dsh/lib/hooks.d.ts +42 -0
  70. package/plugins/dsh/lib/hooks.d.ts.map +1 -0
  71. package/plugins/dsh/lib/hooks.js +113 -0
  72. package/plugins/dsh/lib/index.d.ts +20 -0
  73. package/plugins/dsh/lib/index.d.ts.map +1 -0
  74. package/plugins/dsh/lib/index.js +430 -0
  75. package/plugins/dsh/lib/skill-provider.d.ts +15 -0
  76. package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
  77. package/plugins/dsh/lib/skill-provider.js +205 -0
  78. package/plugins/dsh/lib/sprint.d.ts +60 -0
  79. package/plugins/dsh/lib/sprint.d.ts.map +1 -0
  80. package/plugins/dsh/lib/sprint.js +92 -0
  81. package/plugins/dsh/package.json +14 -8
  82. package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/plugins/dsh/src/command-single-form.test.ts +67 -0
  87. package/plugins/dsh/src/command.test.ts +21 -17
  88. package/plugins/dsh/src/command.ts +19 -21
  89. package/plugins/dsh/src/compat.test.ts +200 -0
  90. package/plugins/dsh/src/contract.test.ts +7 -1
  91. package/plugins/dsh/src/credentials.test.ts +93 -0
  92. package/plugins/dsh/src/credentials.ts +71 -0
  93. package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
  94. package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
  95. package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
  96. package/plugins/dsh/src/delphi-config.ts +146 -53
  97. package/plugins/dsh/src/delphi-run.ts +27 -4
  98. package/plugins/dsh/src/delphi.ts +133 -65
  99. package/plugins/dsh/src/evidence.test.ts +74 -0
  100. package/plugins/dsh/src/evidence.ts +141 -0
  101. package/plugins/dsh/src/gate-runner.test.ts +14 -6
  102. package/plugins/dsh/src/gate-runner.ts +68 -5
  103. package/plugins/dsh/src/git-context.test.ts +39 -0
  104. package/plugins/dsh/src/git-context.ts +38 -0
  105. package/plugins/dsh/src/hooks.test.ts +87 -0
  106. package/plugins/dsh/src/hooks.ts +126 -0
  107. package/plugins/dsh/src/index.test.ts +63 -6
  108. package/plugins/dsh/src/index.ts +340 -13
  109. package/plugins/dsh/src/skill-provider.test.ts +194 -0
  110. package/plugins/dsh/src/skill-provider.ts +231 -0
  111. package/plugins/dsh/src/sprint.test.ts +64 -0
  112. package/plugins/dsh/src/sprint.ts +122 -0
  113. package/plugins/dsh/src/tri-state.test.ts +123 -0
  114. package/plugins/opencode/index.ts +192 -58
  115. package/plugins/opencode/package.json +1 -1
  116. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  117. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  118. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  122. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  123. package/principles/AGENTS.md +3 -3
  124. package/principles/__tests__/analyze-read-failure.test.ts +70 -0
  125. package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
  126. package/principles/__tests__/baseline-prune.test.ts +61 -0
  127. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  128. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  129. package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
  130. package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
  131. package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
  132. package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
  133. package/principles/adapters/cpp.ts +102 -50
  134. package/principles/adapters/java.ts +22 -3
  135. package/principles/adapters/typescript.ts +548 -178
  136. package/principles/analyzer.ts +36 -3
  137. package/principles/baseline-prune.ts +19 -7
  138. package/principles/baseline.ts +58 -70
  139. package/principles/config.ts +56 -35
  140. package/principles/index.ts +122 -40
  141. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  142. package/principles/rules/clean-code/unused-imports.ts +30 -19
  143. package/principles/rules/solid/dip.ts +30 -20
  144. package/principles/rules/solid/lsp.ts +17 -2
  145. package/principles/rules/solid/ocp.ts +33 -23
  146. package/principles/rules/solid/srp.ts +65 -23
  147. package/scripts/delphi-external-review.cjs +25 -2
  148. package/scripts/sync-package-content.js +28 -1
  149. package/skills/delphi-review/AGENTS.md +3 -3
  150. package/skills/sprint-flow/AGENTS.md +3 -3
  151. 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,80 +180,292 @@ 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
+ /** The three quote kinds tracked by QuoteState, with the flag that marks each. */
199
+ const QUOTE_KINDS: ReadonlyArray<{ quote: string; flag: 'inSingle' | 'inDouble' | 'inTemplate' }> = [
200
+ { quote: "'", flag: 'inSingle' },
201
+ { quote: '"', flag: 'inDouble' },
202
+ { quote: '`', flag: 'inTemplate' },
203
+ ];
204
+
205
+ /**
206
+ * Advance one character of quote tracking.
207
+ *
208
+ * Extracted because `countParams` and `readParenGroup` carried the same
209
+ * six-branch block verbatim (#602): both must ignore delimiters that appear
210
+ * inside a string, and neither may treat an escape as a closing quote.
211
+ *
212
+ * Table-driven (#620): all three quote kinds behave identically -- an escape
213
+ * skips two characters, the matching quote closes the literal -- so they are one
214
+ * rule applied to a list rather than three copies of it.
215
+ *
216
+ * @returns the number of characters consumed (2 when the next char is escaped).
217
+ */
218
+ function advanceQuoteState(src: string, i: number, state: QuoteState): number {
219
+ const c = src[i];
220
+
221
+ for (const kind of QUOTE_KINDS) {
222
+ if (!state[kind.flag]) continue;
223
+ if (c === '\\') return 2;
224
+ if (c === kind.quote) state[kind.flag] = false;
225
+ return 1;
226
+ }
227
+
228
+ // Not inside any literal: open the one this character starts, if any.
229
+ const opener = QUOTE_KINDS.find(kind => kind.quote === c);
230
+ if (opener) state[opener.flag] = true;
231
+ return 1;
232
+ }
233
+
234
+ /** Opening delimiters that raise the generic/bracket depth in a param list. */
235
+ const DEPTH_OPENERS = new Set(['(', '[', '{', '<']);
236
+ /** Closing delimiters that lower it. */
237
+ const DEPTH_CLOSERS = new Set([')', ']', '}', '>']);
238
+
239
+ /**
240
+ * Adjust `depth` for one delimiter.
241
+ * @returns true when the character was a delimiter (so it cannot also be a
242
+ * separator), false otherwise.
243
+ */
244
+ function applyDelimiter(c: string, depth: { value: number }): boolean {
245
+ if (DEPTH_OPENERS.has(c)) { depth.value++; return true; }
246
+ if (DEPTH_CLOSERS.has(c)) { depth.value--; return true; }
247
+ return false;
248
+ }
249
+
250
+ /** True when the character opens a quote/template the scanners must skip. */
251
+ function isQuote(c: string): boolean {
252
+ return c === "'" || c === '"' || c === '`';
253
+ }
254
+
143
255
  /** Count top-level, comma-separated parameters in a raw parameter list. */
144
256
  function countParams(rawList: string): number {
145
257
  const trimmed = rawList.trim();
146
258
  if (trimmed === '') return 0;
147
- let depth = 0;
259
+
260
+ const depth = { value: 0 };
148
261
  let count = 1;
149
- let inSingle = false;
150
- let inDouble = false;
151
- let inTemplate = false;
152
- for (let i = 0; i < trimmed.length; i++) {
262
+ const state = newQuoteState();
263
+
264
+ for (let i = 0; i < trimmed.length;) {
265
+ if (inQuotedState(state)) {
266
+ i += advanceQuoteState(trimmed, i, state);
267
+ continue;
268
+ }
153
269
  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++;
270
+ if (isQuote(c)) {
271
+ advanceQuoteState(trimmed, i, state);
272
+ } else if (!applyDelimiter(c, depth) && c === ',' && depth.value === 0) {
273
+ count++;
274
+ }
275
+ i++;
163
276
  }
164
277
  return count;
165
278
  }
166
279
 
280
+ /** What one step of the paren-group scan did, so the caller can react. */
281
+ type ParenStep =
282
+ | { kind: 'consumed'; next: number }
283
+ | { kind: 'closed'; end: number };
284
+
285
+ /**
286
+ * Advance one step through a paren group.
287
+ *
288
+ * Extracted from `readParenGroup` (#620) so the three-way decision -- inside a
289
+ * literal, opening a literal, or structural bracket -- reads as one rule instead
290
+ * of being folded into the loop that also owns depth and substring slicing.
291
+ *
292
+ * A literal is entered through `advanceQuoteState` at the SAME index rather than
293
+ * consumed: that call flips the state on the opening quote and reports one
294
+ * character consumed, so re-scans resume one past the quote with the state set.
295
+ *
296
+ * @param depth running paren depth, updated in place for structural brackets
297
+ * @returns the next index to read, or the offset just past the closing paren
298
+ */
299
+ function stepParenGroup(
300
+ src: string,
301
+ i: number,
302
+ state: QuoteState,
303
+ depth: { value: number },
304
+ ): ParenStep {
305
+ if (inQuotedState(state)) {
306
+ return { kind: 'consumed', next: i + advanceQuoteState(src, i, state) };
307
+ }
308
+
309
+ const c = src[i];
310
+ if (isQuote(c)) {
311
+ // Entering a literal: flip state here, resume at the next character.
312
+ advanceQuoteState(src, i, state);
313
+ return { kind: 'consumed', next: i + 1 };
314
+ }
315
+ if (c === '(') {
316
+ depth.value++;
317
+ return { kind: 'consumed', next: i + 1 };
318
+ }
319
+ if (c === ')') {
320
+ depth.value--;
321
+ if (depth.value === 0) return { kind: 'closed', end: i + 1 };
322
+ }
323
+ return { kind: 'consumed', next: i + 1 };
324
+ }
325
+
167
326
  /**
168
327
  * Extract the balanced `( ... )` group that starts at `open`.
169
328
  * Returns the inner text and the index just past the closing paren.
170
329
  */
171
330
  function readParenGroup(src: string, open: number): { inner: string; end: number } {
172
- let depth = 0;
173
- let inSingle = false;
174
- let inDouble = false;
175
- let inTemplate = false;
176
- for (let i = open; i < src.length; i++) {
177
- 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 === ')') {
186
- depth--;
187
- if (depth === 0) return { inner: src.substring(open + 1, i), end: i + 1 };
331
+ const state = newQuoteState();
332
+ const depth = { value: 0 };
333
+
334
+ for (let i = open; i < src.length;) {
335
+ const step = stepParenGroup(src, i, state, depth);
336
+ if (step.kind === 'closed') {
337
+ return { inner: src.substring(open + 1, step.end - 1), end: step.end };
188
338
  }
339
+ i = step.next;
189
340
  }
190
341
  return { inner: src.substring(open + 1), end: src.length };
191
342
  }
192
343
 
344
+ /**
345
+ * The lexical states `maxNesting` tracks while counting braces. Braces inside
346
+ * any of these are data, not structure, so they must not change the count.
347
+ */
348
+ interface NestingScanState {
349
+ inSingle: boolean;
350
+ inDouble: boolean;
351
+ inTemplate: boolean;
352
+ inLineComment: boolean;
353
+ inBlockComment: boolean;
354
+ }
355
+
356
+ /**
357
+ * One handler per lexical state. Each returns how many characters were
358
+ * consumed, and mutates `state` only when the construct actually ends.
359
+ *
360
+ * Split out of a single 22-CCN dispatcher (#602): every state's exit condition
361
+ * is independent, so keeping them in one body bought nothing but branch count.
362
+ */
363
+ type StateAdvance = (body: string, i: number, state: NestingScanState) => number;
364
+
365
+ const advanceLineComment: StateAdvance = (body, i, state) => {
366
+ if (body[i] === '\n') state.inLineComment = false;
367
+ return 1;
368
+ };
369
+
370
+ const advanceBlockComment: StateAdvance = (body, i, state) => {
371
+ if (body[i] === '*' && body[i + 1] === '/') {
372
+ state.inBlockComment = false;
373
+ return 2;
374
+ }
375
+ return 1;
376
+ };
377
+
378
+ /** Shared shape of the three quoted states: honour `\`, close on the delimiter. */
379
+ const advanceSingleQuote: StateAdvance = (body, i, state) => {
380
+ if (body[i] === '\\') return 2;
381
+ if (body[i] === "'") state.inSingle = false;
382
+ return 1;
383
+ };
384
+
385
+ const advanceDoubleQuote: StateAdvance = (body, i, state) => {
386
+ if (body[i] === '\\') return 2;
387
+ if (body[i] === '"') state.inDouble = false;
388
+ return 1;
389
+ };
390
+
391
+ const advanceTemplate: StateAdvance = (body, i, state) => {
392
+ if (body[i] === '\\') return 2;
393
+ if (body[i] === '`') state.inTemplate = false;
394
+ return 1;
395
+ };
396
+
397
+ /**
398
+ * Enter a construct when the current character opens one.
399
+ * @returns characters consumed, or 0 when nothing was opened.
400
+ */
401
+ function openConstruct(body: string, i: number, state: NestingScanState): number {
402
+ const c = body[i];
403
+ const next = body[i + 1];
404
+ if (c === '/' && next === '/') { state.inLineComment = true; return 2; }
405
+ if (c === '/' && next === '*') { state.inBlockComment = true; return 2; }
406
+ if (c === "'") { state.inSingle = true; return 1; }
407
+ if (c === '"') { state.inDouble = true; return 1; }
408
+ if (c === '`') { state.inTemplate = true; return 1; }
409
+ return 0;
410
+ }
411
+
412
+ /**
413
+ * Advance the quoting/commenting state machine by one character.
414
+ *
415
+ * Pure with respect to depth: it only updates `state` and reports how many
416
+ * characters were consumed. Extracted from `maxNesting` (#602) so the brace
417
+ * counting and the lexing can be reasoned about separately -- the combined body
418
+ * carried every branch of both.
419
+ *
420
+ * @returns the number of characters consumed (1, or 2 for the escape/close
421
+ * pairs that swallow the next character).
422
+ */
423
+ function advanceNestingState(
424
+ body: string,
425
+ i: number,
426
+ state: NestingScanState,
427
+ ): number {
428
+ if (state.inLineComment) return advanceLineComment(body, i, state);
429
+ if (state.inBlockComment) return advanceBlockComment(body, i, state);
430
+ if (state.inSingle) return advanceSingleQuote(body, i, state);
431
+ if (state.inDouble) return advanceDoubleQuote(body, i, state);
432
+ if (state.inTemplate) return advanceTemplate(body, i, state);
433
+ const opened = openConstruct(body, i, state);
434
+ return opened === 0 ? 1 : opened;
435
+ }
436
+
437
+ /** True when the character at `i` is structural (not inside a literal/comment). */
438
+ function isStructuralAt(state: NestingScanState): boolean {
439
+ return !state.inSingle && !state.inDouble && !state.inTemplate &&
440
+ !state.inLineComment && !state.inBlockComment;
441
+ }
442
+
193
443
  /** Max brace nesting depth inside a function body (1 = body itself). */
194
444
  function maxNesting(body: string): number {
195
445
  let depth = 0;
196
446
  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--;
447
+ const state: NestingScanState = {
448
+ inSingle: false,
449
+ inDouble: false,
450
+ inTemplate: false,
451
+ inLineComment: false,
452
+ inBlockComment: false,
453
+ };
454
+
455
+ for (let i = 0; i < body.length;) {
456
+ const consumed = advanceNestingState(body, i, state);
457
+ if (consumed === 2) {
458
+ // The pair (escape, comment close, comment open) is consumed whole. A
459
+ // comment opener must not also be read as a brace-less character below.
460
+ i += 2;
461
+ continue;
462
+ }
463
+ if (isStructuralAt(state)) {
464
+ const c = body[i];
465
+ if (c === '{') { depth++; if (depth > max) max = depth; }
466
+ else if (c === '}') depth--;
467
+ }
468
+ i += 1;
217
469
  }
218
470
  return max;
219
471
  }
@@ -232,12 +484,121 @@ function ioOperationsIn(body: string): string[] {
232
484
  return [...found];
233
485
  }
234
486
 
487
+ /**
488
+ * Blank a `//` line comment starting at `start`.
489
+ * Stops AT the newline so line numbering survives (the newline is the caller's
490
+ * to consume as an ordinary character).
491
+ * @returns the index to resume scanning from.
492
+ */
493
+ function maskLineComment(src: string, out: string[], start: number): number {
494
+ let i = start;
495
+ while (i < src.length && src[i] !== '\n') { out[i] = ' '; i++; }
496
+ return i;
497
+ }
498
+
499
+ /**
500
+ * Blank a block comment starting at `start` (which holds `/`).
501
+ *
502
+ * #602: newlines inside the comment must survive. Blanking them shifts every
503
+ * subsequent line number, so `lineOf()` and every line-numbered violation after
504
+ * a multi-line comment would point at the wrong line -- exactly the defect the
505
+ * line-comment branch never had, because it stops AT the newline.
506
+ *
507
+ * @returns the index to resume scanning from.
508
+ */
509
+ function maskBlockComment(src: string, out: string[], start: number): number {
510
+ let i = start;
511
+ out[i] = ' '; out[i + 1] = ' '; i += 2;
512
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) {
513
+ if (src[i] !== '\n') out[i] = ' ';
514
+ i++;
515
+ }
516
+ if (i < src.length) { out[i] = ' '; out[i + 1] = ' '; i += 2; }
517
+ return i;
518
+ }
519
+
520
+ /**
521
+ * Decide whether the `/` at `index` opens a regex literal rather than being
522
+ * division. A regex may only start where an expression may start: at the very
523
+ * beginning, after an operator/punctuation, or after a keyword that expects an
524
+ * expression (`return /re/`, `typeof /re/`, ...).
525
+ */
526
+ function opensRegexLiteral(src: string, index: number): boolean {
527
+ const before = src.substring(0, index).replace(/\s+$/, '');
528
+ const prev = before[before.length - 1];
529
+ if (prev === undefined) return true;
530
+ if (/[=(,:[!&|?{};+\-*%^~<>]/.test(prev)) return true;
531
+ return /(?:^|[^\w$])(return|typeof|case|in|of|new|delete|void|instanceof)$/.test(before);
532
+ }
533
+
534
+ /**
535
+ * Find where a regex literal starting at `start` closes, honouring `\` escapes
536
+ * and character classes (`[/]` must not close the literal).
537
+ * @returns the index of the closing `/`, or -1 when the line ends first (so the
538
+ * slash really was division).
539
+ */
540
+ function findRegexClose(src: string, start: number): number {
541
+ let inClass = false;
542
+ for (let j = start + 1; j < src.length; j++) {
543
+ const d = src[j];
544
+ if (d === '\\') { j++; continue; }
545
+ if (d === '\n') return -1;
546
+ if (d === '[') { inClass = true; continue; }
547
+ if (d === ']') { inClass = false; continue; }
548
+ if (d === '/' && !inClass) return j;
549
+ }
550
+ return -1;
551
+ }
552
+
553
+ /**
554
+ * Blank a regex literal (and its trailing flags) starting at `start`.
555
+ * @returns the index to resume scanning from.
556
+ */
557
+ function maskRegexLiteral(src: string, out: string[], start: number): number {
558
+ const close = findRegexClose(src, start);
559
+ if (close < 0) return start;
560
+ for (let k = start; k <= close; k++) out[k] = ' ';
561
+ let i = close + 1;
562
+ while (i < src.length && /[a-z]/i.test(src[i])) { out[i] = ' '; i++; }
563
+ return i;
564
+ }
565
+
566
+ /**
567
+ * Blank a quoted or template literal starting at `start`.
568
+ * A backtick runs to its closing backtick (interpolations included); a single
569
+ * or double quote stops at an unescaped newline, matching a syntax error.
570
+ * @returns the index to resume scanning from.
571
+ */
572
+ function maskStringLiteral(src: string, out: string[], start: number): number {
573
+ const quote = src[start];
574
+ out[start] = ' ';
575
+ let i = start + 1;
576
+ while (i < src.length) {
577
+ const d = src[i];
578
+ if (d === '\\') {
579
+ out[i] = ' ';
580
+ if (i + 1 < src.length) out[i + 1] = ' ';
581
+ i += 2;
582
+ continue;
583
+ }
584
+ if (d === quote) { out[i] = ' '; i++; break; }
585
+ if (d === '\n' && quote !== '`') break;
586
+ out[i] = ' ';
587
+ i++;
588
+ }
589
+ return i;
590
+ }
591
+
235
592
  /**
236
593
  * Blank out comments and string/template literals, preserving offsets.
237
594
  * A single left-to-right scan is used rather than chained `.replace()` calls:
238
595
  * sequential regex replacement re-scans text that earlier passes produced and
239
596
  * can pair a quote inside a regex literal with a quote in a later comment,
240
597
  * swallowing hundreds of lines (observed as a 302-line phantom function).
598
+ *
599
+ * The scan dispatches to one masking routine per construct and otherwise copies
600
+ * the character through, so the loop's own branching stays flat (#602: split
601
+ * from a single 36-CCN body into five routines the tests can pin separately).
241
602
  */
242
603
  function maskLiterals(src: string): string {
243
604
  const out = src.split('');
@@ -247,56 +608,20 @@ function maskLiterals(src: string): string {
247
608
  const next = src[i + 1];
248
609
 
249
610
  if (c === '/' && next === '/') {
250
- while (i < src.length && src[i] !== '\n') { out[i] = ' '; i++; }
611
+ i = maskLineComment(src, out, i);
251
612
  continue;
252
613
  }
253
614
  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; }
615
+ i = maskBlockComment(src, out, i);
257
616
  continue;
258
617
  }
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
- }
618
+ if (c === '/' && opensRegexLiteral(src, i)) {
619
+ const resumed = maskRegexLiteral(src, out, i);
620
+ // -1/findRegexClose failure keeps start, so `i` only advances on a match.
621
+ if (resumed !== i) { i = resumed; continue; }
286
622
  }
287
-
288
623
  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
- }
624
+ i = maskStringLiteral(src, out, i);
300
625
  continue;
301
626
  }
302
627
 
@@ -513,15 +838,31 @@ export class TypeScriptAdapter extends BaseAdapter implements Adapter {
513
838
  }
514
839
 
515
840
  extractFunctions(): unknown[] {
516
- const src = this.fileContent;
517
841
  // Match declarations against the masked source so identifiers that appear
518
842
  // only in comments or strings are never mistaken for functions, while
519
843
  // bodies/positions still come from the original text (same offsets).
520
- const masked = this.maskedSource;
521
- const found: FunctionMetrics[] = [];
522
844
  const seen = new Set<string>();
845
+ const found: FunctionMetrics[] = [];
846
+ // Three independent syntactic shapes, one collector each. Splitting them out
847
+ // of the single CCN-20 body also keeps each shape's quirks (see the comments
848
+ // on the collectors) next to the code they explain.
849
+ this.collectFunctionDecls(seen, found);
850
+ this.collectClassMethods(seen, found);
851
+ this.collectArrowFunctions(seen, found);
852
+ return found;
853
+ }
523
854
 
524
- // 1. `function name(...)` declarations (incl. export / async / generators).
855
+ /** Claim a `name@offset` key once, so overlapping shapes cannot double-count. */
856
+ private claim(seen: Set<string>, name: string, nameIndex: number): boolean {
857
+ const key = `${name}@${nameIndex}`;
858
+ if (seen.has(key)) return false;
859
+ seen.add(key);
860
+ return true;
861
+ }
862
+
863
+ /** `function name(...)` declarations, including export / async / generators. */
864
+ private collectFunctionDecls(seen: Set<string>, found: FunctionMetrics[]): void {
865
+ const masked = this.maskedSource;
525
866
  const declRe = /(?:^|[^\w$.])(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*/g;
526
867
  let m: RegExpExecArray | null;
527
868
  while ((m = declRe.exec(masked)) !== null) {
@@ -530,16 +871,20 @@ export class TypeScriptAdapter extends BaseAdapter implements Adapter {
530
871
  if (parenIndex === -1) continue;
531
872
  const bodyOpen = findBodyBrace(masked, parenIndex);
532
873
  if (bodyOpen === -1) continue;
533
- const key = `${m[1]}@${nameIndex}`;
534
- if (seen.has(key)) continue;
535
- seen.add(key);
874
+ if (!this.claim(seen, m[1], nameIndex)) continue;
536
875
  found.push(this.buildFunction(m[1], 'function', nameIndex, parenIndex, bodyOpen));
537
876
  }
877
+ }
538
878
 
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.
879
+ /**
880
+ * Class methods: `name(...) {`. Not anchored to line start so minified
881
+ * single-line bodies count too. Control-flow keywords are excluded -- a bare
882
+ * `if (a > 0) {` matches the shape but is not a method.
883
+ */
884
+ private collectClassMethods(seen: Set<string>, found: FunctionMetrics[]): void {
885
+ const masked = this.maskedSource;
542
886
  const memberRe = /(?<![\w$.])(?:public\s+|private\s+|protected\s+|static\s+|async\s+|readonly\s+|get\s+|set\s+|\*\s*)*(\w+)\s*\(/g;
887
+ let m: RegExpExecArray | null;
543
888
  while ((m = memberRe.exec(masked)) !== null) {
544
889
  if (CONTROL_KEYWORDS.has(m[1])) continue;
545
890
  // The pattern ends with `\(`, so the match's last character IS the opening
@@ -550,73 +895,98 @@ export class TypeScriptAdapter extends BaseAdapter implements Adapter {
550
895
  // Only treat as a method when a body brace follows the parameter list.
551
896
  const bodyOpen = findBodyBrace(masked, parenIndex);
552
897
  if (bodyOpen === -1) continue;
553
- const key = `${m[1]}@${nameIndex}`;
554
- if (seen.has(key)) continue;
555
- seen.add(key);
898
+ if (!this.claim(seen, m[1], nameIndex)) continue;
556
899
  found.push(this.buildFunction(m[1], 'method', nameIndex, parenIndex, bodyOpen));
557
900
  }
901
+ }
558
902
 
559
- // 3. Arrow consts / assignments: `const name = (...) =>` / `name = x =>`.
903
+ /** Arrow consts / assignments: `const name = (...) =>` / `name = x =>`. */
904
+ private collectArrowFunctions(seen: Set<string>, found: FunctionMetrics[]): void {
905
+ const masked = this.maskedSource;
560
906
  const arrowRe = /(?:const|let|var)\s+(\w+)\s*(?::[^=]+)?=\s*(?:async\s*)?/g;
907
+ let m: RegExpExecArray | null;
561
908
  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
-
909
+ const arrow = this.parseArrowAt(m, arrowRe.lastIndex);
910
+ if (!arrow) continue;
578
911
  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
- }
912
+ if (!this.claim(seen, m[1], nameIndex)) continue;
913
+ found.push(this.buildArrowMetrics(m[1], nameIndex, arrow));
914
+ }
915
+ }
599
916
 
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],
917
+ /**
918
+ * Resolve an arrow assignment's parameter list and body brace.
919
+ * @returns null when the text after the assignment is not an arrow function.
920
+ */
921
+ private parseArrowAt(
922
+ m: RegExpExecArray,
923
+ scanFrom: number,
924
+ ): { parenIndex: number; afterParams: number; bodyOpen: number } | null {
925
+ const masked = this.maskedSource;
926
+ const rest = masked.substring(scanFrom);
927
+ const parenOffset = /^\s*\(/.test(rest) ? rest.indexOf('(') : -1;
928
+ let parenIndex: number;
929
+ if (parenOffset !== -1) {
930
+ parenIndex = scanFrom + parenOffset;
931
+ } else {
932
+ // Single-identifier param: `x => ...`
933
+ if (!/^\s*(\w+)\s*=>/.test(rest)) return null;
934
+ parenIndex = -1;
935
+ }
936
+ const afterParams = parenIndex === -1
937
+ ? scanFrom + (/^\s*(\w+)/.exec(rest) as RegExpExecArray)[0].length
938
+ : readParenGroup(this.fileContent, parenIndex).end;
939
+ if (!/^\s*(?::[^=]+)?=>/.test(masked.substring(afterParams))) return null;
940
+
941
+ const braceAfter = masked.indexOf('{', afterParams);
942
+ const isBlockBody = braceAfter !== -1 &&
943
+ /^\s*(?::[^=]+)?=>\s*\{/.test(masked.substring(afterParams, braceAfter + 1));
944
+ return { parenIndex, afterParams, bodyOpen: isBlockBody ? braceAfter : -1 };
945
+ }
946
+
947
+ /** Derive the metrics for one arrow function, whether or not it has a block body. */
948
+ private buildArrowMetrics(
949
+ name: string,
950
+ nameIndex: number,
951
+ arrow: { parenIndex: number; afterParams: number; bodyOpen: number },
952
+ ): FunctionMetrics {
953
+ const src = this.fileContent;
954
+ const startLine = lineOf(src, nameIndex);
955
+
956
+ // A concise body (`() => x + 1`) has no block, so it carries no measurable
957
+ // span: length 1, no nesting, no io. Only a block body is inspected.
958
+ if (arrow.bodyOpen === -1) {
959
+ return {
960
+ name,
606
961
  type: 'arrow',
607
962
  line: startLine,
608
963
  startLine,
609
964
  endLine: startLine,
610
- length,
611
- paramCount: parenIndex === -1 ? 1 : countParams(readParenGroup(src, parenIndex).inner),
612
- nestingDepth,
613
- hasTryCatch,
614
- ioOperations,
615
- code,
616
- });
965
+ length: 1,
966
+ paramCount: arrow.parenIndex === -1 ? 1 : countParams(readParenGroup(src, arrow.parenIndex).inner),
967
+ nestingDepth: 0,
968
+ hasTryCatch: false,
969
+ ioOperations: [],
970
+ code: '',
971
+ };
617
972
  }
618
973
 
619
- return found;
974
+ const bodyEnd = findBlockEnd(this.maskedSource, arrow.bodyOpen);
975
+ const code = src.substring(arrow.bodyOpen, bodyEnd);
976
+ const endLine = lineOf(src, bodyEnd);
977
+ return {
978
+ name,
979
+ type: 'arrow',
980
+ line: startLine,
981
+ startLine,
982
+ endLine: startLine,
983
+ length: Math.max(1, endLine - startLine + 1),
984
+ paramCount: arrow.parenIndex === -1 ? 1 : countParams(readParenGroup(src, arrow.parenIndex).inner),
985
+ nestingDepth: maxNesting(code),
986
+ hasTryCatch: /\btry\s*\{/.test(code),
987
+ ioOperations: ioOperationsIn(code),
988
+ code,
989
+ };
620
990
  }
621
991
 
622
992
  extractClasses(): unknown[] {