|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #10653 — the premise, pinned. Every claim in `checked-parse.ts`'s header is a |
| 4 | +// measurement, and a measurement that lives only in a comment is one nobody |
| 5 | +// re-runs. The two that matter: |
| 6 | +// |
| 7 | +// 1. `ts.createSourceFile` does not throw on a wreck. That is what makes the |
| 8 | +// `try/catch` this module replaced dead code, and what makes the unread |
| 9 | +// `parseDiagnostics` the LIVE path. If a future TypeScript ever starts |
| 10 | +// throwing, `cannot throw` below fails and the callers' catch-free parses |
| 11 | +// need revisiting — loudly, rather than by a caller crashing in the field. |
| 12 | +// 2. The diagnostics are REACHED. `parseDiagnostics` is internal to the |
| 13 | +// compiler and typed optional here, so a rename would make every checked |
| 14 | +// parse silently return "no failure" — the exact green-line-that-lies this |
| 15 | +// module exists to remove, wearing its own badge. `the property is really |
| 16 | +// there` is the non-vacuity proof (#4690: a check that has only ever been |
| 17 | +// green must be tellable apart from a dead one). |
| 18 | +// |
| 19 | +// The test imports `typescript` directly, which the RULES may not do — they take |
| 20 | +// the compiler as a parameter and load it lazily (`lazy-deps.test.ts` guards |
| 21 | +// that). A test file is not on the kernel boot path. |
| 22 | +import ts from 'typescript'; |
| 23 | +import { describe, it, expect } from 'vitest'; |
| 24 | + |
| 25 | +import { createSourceFileChecked, describeParseFailure } from './checked-parse.js'; |
| 26 | + |
| 27 | +/** Wrecks, and the ScriptKind each is measured under. */ |
| 28 | +const WRECKS: ReadonlyArray<{ label: string; source: string; kind: ts.ScriptKind }> = [ |
| 29 | + { |
| 30 | + label: 'merge-conflict markers', |
| 31 | + source: '<<<<<<< HEAD\nconst a = 1;\n=======\nconst a = 2;\n>>>>>>> other\n', |
| 32 | + kind: ts.ScriptKind.TS, |
| 33 | + }, |
| 34 | + { label: 'truncated function body', source: 'export function f() {\n const x = 1;\n', kind: ts.ScriptKind.TS }, |
| 35 | + { label: 'unterminated string', source: "const s = 'oops;\n", kind: ts.ScriptKind.TS }, |
| 36 | + { label: 'unterminated block comment', source: 'function f() {\n /* TODO\n}\n', kind: ts.ScriptKind.TS }, |
| 37 | + { label: 'unterminated template literal', source: 'const s = `oops;\n', kind: ts.ScriptKind.TS }, |
| 38 | + { label: 'a JSX element read as TS', source: 'const a = <div>hi</div>;\n', kind: ts.ScriptKind.TS }, |
| 39 | +]; |
| 40 | + |
| 41 | +const parse = (source: string, kind: ts.ScriptKind, synthesizedLinesBefore?: number) => |
| 42 | + createSourceFileChecked(ts, 'probe.tsx', source, { |
| 43 | + target: ts.ScriptTarget.Latest, |
| 44 | + setParentNodes: true, |
| 45 | + scriptKind: kind, |
| 46 | + ...(synthesizedLinesBefore === undefined ? {} : { synthesizedLinesBefore }), |
| 47 | + }); |
| 48 | + |
| 49 | +describe('createSourceFileChecked — the premise (#10653)', () => { |
| 50 | + it.each(WRECKS)('cannot throw: $label', ({ source, kind }) => { |
| 51 | + // The claim is about `createSourceFile` itself, so it is called RAW here — |
| 52 | + // going through the helper would prove only that the helper does not throw. |
| 53 | + expect(() => ts.createSourceFile('probe.tsx', source, ts.ScriptTarget.Latest, true, kind)).not.toThrow(); |
| 54 | + }); |
| 55 | + |
| 56 | + it.each(WRECKS)('reports a failure instead of a clean tree: $label', ({ source, kind }) => { |
| 57 | + const { failure } = parse(source, kind); |
| 58 | + expect(failure, 'a source that does not parse must come back with a failure').toBeDefined(); |
| 59 | + expect(failure!.count).toBeGreaterThan(0); |
| 60 | + expect(failure!.message.length).toBeGreaterThan(0); |
| 61 | + expect(failure!.line).toBeGreaterThanOrEqual(1); |
| 62 | + expect(failure!.column).toBeGreaterThanOrEqual(1); |
| 63 | + }); |
| 64 | + |
| 65 | + it('the property is really there — non-vacuity of the whole module (#4690)', () => { |
| 66 | + // If `parseDiagnostics` is ever renamed or dropped, the optional read in |
| 67 | + // checked-parse.ts yields undefined and EVERY checked parse in this package |
| 68 | + // goes quietly back to scoring wreckage clean. Nothing else would fail. So |
| 69 | + // the property is asserted directly, on the compiler, once. |
| 70 | + const sf = ts.createSourceFile('probe.ts', 'const a = ;\n', ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); |
| 71 | + const diagnostics = (sf as unknown as { parseDiagnostics?: readonly ts.Diagnostic[] }).parseDiagnostics; |
| 72 | + expect(Array.isArray(diagnostics), '`SourceFile.parseDiagnostics` is gone — checked-parse.ts is now inert').toBe( |
| 73 | + true, |
| 74 | + ); |
| 75 | + expect(diagnostics!.length).toBeGreaterThan(0); |
| 76 | + }); |
| 77 | +}); |
| 78 | + |
| 79 | +describe('createSourceFileChecked — a source that DOES parse', () => { |
| 80 | + it.each([ |
| 81 | + ['plain TS', 'export const a: number = 1;\n', ts.ScriptKind.TS], |
| 82 | + ['TSX with JSX', 'export default function P() {\n return <ObjectForm objectName="a" />;\n}\n', ts.ScriptKind.TSX], |
| 83 | + ['empty', '', ts.ScriptKind.TS], |
| 84 | + ] as const)('reports no failure: %s', (_label, source, kind) => { |
| 85 | + expect(parse(source, kind).failure).toBeUndefined(); |
| 86 | + }); |
| 87 | + |
| 88 | + it('the SAME source under the wrong ScriptKind is a failure, not an opinion', () => { |
| 89 | + // The ScriptKind hat: `<div>…</div>` is a JSX element in TSX and a wreck in |
| 90 | + // TS. Both verdicts are correct for their kind, and neither is silent. |
| 91 | + const jsx = 'const a = <div>hi</div>;\n'; |
| 92 | + expect(parse(jsx, ts.ScriptKind.TSX).failure).toBeUndefined(); |
| 93 | + expect(parse(jsx, ts.ScriptKind.TS).failure).toBeDefined(); |
| 94 | + }); |
| 95 | +}); |
| 96 | + |
| 97 | +describe('createSourceFileChecked — position reporting', () => { |
| 98 | + it('reports the line the error is on', () => { |
| 99 | + const { failure } = parse('const a = 1;\nconst b = 2;\nconst c = ;\n', ts.ScriptKind.TS); |
| 100 | + expect(failure!.line).toBe(3); |
| 101 | + }); |
| 102 | + |
| 103 | + it('subtracts the caller-synthesised lines, so a wrapper never shifts the blame', () => { |
| 104 | + // What validate-hook-body-writes.ts does: the author wrote `const c = ;` on |
| 105 | + // line 2 of their BODY, and the wrapper puts it on line 3 of what is parsed. |
| 106 | + const body = 'const a = 1;\nconst c = ;\n'; |
| 107 | + const wrapped = `async function __body(ctx) {\n${body}\n}`; |
| 108 | + expect(parse(wrapped, ts.ScriptKind.TS).failure!.line, 'raw: the wrapper line counts').toBe(3); |
| 109 | + expect(parse(wrapped, ts.ScriptKind.TS, 1).failure!.line, "remapped: the author's own line").toBe(2); |
| 110 | + }); |
| 111 | + |
| 112 | + it('clamps to line 1 — a diagnostic on a synthesised line never points above the source', () => { |
| 113 | + // A diagnostic that lands ON the wrapper's own first line would remap to 0, |
| 114 | + // which is not a line anybody wrote. |
| 115 | + const failure = parse('const a = ;\n', ts.ScriptKind.TS, 5).failure!; |
| 116 | + expect(failure.line).toBe(1); |
| 117 | + }); |
| 118 | + |
| 119 | + it('counts every diagnostic, and names the first', () => { |
| 120 | + const failure = parse('<<<<<<< HEAD\nconst a = 1;\n=======\nconst a = 2;\n>>>>>>> other\n', ts.ScriptKind.TS) |
| 121 | + .failure!; |
| 122 | + expect(failure.count).toBeGreaterThan(1); |
| 123 | + expect(describeParseFailure(failure)).toContain(`${failure.count} syntax errors in total`); |
| 124 | + expect(describeParseFailure(failure)).toContain(failure.message); |
| 125 | + }); |
| 126 | + |
| 127 | + it('a single diagnostic is described without a count', () => { |
| 128 | + const failure = parse('const a = ;\n', ts.ScriptKind.TS).failure!; |
| 129 | + expect(failure.count).toBe(1); |
| 130 | + expect(describeParseFailure(failure)).not.toContain('in total'); |
| 131 | + }); |
| 132 | +}); |
0 commit comments