Read imports from the AST, and retire a stripper that quietly stopped working - #109
Merged
Merged
Conversation
…ietly stopped working
Two detectors asked "does this file still import X?" by searching the raw text with
comments blanked first, because a docblock that DOCUMENTS the conversion quotes the
very syntax they look for. The blanking used a standalone `ts.createScanner` in a
plain `scan()` loop — and that loop cannot read a template literal with a
substitution: the scanner returns `TemplateHead` at `` `x${ `` and the caller must
call `reScanTemplateToken()` to continue. Nothing did. From that token on it was
desynchronised, stopped classifying comment trivia as trivia, and every comment below
passed through verbatim.
Measured over the exact file sets both call sites scan: degraded in 23 of 76 real
frontend files (3 of them `.vue`) and 21 of 50 backend files — template literals are
everywhere. Files where a SURVIVING comment also quotes a needle, i.e. real false
alarms: 0. A live trap, not a live bug: it fires the day someone writes a docblock
quoting an import below a template literal, and it will look like the detector
regressed rather than like the stripper did.
`src/lib/module-specifiers.ts` removes the need instead of improving the stripper.
`importedSpecifiers` walks the AST for `import`, `export … from`, `import x =
require()`, dynamic `import()` and `require()`; both call sites now take a predicate
over a specifier (`isPackageImport`, `isRelativeCoreImport`) instead of a text needle.
A comment is not part of the AST, so there is nothing to strip and nothing to get
wrong — and the check gets stricter as a side effect: `'node_modules/@lenne.tech/
nest-server/dist/x'` in a string no longer counts, `@lenne.tech/nest-server-extras`
no longer matches `@lenne.tech/nest-server`.
`.vue` is not an afterthought here. An SFC is not valid TypeScript as a whole, so the
scanner had even less to work with — 3 of the 23 degraded frontend files were `.vue`.
`fileImportedSpecifiers` splits the `<script>` blocks out and parses each one, and
skips `<template>`, where a commented-out import is prose by definition.
`stripComments` had no callers left, so it is deleted rather than documented. Its
docblock promised that "string literals, template literals and regex literals … are
handled correctly by construction" — true for a comment marker INSIDE a literal, false
for a template that interpolates. A helper kept for a future consumer would have
carried that defect with a warning label; the counter-test records it better.
A correction worth keeping: the first published cause for this was "a regex literal
followed by a division, which a scanner has no parser context to tell apart", read off
the offset where blanking stopped. It is wrong. Bisecting growing prefixes of
`src/templates/check/check.mjs` puts the regex (line 63) and the division (line 66)
both on the clean side and the break at line 67's `` `${s.toFixed(1)}s` ``; isolated
per construct, interpolating template leaks while plain template, division alone and
regex-then-division are all clean. The counter-test written from the wrong theory is
what failed and exposed it. Write the counter-test before publishing the cause.
Tests: 71 suites / 1073 tests plus the four slow suites separately (4 / 65), run
serially — `git-commands` excluded, it makes real network calls (see CLAUDE.md).
Three mutations of the new module each turn exactly one test red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH
This was referenced Sep 21, 2026
DKoenig9
marked this pull request as ready for review
September 21, 2026 12:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the
stripCommentsfinding from #108 by making the helper unnecessary at both of its call sites, rather than by improving it.The finding, measured
Two detectors asked "does this file still import X?" by searching the raw source with comments blanked first — because a docblock that DOCUMENTS the conversion quotes the very syntax they look for (
nest-server-starter/tests/unit/bootstrap-diagnostics.spec.tsnamesfrom '@lenne.tech/nest-server'in prose, and the detector told the user to rewrite imports that file does not have).The blanking used a standalone
ts.createScannerin a plainscan()loop. That loop cannot read a template literal with a substitution: the scanner returnsTemplateHeadat`x${and the caller must callreScanTemplateToken()to continue. Nothing did. From that token on it is desynchronised, stops classifying comment trivia as trivia, and every comment below passes through verbatim.Ran over the exact file sets both call sites scan:
nuxt-base-templateapp + tests).vue)nest-server-starterscanned globs)So roughly a third of real files degrade — template literals are everywhere — while the harmful combination is currently absent. A live trap, not a live bug: it fires the day someone writes a docblock quoting an import below a template literal, and it will read as the detector regressing rather than the stripper.
The fix
src/lib/module-specifiers.tsasks the parser instead.importedSpecifierswalks the AST forimport,import 'x',export … from,export * from,import x = require(), dynamicimport()andrequire(). Both call sites now take a predicate over a specifier instead of a text needle:findStaleImports(dest, matches)→(s) => isPackageImport(s, '@lenne.tech/nest-server')andisRelativeCoreImportfindStaleFrontendImports(appDir, matches, skip?)→isRelativeCoreImportand(s) => isPackageImport(s, '@lenne.tech/nuxt-extensions')A comment is not part of the AST, so there is nothing to strip and nothing to get wrong. Two precision gains fall out: a path in a string literal (
'node_modules/@lenne.tech/nest-server/dist/x') no longer counts as an import, and@lenne.tech/nest-server-extrasno longer matches@lenne.tech/nest-server..vueis handled deliberately, not incidentallyA
.vueSFC is not valid TypeScript as a whole, so handing it tocreateSourceFilewould parse the<template>block as expression syntax and recover badly — which is also why the scanner had even less to work with there (3 of the 23 degraded frontend files are.vue).fileImportedSpecifierstherefore extracts the<script>/<script setup>blocks and parses each one on its own, naming them<file>.tsso the TS dialect is picked.<template>and<style>are skipped on purpose: a commented-out import inside a template is prose by definition, and the test pins exactly that (an HTML comment quoting@lenne.tech/nuxt-extensionsin<template>must not be reported, while the real import in<script setup>must).stripCommentsitselfDeleted, with its test. After this change it has no callers, and its docblock's promise — "string literals, template literals and regex literals containing
//or/*are handled correctly by construction" — is true only for the case it names (a comment marker INSIDE a literal) and false for a template that interpolates. Keeping it for a hypothetical third consumer would mean keeping that defect behind a warning label. The two places that pointed at it (heal-vendor-migrate-store.ts's docblock and the matching CLAUDE.md rule) now say: parse, a scanner is not enough either.A correction I owe this PR
The cause I published for this in #108 — "a regex literal followed by a division, which a scanner has no parser context to tell apart" — is wrong. I read it off the offset where blanking stopped; it was plausible and it was not tested. The counter-test built from it passed, which is how it came out.
Bisecting growing prefixes of
src/templates/check/check.mjs, each with a known comment appended:rel.replace(/^projects\//, '')— regex literalconst s = ms / 1000;— divisionif (s < 60) return `${s.toFixed(1)}s`;Isolated per construct: interpolating template → leaks; plain template → clean; division alone → clean; regex-then-division → clean. #108 carries its own correction commit for Rule 1b. Write the counter-test before publishing the cause.
Checks
tsc --noEmit, eslint cleangit-commandsexcluded — it makes real network calls (documented in CLAUDE.md), unrelated to this change..vue, drop the relative-path check, loosenisPackageImporttoincludes) each turn exactly one test red.Draft until the cli release goes out, as agreed.
🤖 Generated with Claude Code
https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH