|
| 1 | +// angle-depth-probe.ts — the depth gate for the DELIMITER + LOOKAHEAD nesting class (TS/TSX angle |
| 2 | +// brackets), the category that had ZERO depth coverage and where the type-cast `\g<TC>` cliff lived. |
| 3 | +// |
| 4 | +// Two structural checks over the GENERATED typescript/typescriptreact/javascript/javascriptreact |
| 5 | +// grammars, so a new construct cannot be added without this gate seeing it: |
| 6 | +// |
| 7 | +// PART 1 — DEPTH SWEEP (self-baselining). The angle-bracket disambiguations (generic call, type |
| 8 | +// cast, generic arrow, JSX tag type-args, generic type) confirm `<…>` is a type span. A confirm |
| 9 | +// built on an Oniguruma `\g<>` subroutine has a ~20-level recursion cap, so a deep nested generic |
| 10 | +// flips the discriminating scope at d=20 (the cast did exactly this: `<` → relational at d=20). |
| 11 | +// For each construct we sweep d=1,5,19,20,24,30 and assert the probe token's scope at every depth |
| 12 | +// EQUALS its shallow (d=1) value — a flip anywhere is a depth cliff. Self-baselining, so it is |
| 13 | +// robust to scope renames; it would have caught the cast cliff (gen-tm 1967) and the TSX arrow |
| 14 | +// cliff (the formerly-recursive arrowEndConfirm) before they shipped. |
| 15 | +// |
| 16 | +// PART 2 — `\g<>` CENSUS. Every Oniguruma subroutine `\g<>` reachable from a begin/match/while/end |
| 17 | +// in the emitted grammars is enumerated and matched against an ALLOWLIST of graceful-degraders |
| 18 | +// (a `\g<>` inside a negative lookahead or an optional group, whose overflow is benign). A `\g<>` |
| 19 | +// NOT on the list is a latent sole-confirmer cliff → FAIL, forcing a flat-confirm migration (as |
| 20 | +// done for generic-call/arrow/cast) or an explicit, depth-probed allowlist entry. This makes the |
| 21 | +// cast-cliff class structurally un-missable: it walks the EMITTED output, not a hand list. |
| 22 | +// |
| 23 | +// Run (bare node): node test/angle-depth-probe.ts · Exit 0 iff no cliff AND census clean. |
| 24 | +import { readFileSync } from 'node:fs'; |
| 25 | +import { createRequire } from 'node:module'; |
| 26 | +import vsctm from 'vscode-textmate'; |
| 27 | +import onig from 'vscode-oniguruma'; |
| 28 | + |
| 29 | +const { INITIAL, Registry } = vsctm; |
| 30 | +const { loadWASM, OnigScanner, OnigString } = onig; |
| 31 | +const require = createRequire(import.meta.url); |
| 32 | +const wasm = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm')); |
| 33 | +await loadWASM(wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength)); |
| 34 | + |
| 35 | +async function load(path: string) { |
| 36 | + const raw = JSON.parse(readFileSync(path, 'utf8')); |
| 37 | + const reg = new Registry({ |
| 38 | + onigLib: Promise.resolve({ |
| 39 | + createOnigScanner: (ps: string[]) => new OnigScanner(ps), |
| 40 | + createOnigString: (s: string) => new OnigString(s), |
| 41 | + }), |
| 42 | + loadGrammar: async (scope: string) => (scope === raw.scopeName ? raw : null), |
| 43 | + }); |
| 44 | + return reg.loadGrammar(raw.scopeName); |
| 45 | +} |
| 46 | + |
| 47 | +const TS = await load('./typescript.tmLanguage.json'); |
| 48 | +const TSX = await load('./typescriptreact.tmLanguage.json'); |
| 49 | + |
| 50 | +// scope of the FIRST token whose text === `needle` (optionally the nth), innermost scope. |
| 51 | +function scopeOf(g: any, src: string, needle: string, nth = 1): string | null { |
| 52 | + const lines = src.split('\n'); |
| 53 | + let rs = INITIAL; |
| 54 | + let seen = 0; |
| 55 | + for (const line of lines) { |
| 56 | + const r = g.tokenizeLine(line, rs); |
| 57 | + for (const t of r.tokens) { |
| 58 | + if (line.slice(t.startIndex, t.endIndex) === needle && ++seen === nth) return t.scopes[t.scopes.length - 1]; |
| 59 | + } |
| 60 | + rs = r.ruleStack; |
| 61 | + } |
| 62 | + return null; |
| 63 | +} |
| 64 | + |
| 65 | +// A d-deep nested generic type span: A0<A1<…<X>…>> (no spaces, the densest form). |
| 66 | +const nest = (d: number) => { |
| 67 | + let s = 'X'; |
| 68 | + for (let i = 0; i < d; i++) s = `A${i}<${s}>`; |
| 69 | + return s; |
| 70 | +}; |
| 71 | + |
| 72 | +interface Construct { |
| 73 | + name: string; |
| 74 | + g: any; |
| 75 | + build: (d: number) => string; |
| 76 | + needle: string; // token whose scope must stay stable across depth |
| 77 | + nth?: number; |
| 78 | +} |
| 79 | + |
| 80 | +const CONSTRUCTS: Construct[] = [ |
| 81 | + { name: 'generic-call (ts)', g: TS, build: (d) => `const z = f<${nest(d)}>(0);\n`, needle: '<' }, |
| 82 | + { name: 'type-cast (ts)', g: TS, build: (d) => `const x = <${nest(d)}>v;\n`, needle: '<' }, |
| 83 | + { name: 'generic-type-annotation (ts)', g: TS, build: (d) => `let a: ${nest(d)};\n`, needle: '<' }, |
| 84 | + { name: 'generic-arrow (tsx)', g: TSX, build: (d) => `const f = <T extends ${nest(d)}>(p: T) => p;\n`, needle: 'p', nth: 1 }, |
| 85 | + { name: 'jsx-tag-type-args (tsx)', g: TSX, build: (d) => `const e = <Comp<${nest(d)}> a={1} />;\n`, needle: 'Comp' }, |
| 86 | +]; |
| 87 | + |
| 88 | +const DEPTHS = [1, 5, 19, 20, 24, 30]; |
| 89 | +const cliffs: string[] = []; |
| 90 | + |
| 91 | +for (const c of CONSTRUCTS) { |
| 92 | + const baseline = scopeOf(c.g, c.build(1), c.needle, c.nth); |
| 93 | + const row: string[] = []; |
| 94 | + for (const d of DEPTHS) { |
| 95 | + const sc = scopeOf(c.g, c.build(d), c.needle, c.nth); |
| 96 | + const ok = sc === baseline; |
| 97 | + row.push(`d${d}:${ok ? 'ok' : 'FLIP'}`); |
| 98 | + if (!ok) cliffs.push(`${c.name} «${c.needle}» d=${d}: ${sc} (shallow was ${baseline})`); |
| 99 | + } |
| 100 | + console.log(` ${c.name.padEnd(30)} «${c.needle}»→${baseline}\n ${row.join(' ')}`); |
| 101 | +} |
| 102 | + |
| 103 | +// PART 2 — \g<> census over the emitted grammars. |
| 104 | +// Allowlist: a graceful-degrader is a `\g<>` whose overflow is benign — inside a NEGATIVE lookahead |
| 105 | +// (the bail-out just doesn't fire) or an OPTIONAL group (a flat fallback follows). Each entry names |
| 106 | +// the repository key + subroutine and WHY it is safe. |
| 107 | +const ALLOW: { grammar: RegExp; keyRe: RegExp; sub: string; why: string }[] = [ |
| 108 | + { grammar: /typescript(react)?/, keyRe: /generic-call-multiline/, sub: 'B', why: 'inside a NEGATIVE lookahead (?!…) — overflow makes the bail-out not fire (benign), verified stable to d=30' }, |
| 109 | + { grammar: /typescriptreact/, keyRe: /jsx/, sub: 'TA', why: 'inside an OPTIONAL group (?:…)? with a flat [^>]* fallback — overflow falls back to the region, verified stable to d=30' }, |
| 110 | +]; |
| 111 | +const census: string[] = []; |
| 112 | +const unrecognized: string[] = []; |
| 113 | +for (const [gf] of [['typescript.tmLanguage.json'], ['typescriptreact.tmLanguage.json'], ['javascript.tmLanguage.json'], ['javascriptreact.tmLanguage.json']] as const) { |
| 114 | + const g = JSON.parse(readFileSync(`./${gf}`, 'utf8')); |
| 115 | + const walk = (node: any, key: string) => { |
| 116 | + if (!node || typeof node !== 'object') return; |
| 117 | + for (const f of ['begin', 'match', 'while', 'end'] as const) { |
| 118 | + const re = node[f]; |
| 119 | + if (typeof re === 'string') { |
| 120 | + for (const m of re.matchAll(/\\g<([^>]*)>/g)) { |
| 121 | + const sub = m[1]; |
| 122 | + census.push(`${gf}:${key}.${f}:\\g<${sub}>`); |
| 123 | + const ok = ALLOW.some((a) => a.grammar.test(gf) && a.keyRe.test(key) && a.sub === sub); |
| 124 | + if (!ok) unrecognized.push(`${gf} ${key}.${f} \\g<${sub}>`); |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + for (const k in node) if (k !== 'repository' && typeof node[k] === 'object') walk(node[k], key); |
| 129 | + }; |
| 130 | + for (const k in (g.repository || {})) walk(g.repository[k], k); |
| 131 | +} |
| 132 | + |
| 133 | +console.log(`\n \\g<> census: ${census.length} subroutine use(s); ${unrecognized.length} unrecognized (not a known graceful-degrader)`); |
| 134 | +for (const u of unrecognized) console.log(` UNRECOGNIZED \\g<> (latent depth cliff — flat-migrate or allowlist with a depth-probe): ${u}`); |
| 135 | + |
| 136 | +const fail = cliffs.length + unrecognized.length; |
| 137 | +if (cliffs.length) { console.log('\n DEPTH CLIFFS:'); for (const c of cliffs) console.log(` ${c}`); } |
| 138 | +console.log(fail === 0 |
| 139 | + ? '\n✓ no angle-bracket depth cliff (stable to d=30) and \\g<> census clean' |
| 140 | + : `\n✗ ${cliffs.length} cliff(s) + ${unrecognized.length} unrecognized \\g<>`); |
| 141 | +process.exit(fail === 0 ? 0 : 1); |
0 commit comments