Skip to content

Commit 6d7634a

Browse files
fix(cli)!: the census masker sees past nested templates and regex literals; docs/nav-target judges package-body apps (#27342)
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_ Fixes #27210 Clause-②: yes (narrowing) — on a multi-package `composeStacks(…, { manifest: 'preserve' })` artifact, a `type: 'doc'` navigation item inside an app a package body carries, whose `doc` or `book` names nothing the artifact carries, built clean before and is now refused by `os build`, `os validate` and `os lint` (`docs/nav-target`). Metadata-protocol: none Skills-docs: none — `skills/objectstack-ui/rules/pages.md` (the `type: 'doc'` nav section) and `content/docs/ui/apps.mdx` (the `docs/nav-target` paragraph) already teach the refusal with no single-package limit; this change makes multi-package artifacts meet that text. ## 维护者速读 - **改了什么**:修好 cli 普查测试 `normalized-call-sites` 的代码遮罩 `codeOnly`:嵌套模板字符串和正则字面量不再让它"走丢位置";修好后普查新看见一处读取 `stack.apps`(`os build`/`os validate`/`os lint` 的文档导航检查),顺手把它改为在多包产物上读各包自己的 apps,并补一条普查分类行。 - **为什么改**:旧遮罩遇到嵌套模板或含引号的正则就把后面的真代码当字符串抹掉,普查因此对看不见的读取答"绿"。多包 `preserve` 产物顶层没有 apps,所以包里 app 的文档导航项(指向不存在的 doc/book)从没被检查过。 - **风险与回滚**:测试改动零运行时风险。源码改动是一次收窄(BREAKING,patch 级,同 #27204 先例):多包产物里"包内 app 指向不存在文档"的导航项从静默通过变为 `docs/nav-target` 构建错误(单包应用不变;仓内示例零命中;dogfood 全绿)。回滚即 revert 本 PR。 - **先看**:`packages/cli/test/normalized-call-sites.test.ts` 的 `codeOnly`;`packages/cli/src/utils/collect-docs.ts` 中 `lintDocNavTargets` 的 `apps` 一行;`.changeset/27210-doc-nav-target-package-apps.md`。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/normalized-call-sites.test.ts src/utils/collect-docs.nav-target.test.ts`,预期全部通过。 - **需要你做的**:无,席位落地。 ## What changes **The census masker (`codeOnly`, test only).** It scanned quotes linearly, so two shapes made it lose its place and blank real code after them: - a template literal nested inside another's interpolation (the did-you-mean suffix of the doc refusals); - a quote or backtick inside a regex literal (for example the quote-stripping regex near the top of `collect-docs.ts`). The masker now tracks template interpolations with a per-hole brace count, so the code inside an interpolation is code at any depth. It also recognises a regex literal by the code character before its `/` and masks the regex body. Template text, string bodies, comments and regex bodies are blanked; offsets, quotes and newlines are kept, so the bracket-read path still reads keys back from the source. Both header paragraphs that stated the old bound ("a site inside a template literal's interpolation is blanked too") now state the new one. **What the fixed masker sees on the real tree (origin/main `ebfadb02`).** Exactly one new read, `utils/collect-docs.ts :: stack.apps` in `lintDocNavTargets`. A nested template literal hid it. The card's four reads are classified as follows: | Read | On origin/main `ebfadb02` | Classification | |:--|:--|:--| | `stack.objects`, `stack.flows`, `stack.permissions` | Gone. #27204 replaced them with `collection(key)` over the doc owner plus its declared dependencies in `lintMetadataEmbeds`. | Not a top-level member read any more. A row would fail the census's own "no row is stale" test. | | `stack.books` | Live, seen by both maskers. | Existing `top-level` row (added by #27204). Its `why` now names the measured cause. | | `stack.apps` (new) | Live. A nested template hid it. | Source fix plus a `top-level` row, see below. | **The one source change (`collect-docs.ts`, declared).** `lintDocNavTargets` walked only the top level's `apps`. A multi-package `composeStacks(…, { manifest: 'preserve' })` artifact keeps its apps in the package bodies, so a `type: 'doc'` nav item in a package's app was never judged. It now walks the top level's apps when the stack carries the key, otherwise each package body's (`artifactPackages(stack)`, the same walk the function already uses for books). That is `resolveStackCollection`'s rule. The core helper itself is not used because its whole-entry parse throws on a duplicate-id artifact, and `collect-docs.flat-docs-owner.test.ts` pins that `collectAndLintDocs` does not throw on that shape. The census row for `stack.apps` is `top-level`, with that reason. Single-package stacks are unchanged. No in-tree example carries a `type: 'doc'` nav item. ## Tests - `normalized-call-sites.test.ts`: two new cases. The first covers a nested template followed by a package-owned read, reads inside interpolations (with braces, strings and a nested template), a regex literal holding quotes and a backtick, a division that must not read as a regex, and a quoted `${`. The second checks the exact masked output of a nested template and a regex. - `collect-docs.nav-target.test.ts`: one new case on a real two-package `preserve` composition. It asserts that the top level carries no `apps`, that a resolving doc passes, and that a dead doc target in the package body's app is refused at its path. ## Acceptance notes - **The card's mechanism, measured.** On the pre-#27204 `collect-docs.ts` (`39fd5fbf`), a masker that fixes nested templates alone still hides all four reads. The regex literal is what flipped the parity there. Templates plus regex reveals all four, and also `stack.apps`. On origin/main, the nested template alone hides `stack.apps`. - **Exactness, measured once.** The new masker's output was compared with a TypeScript-scanner reference over all 179 scanned `packages/cli/src` files: 0 files differ. The origin/main masker differs on 140 files, mostly the interpolations it blanked by design. A templates-only fix still differs on 12 files, all at regex literals. This was a one-time scratch comparison; no permanent test file was added. - **Declaration lines.** The seat ruled the newly reached refusal a narrowing, following #27204: the changeset carries the `fix(cli)!` title, the `Clause-②: yes (narrowing)` line, the BREAKING banner and the ADR-0087 `not-required (no-migration-prescription)` disposition, at `patch`. `check-adr-0087-registration` reads it as `BREAKING+bang+clause-②-narrowing`. - **Sibling, not fixed here.** `lintDocNavTargets` reads `navigationContributions` off the top-level `manifest` only. On a `preserve` artifact, a package that does not own the artifact manifest carries its contributions in its body, and a dead doc target there is not judged. Measured: a single-package stack returns one `apps/setup/navigation/nav_help` issue; the same contribution on the non-owner package of a two-package `preserve` composition returns none. The census cannot see this read (a member chain off an envelope key). Reported to the seat. ## Verification Test readings are at head `47f1a12a`; the dogfood battery and the gate re-run are at head `1139c31f` (the changeset declaration only, on top of `47f1a12a`), both on origin/main `ebfadb02`. <details><summary>Red, then green: the masker</summary> Old masker with the new cases, at `ebfadb02` plus the uncommitted cases: `Tests 2 failed | 11 passed (13)`. - `keeps its place past a nested template literal and a regex literal`: `expected [] to deeply equal [ 'stack.objects' ]` - `masks a nested template exactly`: the received output shows the interpolation blanked. The census's own "no read is unclassified" stayed green over the old masker: that is the false green. New masker: the masker cases pass, and the census goes red with exactly one unclassified read, `utils/collect-docs.ts :: stack.apps`. </details> <details><summary>Red, then green: docs/nav-target</summary> Before the source change, the new case gave `Tests 1 failed | 8 passed (9)`, with `expected [] to deeply equal [ [ 'docs/nav-target', … ] ]`. After it: `normalized-call-sites.test.ts` plus `src/utils/collect-docs*` gave `Test Files 5 passed (5) · Tests 126 passed (126)`. </details> <details><summary>Package and repo checks</summary> - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 342 passed`, `Tests 4961 passed | 29 skipped`. Two pin files failed only because `packages/cli` was not built (`./dist/index.js is absent`). After `pnpm --filter @objectstack/cli build`, both pass: `Test Files 2 passed (2) · Tests 29 passed (29)`. The integration tier is left to CI. - `pnpm --filter @objectstack/cli typecheck`: exit 0, and `check:test-typecheck` OK. - `pnpm lint`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 61 families derived. All were run. 60 exit 0. `pnpm check:dual-build-cjs-loads` exits 3 with PREREQUISITE NOT MET (it needs a full workspace build), so it is NOT MEASURED locally and left to CI. `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` exit 0 after the cli build. `check:registry-log-declared` exits 0 on a rerun; its first run hit an ENOENT on a temp file the concurrent cli unit run was writing. - `dispatch-gates --ran`: `61 derived famil(ies) accounted for — 60 run, 1 NOT-MEASURED`. - Re-run at `1139c31f`: 61 derived, 60 exit 0, `check:dual-build-cjs-loads` exit 3 (NOT MEASURED, left to CI); `--ran`: `60 run, 1 NOT-MEASURED, 0 UNRUN`. `check-adr-0087-registration --base origin/main`: exit 0, one declared-breaking changeset carrying its disposition. Census plus `src/utils/collect-docs*`: `Test Files 5 passed (5) · Tests 126 passed (126)`. </details> <details><summary>Dogfood battery (owed by the narrowing arm)</summary> At `1139c31f`, under the verify lock, after building the `@objectstack/dogfood` dependency closure, `pnpm --filter @objectstack/dogfood test` with no shard and no path: `Test Files 348 passed | 3 skipped (351)`, `Tests 2806 passed | 20 skipped (2826)`, lock `VERDICT command-exit 0`. No failures. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXc5hequf76uJfzrdK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 753a14e commit 6d7634a

4 files changed

Lines changed: 139 additions & 10 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli)!: `docs/nav-target` judges the apps a package body carries on a multi-package artifact
6+
7+
Clause-②: yes (narrowing) — on a multi-package `composeStacks(…, { manifest: 'preserve' })` artifact, a `type: 'doc'` navigation item inside an app a package body carries, whose `doc` or `book` names nothing the artifact carries, built clean before and is now refused by `os build`, `os validate` and `os lint` (`docs/nav-target`).
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of the package's public surface changes: the build-time docs lint now reaches the apps a package body carries, judging them by the rule it already applies to a stack's own apps. A refused nav item is fixed in the app's navigation; there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing in the build-time docs lint (`os build`, `os validate`, `os lint`).
13+
14+
- **Before.** The `docs/nav-target` check walked only the artifact's top-level `apps`. A multi-package `preserve` artifact keeps its apps inside the package bodies and has none at its top level, so a nav item in a package's app was never judged: a dead `doc` or `book` target built clean and degraded at runtime.
15+
- **Now.** The check walks the top level's apps when the stack carries the key, otherwise each package body's, the same rule `resolveStackCollection` applies. A package app's `doc` item resolves against every doc the artifact carries, and its `book` item against the declared books and package ids, as a single-package app's always did.
16+
- **Unchanged.** A stack with no `packages[]`, and any stack whose top level carries `apps`, is judged exactly as before.
17+
18+
**The remedy.** For a refused item, name a doc in `src/docs/*.md` or a declared book (a package id is its own implicit book), or remove the item. The refusal message names the nearest match.

‎packages/cli/src/utils/collect-docs.nav-target.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { describe, it, expect, afterEach } from 'vitest';
1515
import fs from 'fs';
1616
import os from 'os';
1717
import path from 'path';
18+
import { composeStacks, defineStack } from '@objectstack/spec';
1819
import { collectAndLintDocs, lintDocNavTargets } from './collect-docs.js';
1920

2021
const app = (navigation: unknown[]) => ({ name: 'crm_app', label: 'CRM', navigation });
@@ -88,6 +89,21 @@ describe('docs/nav-target — a doc nav item must open something this package ha
8889
expect(issues.map((i) => i.path)).toEqual(['apps/setup/navigation/nav_crm_help']);
8990
});
9091

92+
it('judges an app a package body carries on a multi-package `preserve` artifact', () => {
93+
// The real composer: a `preserve` artifact keeps `apps` in the package body, not at its top level.
94+
const composed = (doc: string) => JSON.parse(JSON.stringify(composeStacks([
95+
defineStack({ manifest: { id: 'com.example.svc', name: 'svc', namespace: 'svc', version: '1.0.0', type: 'app' } } as never, { strict: false }),
96+
defineStack({
97+
manifest: { id: 'com.example.crm', name: 'crm', namespace: 'crm', version: '1.0.0', type: 'app' },
98+
apps: [app([{ id: 'nav_page', type: 'doc', label: 'Guide', doc }])],
99+
} as never, { strict: false }),
100+
], { manifest: 'preserve' }))) as Record<string, unknown>;
101+
expect(composed('crm_lead_guide').apps).toBeUndefined();
102+
expect(lintDocNavTargets(composed('crm_lead_guide'), DOCS)).toEqual([]);
103+
const issues = lintDocNavTargets(composed('crm_lead_gide'), DOCS);
104+
expect(issues.map((i) => [i.rule, i.path])).toEqual([['docs/nav-target', 'apps/crm_app/navigation/nav_page']]);
105+
});
106+
91107
it('resolves a book declared on a package body, and that package id as its implicit book', () => {
92108
expect(lintDocNavTargets({
93109
packages: [{ manifest: { id: 'com.example.help', books: [{ name: 'help_centre', groups: [] }] } }],

‎packages/cli/src/utils/collect-docs.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,7 +1109,8 @@ export function lintMetadataEmbeds(
11091109
* judged here so one nav item is judged in one place.
11101110
*
11111111
* Walked: every app's `navigation` and `areas[].navigation` (children
1112-
* included), and the items of `manifest.navigationContributions`.
1112+
* included) — the top level's apps, or each package body's when the top level
1113+
* carries none — and the items of `manifest.navigationContributions`.
11131114
*
11141115
* ## What resolves
11151116
*
@@ -1175,7 +1176,13 @@ export function lintDocNavTargets(stack: Record<string, unknown>, docNames: Read
11751176
}
11761177
};
11771178

1178-
const apps = Array.isArray(stack.apps) ? (stack.apps as AnyRec[]) : [];
1179+
// A multi-package `preserve` artifact carries its apps in the package bodies
1180+
// and none at its top level. The top level's apps when it carries the key,
1181+
// otherwise each body's: `resolveStackCollection`'s rule, over the same
1182+
// `artifactPackages` walk as the books above, which refuses no entry shape.
1183+
const apps = (Array.isArray(stack.apps)
1184+
? stack.apps
1185+
: artifactPackages(stack).flatMap(({ body }) => (Array.isArray(body.apps) ? body.apps : []))) as AnyRec[];
11791186
for (const app of apps) {
11801187
if (!app || typeof app !== 'object') continue;
11811188
const appName = typeof app.name === 'string' ? app.name : '(unnamed)';

‎packages/cli/test/normalized-call-sites.test.ts‎

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@
5050
* By file and by the chain of callees directly wrapping the identifier, never
5151
* by line or by count: `computeI18nCoverage(authoringRuleUnionStack(normalized`
5252
* names both the analysis and the fold it reads through. A grouping paren is
53-
* named by the member read off it: `(normalized).i18n`. Comments and string
54-
* bodies are blanked first, so prose and messages do not count; the bound that
55-
* buys is that a site inside a template literal's `${…}` is blanked too, and none
56-
* exists today. Test files are not scanned.
53+
* named by the member read off it: `(normalized).i18n`. Comments, string
54+
* bodies, template text and regex bodies are blanked first, so prose and
55+
* messages do not count; a template literal's `${…}` is code and is scanned, at
56+
* any depth. Test files are not scanned.
5757
*/
5858

5959
import { describe, expect, it } from 'vitest';
@@ -152,12 +152,46 @@ const KEYWORDS: ReadonlySet<string> = new Set([
152152
'new', 'do', 'else', 'yield', 'void', 'delete', 'in', 'of', 'as', 'async',
153153
]);
154154

155-
/** The source with comment bodies and string/template bodies blanked to spaces. */
155+
/** Words a `/` can follow and still open a regex literal rather than divide. */
156+
const REGEX_AFTER_WORD: ReadonlySet<string> = new Set([
157+
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await',
158+
]);
159+
160+
/** Does the `/` at `at` open a regex literal? Judged by the last code character before it. */
161+
function regexMayStart(code: readonly string[], at: number): boolean {
162+
let k = at - 1;
163+
while (k >= 0 && /\s/.test(code[k])) k -= 1;
164+
if (k < 0 || '(,=:[!&|?{};+-*%<>~^}'.includes(code[k])) return true;
165+
const word = /[A-Za-z_$][\w$]*$/.exec(code.slice(Math.max(0, k - 16), k + 1).join(''));
166+
return word !== null && REGEX_AFTER_WORD.has(word[0]);
167+
}
168+
169+
/**
170+
* The source with comment bodies, string bodies, template TEXT and regex
171+
* literal bodies blanked to spaces; offsets, quotes and newlines kept.
172+
*
173+
* A template literal's `${…}` is code at any depth: `holes` holds one count per
174+
* open `${` of the `{` opened inside it, so its own `}` hands back to the text,
175+
* and a template, string or object literal inside it is masked as code would
176+
* be. A `/` after an operator, an opening bracket or a keyword opens a regex
177+
* literal (its character class may hold a `/`); anywhere else it divides.
178+
*/
156179
function codeOnly(src: string): string {
157180
const out = src.split('');
158181
const blank = (from: number, to: number) => {
159182
for (let k = from; k < to && k < src.length; k++) if (out[k] !== '\n') out[k] = ' ';
160183
};
184+
const holes: number[] = [];
185+
/** Blanks template text from `from`; the index past its closing backtick or its `${`. */
186+
const templateText = (from: number): number => {
187+
let j = from;
188+
while (j < src.length && src[j] !== '`' && !(src[j] === '$' && src[j + 1] === '{')) j += src[j] === '\\' ? 2 : 1;
189+
blank(from, j);
190+
if (j >= src.length) return src.length;
191+
if (src[j] === '`') return j + 1;
192+
holes.push(0);
193+
return j + 2;
194+
};
161195
let i = 0;
162196
while (i < src.length) {
163197
const c = src[i];
@@ -172,11 +206,39 @@ function codeOnly(src: string): string {
172206
const end = j === -1 ? src.length : j + 2;
173207
blank(i, end);
174208
i = end;
175-
} else if (c === "'" || c === '"' || c === '`') {
209+
} else if (c === "'" || c === '"') {
176210
let j = i + 1;
177211
while (j < src.length && src[j] !== c) j += src[j] === '\\' ? 2 : 1;
178212
blank(i + 1, j);
179213
i = j + 1;
214+
} else if (c === '`') {
215+
i = templateText(i + 1);
216+
} else if (c === '/' && regexMayStart(out, i)) {
217+
let j = i + 1;
218+
let inClass = false;
219+
while (j < src.length && src[j] !== '\n' && (inClass || src[j] !== '/')) {
220+
if (src[j] === '[') inClass = true;
221+
else if (src[j] === ']') inClass = false;
222+
j += src[j] === '\\' ? 2 : 1;
223+
}
224+
// No closing `/` on the line: not a regex after all, so read on as code.
225+
if (src[j] === '/') {
226+
blank(i + 1, j);
227+
i = j + 1;
228+
} else {
229+
i += 1;
230+
}
231+
} else if (c === '{' && holes.length > 0) {
232+
holes[holes.length - 1] += 1;
233+
i += 1;
234+
} else if (c === '}' && holes.length > 0) {
235+
if (holes[holes.length - 1] === 0) {
236+
holes.pop();
237+
i = templateText(i + 1);
238+
} else {
239+
holes[holes.length - 1] -= 1;
240+
i += 1;
241+
}
180242
} else {
181243
i += 1;
182244
}
@@ -317,7 +379,8 @@ describe('the site scanner', () => {
317379
// A receiver holds a stack by its NAME: `config`, `stack`, `normalized`,
318380
// `bundle`, `cfg`, or any name ending in `Config`, `Stack` or `Bundle`. The
319381
// read forms are `R.key`, `R?.key`, `R!.key`, `(R as T).key` (a `T` without
320-
// parentheses) and `R['key']`. Comments and string bodies are blanked first.
382+
// parentheses) and `R['key']`. Comments, string bodies, template text and
383+
// regex bodies are blanked first; a template's `${…}` is code and is read.
321384
// An assignment target (`R.key = …`) is a write and is not a read.
322385
//
323386
// ⛔ Bounds, stated so they are not mistaken for coverage: a stack held under
@@ -424,7 +487,11 @@ const READS: Readonly<Record<string, ReadRow>> = {
424487
},
425488
'utils/collect-docs.ts :: stack.books': {
426489
reads: 'top-level',
427-
why: '`lintDocNavTargets` resolves a nav item\'s `book` against the artifact\'s own top-level `books` AND each package body\'s (`addBooks(body.books)` over `artifactPackages(stack)`), so the top-level read is one half of the union on purpose (#27090 measured it: the read was there before, hidden from this census by `codeOnly` losing its place in a nested template literal).',
490+
why: '`lintDocNavTargets` resolves a nav item\'s `book` against the artifact\'s own top-level `books` AND each package body\'s (`addBooks(body.books)` over `artifactPackages(stack)`), so the top-level read is one half of the union on purpose (#27090 measured it: the read was there before, hidden from this census by `codeOnly` losing its place; #27210 measured the cause there as a regex literal holding a quote).',
491+
},
492+
'utils/collect-docs.ts :: stack.apps': {
493+
reads: 'top-level',
494+
why: '`lintDocNavTargets` walks the top level\'s apps when it carries the key, otherwise each package body\'s (`artifactPackages(stack)`): `resolveStackCollection`\'s rule, over a walk that refuses no entry shape. Measured on a two-package `preserve` app in `collect-docs.nav-target.test.ts` (#27210: before, a body\'s app was never judged, and a nested template literal hid this read from the census).',
428495
},
429496
'utils/format.ts :: stack': {
430497
reads: 'resolved',
@@ -597,6 +664,27 @@ describe('the package-owned read scanner', () => {
597664
expect(packageOwnedReadsOf('// (config as any).requires\nconst s = \'config.requires\';\n', keys)).toEqual([]);
598665
});
599666

667+
it('keeps its place past a nested template literal and a regex literal (#27210)', () => {
668+
// The did-you-mean shape `collect-docs.ts` carries: a template nested in another's `${…}`.
669+
const nested = 'err(`unknown "${t}"${s ? ` — did you mean \\`${s}\\`?` : ` (expected ${x})`}.`);\n';
670+
expect(packageOwnedReadsOf(`${nested}const o = stack.objects;\n`, keys)).toEqual(['stack.objects']);
671+
// A `${…}` is code at any depth, braces and strings inside it included.
672+
expect(packageOwnedReadsOf('const m = `${stack.flows.length} of ${ {a: \'}\'}.a } ${ `${config.requires}` }`;\n', keys))
673+
.toEqual(['stack.flows', 'config.requires']);
674+
// A quote or backtick inside a regex literal opens nothing; a division is not a regex.
675+
expect(packageOwnedReadsOf('const v = l.replace(/^[\'"`]|[\'"]$/g, \'\'); const o = stack.objects;\n', keys))
676+
.toEqual(['stack.objects']);
677+
expect(packageOwnedReadsOf('const h = w / 2; const o = stack.objects; const q = h / 3;\n', keys))
678+
.toEqual(['stack.objects']);
679+
// A `${` in a quoted string opens no interpolation.
680+
expect(packageOwnedReadsOf('const s = \'${\'; const o = stack.objects;\n', keys)).toEqual(['stack.objects']);
681+
});
682+
683+
it('masks a nested template exactly: its text blanked, its interpolations kept', () => {
684+
expect(codeOnly('`a ${ `b ${c}` } d`;')).toBe('` ${ ` ${c}` } `;');
685+
expect(codeOnly('`${ {k: "}"} }x`; /[/`]/.test(y) / 2;')).toBe('`${ {k: " "} } `; / /.test(y) / 2;');
686+
});
687+
600688
it('sees the reads where they are (positive control on the real tree)', () => {
601689
expect(scanPackageOwnedReads(packageOwnedCollectionKeys())).toEqual(expect.arrayContaining([
602690
'utils/format.ts :: stack.objects',

0 commit comments

Comments
 (0)