diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs
index ab928835..22ebb9ad 100644
--- a/scripts/verify-dep-lockstep.mjs
+++ b/scripts/verify-dep-lockstep.mjs
@@ -175,7 +175,30 @@ function typescript() {
}
/**
- * Every third-party package name imported by a blob of TypeScript source.
+ * The package(s) a `/// ` directive can resolve to
+ * (Copilot, #1962). Such a directive pulls in declarations exactly like an
+ * import does, but TypeScript reports it separately from `importedFiles`, so
+ * reading only the latter would let a referenced package skew unseen.
+ *
+ * Both candidates are returned because the directive name is the *type* name,
+ * not the package: `node` resolves to `@types/node`, while a package shipping
+ * its own declarations resolves to itself. Returning both over-approximates,
+ * which is the safe direction — whichever isn't installed drops out. A scoped
+ * name mangles as `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention.
+ */
+export function typeReferencePackageNames(directive) {
+ const name = packageNameOf(directive);
+ if (!name) return [];
+ const scoped = /^@([^/]+)\/(.+)$/.exec(name);
+ const typesName = scoped
+ ? `@types/${scoped[1]}__${scoped[2]}`
+ : `@types/${name}`;
+ return [name, typesName];
+}
+
+/**
+ * Every third-party package name whose declarations a blob of TypeScript source
+ * pulls in — via an import of any form, or a triple-slash type reference.
* Over-approximating is safe (a name absent from every lockfile contributes
* nothing downstream — `@inspector/core` is a build-time alias, not a package,
* and drops out that way); under-approximating is not, since a missed package
@@ -185,11 +208,14 @@ export function importedPackageNames(source) {
const names = new Set();
// (source, readImportFiles, detectJavaScriptImports) — the latter two make it
// report `require(…)` and dynamic imports as well as static ones.
- const { importedFiles } = typescript().preProcessFile(source, true, true);
+ const { importedFiles, typeReferenceDirectives } =
+ typescript().preProcessFile(source, true, true);
for (const { fileName } of importedFiles) {
const name = packageNameOf(fileName);
if (name) names.add(name);
}
+ for (const { fileName } of typeReferenceDirectives ?? [])
+ for (const name of typeReferencePackageNames(fileName)) names.add(name);
return names;
}
diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs
index 79974404..a00ce642 100644
--- a/scripts/verify-dep-lockstep.test.mjs
+++ b/scripts/verify-dep-lockstep.test.mjs
@@ -16,6 +16,7 @@ import {
partitionSkew,
sourcesWithNoFiles,
topLevelLockVersions,
+ typeReferencePackageNames,
} from "./verify-dep-lockstep.mjs";
test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => {
@@ -196,6 +197,41 @@ test("importedPackageNames: static, side-effect, and dynamic forms; builtins and
]);
});
+test("importedPackageNames: triple-slash type references count (Copilot, #1962)", () => {
+ // A `/// ` pulls in declarations exactly like an
+ // import, but TypeScript reports it in `typeReferenceDirectives`, not
+ // `importedFiles` — so reading only the latter let a referenced package skew
+ // unseen. `path` references name a file, not a package, and are ignored.
+ const source = [
+ '/// ',
+ '/// ',
+ '/// ',
+ 'import { z } from "zod";',
+ ].join("\n");
+ assert.deepEqual([...importedPackageNames(source)].sort(), [
+ "@types/express",
+ "@types/node",
+ "express",
+ "node",
+ "zod",
+ ]);
+});
+
+test("typeReferencePackageNames: both the bare and the @types form (Copilot, #1962)", () => {
+ // The directive names a *type*, not a package: `node` resolves to
+ // `@types/node`, while a package shipping its own declarations resolves to
+ // itself. Returning both over-approximates, the safe direction — whichever
+ // isn't installed drops out downstream.
+ assert.deepEqual(typeReferencePackageNames("node"), ["node", "@types/node"]);
+ // Scoped names mangle with a double underscore, TypeScript's convention.
+ assert.deepEqual(typeReferencePackageNames("@scope/pkg"), [
+ "@scope/pkg",
+ "@types/scope__pkg",
+ ]);
+ assert.deepEqual(typeReferencePackageNames("./relative"), []);
+ assert.deepEqual(typeReferencePackageNames(""), []);
+});
+
test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => {
// The regex scan this replaced could not tell code from a comment, so
// `// adapted from "react"` added `react` to the candidate set — and if that