diff --git a/src/gen-tm.ts b/src/gen-tm.ts
index 3dad3e5..8d5d580 100644
--- a/src/gen-tm.ts
+++ b/src/gen-tm.ts
@@ -69,11 +69,16 @@ function escapeForCharClass(s: string): string {
return s.replace(/[\[\]\\^-]/g, '\\$&');
}
-/** A CASE-INSENSITIVE, Onigmo-PORTABLE pattern matching `s` regardless of letter case.
- * Each cased letter becomes a two-char class (`s` → `[Ss]`); other chars are escaped as
- * usual. We do NOT use an inline `(?i)` flag — vscode-textmate/Onigmo does not honor inline
- * case flags reliably (the tm-diagnostics gate rejects them). Used by the markup emitter so a
- * raw-text element (`` after a JS `//` comment (tmbundle#85) — or inside a JS string —
- // still closes the element, matching parse5 / the HTML tokenizer, which close at the FIRST
- // `` regardless of embedded-language context. The embed stays ONE continuous
- // region across lines (the `while` only TESTS, never re-anchors), so a multi-line template
- // literal / block comment in the body is unbroken; only a line that actually contains the
- // close tag drops. The close-tag test is `` / END-OF-LINE (`$`): the EOL
- // alternative drops a line whose `` DEFERRED to a later line (tmbundle#97),
- // so that line leaves the embed and the sibling `#-close-ml` re-embeds its pre-close
- // content. CRUCIALLY the line-start drop FORCE-UNWINDS a still-open embedded region
- // (e.g. a trailing unterminated `type T =` whose body would otherwise read `` as
- // `< script >` type-args) — an `end:(?=-close` rule below. The close tag is matched by host #tag.
- repository[key] = {
- name: `meta.${tag}.${L}`,
- begin: `(${o})(${tagRe})\\b${attrs}(${c})`,
- beginCaptures: { '1': { name: sOpen }, '2': { name: sName }, '3': attrCap, '4': { name: sClose } },
- while: `^(?!.*${o}${slash}${tagRe}(?:[\\s${ccClose}]|$))`,
- contentName: embed,
- patterns: [{ include: embed }],
- };
- // (3) CLOSE LINE with leading content — `BODY` where BODY shares the close's line (the
- // open tag was on an earlier line, so this is NOT the `-inline` single-line shape; and the
- // `begin/while` above DROPS this line because it contains the close). Without this, that
- // pre-close BODY falls to plain host text (the #2060 / #5538 same-line-close gap). Here it is
- // re-tokenized as a BOUNDED capture-embed: BODY is captured up to the close and run through
- // the embed in ISOLATION, so the embed's own greedy line-comment / regex / unterminated
- // construct physically cannot reach across the close — the close stays clean tag punctuation
- // AND its preceding code is highlighted. The BODY needs ≥1 char before the close, so a BARE
- // close line (just ``) does NOT match here — it stays on the `begin/while` force-unwind
- // path (preserving #5538's open-type unwind). The BODY is a tempered-greedy run
- // `(?:(?!…` (that whole line is the `-inline` shape, claimed earlier) nor
- // swallow a following block's open tag, yet a bare `<` in the body (`a < b`) is fine. Agnostic:
- // keys only on the tag + `<`/`/`/`>` delimiters (DATA), never on the embed's syntax.
- repository[`${key}-close`] = {
- name: `meta.${tag}.${L}`,
- match: `^(\\s*)((?:(?!${o}${tagRe}\\b).)+?)(${o}${slash})(${tagRe})\\s*(${c})`,
- captures: { '2': bodyCap, '3': { name: sOpen }, '4': { name: sName }, '5': { name: sClose } },
- };
- // (3b) ORPHAN CLOSE LINE whose `>` is DEFERRED — `BODY` on a later
- // one (the case-(3) sibling but with the close `>` split off, the tmbundle#97 deferred-`>`
- // shape after the body sits on its own line). The widened `begin/while` (2) drops this line
- // (its `` is deferred), and `end`s at the deferred `>`. Same tempered-greedy
- // BODY run as (3) so it can't swallow a following block's `` (#97)
- top.push({ include: `#${key}` });
- top.push({ include: `#${key}-close` }); // orphan close line (BODY) — after the open-tag rules
- top.push({ include: `#${key}-close-ml` }); // orphan close line with a DEFERRED `>` (#97)
+ const closeAhead = `${o}${slash}${tagRe}(?:[\\s${ccClose}]|$)`; // `` / EOL (a DEFERRED `>` on a later line, tmbundle#97)
+ if (forceClose) {
+ // (2) multi-line `begin/while` — the `while` re-checks each line and DROPS the region
+ // (popping any open embedded region) at the first line CONTAINING `` (the `.*`
+ // reaches it anywhere on the line, not just `^\s*` at the start). So the close wins even
+ // MID-LINE: a `` after a JS `//` comment (tmbundle#85) — or inside a JS string —
+ // still closes the element, matching parse5 / the HTML tokenizer, which close at the FIRST
+ // `` regardless of embedded-language context. The embed stays ONE continuous
+ // region across lines (the `while` only TESTS, never re-anchors), so a multi-line template
+ // literal / block comment in the body is unbroken; only a line that actually contains the
+ // close tag drops. The close-tag test is `` / END-OF-LINE (`$`): the EOL
+ // alternative drops a line whose `` DEFERRED to a later line (tmbundle#97),
+ // so that line leaves the embed and the sibling `#-close-ml` re-embeds its pre-close
+ // content. CRUCIALLY the line-start drop FORCE-UNWINDS a still-open embedded region
+ // (e.g. a trailing unterminated `type T =` whose body would otherwise read `` as
+ // `< script >` type-args) — an `end:(?=-close` rule below. The close tag is matched by host #tag.
+ // This is the `forceClose` shape — for a body that can swallow the close mid-line (JS): it
+ // CANNOT keep a non-first DIALECT's close-line content in its own dialect (the dropped line
+ // leaves the region and lands on a lang-INDEPENDENT close rule — only the first fires). That
+ // is the dual of #85: mid-line force-close OR dialect-correct close lines, not both. Script
+ // takes force-close; a well-behaved embed (CSS) takes the `else` branch (dialect-correct).
+ repository[key] = {
+ name: `meta.${tag}.${L}`,
+ begin: `(${o})(${tagRe})\\b${attrs}(${c})`,
+ beginCaptures: { '1': { name: sOpen }, '2': { name: sName }, '3': attrCap, [g(4)]: { name: sClose } },
+ while: `^(?!.*${closeAhead})`,
+ contentName: embed,
+ patterns: [{ include: embed }],
+ };
+ // (3) CLOSE LINE with leading content — `BODY` where BODY shares the close's line (the
+ // open tag was on an earlier line, so this is NOT the `-inline` single-line shape; and the
+ // `begin/while` above DROPS this line because it contains the close). Without this, that
+ // pre-close BODY falls to plain host text (the #2060 / #5538 same-line-close gap). Here it is
+ // re-tokenized as a BOUNDED capture-embed: BODY is captured up to the close and run through
+ // the embed in ISOLATION, so the embed's own greedy line-comment / regex / unterminated
+ // construct physically cannot reach across the close — the close stays clean tag punctuation
+ // AND its preceding code is highlighted. The BODY needs ≥1 char before the close, so a BARE
+ // close line (just ``) does NOT match here — it stays on the `begin/while` force-unwind
+ // path (preserving #5538's open-type unwind). The BODY is a tempered-greedy run
+ // `(?:(?!…` (that whole line is the `-inline` shape, claimed earlier) nor
+ // swallow a following block's open tag, yet a bare `<` in the body (`a < b`) is fine. Agnostic:
+ // keys only on the tag + `<`/`/`/`>` delimiters (DATA), never on the embed's syntax.
+ top.push({ include: `#${key}` });
+ // The close-line rules (3)/(3b) key on the TAG ONLY (a close line carries no `lang=`), so emitting
+ // them per dialect yields byte-identical regex where only the first-included fires and the rest are
+ // dead (monogram#43 "will never match" / "all raw embedded languages"). Emit them ONCE — on the
+ // lang-less call (`langVal === undefined`), with the default embed — and skip them for every dialect.
+ if (langVal === undefined) {
+ repository[`${key}-close`] = {
+ name: `meta.${tag}.${L}`,
+ match: `^(\\s*)((?:(?!${o}${tagRe}\\b).)+?)(${o}${slash})(${tagRe})\\s*(${c})`,
+ captures: { '2': bodyCap, '3': { name: sOpen }, '4': { name: sName }, '5': { name: sClose } },
+ };
+ // (3b) ORPHAN CLOSE LINE whose `>` is DEFERRED — `BODY` on a later
+ // one (the case-(3) sibling but with the close `>` split off, the tmbundle#97 deferred-`>`
+ // shape after the body sits on its own line). The widened `begin/while` (2) drops this line
+ // (its `` is deferred), and `end`s at the deferred `>`. Same tempered-greedy
+ // BODY run as (3) so it can't swallow a following block's `) — once per tag, lang-independent
+ top.push({ include: `#${key}-close-ml` }); // orphan close line with a DEFERRED `>` (#97)
+ }
+ } else {
+ // (2′) WELL-BEHAVED embed (no greedy construct can swallow the close, e.g. CSS) — a single
+ // `begin/end` region with a LOOKAHEAD `end` that never CONSUMES the close tag. Because the
+ // embed (`contentName`/`patterns`) stays active right up to ``, the open tag on an earlier line) is tokenised by THIS region's embed —
+ // so it keeps its own DIALECT (issue #43); the lang-independent top-level close rules of the
+ // `forceClose` path (which only the first-listed dialect could win) are GONE. The `]|$)` after `` (#97) shape (the `$`/ws alternatives), so no separate close-ml
+ // rule is needed. This is the official Vue grammar's `multi-line-style-tag-stuff` body shape,
+ // generalised to the config delimiters. Safe ONLY for embeds that cannot force-close mid-line
+ // (see `forceClose` doc on RawEmbed) — script keeps the `if` branch.
+ repository[key] = {
+ name: `meta.${tag}.${L}`,
+ begin: `(${o})(${tagRe})\\b${attrs}(${c})`,
+ beginCaptures: { '1': { name: sOpen }, '2': { name: sName }, '3': attrCap, [g(4)]: { name: sClose } },
+ end: `(?=${closeAhead})`,
+ contentName: embed,
+ patterns: [{ include: embed }],
+ };
+ top.push({ include: `#${key}` });
+ }
};
// Multi-line START TAG variant — `` on one line must
+ // still close — tmbundle#85; an unterminated `type T =` must unwind before `` is read as
+ // type-args — #5538/#2060): the body uses a `begin/while` open region whose `.*` drops at the first
+ // line CONTAINING the close, plus separate close-LINE rules. That `while` drop is what makes a non-
+ // first DIALECT's close-line content land on a lang-INDEPENDENT close rule (only the first fires),
+ // so a multi-dialect `forceClose` embed cannot keep per-dialect close lines — script accepts this
+ // (mid-line force-close is the priority for JS). FALSE (the default for a `{ default, lang }` embed)
+ // is for a well-behaved embed like CSS — no greedy line-comment swallows ``, nothing leaves
+ // an open construct — so the body uses a lookahead-`end` region (matching the official Vue grammar):
+ // the embed stays active up to `` cliff lived.
+//
+// Two structural checks over the GENERATED typescript/typescriptreact/javascript/javascriptreact
+// grammars, so a new construct cannot be added without this gate seeing it:
+//
+// PART 1 — DEPTH SWEEP (self-baselining). The angle-bracket disambiguations (generic call, type
+// cast, generic arrow, JSX tag type-args, generic type) confirm `<…>` is a type span. A confirm
+// built on an Oniguruma `\g<>` subroutine has a ~20-level recursion cap, so a deep nested generic
+// flips the discriminating scope at d=20 (the cast did exactly this: `<` → relational at d=20).
+// For each construct we sweep d=1,5,19,20,24,30 and assert the probe token's scope at every depth
+// EQUALS its shallow (d=1) value — a flip anywhere is a depth cliff. Self-baselining, so it is
+// robust to scope renames; it would have caught the cast cliff (gen-tm 1967) and the TSX arrow
+// cliff (the formerly-recursive arrowEndConfirm) before they shipped.
+//
+// PART 2 — `\g<>` CENSUS. Every Oniguruma subroutine `\g<>` reachable from a begin/match/while/end
+// in the emitted grammars is enumerated and matched against an ALLOWLIST of graceful-degraders
+// (a `\g<>` inside a negative lookahead or an optional group, whose overflow is benign). A `\g<>`
+// NOT on the list is a latent sole-confirmer cliff → FAIL, forcing a flat-confirm migration (as
+// done for generic-call/arrow/cast) or an explicit, depth-probed allowlist entry. This makes the
+// cast-cliff class structurally un-missable: it walks the EMITTED output, not a hand list.
+//
+// Run (bare node): node test/angle-depth-probe.ts · Exit 0 iff no cliff AND census clean.
+import { readFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import vsctm from 'vscode-textmate';
+import onig from 'vscode-oniguruma';
+
+const { INITIAL, Registry } = vsctm;
+const { loadWASM, OnigScanner, OnigString } = onig;
+const require = createRequire(import.meta.url);
+const wasm = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm'));
+await loadWASM(wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength));
+
+async function load(path: string) {
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
+ const reg = new Registry({
+ onigLib: Promise.resolve({
+ createOnigScanner: (ps: string[]) => new OnigScanner(ps),
+ createOnigString: (s: string) => new OnigString(s),
+ }),
+ loadGrammar: async (scope: string) => (scope === raw.scopeName ? raw : null),
+ });
+ return reg.loadGrammar(raw.scopeName);
+}
+
+const TS = await load('./typescript.tmLanguage.json');
+const TSX = await load('./typescriptreact.tmLanguage.json');
+
+// scope of the FIRST token whose text === `needle` (optionally the nth), innermost scope.
+function scopeOf(g: any, src: string, needle: string, nth = 1): string | null {
+ const lines = src.split('\n');
+ let rs = INITIAL;
+ let seen = 0;
+ for (const line of lines) {
+ const r = g.tokenizeLine(line, rs);
+ for (const t of r.tokens) {
+ if (line.slice(t.startIndex, t.endIndex) === needle && ++seen === nth) return t.scopes[t.scopes.length - 1];
+ }
+ rs = r.ruleStack;
+ }
+ return null;
+}
+
+// A d-deep nested generic type span: A0…>> (no spaces, the densest form).
+const nest = (d: number) => {
+ let s = 'X';
+ for (let i = 0; i < d; i++) s = `A${i}<${s}>`;
+ return s;
+};
+
+interface Construct {
+ name: string;
+ g: any;
+ build: (d: number) => string;
+ needle: string; // token whose scope must stay stable across depth
+ nth?: number;
+}
+
+const CONSTRUCTS: Construct[] = [
+ { name: 'generic-call (ts)', g: TS, build: (d) => `const z = f<${nest(d)}>(0);\n`, needle: '<' },
+ { name: 'type-cast (ts)', g: TS, build: (d) => `const x = <${nest(d)}>v;\n`, needle: '<' },
+ { name: 'generic-type-annotation (ts)', g: TS, build: (d) => `let a: ${nest(d)};\n`, needle: '<' },
+ { name: 'generic-arrow (tsx)', g: TSX, build: (d) => `const f = (p: T) => p;\n`, needle: 'p', nth: 1 },
+ { name: 'jsx-tag-type-args (tsx)', g: TSX, build: (d) => `const e = a={1} />;\n`, needle: 'Comp' },
+];
+
+const DEPTHS = [1, 5, 19, 20, 24, 30];
+const cliffs: string[] = [];
+
+for (const c of CONSTRUCTS) {
+ const baseline = scopeOf(c.g, c.build(1), c.needle, c.nth);
+ const row: string[] = [];
+ for (const d of DEPTHS) {
+ const sc = scopeOf(c.g, c.build(d), c.needle, c.nth);
+ const ok = sc === baseline;
+ row.push(`d${d}:${ok ? 'ok' : 'FLIP'}`);
+ if (!ok) cliffs.push(`${c.name} «${c.needle}» d=${d}: ${sc} (shallow was ${baseline})`);
+ }
+ console.log(` ${c.name.padEnd(30)} «${c.needle}»→${baseline}\n ${row.join(' ')}`);
+}
+
+// PART 2 — \g<> census over the emitted grammars.
+// Allowlist: a graceful-degrader is a `\g<>` whose overflow is benign — inside a NEGATIVE lookahead
+// (the bail-out just doesn't fire) or an OPTIONAL group (a flat fallback follows). Each entry names
+// the repository key + subroutine and WHY it is safe.
+const ALLOW: { grammar: RegExp; keyRe: RegExp; sub: string; why: string }[] = [
+ { 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' },
+ { 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' },
+];
+const census: string[] = [];
+const unrecognized: string[] = [];
+for (const [gf] of [['typescript.tmLanguage.json'], ['typescriptreact.tmLanguage.json'], ['javascript.tmLanguage.json'], ['javascriptreact.tmLanguage.json']] as const) {
+ const g = JSON.parse(readFileSync(`./${gf}`, 'utf8'));
+ const walk = (node: any, key: string) => {
+ if (!node || typeof node !== 'object') return;
+ for (const f of ['begin', 'match', 'while', 'end'] as const) {
+ const re = node[f];
+ if (typeof re === 'string') {
+ for (const m of re.matchAll(/\\g<([^>]*)>/g)) {
+ const sub = m[1];
+ census.push(`${gf}:${key}.${f}:\\g<${sub}>`);
+ const ok = ALLOW.some((a) => a.grammar.test(gf) && a.keyRe.test(key) && a.sub === sub);
+ if (!ok) unrecognized.push(`${gf} ${key}.${f} \\g<${sub}>`);
+ }
+ }
+ }
+ for (const k in node) if (k !== 'repository' && typeof node[k] === 'object') walk(node[k], key);
+ };
+ for (const k in (g.repository || {})) walk(g.repository[k], k);
+}
+
+console.log(`\n \\g<> census: ${census.length} subroutine use(s); ${unrecognized.length} unrecognized (not a known graceful-degrader)`);
+for (const u of unrecognized) console.log(` UNRECOGNIZED \\g<> (latent depth cliff — flat-migrate or allowlist with a depth-probe): ${u}`);
+
+const fail = cliffs.length + unrecognized.length;
+if (cliffs.length) { console.log('\n DEPTH CLIFFS:'); for (const c of cliffs) console.log(` ${c}`); }
+console.log(fail === 0
+ ? '\n✓ no angle-bracket depth cliff (stable to d=30) and \\g<> census clean'
+ : `\n✗ ${cliffs.length} cliff(s) + ${unrecognized.length} unrecognized \\g<>`);
+process.exit(fail === 0 ? 0 : 1);
diff --git a/test/check.ts b/test/check.ts
index 2c3081f..25184ad 100644
--- a/test/check.ts
+++ b/test/check.ts
@@ -34,6 +34,7 @@ const GATES: Gate[] = [
{ group: 'conformance', name: 'html', args: ['test/html-conformance.ts'] },
{ group: 'highlighter', name: 'tm-guards', args: ['test/tm-highlight-guards.ts'] },
{ group: 'highlighter', name: 'tm-diagnostics', args: ['test/redcmd-tm-diagnostics.ts'] },
+ { group: 'highlighter', name: 'angle-depth', args: ['test/angle-depth-probe.ts'] },
{ group: 'highlighter', name: 'html-monarch', args: ['test/html-monarch.ts'] },
{ group: 'highlighter', name: 'html-embed-js', args: ['test/html-embed-js.ts'] },
{ group: 'highlighter', name: 'html-lexer-spike', args: ['test/html-lexer-spike.ts'] },
@@ -41,6 +42,7 @@ const GATES: Gate[] = [
{ group: 'highlighter', name: 'raw-text-case-sites', args: ['test/raw-text-case-sites.ts'] },
{ group: 'vue', name: 'directives', args: ['test/vue-directives.ts'] },
{ group: 'vue', name: 'embed-boundary', args: ['test/vue-embed-boundary.ts'] },
+ { group: 'vue', name: 'raw-style-embed', args: ['test/vue-raw-style-embed-sites.ts'] },
{ group: 'vue', name: 'interp-expr', args: ['test/vue-interp-expr.ts'] },
{ group: 'core', name: 'indent-extensions', args: ['test/indent-extensions.ts'] },
{ group: 'yaml', name: 'issue12-regressions', args: ['test/yaml-issue12-regressions.ts'] },
@@ -48,6 +50,8 @@ const GATES: Gate[] = [
{ group: 'yaml', name: 'depth-sites', args: ['test/depth-sites.ts'] },
{ group: 'yaml', name: 'flow-sites', args: ['test/flow-sites.ts'] },
{ group: 'yaml', name: 'compact-nest-sites', args: ['test/compact-nest-sites.ts'] },
+ { group: 'yaml', name: 'deepest-sibling', args: ['test/yaml-deepest-sibling-probe.ts'] },
+ { group: 'yaml', name: 'blockscalar-depth', args: ['test/yaml-blockscalar-depth-probe.ts'] },
{ group: 'generative', name: 'scope≡role', args: ['test/generative.ts'] },
{ group: 'generative', name: 'gap-ledger-selftest', args: ['test/gap-ledger-selftest.ts'] },
{ group: 'generative', name: 'gap-ledger-check', args: ['test/gap-ledger.ts', '--check'] },
diff --git a/test/vue-raw-style-embed-sites.ts b/test/vue-raw-style-embed-sites.ts
new file mode 100644
index 0000000..031cabf
--- /dev/null
+++ b/test/vue-raw-style-embed-sites.ts
@@ -0,0 +1,70 @@
+// vue-raw-style-embed-sites.ts — an enumerator of the `), so every dialect's close rule shares one regex and only the first-
+// listed fires — a non-first dialect's CLOSE-LINE content (the pre-`` text) is then embedded
+// in the WRONG dialect. A bug is exactly: content that should be source.css.X carries some other
+// source.css.* instead.
+//
+// WHY THE EXISTING GATES MISSED IT (see issue #43 discussion): scope-gap-vue grades the Vue shell
+// (tags/directives/interpolation) against @vue/compiler-sfc+parse5+tsc — it has NO CSS oracle, so the
+// embedded dialect is structurally ungraded. This test adds that missing axis: an oracle that is the
+// grammar's DECLARED embed scope (not Monogram's parser, which raw-texts the body as a blob), and
+// DERIVED witnesses (each dialect × each structural position) rather than a thin corpus.
+//
+// CLOSED LOOP / no hardcoding: the dialects + their expected scopes come from the SAME grammar source
+// the emitter derives the rules from (`grammar.markup.rawText.embed.style`).
+//
+// Run: node test/vue-raw-style-embed-sites.ts
+import { tokenize } from './vue-grammar-harness.ts';
+import grammar from '../vue.ts';
+
+const style = (grammar as any).markup?.rawText?.embed?.style;
+if (!style) { console.error('vue grammar has no markup.rawText.embed.style'); process.exit(1); }
+
+// [langAttr | null (default), expectedScope]; closed-loop from the grammar's own embed map.
+const dialects: [string | null, string][] = [
+ [null, style.default],
+ ...Object.entries(style.lang as Record).map(([k, v]) => [k, v] as [string, string]),
+];
+
+// Structural positions. Each builds a `, find: 'midline' },
+ // THE BUG: content on the SAME line as the close `` — the per-dialect close rule's capture.
+ { pos: 'close-line ', src: `${open}\n.firstline { a: 1 }\n.closeline { b: 2 }`, find: 'closeline' },
+ // single-line: open, content, and close all on one line.
+ { pos: 'single-line ', src: `${open}.oneline { c: 3 }`, find: 'oneline' },
+ ];
+}
+
+const cssScope = (chain: string) => chain.split(' ').find(s => s.startsWith('source.css') || s === 'source.sass' || s === 'source.stylus' || s === 'source.postcss') ?? '(none)';
+
+let cells = 0, wrong = 0;
+const fails: string[] = [];
+for (const [lang, expected] of dialects) {
+ for (const w of witnesses(lang)) {
+ cells++;
+ const toks = await tokenize('mono', w.src);
+ const t = toks.find(x => x.text.includes(w.find));
+ const got = t ? cssScope(t.scopes) : '(token not found)';
+ const ok = t !== undefined && t.scopes.split(' ').includes(expected);
+ if (!ok) { wrong++; fails.push(`