fix(scaffold): resolve the framework peer from this repository, not a registry

Two sites reached a registry for the vendored framework, which the rescope
turns from a silent second copy into a hard failure.

Live-link mode relinked only the root manifest, so a generated workspace member
— `plugins/*/package.json` — resolved its own dependencies from the registry
and installed upstream cordis beside this repository's vendored copy.
`LinkWorkspace.relinkNestedManifest()` relinks every nested generated manifest;
`peerDependencies` keeps its range because package managers reject a link spec
there.

The sandbox publish-path rehearsal installs this repository's vendored cordis
and cosmokit tarballs instead of naming a registry version.
This commit is contained in:
imccyu
2026-08-10 11:26:59 +08:00
parent 194828e8b8
commit 78e9b8bec5
4 changed files with 78 additions and 2 deletions
@@ -371,6 +371,27 @@ describe('package manager strategies', () => {
expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis'))
expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')
expect(workspace.packageDirectory('missing')).toBeUndefined()
// A generated workspace member resolves its own dependencies: every local name it
// declares relinks, while a peer keeps the range package managers require there.
const nested = workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', `${JSON.stringify({
name: 'probe',
dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1', 'left-pad': '^1' },
peerDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' },
devDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' },
}, null, 2)}\n`, new PnpmPackageManager('10.0.0'))
const nestedManifest = JSON.parse(nested) as {
dependencies: Record<string, string>
peerDependencies: Record<string, string>
devDependencies: Record<string, string>
}
expect(nestedManifest.dependencies['@deepseek-ai/dsh-helper']).toMatch(/^link:\.\.\/\.\.\//)
expect(nestedManifest.dependencies['left-pad']).toBe('^1')
expect(nestedManifest.devDependencies['@deepseek-ai/dsh-scripts']).toMatch(/^link:\.\.\/\.\.\//)
expect(nestedManifest.peerDependencies['@deepseek-ai/dsh-scripts']).toBe('^0.0.1')
// Nothing local to relink, and a non-object section, leave the text byte-identical.
const untouched = `${JSON.stringify({ name: 'probe', dependencies: { 'left-pad': '^1' }, devDependencies: null }, null, 2)}\n`
expect(workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', untouched, new PnpmPackageManager('10.0.0')))
.toBe(untouched)
const yarnManifest = PackageJsonFile.create('{"name":"consumer"}')
yarnManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
workspace.apply(join(root, 'consumer-yarn'), yarnManifest, new YarnPackageManager('4.0.0'), [])
+11 -1
View File
@@ -698,14 +698,24 @@ describe('SdkProject and ProjectEditSession', () => {
it('does not mistake a linked NPM dependency closure for an installed feature', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-link-closure-inspection-'))
temporary.push(root)
const base = request([selection('hooks', ['claude'])])
const base = request([selection('hooks', ['claude'])], [new LocalPluginBlueprint('probe', 'plugin')])
const creation: ProjectCreationRequest = { ...base, linkWorkspaceRoot: repoRoot }
const project = SdkProject.create(root, creation)
const registry = createBuiltinRegistry(project.profile)
const edit = project.edit(registry)
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
for (const blueprint of creation.localPlugins) edit.addPlugin(blueprint)
const committed = (await edit.commit()).project
expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/)
// A generated workspace member resolves its own dependencies, so its manifest links too.
const plugin = JSON.parse(await readFile(join(root, 'plugins/probe/package.json'), 'utf8')) as {
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
// Asserted by shape, not by the framework's name: what matters is that the
// resolved section links into this repository while the peer keeps its range.
expect(Object.values(plugin.devDependencies ?? {}).every(spec => spec.startsWith('file:'))).toBe(true)
expect(Object.values(plugin.peerDependencies ?? {}).some(spec => spec.startsWith('^'))).toBe(true)
expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent')
})