playwright-intelligence-engine 2.0.1 → 2.0.2

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 (48) hide show
  1. package/README.md +229 -229
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/core/version.d.ts +3 -3
  4. package/dist/index.js +81 -24
  5. package/package.json +76 -76
  6. package/src/authoring/author.ts +81 -81
  7. package/src/authoring/generation-contract.ts +283 -283
  8. package/src/authoring/hybrid-author.ts +115 -115
  9. package/src/authoring/index.ts +7 -7
  10. package/src/authoring/llm-author.ts +282 -282
  11. package/src/authoring/naming/name-plan.ts +28 -28
  12. package/src/authoring/naming/name-planner.ts +339 -339
  13. package/src/authoring/naming/naming-linter.ts +292 -292
  14. package/src/cli/index.ts +1238 -1172
  15. package/src/collectors/git-diff-collector.ts +190 -190
  16. package/src/core/version.ts +25 -25
  17. package/src/diagnostics/diagnostic-report.ts +204 -204
  18. package/src/diagnostics/failure-classifier.ts +701 -701
  19. package/src/evaluation/repository-runner.ts +197 -197
  20. package/src/gate/quality.ts +101 -101
  21. package/src/generator/architecture-generator.ts +582 -582
  22. package/src/generator/assertion-quality-gate.ts +88 -88
  23. package/src/generator/candidate-writer.ts +288 -288
  24. package/src/generator/test-generator.ts +52 -52
  25. package/src/index.ts +107 -107
  26. package/src/intelligence/evidence-bundle.ts +300 -300
  27. package/src/intelligence/generation-agent.ts +561 -561
  28. package/src/intelligence/generation-verifier.ts +1115 -1115
  29. package/src/intelligence/locator-disambiguation-engine.ts +331 -331
  30. package/src/intelligence/project-intelligence.ts +140 -140
  31. package/src/intelligence/repository-profile.ts +796 -796
  32. package/src/intelligence/trace-proposal-workflow.ts +318 -318
  33. package/src/intelligence/workflow-planner.ts +966 -966
  34. package/src/mcp/server.ts +970 -970
  35. package/src/perception/app-model.ts +32 -32
  36. package/src/perception/crawler.ts +263 -263
  37. package/src/perception/index.ts +3 -3
  38. package/src/repair/assertion-hasher.ts +386 -386
  39. package/src/repair/diff-allowlist.ts +612 -612
  40. package/src/repair/intent-validator.ts +761 -761
  41. package/src/repair/repair-planner.ts +467 -467
  42. package/src/repair/statement-patcher.ts +544 -544
  43. package/src/reproduce/interactive-workflow-runner.ts +828 -828
  44. package/src/sandbox/execution-runner.ts +408 -408
  45. package/src/selector/abstention-policy.ts +175 -175
  46. package/src/selector/candidate-extractor.ts +343 -343
  47. package/src/selector/repair-candidate-ranker.ts +92 -92
  48. package/src/selector/selector-scorer.ts +344 -344
@@ -1,386 +1,386 @@
1
- import { Project, Node, CallExpression } from 'ts-morph';
2
- import crypto from 'node:crypto';
3
-
4
- export interface CompositeIntentFingerprint {
5
- assertionFingerprint: string;
6
- testBodyFingerprint: string;
7
- fixtureFingerprint: string;
8
- compositeHash: string;
9
- }
10
-
11
- export class AssertionHasher {
12
- /**
13
- * Computes a canonical, whitespace-and-formatting-insensitive SHA-256 hash of all expect() assertions.
14
- * Ensures that code repairs never mutate, delete, or weaken test assertions.
15
- * Preserves exact whitespace and characters inside string literals.
16
- */
17
- public static computeFingerprint(testFilePath: string): string {
18
- return AssertionHasher.computeAssertionFingerprint(testFilePath);
19
- }
20
-
21
- /**
22
- * Discovers assertion identifiers imported from @playwright/test (e.g. `expect`, or aliased `expect as verify`).
23
- */
24
- public static discoverAssertionIdentifiers(sourceNode: Node): Set<string> {
25
- const identifiers = new Set(['expect']);
26
- const root = sourceNode.getSourceFile();
27
- if (root) {
28
- for (const imp of root.getImportDeclarations()) {
29
- const spec = imp.getModuleSpecifierValue();
30
- if (spec === '@playwright/test' || spec.endsWith('/test')) {
31
- for (const named of imp.getNamedImports()) {
32
- if (named.getName() === 'expect') {
33
- const alias = named.getAliasNode()?.getText();
34
- if (alias) {
35
- identifiers.add(alias);
36
- }
37
- }
38
- }
39
- }
40
- }
41
- }
42
- return identifiers;
43
- }
44
-
45
- /**
46
- * Computes SHA-256 of all assertion expressions in the file.
47
- * Identifies assertions semantically (calls to expect(), expect.soft(), expect.poll(), or aliased verify()).
48
- * Preserves exact string literal contents and argument ordering.
49
- */
50
- public static computeAssertionFingerprint(testFilePath: string): string {
51
- const project = new Project({ skipAddingFilesFromTsConfig: true });
52
- const sourceFile = project.addSourceFileAtPath(testFilePath);
53
- const identifiers = AssertionHasher.discoverAssertionIdentifiers(sourceFile);
54
-
55
- const assertionRecords: any[] = [];
56
- sourceFile.forEachDescendant((node) => {
57
- if (Node.isCallExpression(node)) {
58
- if (AssertionHasher.isOutermostAssertionCall(node, identifiers)) {
59
- assertionRecords.push(AssertionHasher.canonicalizeAssertion(node));
60
- }
61
- }
62
- });
63
-
64
- const canonicalString = JSON.stringify(assertionRecords);
65
- return crypto.createHash('sha256').update(canonicalString).digest('hex');
66
- }
67
-
68
- /**
69
- * Semantically identifies whether a CallExpression is an assertion API call (or part of an assertion chain).
70
- */
71
- public static isAssertionCall(callExpr: CallExpression, knownIdentifiers?: Set<string>): boolean {
72
- const identifiers = knownIdentifiers ?? AssertionHasher.discoverAssertionIdentifiers(callExpr);
73
- const expr = callExpr.getExpression();
74
- const exprText = expr.getText();
75
-
76
- for (const id of identifiers) {
77
- // Matches id(...), id.soft(...), id.poll(...)
78
- const idPattern = new RegExp(`^${id}(\\.(soft|poll))?$`);
79
- if (idPattern.test(exprText)) {
80
- return true;
81
- }
82
- // Matches chained matchers like id(...).toBe(...) or await id.soft(...).toHaveText(...)
83
- if (Node.isPropertyAccessExpression(expr)) {
84
- const rootText = expr.getText();
85
- if (
86
- rootText.startsWith(`${id}(`) ||
87
- rootText.startsWith(`${id}.soft(`) ||
88
- rootText.startsWith(`${id}.poll(`)
89
- ) {
90
- return true;
91
- }
92
- }
93
- }
94
-
95
- return false;
96
- }
97
-
98
- /**
99
- * Determines whether a CallExpression is the outermost assertion call in a chain.
100
- * Prevents recording duplicate partial assertion calls (e.g. both `expect(x)` and `expect(x).toBe(y)`).
101
- */
102
- public static isOutermostAssertionCall(callExpr: CallExpression, knownIdentifiers?: Set<string>): boolean {
103
- const identifiers = knownIdentifiers ?? AssertionHasher.discoverAssertionIdentifiers(callExpr);
104
- if (!AssertionHasher.isAssertionCall(callExpr, identifiers)) {
105
- return false;
106
- }
107
-
108
- // Check if the parent is a PropertyAccessExpression that is itself invoked by a parent CallExpression
109
- const parent = callExpr.getParent();
110
- if (parent && Node.isPropertyAccessExpression(parent)) {
111
- const grandParent = parent.getParent();
112
- if (grandParent && Node.isCallExpression(grandParent)) {
113
- if (AssertionHasher.isAssertionCall(grandParent, identifiers)) {
114
- return false; // This is an inner receiver in a chained assertion call
115
- }
116
- }
117
- }
118
-
119
- return true;
120
- }
121
-
122
- /**
123
- * Computes SHA-256 structural fingerprint of test body statements, control flow, conditions and operators.
124
- * Ensures statement sequence, logical operators, condition expressions, and argument structure remain invariant.
125
- */
126
- public static computeTestBodyFingerprint(testFilePath: string): string {
127
- const project = new Project({ skipAddingFilesFromTsConfig: true });
128
- const sourceFile = project.addSourceFileAtPath(testFilePath);
129
- const identifiers = AssertionHasher.discoverAssertionIdentifiers(sourceFile);
130
-
131
- const structuralTokens: any[] = [];
132
-
133
- sourceFile.forEachDescendant((node) => {
134
- // 1. Control flow statements
135
- if (
136
- Node.isIfStatement(node) ||
137
- Node.isForStatement(node) ||
138
- Node.isForOfStatement(node) ||
139
- Node.isForInStatement(node) ||
140
- Node.isWhileStatement(node) ||
141
- Node.isDoStatement(node) ||
142
- Node.isTryStatement(node) ||
143
- Node.isSwitchStatement(node) ||
144
- Node.isReturnStatement(node)
145
- ) {
146
- const conditionText = Node.isIfStatement(node) || Node.isWhileStatement(node)
147
- ? node.getExpression().getText().trim()
148
- : '';
149
- structuralTokens.push({
150
- kind: node.getKindName(),
151
- childCount: node.getChildCount(),
152
- condition: conditionText,
153
- });
154
- }
155
- // 2. Expression statements & binary/unary expressions (captures operators like !user.isActive vs user.isActive)
156
- else if (Node.isBinaryExpression(node)) {
157
- structuralTokens.push({
158
- kind: 'BinaryExpression',
159
- operator: node.getOperatorToken().getText(),
160
- left: node.getLeft().getText().trim(),
161
- right: node.getRight().getText().trim(),
162
- });
163
- } else if (Node.isPrefixUnaryExpression(node)) {
164
- structuralTokens.push({
165
- kind: 'PrefixUnaryExpression',
166
- operator: node.getOperatorToken(),
167
- operand: node.getOperand().getText().trim(),
168
- });
169
- }
170
- // 3. Call expressions (non-assertion calls)
171
- else if (Node.isCallExpression(node)) {
172
- if (!AssertionHasher.isAssertionCall(node, identifiers)) {
173
- structuralTokens.push({
174
- kind: 'CallExpression',
175
- expr: node.getExpression().getText().trim(),
176
- argCount: node.getArguments().length,
177
- });
178
- }
179
- }
180
- });
181
-
182
- const canonical = JSON.stringify(structuralTokens);
183
- return crypto.createHash('sha256').update(canonical).digest('hex');
184
- }
185
-
186
- /**
187
- * Computes SHA-256 of test fixtures, parameters, and input literals.
188
- * Preserves exact ordering and associations (e.g. object property key-value pairs).
189
- * Does NOT sort away key-value associations.
190
- */
191
- public static computeFixtureFingerprint(testFilePath: string): string {
192
- const project = new Project({ skipAddingFilesFromTsConfig: true });
193
- const sourceFile = project.addSourceFileAtPath(testFilePath);
194
-
195
- const fixtureTokens: string[] = [];
196
-
197
- sourceFile.forEachDescendant((node) => {
198
- // 1. Property assignments: key + value preserved together (e.g. username: 'admin')
199
- if (Node.isPropertyAssignment(node)) {
200
- const key = node.getName();
201
- const initializer = node.getInitializer();
202
- const value = initializer ? initializer.getText() : '';
203
- fixtureTokens.push(`Prop:${key}=${value}`);
204
- }
205
- // 2. Standalone literals not inside property assignment
206
- else if (
207
- (Node.isStringLiteral(node) ||
208
- Node.isNumericLiteral(node) ||
209
- Node.isTrueLiteral(node) ||
210
- Node.isFalseLiteral(node)) &&
211
- !Node.isPropertyAssignment(node.getParent())
212
- ) {
213
- fixtureTokens.push(`Literal:${node.getKindName()}:${node.getText()}`);
214
- }
215
- });
216
-
217
- // Sequence preserved (do not sort, to capture reordering of data properties)
218
- const canonical = fixtureTokens.join(';;');
219
- return crypto.createHash('sha256').update(canonical).digest('hex');
220
- }
221
-
222
- /**
223
- * Computes full composite intent fingerprint combining assertions, test structure, and fixtures.
224
- */
225
- public static computeCompositeFingerprint(testFilePath: string): CompositeIntentFingerprint {
226
- const assertionFingerprint = AssertionHasher.computeAssertionFingerprint(testFilePath);
227
- const testBodyFingerprint = AssertionHasher.computeTestBodyFingerprint(testFilePath);
228
- const fixtureFingerprint = AssertionHasher.computeFixtureFingerprint(testFilePath);
229
-
230
- const compositeHash = crypto
231
- .createHash('sha256')
232
- .update(`${assertionFingerprint}:${testBodyFingerprint}:${fixtureFingerprint}`)
233
- .digest('hex');
234
-
235
- return {
236
- assertionFingerprint,
237
- testBodyFingerprint,
238
- fixtureFingerprint,
239
- compositeHash,
240
- };
241
- }
242
-
243
- /**
244
- * Extracts canonical assertion records from in-memory source.
245
- */
246
- public static extractAssertionRecordsFromSource(sourceText: string, fileName = 'in-memory.spec.ts'): any[] {
247
- const project = new Project({ useInMemoryFileSystem: true });
248
- const sourceFile = project.createSourceFile(fileName, sourceText, { overwrite: true });
249
- const identifiers = AssertionHasher.discoverAssertionIdentifiers(sourceFile);
250
- const records: any[] = [];
251
- sourceFile.forEachDescendant((node) => {
252
- if (Node.isCallExpression(node) && AssertionHasher.isOutermostAssertionCall(node, identifiers)) {
253
- records.push(AssertionHasher.canonicalizeAssertion(node));
254
- }
255
- });
256
- return records;
257
- }
258
-
259
- /**
260
- * Computes composite intent fingerprint directly from in-memory source code.
261
- */
262
- public static computeCompositeFingerprintFromSource(
263
- sourceText: string,
264
- fileName = 'in-memory.spec.ts'
265
- ): CompositeIntentFingerprint {
266
- const project = new Project({ useInMemoryFileSystem: true });
267
- const sourceFile = project.createSourceFile(fileName, sourceText, { overwrite: true });
268
- const identifiers = AssertionHasher.discoverAssertionIdentifiers(sourceFile);
269
-
270
- // Assertions
271
- const assertionRecords: any[] = [];
272
- sourceFile.forEachDescendant((node) => {
273
- if (Node.isCallExpression(node) && AssertionHasher.isOutermostAssertionCall(node, identifiers)) {
274
- assertionRecords.push(AssertionHasher.canonicalizeAssertion(node));
275
- }
276
- });
277
- const assertionFingerprint = crypto.createHash('sha256').update(JSON.stringify(assertionRecords)).digest('hex');
278
-
279
- // Test body
280
- const structuralTokens: any[] = [];
281
- sourceFile.forEachDescendant((node) => {
282
- if (
283
- Node.isIfStatement(node) ||
284
- Node.isForStatement(node) ||
285
- Node.isForOfStatement(node) ||
286
- Node.isForInStatement(node) ||
287
- Node.isWhileStatement(node) ||
288
- Node.isDoStatement(node) ||
289
- Node.isTryStatement(node) ||
290
- Node.isSwitchStatement(node) ||
291
- Node.isReturnStatement(node)
292
- ) {
293
- const conditionText = Node.isIfStatement(node) || Node.isWhileStatement(node)
294
- ? node.getExpression().getText().trim()
295
- : '';
296
- structuralTokens.push({
297
- kind: node.getKindName(),
298
- childCount: node.getChildCount(),
299
- condition: conditionText,
300
- });
301
- } else if (Node.isBinaryExpression(node)) {
302
- structuralTokens.push({
303
- kind: 'BinaryExpression',
304
- operator: node.getOperatorToken().getText(),
305
- left: node.getLeft().getText().trim(),
306
- right: node.getRight().getText().trim(),
307
- });
308
- } else if (Node.isPrefixUnaryExpression(node)) {
309
- structuralTokens.push({
310
- kind: 'PrefixUnaryExpression',
311
- operator: node.getOperatorToken(),
312
- operand: node.getOperand().getText().trim(),
313
- });
314
- } else if (Node.isCallExpression(node)) {
315
- if (!AssertionHasher.isAssertionCall(node, identifiers)) {
316
- structuralTokens.push({
317
- kind: 'CallExpression',
318
- expr: node.getExpression().getText().trim(),
319
- argCount: node.getArguments().length,
320
- });
321
- }
322
- }
323
- });
324
- const testBodyFingerprint = crypto.createHash('sha256').update(JSON.stringify(structuralTokens)).digest('hex');
325
-
326
- // Fixtures
327
- const fixtureTokens: string[] = [];
328
- sourceFile.forEachDescendant((node) => {
329
- if (Node.isPropertyAssignment(node)) {
330
- const key = node.getName();
331
- const initializer = node.getInitializer();
332
- const value = initializer ? initializer.getText() : '';
333
- fixtureTokens.push(`Prop:${key}=${value}`);
334
- } else if (
335
- (Node.isStringLiteral(node) ||
336
- Node.isNumericLiteral(node) ||
337
- Node.isTrueLiteral(node) ||
338
- Node.isFalseLiteral(node)) &&
339
- !Node.isPropertyAssignment(node.getParent())
340
- ) {
341
- fixtureTokens.push(`Literal:${node.getKindName()}:${node.getText()}`);
342
- }
343
- });
344
- const fixtureFingerprint = crypto.createHash('sha256').update(fixtureTokens.join(';;')).digest('hex');
345
-
346
- const compositeHash = crypto
347
- .createHash('sha256')
348
- .update(`${assertionFingerprint}:${testBodyFingerprint}:${fixtureFingerprint}`)
349
- .digest('hex');
350
-
351
- return {
352
- assertionFingerprint,
353
- testBodyFingerprint,
354
- fixtureFingerprint,
355
- compositeHash,
356
- };
357
- }
358
-
359
- /**
360
- * Canonicalizes an assertion node while strictly preserving string literals exactly.
361
- */
362
- private static canonicalizeAssertion(callExpr: CallExpression): any {
363
- // Normalizes formatting whitespace around tokens WITHOUT modifying string literal contents
364
- const normalizeExpression = (str: string) =>
365
- str.replace(/\s+/g, ' ').replace(/\s*([().,{}:])\s*/g, '$1').trim();
366
-
367
- return {
368
- kind: callExpr.getKindName(),
369
- expression: normalizeExpression(callExpr.getExpression().getText()),
370
- arguments: callExpr.getArguments().map((arg) => {
371
- if (Node.isStringLiteral(arg)) {
372
- // Preserve EXACT text and whitespace inside string literals
373
- return {
374
- kind: 'StringLiteral',
375
- value: arg.getLiteralValue(),
376
- };
377
- }
378
- return {
379
- kind: arg.getKindName(),
380
- text: normalizeExpression(arg.getText()),
381
- };
382
- }),
383
- typeArguments: callExpr.getTypeArguments().map((t) => normalizeExpression(t.getText())),
384
- };
385
- }
386
- }
1
+ import { Project, Node, CallExpression } from 'ts-morph';
2
+ import crypto from 'node:crypto';
3
+
4
+ export interface CompositeIntentFingerprint {
5
+ assertionFingerprint: string;
6
+ testBodyFingerprint: string;
7
+ fixtureFingerprint: string;
8
+ compositeHash: string;
9
+ }
10
+
11
+ export class AssertionHasher {
12
+ /**
13
+ * Computes a canonical, whitespace-and-formatting-insensitive SHA-256 hash of all expect() assertions.
14
+ * Ensures that code repairs never mutate, delete, or weaken test assertions.
15
+ * Preserves exact whitespace and characters inside string literals.
16
+ */
17
+ public static computeFingerprint(testFilePath: string): string {
18
+ return AssertionHasher.computeAssertionFingerprint(testFilePath);
19
+ }
20
+
21
+ /**
22
+ * Discovers assertion identifiers imported from @playwright/test (e.g. `expect`, or aliased `expect as verify`).
23
+ */
24
+ public static discoverAssertionIdentifiers(sourceNode: Node): Set<string> {
25
+ const identifiers = new Set(['expect']);
26
+ const root = sourceNode.getSourceFile();
27
+ if (root) {
28
+ for (const imp of root.getImportDeclarations()) {
29
+ const spec = imp.getModuleSpecifierValue();
30
+ if (spec === '@playwright/test' || spec.endsWith('/test')) {
31
+ for (const named of imp.getNamedImports()) {
32
+ if (named.getName() === 'expect') {
33
+ const alias = named.getAliasNode()?.getText();
34
+ if (alias) {
35
+ identifiers.add(alias);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return identifiers;
43
+ }
44
+
45
+ /**
46
+ * Computes SHA-256 of all assertion expressions in the file.
47
+ * Identifies assertions semantically (calls to expect(), expect.soft(), expect.poll(), or aliased verify()).
48
+ * Preserves exact string literal contents and argument ordering.
49
+ */
50
+ public static computeAssertionFingerprint(testFilePath: string): string {
51
+ const project = new Project({ skipAddingFilesFromTsConfig: true });
52
+ const sourceFile = project.addSourceFileAtPath(testFilePath);
53
+ const identifiers = AssertionHasher.discoverAssertionIdentifiers(sourceFile);
54
+
55
+ const assertionRecords: any[] = [];
56
+ sourceFile.forEachDescendant((node) => {
57
+ if (Node.isCallExpression(node)) {
58
+ if (AssertionHasher.isOutermostAssertionCall(node, identifiers)) {
59
+ assertionRecords.push(AssertionHasher.canonicalizeAssertion(node));
60
+ }
61
+ }
62
+ });
63
+
64
+ const canonicalString = JSON.stringify(assertionRecords);
65
+ return crypto.createHash('sha256').update(canonicalString).digest('hex');
66
+ }
67
+
68
+ /**
69
+ * Semantically identifies whether a CallExpression is an assertion API call (or part of an assertion chain).
70
+ */
71
+ public static isAssertionCall(callExpr: CallExpression, knownIdentifiers?: Set<string>): boolean {
72
+ const identifiers = knownIdentifiers ?? AssertionHasher.discoverAssertionIdentifiers(callExpr);
73
+ const expr = callExpr.getExpression();
74
+ const exprText = expr.getText();
75
+
76
+ for (const id of identifiers) {
77
+ // Matches id(...), id.soft(...), id.poll(...)
78
+ const idPattern = new RegExp(`^${id}(\\.(soft|poll))?$`);
79
+ if (idPattern.test(exprText)) {
80
+ return true;
81
+ }
82
+ // Matches chained matchers like id(...).toBe(...) or await id.soft(...).toHaveText(...)
83
+ if (Node.isPropertyAccessExpression(expr)) {
84
+ const rootText = expr.getText();
85
+ if (
86
+ rootText.startsWith(`${id}(`) ||
87
+ rootText.startsWith(`${id}.soft(`) ||
88
+ rootText.startsWith(`${id}.poll(`)
89
+ ) {
90
+ return true;
91
+ }
92
+ }
93
+ }
94
+
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Determines whether a CallExpression is the outermost assertion call in a chain.
100
+ * Prevents recording duplicate partial assertion calls (e.g. both `expect(x)` and `expect(x).toBe(y)`).
101
+ */
102
+ public static isOutermostAssertionCall(callExpr: CallExpression, knownIdentifiers?: Set<string>): boolean {
103
+ const identifiers = knownIdentifiers ?? AssertionHasher.discoverAssertionIdentifiers(callExpr);
104
+ if (!AssertionHasher.isAssertionCall(callExpr, identifiers)) {
105
+ return false;
106
+ }
107
+
108
+ // Check if the parent is a PropertyAccessExpression that is itself invoked by a parent CallExpression
109
+ const parent = callExpr.getParent();
110
+ if (parent && Node.isPropertyAccessExpression(parent)) {
111
+ const grandParent = parent.getParent();
112
+ if (grandParent && Node.isCallExpression(grandParent)) {
113
+ if (AssertionHasher.isAssertionCall(grandParent, identifiers)) {
114
+ return false; // This is an inner receiver in a chained assertion call
115
+ }
116
+ }
117
+ }
118
+
119
+ return true;
120
+ }
121
+
122
+ /**
123
+ * Computes SHA-256 structural fingerprint of test body statements, control flow, conditions and operators.
124
+ * Ensures statement sequence, logical operators, condition expressions, and argument structure remain invariant.
125
+ */
126
+ public static computeTestBodyFingerprint(testFilePath: string): string {
127
+ const project = new Project({ skipAddingFilesFromTsConfig: true });
128
+ const sourceFile = project.addSourceFileAtPath(testFilePath);
129
+ const identifiers = AssertionHasher.discoverAssertionIdentifiers(sourceFile);
130
+
131
+ const structuralTokens: any[] = [];
132
+
133
+ sourceFile.forEachDescendant((node) => {
134
+ // 1. Control flow statements
135
+ if (
136
+ Node.isIfStatement(node) ||
137
+ Node.isForStatement(node) ||
138
+ Node.isForOfStatement(node) ||
139
+ Node.isForInStatement(node) ||
140
+ Node.isWhileStatement(node) ||
141
+ Node.isDoStatement(node) ||
142
+ Node.isTryStatement(node) ||
143
+ Node.isSwitchStatement(node) ||
144
+ Node.isReturnStatement(node)
145
+ ) {
146
+ const conditionText = Node.isIfStatement(node) || Node.isWhileStatement(node)
147
+ ? node.getExpression().getText().trim()
148
+ : '';
149
+ structuralTokens.push({
150
+ kind: node.getKindName(),
151
+ childCount: node.getChildCount(),
152
+ condition: conditionText,
153
+ });
154
+ }
155
+ // 2. Expression statements & binary/unary expressions (captures operators like !user.isActive vs user.isActive)
156
+ else if (Node.isBinaryExpression(node)) {
157
+ structuralTokens.push({
158
+ kind: 'BinaryExpression',
159
+ operator: node.getOperatorToken().getText(),
160
+ left: node.getLeft().getText().trim(),
161
+ right: node.getRight().getText().trim(),
162
+ });
163
+ } else if (Node.isPrefixUnaryExpression(node)) {
164
+ structuralTokens.push({
165
+ kind: 'PrefixUnaryExpression',
166
+ operator: node.getOperatorToken(),
167
+ operand: node.getOperand().getText().trim(),
168
+ });
169
+ }
170
+ // 3. Call expressions (non-assertion calls)
171
+ else if (Node.isCallExpression(node)) {
172
+ if (!AssertionHasher.isAssertionCall(node, identifiers)) {
173
+ structuralTokens.push({
174
+ kind: 'CallExpression',
175
+ expr: node.getExpression().getText().trim(),
176
+ argCount: node.getArguments().length,
177
+ });
178
+ }
179
+ }
180
+ });
181
+
182
+ const canonical = JSON.stringify(structuralTokens);
183
+ return crypto.createHash('sha256').update(canonical).digest('hex');
184
+ }
185
+
186
+ /**
187
+ * Computes SHA-256 of test fixtures, parameters, and input literals.
188
+ * Preserves exact ordering and associations (e.g. object property key-value pairs).
189
+ * Does NOT sort away key-value associations.
190
+ */
191
+ public static computeFixtureFingerprint(testFilePath: string): string {
192
+ const project = new Project({ skipAddingFilesFromTsConfig: true });
193
+ const sourceFile = project.addSourceFileAtPath(testFilePath);
194
+
195
+ const fixtureTokens: string[] = [];
196
+
197
+ sourceFile.forEachDescendant((node) => {
198
+ // 1. Property assignments: key + value preserved together (e.g. username: 'admin')
199
+ if (Node.isPropertyAssignment(node)) {
200
+ const key = node.getName();
201
+ const initializer = node.getInitializer();
202
+ const value = initializer ? initializer.getText() : '';
203
+ fixtureTokens.push(`Prop:${key}=${value}`);
204
+ }
205
+ // 2. Standalone literals not inside property assignment
206
+ else if (
207
+ (Node.isStringLiteral(node) ||
208
+ Node.isNumericLiteral(node) ||
209
+ Node.isTrueLiteral(node) ||
210
+ Node.isFalseLiteral(node)) &&
211
+ !Node.isPropertyAssignment(node.getParent())
212
+ ) {
213
+ fixtureTokens.push(`Literal:${node.getKindName()}:${node.getText()}`);
214
+ }
215
+ });
216
+
217
+ // Sequence preserved (do not sort, to capture reordering of data properties)
218
+ const canonical = fixtureTokens.join(';;');
219
+ return crypto.createHash('sha256').update(canonical).digest('hex');
220
+ }
221
+
222
+ /**
223
+ * Computes full composite intent fingerprint combining assertions, test structure, and fixtures.
224
+ */
225
+ public static computeCompositeFingerprint(testFilePath: string): CompositeIntentFingerprint {
226
+ const assertionFingerprint = AssertionHasher.computeAssertionFingerprint(testFilePath);
227
+ const testBodyFingerprint = AssertionHasher.computeTestBodyFingerprint(testFilePath);
228
+ const fixtureFingerprint = AssertionHasher.computeFixtureFingerprint(testFilePath);
229
+
230
+ const compositeHash = crypto
231
+ .createHash('sha256')
232
+ .update(`${assertionFingerprint}:${testBodyFingerprint}:${fixtureFingerprint}`)
233
+ .digest('hex');
234
+
235
+ return {
236
+ assertionFingerprint,
237
+ testBodyFingerprint,
238
+ fixtureFingerprint,
239
+ compositeHash,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Extracts canonical assertion records from in-memory source.
245
+ */
246
+ public static extractAssertionRecordsFromSource(sourceText: string, fileName = 'in-memory.spec.ts'): any[] {
247
+ const project = new Project({ useInMemoryFileSystem: true });
248
+ const sourceFile = project.createSourceFile(fileName, sourceText, { overwrite: true });
249
+ const identifiers = AssertionHasher.discoverAssertionIdentifiers(sourceFile);
250
+ const records: any[] = [];
251
+ sourceFile.forEachDescendant((node) => {
252
+ if (Node.isCallExpression(node) && AssertionHasher.isOutermostAssertionCall(node, identifiers)) {
253
+ records.push(AssertionHasher.canonicalizeAssertion(node));
254
+ }
255
+ });
256
+ return records;
257
+ }
258
+
259
+ /**
260
+ * Computes composite intent fingerprint directly from in-memory source code.
261
+ */
262
+ public static computeCompositeFingerprintFromSource(
263
+ sourceText: string,
264
+ fileName = 'in-memory.spec.ts'
265
+ ): CompositeIntentFingerprint {
266
+ const project = new Project({ useInMemoryFileSystem: true });
267
+ const sourceFile = project.createSourceFile(fileName, sourceText, { overwrite: true });
268
+ const identifiers = AssertionHasher.discoverAssertionIdentifiers(sourceFile);
269
+
270
+ // Assertions
271
+ const assertionRecords: any[] = [];
272
+ sourceFile.forEachDescendant((node) => {
273
+ if (Node.isCallExpression(node) && AssertionHasher.isOutermostAssertionCall(node, identifiers)) {
274
+ assertionRecords.push(AssertionHasher.canonicalizeAssertion(node));
275
+ }
276
+ });
277
+ const assertionFingerprint = crypto.createHash('sha256').update(JSON.stringify(assertionRecords)).digest('hex');
278
+
279
+ // Test body
280
+ const structuralTokens: any[] = [];
281
+ sourceFile.forEachDescendant((node) => {
282
+ if (
283
+ Node.isIfStatement(node) ||
284
+ Node.isForStatement(node) ||
285
+ Node.isForOfStatement(node) ||
286
+ Node.isForInStatement(node) ||
287
+ Node.isWhileStatement(node) ||
288
+ Node.isDoStatement(node) ||
289
+ Node.isTryStatement(node) ||
290
+ Node.isSwitchStatement(node) ||
291
+ Node.isReturnStatement(node)
292
+ ) {
293
+ const conditionText = Node.isIfStatement(node) || Node.isWhileStatement(node)
294
+ ? node.getExpression().getText().trim()
295
+ : '';
296
+ structuralTokens.push({
297
+ kind: node.getKindName(),
298
+ childCount: node.getChildCount(),
299
+ condition: conditionText,
300
+ });
301
+ } else if (Node.isBinaryExpression(node)) {
302
+ structuralTokens.push({
303
+ kind: 'BinaryExpression',
304
+ operator: node.getOperatorToken().getText(),
305
+ left: node.getLeft().getText().trim(),
306
+ right: node.getRight().getText().trim(),
307
+ });
308
+ } else if (Node.isPrefixUnaryExpression(node)) {
309
+ structuralTokens.push({
310
+ kind: 'PrefixUnaryExpression',
311
+ operator: node.getOperatorToken(),
312
+ operand: node.getOperand().getText().trim(),
313
+ });
314
+ } else if (Node.isCallExpression(node)) {
315
+ if (!AssertionHasher.isAssertionCall(node, identifiers)) {
316
+ structuralTokens.push({
317
+ kind: 'CallExpression',
318
+ expr: node.getExpression().getText().trim(),
319
+ argCount: node.getArguments().length,
320
+ });
321
+ }
322
+ }
323
+ });
324
+ const testBodyFingerprint = crypto.createHash('sha256').update(JSON.stringify(structuralTokens)).digest('hex');
325
+
326
+ // Fixtures
327
+ const fixtureTokens: string[] = [];
328
+ sourceFile.forEachDescendant((node) => {
329
+ if (Node.isPropertyAssignment(node)) {
330
+ const key = node.getName();
331
+ const initializer = node.getInitializer();
332
+ const value = initializer ? initializer.getText() : '';
333
+ fixtureTokens.push(`Prop:${key}=${value}`);
334
+ } else if (
335
+ (Node.isStringLiteral(node) ||
336
+ Node.isNumericLiteral(node) ||
337
+ Node.isTrueLiteral(node) ||
338
+ Node.isFalseLiteral(node)) &&
339
+ !Node.isPropertyAssignment(node.getParent())
340
+ ) {
341
+ fixtureTokens.push(`Literal:${node.getKindName()}:${node.getText()}`);
342
+ }
343
+ });
344
+ const fixtureFingerprint = crypto.createHash('sha256').update(fixtureTokens.join(';;')).digest('hex');
345
+
346
+ const compositeHash = crypto
347
+ .createHash('sha256')
348
+ .update(`${assertionFingerprint}:${testBodyFingerprint}:${fixtureFingerprint}`)
349
+ .digest('hex');
350
+
351
+ return {
352
+ assertionFingerprint,
353
+ testBodyFingerprint,
354
+ fixtureFingerprint,
355
+ compositeHash,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Canonicalizes an assertion node while strictly preserving string literals exactly.
361
+ */
362
+ private static canonicalizeAssertion(callExpr: CallExpression): any {
363
+ // Normalizes formatting whitespace around tokens WITHOUT modifying string literal contents
364
+ const normalizeExpression = (str: string) =>
365
+ str.replace(/\s+/g, ' ').replace(/\s*([().,{}:])\s*/g, '$1').trim();
366
+
367
+ return {
368
+ kind: callExpr.getKindName(),
369
+ expression: normalizeExpression(callExpr.getExpression().getText()),
370
+ arguments: callExpr.getArguments().map((arg) => {
371
+ if (Node.isStringLiteral(arg)) {
372
+ // Preserve EXACT text and whitespace inside string literals
373
+ return {
374
+ kind: 'StringLiteral',
375
+ value: arg.getLiteralValue(),
376
+ };
377
+ }
378
+ return {
379
+ kind: arg.getKindName(),
380
+ text: normalizeExpression(arg.getText()),
381
+ };
382
+ }),
383
+ typeArguments: callExpr.getTypeArguments().map((t) => normalizeExpression(t.getText())),
384
+ };
385
+ }
386
+ }