fix(vscode-ide-companion): exclude workspace packages from NOTICES.txt generation (#4455)

* fix(vscode-ide-companion): exclude workspace packages from NOTICES.txt generation

Workspace-linked packages (link: true in package-lock.json) have no
version field, causing @undefined entries in NOTICES.txt. Skip them
during dependency collection since they are first-party code.

Regenerated NOTICES.txt on top of the express 5.2.1 lockfile update.

Closes #4446

* fix(vscode-ide-companion): resolve workspace and nested deps in NOTICES.txt generation

Rewrite collectDependencies to mirror Node.js module resolution:
- Walk up node_modules chain from each package's location (not just root)
- Follow workspace link resolved pointers to collect their third-party deps
- Pass resolved lockfile key to getDependencyLicense for exact path lookup

Previously @modelcontextprotocol/sdk and its transitive deps (zod-to-json-schema,
ajv@8, etc.) were missing because they are installed under the workspace's local
node_modules and were never hoisted to root. Also fixes version mismatch where
ajv@6.12.6 license was incorrectly used instead of ajv@8.17.1.

Total NOTICES entries: 80 → 102.
This commit is contained in:
易良 2026-05-27 11:24:38 +08:00 committed by GitHub
parent 5aca042421
commit 92d533265c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 889 additions and 347 deletions

File diff suppressed because it is too large Load diff

View file

@ -14,27 +14,26 @@ const projectRoot = path.resolve(
const packagePath = path.join(projectRoot, 'packages', 'vscode-ide-companion');
const noticeFilePath = path.join(packagePath, 'NOTICES.txt');
async function getDependencyLicense(depName, depVersion) {
let depPackageJsonPath;
/**
* Read license information for a dependency from its on-disk location.
*
* @param {string} depName - Package name
* @param {string} depVersion - Resolved version string
* @param {string} resolvedKey - Lockfile key indicating where the package is installed
* @returns {Promise<{name: string, version: string, repository: string, license: string}>}
*/
async function getDependencyLicense(depName, depVersion, resolvedKey) {
let licenseContent = 'License text not found.';
let repositoryUrl = 'No repository found';
try {
depPackageJsonPath = path.join(
projectRoot,
'node_modules',
depName,
'package.json',
);
if (!(await fs.stat(depPackageJsonPath).catch(() => false))) {
depPackageJsonPath = path.join(
packagePath,
'node_modules',
depName,
'package.json',
);
}
// Derive the on-disk path directly from the lockfile key
const depPackageJsonPath = path.join(
projectRoot,
resolvedKey,
'package.json',
);
try {
const depPackageJsonContent = await fs.readFile(
depPackageJsonPath,
'utf-8',
@ -76,7 +75,7 @@ async function getDependencyLicense(depName, depVersion) {
}
} catch (e) {
console.warn(
`Warning: Could not find package.json for ${depName}: ${e.message}`,
`Warning: Could not find package.json for ${depName} at ${depPackageJsonPath}: ${e.message}`,
);
}
@ -88,24 +87,90 @@ async function getDependencyLicense(depName, depVersion) {
};
}
function collectDependencies(packageName, packageLock, dependenciesMap) {
/**
* Resolve a package in the lockfile by walking up the node_modules chain,
* mirroring Node.js module resolution algorithm.
*
* @param {string} packageName - Package to find
* @param {object} packages - packageLock.packages map
* @param {string} resolveFrom - Lockfile key to start resolution from
* @returns {{info: object, key: string} | null}
*/
function resolveInLockfile(packageName, packages, resolveFrom) {
// Walk up from resolveFrom, trying each node_modules level
let current = resolveFrom;
while (current) {
const candidate = `${current}/node_modules/${packageName}`;
if (packages[candidate]) {
return { info: packages[candidate], key: candidate };
}
// Move up: strip the last /node_modules/... segment
const lastNm = current.lastIndexOf('/node_modules/');
if (lastNm === -1) break;
current = current.slice(0, lastNm);
}
// Finally try root hoisted level
const hoistedKey = `node_modules/${packageName}`;
if (packages[hoistedKey]) {
return { info: packages[hoistedKey], key: hoistedKey };
}
return null;
}
/**
* Recursively collect third-party dependencies by walking the lockfile.
* Mirrors Node.js module resolution: walks up the node_modules chain from
* the current package's location.
*
* @param {string} packageName - Package to resolve
* @param {object} packageLock - Parsed package-lock.json
* @param {Map<string, {version: string, resolvedKey: string}>} dependenciesMap - Accumulated results
* @param {string} resolveFrom - Lockfile key prefix to resolve from (e.g. "packages/vscode-ide-companion")
*/
function collectDependencies(
packageName,
packageLock,
dependenciesMap,
resolveFrom,
) {
if (dependenciesMap.has(packageName)) {
return;
}
const packageInfo = packageLock.packages[`node_modules/${packageName}`];
if (!packageInfo) {
const resolved = resolveInLockfile(
packageName,
packageLock.packages,
resolveFrom,
);
if (!resolved) {
console.warn(
`Warning: Could not find package info for ${packageName} in package-lock.json.`,
);
return;
}
dependenciesMap.set(packageName, packageInfo.version);
const { info: packageInfo, key: resolvedKey } = resolved;
// Workspace-linked packages: follow resolved pointer to collect their third-party deps
if (packageInfo.link) {
const realInfo = packageLock.packages[packageInfo.resolved];
if (realInfo?.dependencies) {
for (const depName of Object.keys(realInfo.dependencies)) {
collectDependencies(depName, packageLock, dependenciesMap, resolveFrom);
}
}
return;
}
dependenciesMap.set(packageName, {
version: packageInfo.version,
resolvedKey,
});
if (packageInfo.dependencies) {
for (const depName of Object.keys(packageInfo.dependencies)) {
collectDependencies(depName, packageLock, dependenciesMap);
// Resolve transitive deps from THIS package's location
collectDependencies(depName, packageLock, dependenciesMap, resolvedKey);
}
}
}
@ -125,15 +190,22 @@ async function main() {
const allDependencies = new Map();
const directDependencies = Object.keys(packageJson.dependencies);
const workspacePrefix = path.relative(projectRoot, packagePath);
for (const depName of directDependencies) {
collectDependencies(depName, packageLockJson, allDependencies);
collectDependencies(
depName,
packageLockJson,
allDependencies,
workspacePrefix,
);
}
const dependencyEntries = Array.from(allDependencies.entries());
const licensePromises = dependencyEntries.map(([depName, depVersion]) =>
getDependencyLicense(depName, depVersion),
const licensePromises = dependencyEntries.map(
([depName, { version, resolvedKey }]) =>
getDependencyLicense(depName, version, resolvedKey),
);
const dependencyLicenses = await Promise.all(licensePromises);
@ -151,6 +223,7 @@ async function main() {
await fs.writeFile(noticeFilePath, noticeText);
console.log(`NOTICES.txt generated at ${noticeFilePath}`);
console.log(`Total dependencies: ${dependencyEntries.length}`);
} catch (error) {
console.error('Error generating NOTICES.txt:', error);
process.exit(1);