More tests for the ConformancePatterns, and fix substr-based confusion

Adding a bunch more tests around various more complex cases (shadowing, function parameters, and whitespace) revealed a bug where I mixed start/end and start/length patterns. So I'm taking the opportunity to switch to String.prototype.slice for the matcher, which is slightly less surprising in its behavior.

PiperOrigin-RevId: 250134904
diff --git a/internal/tsetse/tests/ban_conformance_pattern/property_write_test.ts b/internal/tsetse/tests/ban_conformance_pattern/property_write_test.ts
index 997577e..42290f2 100644
--- a/internal/tsetse/tests/ban_conformance_pattern/property_write_test.ts
+++ b/internal/tsetse/tests/ban_conformance_pattern/property_write_test.ts
@@ -3,19 +3,63 @@
 import {compileAndCheck, customMatchers} from '../../util/testing/test_support';
 
 describe('BANNED_PROPERTY_WRITE', () => {
+  const rule = new ConformancePatternRule({
+    errorMessage: 'do not cite',
+    kind: PatternKind.BANNED_PROPERTY_WRITE,
+    values: ['HTMLQuoteElement.prototype.cite']
+  });
+
   it('matches simple examples', () => {
-    const source = `const q = document.createElement('q');\n` +
-        `q.cite = 'some example string';\n`;
-    const rule = new ConformancePatternRule({
-      errorMessage: 'do not cite',
-      kind: PatternKind.BANNED_PROPERTY_WRITE,
-      values: ['HTMLQuoteElement.prototype.cite']
-    });
+    const source = [
+      `const q = document.createElement('q');`,
+      `q.cite = 'some example string';`,
+    ].join('\n');
     const results = compileAndCheck(rule, source);
 
     expect(results.length).toBe(1);
-    expect(results[0])
-        .toBeFailureMatching({start: 39, end: 69, errorMessage: 'do not cite'});
+    expect(results[0]).toBeFailureMatching({
+      matchedCode: `q.cite = 'some example string'`,
+      errorMessage: 'do not cite'
+    });
+  });
+
+  it('matches precisely, even with whitespace or comments', () => {
+    const source = [
+      `const q = document.createElement('q');`,
+      `    q.cite = 'exampleA';`,
+      `q.cite = 'exampleB'    ;`,
+      `/* test1 */ q.cite = /* test2 */ 'exampleC' /* test3 */;`,
+    ].join('\n');
+    const results = compileAndCheck(rule, source);
+
+    expect(results.length).toBe(3);
+    expect(results[0]).toBeFailureMatching({
+      matchedCode: `q.cite = 'exampleA'`,
+      errorMessage: 'do not cite'
+    });
+    expect(results[1]).toBeFailureMatching({
+      matchedCode: `q.cite = 'exampleB'`,
+      errorMessage: 'do not cite'
+    });
+    expect(results[2]).toBeFailureMatching({
+      matchedCode: `q.cite = /* test2 */ 'exampleC'`,
+      errorMessage: 'do not cite'
+    });
+  })
+
+  it('understands function prototypes', () => {
+    const source = [
+      `function foo(q:HTMLQuoteElement) {`,
+      `    q.cite = 'some example string';`,
+      `}`,
+    ].join('\n');
+    const results = compileAndCheck(rule, source);
+
+    expect(results.length).toBe(1);
+    expect(results[0]).toBeFailureMatching({
+      matchedCode: `q.cite = 'some example string'`,
+      errorMessage: 'do not cite'
+    });
   });
 
   it('understands imported symbols', () => {
@@ -23,16 +67,11 @@
       `const q = document.createElement('q'); export {q};`,
       `import {q} from './file_0'; q.cite = window.name;`
     ];
-    const rule = new ConformancePatternRule({
-      errorMessage: 'do not cite',
-      kind: PatternKind.BANNED_PROPERTY_WRITE,
-      values: ['HTMLQuoteElement.prototype.cite']
-    });
     const results = compileAndCheck(rule, ...sources);
 
     expect(results.length).toBe(1);
     expect(results[0]).toBeFailureMatching({
-      matchedCode: 'q.cite = window.name;',
+      matchedCode: 'q.cite = window.name',
       fileName: 'file_1.ts',
       errorMessage: 'do not cite',
     });
@@ -41,16 +80,16 @@
 
   describe('with inheritance', () => {
     const source = [
-      `class Parent {x:number}`,
+      `class Parent { x: number }`,
       `class Child extends Parent {}`,
-      `const c:Child = new Child();`,
+      `const c: Child = new Child();`,
       `c.x = 1;`,
     ].join('\n');
 
     // Both of these should have the same results: in `c.x`, `x` matches,
     // and `c` is both a Parent and a Child.
     const expectedFailure = {
-      matchedCode: 'c.x = 1;',
+      matchedCode: 'c.x = 1',
       errorMessage: 'found write to x',
     };
 
@@ -76,6 +115,23 @@
       expect(r[0]).toBeFailureMatching(expectedFailure);
     });
   });
+
+  describe('with shadowing', () => {
+    it('does not over-match', () => {
+      const source = [
+        `const q = document.createElement('q');`,
+        `const f1 = (q: {cite: string}) => { q.cite = 'example 1'; };`,
+      ].join('\n');
+      const rule = new ConformancePatternRule({
+        errorMessage: 'do not cite',
+        kind: PatternKind.BANNED_PROPERTY_WRITE,
+        values: ['HTMLQuoteElement.prototype.cite']
+      });
+      const results = compileAndCheck(rule, source);
+
+      expect(results.length).toBe(0);
+    });
+  });
 });
 
 beforeEach(() => {
diff --git a/internal/tsetse/util/pattern_engines/property_write_engine.ts b/internal/tsetse/util/pattern_engines/property_write_engine.ts
index 7af7ccf..ea90e60 100644
--- a/internal/tsetse/util/pattern_engines/property_write_engine.ts
+++ b/internal/tsetse/util/pattern_engines/property_write_engine.ts
@@ -38,10 +38,12 @@
         !isPropertyWriteExpression(n)) {
       return;
     }
-    debugLog(`inspecting ${n.getFullText().trim()}`);
+    debugLog(`inspecting ${n.getText().trim()}`);
     if (this.matcher.matches(n.left, c.typeChecker)) {
       const fix: Fix|undefined =
           this.fixer ? this.fixer.getFixForFlaggedNode(n) : undefined;
+      debugLog(`Match. Reporting failure (boundaries: ${n.getStart()}, ${
+          n.getEnd()}] on node [${n.getText()}]`);
       c.addFailureAtNode(n, this.config.errorMessage, fix);
     }
   }
diff --git a/internal/tsetse/util/testing/test_support.ts b/internal/tsetse/util/testing/test_support.ts
index f79192c..6177710 100644
--- a/internal/tsetse/util/testing/test_support.ts
+++ b/internal/tsetse/util/testing/test_support.ts
@@ -79,7 +79,7 @@
         const actualDiagnostic = actualFailure.toDiagnostic();
         let regrets = '';
         if (exp === undefined) {
-          regrets += 'The rule requires two arguments. ';
+          regrets += 'The matcher requires two arguments. ';
         }
         if (exp.fileName) {
           if (!actualDiagnostic.file) {
@@ -100,12 +100,17 @@
           if (!actualDiagnostic.file) {
             regrets += `Expected diagnostic to have a source file, but it had ${
                 actualDiagnostic.file}. `;
+          } else if (!actualDiagnostic.start) {
+            // I don't know how this could happen, but typings say so.
+            regrets += `Expected diagnostic to have a starting position. `;
           } else {
-            const foundMatchedCode = actualDiagnostic.file.getFullText().substr(
+            const foundMatchedCode = actualDiagnostic.file.getFullText().slice(
                 Number(actualDiagnostic.start), actualDiagnostic.end);
             if (foundMatchedCode != exp.matchedCode) {
               regrets += `Expected diagnostic to match ${
-                  exp.matchedCode}, but was ${foundMatchedCode}`;
+                  exp.matchedCode}, but was ${foundMatchedCode} (from ${
+                  Number(
+                      actualDiagnostic.start)} to ${actualDiagnostic.end}). `;
             }
           }
         }