diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c7923..c9bb3b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,48 +128,20 @@ jobs: - name: 按锁文件安装依赖 run: pnpm install --frozen-lockfile - - name: 创建并验证正式 Narratica Profile + - name: 创建正式 Narratica Profile env: DSH_HOME: ${{ runner.temp }}/narratica-dsh-home - run: pnpm run profile:bootstrap > "$RUNNER_TEMP/narratica-dump-config.txt" + run: pnpm run profile:bootstrap - - name: 确认 Profile 只有一个正式 Narratica Bundle + - name: 验证正式 Profile 装配契约 env: DSH_HOME: ${{ runner.temp }}/narratica-dsh-home - run: | - node - <<'NODE' - const fs = require('node:fs') - const path = require('node:path') - const profile = JSON.parse(fs.readFileSync(path.join(process.env.DSH_HOME, 'profiles', 'narratica', 'package.json'), 'utf8')) - const bundles = profile.dsh?.profile?.bundles ?? [] - const expected = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@narratica/narratica'] - for (const name of expected) if (!bundles.includes(name)) throw new Error(`缺少 Bundle: ${name}`) - for (const name of ['@narratica/bundle-core', '@narratica/bundle-production', '@narratica/bundle-app']) if (bundles.includes(name)) throw new Error(`正式 Profile 不应包含旧 Bundle: ${name}`) - console.log(bundles.join(' -> ')) - NODE - - - name: 确认 DSH 宿主与 Narratica 插件同时进入组合配置 - run: | - for plugin in ui-layout ui-sidebar ui-conversation ui-settings narratica-stories narratica-skill-pack narratica-providers narratica-media narratica-production narratica-story-tools narratica-client; do - grep -q -- "- id: $plugin" "$RUNNER_TEMP/narratica-dump-config.txt" - done - grep -q "name: '@narratica/narratica'" "$RUNNER_TEMP/narratica-dump-config.txt" - for legacy_client in narratica-client-runtime narratica-client-layout narratica-client-workspace narratica-client-story-library narratica-client-novel narratica-client-director; do - if grep -q -- "- id: $legacy_client" "$RUNNER_TEMP/narratica-dump-config.txt"; then - echo "正式 Profile 不应再包含旧 Client Loader row: $legacy_client" - exit 1 - fi - done + run: pnpm run verify:profile-contract -- "$RUNNER_TEMP/narratica-dump-config.txt" - name: 验证模式一 Story Repository 与 Skill Provider 探针 env: DSH_HOME: ${{ runner.temp }}/narratica-dsh-home - run: | - pnpm run profile:mode1:dump > "$RUNNER_TEMP/narratica-mode1-dump-config.txt" - grep -q "tests/fixtures/story-repository" "$RUNNER_TEMP/narratica-mode1-dump-config.txt" - grep -q "narratica-skill-pack" "$RUNNER_TEMP/narratica-mode1-dump-config.txt" - grep -q "narratica-skill-filesystem" "$RUNNER_TEMP/narratica-mode1-dump-config.txt" - grep -q "providerName: narratica-novel" "$RUNNER_TEMP/narratica-mode1-dump-config.txt" + run: pnpm run verify:mode1-profile -- "$RUNNER_TEMP/narratica-mode1-dump-config.txt" - name: 启动正式 DSH Web 并探测首页 env: diff --git a/.github/workflows/release-pack-check.yml b/.github/workflows/release-pack-check.yml index 922db13..6013f86 100644 --- a/.github/workflows/release-pack-check.yml +++ b/.github/workflows/release-pack-check.yml @@ -4,6 +4,9 @@ on: pull_request: paths: - 'scripts/release/**' + - 'scripts/profile-contract.mjs' + - 'scripts/dsh-baseline.mjs' + - 'packages/bundle/narratica/cordis.patch.yml' - 'packages/**/package.json' - 'package.json' - 'pnpm-lock.yaml' @@ -14,6 +17,9 @@ on: - main paths: - 'scripts/release/**' + - 'scripts/profile-contract.mjs' + - 'scripts/dsh-baseline.mjs' + - 'packages/bundle/narratica/cordis.patch.yml' - 'packages/**/package.json' - 'package.json' - 'pnpm-lock.yaml' @@ -74,5 +80,10 @@ jobs: - name: npm publish dry-run run: pnpm run release:publish:dry-run -- 0.0.0-alpha.0 alpha + - name: PR 阶段说明 + if: github.event_name == 'pull_request' + run: echo 'PR 阶段已完成发行打包与 npm dry-run;完整 DSH Web 烟测在 main / 手动触发时执行。' + - name: 本地 tarball DSH Profile 与 Web 烟测 + if: github.event_name != 'pull_request' run: pnpm run release:smoke:local -- 0.0.0-alpha.0 diff --git a/package.json b/package.json index 1e6558b..d488de6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "build": "pnpm run verify:syntax && pnpm run build:host && pnpm run generate:typert && pnpm run build:client:types && pnpm run build:client:bundles", "typecheck": "pnpm run verify:syntax && pnpm run build:host && pnpm run generate:typert && pnpm run build:client:types", "verify:dsh-baseline": "node scripts/verify-dsh-baseline.mjs", + "verify:profile-contract": "node scripts/verify-profile-contract.mjs", + "verify:mode1-profile": "node scripts/verify-mode1-profile-probe.mjs", "profile:bootstrap": "pnpm run build && node scripts/bootstrap-profile.mjs", "prestart": "pnpm run build", "start": "dsh --profile narratica --patch tests/probes/mode1-novel-workspace.patch.yml", diff --git a/scripts/bootstrap-profile.mjs b/scripts/bootstrap-profile.mjs index d39035c..6d23749 100644 --- a/scripts/bootstrap-profile.mjs +++ b/scripts/bootstrap-profile.mjs @@ -5,12 +5,11 @@ import { fileURLToPath } from 'node:url' import { spawnSync } from 'node:child_process' import { - DSH_BASE_BUNDLE, DSH_PROFILE, DSH_VERSION, DSH_WEB_BUNDLE, - NARRATICA_BUNDLE, } from './dsh-baseline.mjs' +import { assertFormalProfileContract } from './profile-contract.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..') @@ -130,37 +129,7 @@ for (const [packageName] of localProfilePlainDependencies) { } } -const requiredBundles = [ - DSH_BASE_BUNDLE, - DSH_WEB_BUNDLE, - NARRATICA_BUNDLE, -] - -for (const required of requiredBundles) { - if (!bundles.includes(required)) { - throw new Error(`Narratica profile is missing required bundle: ${required}`) - } -} - -const narraticaBundles = bundles.filter(name => name.startsWith('@narratica/')) -if (narraticaBundles.length !== 1 || narraticaBundles[0] !== NARRATICA_BUNDLE) { - throw new Error(`Formal Profile must expose exactly one Narratica Bundle: ${narraticaBundles.join(' -> ')}`) -} - -for (const legacyBundle of [ - '@narratica/bundle-core', - '@narratica/bundle-production', - '@narratica/bundle-app', -]) { - if (bundles.includes(legacyBundle)) { - throw new Error(`Formal Narratica profile must not expose legacy bundle: ${legacyBundle}`) - } -} - -const positions = requiredBundles.map(name => bundles.indexOf(name)) -if (!positions.every((position, index) => index === 0 || positions[index - 1] < position)) { - throw new Error(`Unexpected Narratica bundle order: ${bundles.join(' -> ')}`) -} +assertFormalProfileContract(profilePackage) run(pnpm, ['exec', 'dsh', '--profile', DSH_PROFILE, '--dump-config']) console.log(`Narratica profile ready: ${profilePackagePath}`) diff --git a/scripts/profile-contract.mjs b/scripts/profile-contract.mjs new file mode 100644 index 0000000..5808a34 --- /dev/null +++ b/scripts/profile-contract.mjs @@ -0,0 +1,116 @@ +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + DSH_BASE_BUNDLE, + DSH_WEB_BUNDLE, + NARRATICA_BUNDLE, +} from './dsh-baseline.mjs' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const formalBundlePatchPath = resolve(scriptDir, '../packages/bundle/narratica/cordis.patch.yml') + +export const REQUIRED_PROFILE_BUNDLES = Object.freeze([ + DSH_BASE_BUNDLE, + DSH_WEB_BUNDLE, + NARRATICA_BUNDLE, +]) + +export const LEGACY_NARRATICA_BUNDLES = Object.freeze([ + '@narratica/bundle-core', + '@narratica/bundle-production', + '@narratica/bundle-app', +]) + +export const REQUIRED_DSH_HOST_LOADER_IDS = Object.freeze([ + 'ui-layout', + 'ui-sidebar', + 'ui-conversation', + 'ui-settings', +]) + +export const LEGACY_NARRATICA_CLIENT_LOADER_IDS = Object.freeze([ + 'narratica-client-runtime', + 'narratica-client-layout', + 'narratica-client-workspace', + 'narratica-client-story-library', + 'narratica-client-novel', + 'narratica-client-director', +]) + +function loaderIdPattern(id) { + return new RegExp(`(?:^|\\n)\\s*-\\s*id:\\s*${id}(?:\\s|$)`, 'm') +} + +export async function loadFormalNarraticaLoaderIds() { + const patch = await readFile(formalBundlePatchPath, 'utf8') + const ids = [...patch.matchAll(/^\s*-\s*id:\s*(narratica-[A-Za-z0-9-]+)\s*$/gm)].map(match => match[1]) + const unique = [...new Set(ids)] + + if (unique.length !== ids.length) throw new Error('正式 Bundle patch 存在重复 Narratica Loader ID') + if (!unique.includes('narratica-client')) throw new Error('正式 Bundle patch 缺少唯一 Narratica Client Loader') + for (const legacyId of LEGACY_NARRATICA_CLIENT_LOADER_IDS) { + if (unique.includes(legacyId)) throw new Error(`正式 Bundle patch 不应再声明旧 Client Loader:${legacyId}`) + } + + return Object.freeze(unique) +} + +export function assertFormalProfileContract(profile, { distribution = false } = {}) { + const dependencies = profile.dependencies ?? {} + const bundles = profile.dsh?.profile?.bundles ?? [] + + for (const required of REQUIRED_PROFILE_BUNDLES) { + if (!bundles.includes(required)) { + throw new Error(`正式 Profile 缺少 Bundle:${required}`) + } + } + + const narraticaBundles = bundles.filter(name => name.startsWith('@narratica/')) + if (narraticaBundles.length !== 1 || narraticaBundles[0] !== NARRATICA_BUNDLE) { + throw new Error(`正式 Profile 必须只有一个 Narratica Bundle:${narraticaBundles.join(' -> ') || '无'}`) + } + + for (const legacyBundle of LEGACY_NARRATICA_BUNDLES) { + if (bundles.includes(legacyBundle)) { + throw new Error(`正式 Profile 不应包含旧 Bundle:${legacyBundle}`) + } + } + + const positions = REQUIRED_PROFILE_BUNDLES.map(name => bundles.indexOf(name)) + if (!positions.every((position, index) => index === 0 || positions[index - 1] < position)) { + throw new Error(`正式 Profile Bundle 顺序异常:${bundles.join(' -> ')}`) + } + + if (distribution) { + const narraticaDirectDependencies = Object.keys(dependencies).filter(name => name.startsWith('@narratica/')) + if (narraticaDirectDependencies.length !== 1 || narraticaDirectDependencies[0] !== NARRATICA_BUNDLE) { + throw new Error(`发行 Profile 顶层只能依赖正式入口包:${narraticaDirectDependencies.join(' -> ') || '无'}`) + } + } +} + +export async function assertComposedConfigContract(dump) { + const formalNarraticaLoaderIds = await loadFormalNarraticaLoaderIds() + for (const id of [...REQUIRED_DSH_HOST_LOADER_IDS, ...formalNarraticaLoaderIds]) { + if (!loaderIdPattern(id).test(dump)) { + throw new Error(`组合配置缺少正式 Loader:${id}`) + } + } + + for (const id of LEGACY_NARRATICA_CLIENT_LOADER_IDS) { + if (loaderIdPattern(id).test(dump)) { + throw new Error(`组合配置不应再出现旧 Client Loader:${id}`) + } + } + + if (!dump.includes(`name: '${NARRATICA_BUNDLE}'`)) { + throw new Error(`组合配置缺少正式 Narratica 入口:${NARRATICA_BUNDLE}`) + } +} + +export async function assertNarraticaProfileContract({ profile, dump, distribution = false }) { + assertFormalProfileContract(profile, { distribution }) + await assertComposedConfigContract(dump) +} diff --git a/scripts/release/smoke-release.mjs b/scripts/release/smoke-release.mjs index 67fdba2..9d17135 100644 --- a/scripts/release/smoke-release.mjs +++ b/scripts/release/smoke-release.mjs @@ -3,6 +3,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { resolve } from 'node:path' +import { assertNarraticaProfileContract } from '../profile-contract.mjs' import { DSH_VERSION, ENTRY_PACKAGE, @@ -158,19 +159,6 @@ if (mode === 'local') { const profile = JSON.parse(await readFile(profilePackagePath, 'utf8')) const dependencies = profile.dependencies ?? {} const bundles = profile.dsh?.profile?.bundles ?? [] -const requiredBundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ENTRY_PACKAGE] -for (const name of requiredBundles) { - if (!bundles.includes(name)) throw new Error(`发行烟测 Profile 缺少 Bundle:${name}`) -} -const narraticaBundles = bundles.filter(name => name.startsWith('@narratica/')) -if (narraticaBundles.length !== 1 || narraticaBundles[0] !== ENTRY_PACKAGE) { - throw new Error(`发行烟测必须只有一个 Narratica Bundle:${narraticaBundles.join(' -> ')}`) -} - -const narraticaDirectDependencies = Object.keys(dependencies).filter(name => name.startsWith('@narratica/')) -if (narraticaDirectDependencies.length !== 1 || narraticaDirectDependencies[0] !== ENTRY_PACKAGE) { - throw new Error(`发行烟测 Profile 顶层只能依赖正式入口包:${narraticaDirectDependencies.join(' -> ') || '无'}`) -} if (mode === 'local') { const policy = await readFile(workspacePolicyPath, 'utf8') @@ -187,29 +175,7 @@ if (mode === 'local') { } const dump = run(['exec', 'dsh', '--profile', 'narratica', '--dump-config']) -const expectedLoaderIds = [ - 'narratica-stories', - 'narratica-skill-pack', - 'narratica-providers', - 'narratica-media', - 'narratica-production', - 'narratica-client', -] -const legacyClientLoaderIds = [ - 'narratica-client-runtime', - 'narratica-client-layout', - 'narratica-client-workspace', - 'narratica-client-story-library', - 'narratica-client-novel', - 'narratica-client-director', -] -const loaderIdPattern = id => new RegExp(`(?:^|\\n)\\s*-\\s*id:\\s*${id}(?:\\s|$)`, 'm') -for (const id of expectedLoaderIds) { - if (!loaderIdPattern(id).test(dump)) throw new Error(`发行烟测组合配置缺少 Loader:${id}`) -} -for (const id of legacyClientLoaderIds) { - if (loaderIdPattern(id).test(dump)) throw new Error(`发行烟测不应再出现旧 Client Loader:${id}`) -} +await assertNarraticaProfileContract({ profile, dump, distribution: true }) const chunks = [] const child = spawn(pnpm, ['exec', 'dsh', '--profile', 'narratica', '--port', '3189', '--no-open'], { diff --git a/scripts/verify-mode1-profile-probe.mjs b/scripts/verify-mode1-profile-probe.mjs new file mode 100644 index 0000000..a7a12e2 --- /dev/null +++ b/scripts/verify-mode1-profile-probe.mjs @@ -0,0 +1,31 @@ +import { spawnSync } from 'node:child_process' +import { writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const outputPath = process.argv[2] ? resolve(process.argv[2]) : null +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + +const result = spawnSync(pnpm, ['run', 'profile:mode1:dump'], { + encoding: 'utf8', + env: process.env, + shell: process.platform === 'win32', +}) + +if (result.error) throw result.error +if (result.status !== 0) { + throw new Error(`模式一 --dump-config 失败 (${result.status})\n${result.stdout ?? ''}\n${result.stderr ?? ''}`) +} + +const dump = `${result.stdout ?? ''}${result.stderr ?? ''}` +const expectedEvidence = [ + 'tests/fixtures/story-repository', + 'narratica-skill-filesystem', + 'providerName: narratica-novel', +] + +for (const evidence of expectedEvidence) { + if (!dump.includes(evidence)) throw new Error(`模式一 Profile 探针缺少证据:${evidence}`) +} + +if (outputPath) await writeFile(outputPath, dump) +console.log('模式一 Story Repository 与 Skill Provider 探针通过') diff --git a/scripts/verify-profile-contract.mjs b/scripts/verify-profile-contract.mjs new file mode 100644 index 0000000..e5e41dc --- /dev/null +++ b/scripts/verify-profile-contract.mjs @@ -0,0 +1,31 @@ +import { spawnSync } from 'node:child_process' +import { readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +import { DSH_PROFILE } from './dsh-baseline.mjs' +import { assertNarraticaProfileContract } from './profile-contract.mjs' + +const outputPath = process.argv[2] ? resolve(process.argv[2]) : null +const distribution = process.argv.includes('--distribution') +const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh') +const profilePackagePath = join(dshHome, 'profiles', DSH_PROFILE, 'package.json') +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + +const result = spawnSync(pnpm, ['exec', 'dsh', '--profile', DSH_PROFILE, '--dump-config'], { + encoding: 'utf8', + env: process.env, + shell: process.platform === 'win32', +}) + +if (result.error) throw result.error +if (result.status !== 0) { + throw new Error(`DSH --dump-config 失败 (${result.status})\n${result.stdout ?? ''}\n${result.stderr ?? ''}`) +} + +const dump = `${result.stdout ?? ''}${result.stderr ?? ''}` +const profile = JSON.parse(await readFile(profilePackagePath, 'utf8')) +await assertNarraticaProfileContract({ profile, dump, distribution }) + +if (outputPath) await writeFile(outputPath, dump) +console.log(`正式 Profile 契约通过:${profilePackagePath}`) diff --git a/tests/architecture/adr009-bundle-boundary.test.mjs b/tests/architecture/adr009-bundle-boundary.test.mjs index 98e7439..afc36a7 100644 --- a/tests/architecture/adr009-bundle-boundary.test.mjs +++ b/tests/architecture/adr009-bundle-boundary.test.mjs @@ -122,9 +122,6 @@ test('formal Narratica bundle composes Host, Director infrastructure and one agg assert.match(patch, /includeDefaultRoots: false/) assert.match(patch, /ctx\.narraticaSkillPack\.skillDirs\('novel'\)/) - // DSH does not apply dependency bundle patches transitively. The formal - // top-level bundle therefore owns every row it activates directly. Host - // rows bridge through its runtime subpaths; Client is aggregated by itself. assert.doesNotMatch(patch, /@narratica\/bundle-(?:core|production|app)/) }) @@ -179,19 +176,17 @@ test('formal Client layout scopes Narratica CSS away from the DSH host', async ( assert.doesNotMatch(styles, /(?:^|\n)\*\{box-sizing:border-box\}/) }) -test('development link workaround stays plain and exposes only the formal Narratica bundle', async () => { +test('development link workaround stays plain and delegates formal bundle rules to the shared contract', async () => { const bootstrap = await readFile('scripts/bootstrap-profile.mjs', 'utf8') - assert.match(bootstrap, /NARRATICA_BUNDLE/) assert.match(bootstrap, /packages\/bundle\/narratica/) assert.match(bootstrap, /localProfilePlainDependencies/) assert.match(bootstrap, /narraticaBundleDependencies/) assert.match(bootstrap, /workaround drifted outside formal Bundle dependencies/) assert.match(bootstrap, /must remain a plain dependency, not a Bundle/) - assert.match(bootstrap, /Formal Profile must expose exactly one Narratica Bundle/) + assert.match(bootstrap, /assertFormalProfileContract\(profilePackage\)/) assert.match(bootstrap, /addToProfile\(narraticaBundleDir\)/) assert.doesNotMatch(bootstrap, /addToProfile\(coreBundleDir\)/) assert.doesNotMatch(bootstrap, /addToProfile\(productionBundleDir\)/) assert.doesNotMatch(bootstrap, /addToProfile\(appBundleDir\)/) - assert.match(bootstrap, /Formal Narratica profile must not expose legacy bundle/) }) diff --git a/tests/architecture/gate7-production-loop.test.mjs b/tests/architecture/gate7-production-loop.test.mjs index 22b50b6..d421d14 100644 --- a/tests/architecture/gate7-production-loop.test.mjs +++ b/tests/architecture/gate7-production-loop.test.mjs @@ -78,9 +78,9 @@ test('formal Narratica bundle composes providers then media then production with assert.match(patch, /inject:\s*\n\s*- narraticaProviders\s*\n\s*- narraticaMedia/) }) -test('profile bootstrap installs one formal Narratica bundle instead of legacy production layering', async () => { +test('profile bootstrap installs one formal Narratica bundle through the shared contract instead of legacy production layering', async () => { const source = await readFile('scripts/bootstrap-profile.mjs', 'utf8') - assert.match(source, /NARRATICA_BUNDLE/) + assert.match(source, /assertFormalProfileContract\(profilePackage\)/) assert.match(source, /addToProfile\(narraticaBundleDir\)/) assert.doesNotMatch(source, /NARRATICA_(?:CORE|PRODUCTION|APP)_BUNDLE/) assert.doesNotMatch(source, /addToProfile\((?:core|production|app)BundleDir\)/) diff --git a/tests/architecture/profile-contract-governance.test.mjs b/tests/architecture/profile-contract-governance.test.mjs new file mode 100644 index 0000000..030cf3b --- /dev/null +++ b/tests/architecture/profile-contract-governance.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import test from 'node:test' + +import { readFile } from './read-text.mjs' +import { + LEGACY_NARRATICA_CLIENT_LOADER_IDS, + REQUIRED_DSH_HOST_LOADER_IDS, + assertNarraticaProfileContract, + loadFormalNarraticaLoaderIds, +} from '../../scripts/profile-contract.mjs' + +test('Narratica Loader 集合从正式 Bundle patch 推导,不维护第二份 expected 列表', async () => { + const formalLoaderIds = await loadFormalNarraticaLoaderIds() + assert.ok(formalLoaderIds.includes('narratica-client')) + assert.ok(formalLoaderIds.includes('narratica-stories')) + assert.ok(formalLoaderIds.includes('narratica-director-model-policy')) + for (const legacyId of LEGACY_NARRATICA_CLIENT_LOADER_IDS) { + assert.ok(!formalLoaderIds.includes(legacyId)) + } +}) + +test('统一契约同时覆盖 DSH Host、单 Bundle、发行单入口、正式 patch Loader 与旧 Loader 禁止项', async () => { + const formalLoaderIds = await loadFormalNarraticaLoaderIds() + const profile = { + dependencies: { + '@narratica/narratica': '0.1.0-alpha.2', + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + '@narratica/narratica', + ], + }, + }, + } + const dump = [...REQUIRED_DSH_HOST_LOADER_IDS, ...formalLoaderIds] + .map(id => `- id: ${id}`) + .concat(" name: '@narratica/narratica'") + .join('\n') + + await assert.doesNotReject(() => assertNarraticaProfileContract({ profile, dump, distribution: true })) + + const missingHostDump = dump.replace('- id: ui-layout\n', '') + await assert.rejects( + () => assertNarraticaProfileContract({ profile, dump: missingHostDump, distribution: true }), + /ui-layout/, + ) + + const missingFormalLoaderDump = dump.replace('- id: narratica-director-model-policy\n', '') + await assert.rejects( + () => assertNarraticaProfileContract({ profile, dump: missingFormalLoaderDump, distribution: true }), + /narratica-director-model-policy/, + ) + + const legacyDump = `${dump}\n- id: narratica-client-runtime` + await assert.rejects( + () => assertNarraticaProfileContract({ profile, dump: legacyDump, distribution: true }), + /旧 Client Loader/, + ) + + const leakedProfile = { + ...profile, + dependencies: { + ...profile.dependencies, + '@narratica/client-runtime': '0.1.0-alpha.2', + }, + } + await assert.rejects( + () => assertNarraticaProfileContract({ profile: leakedProfile, dump, distribution: true }), + /顶层只能依赖正式入口包/, + ) +}) + +test('CI Workflow 只负责调用统一校验脚本,不再维护 Narratica Loader/Bundle 名单', async () => { + const workflow = await readFile('.github/workflows/ci.yml', 'utf8') + assert.match(workflow, /pnpm run verify:profile-contract/) + assert.match(workflow, /pnpm run verify:mode1-profile/) + assert.doesNotMatch(workflow, /narratica-client-runtime/) + assert.doesNotMatch(workflow, /for plugin in .*narratica-/) + assert.doesNotMatch(workflow, /grep -q .*narratica-/) +}) + +test('发行烟测复用统一 Profile 契约,不再维护独立 Loader 名单', async () => { + const smoke = await readFile('scripts/release/smoke-release.mjs', 'utf8') + assert.match(smoke, /assertNarraticaProfileContract/) + assert.match(smoke, /distribution: true/) + assert.doesNotMatch(smoke, /expectedLoaderIds/) + assert.doesNotMatch(smoke, /legacyClientLoaderIds/) +}) + +test('正式 Bundle patch 与 Profile 契约变化必须触发发行打包预检', async () => { + const workflow = await readFile('.github/workflows/release-pack-check.yml', 'utf8') + for (const path of [ + "scripts/profile-contract.mjs", + "scripts/dsh-baseline.mjs", + "packages/bundle/narratica/cordis.patch.yml", + ]) { + assert.ok(workflow.split(`'${path}'`).length >= 3, `pull_request/push 都必须监听:${path}`) + } +}) + +test('新增 Profile 契约脚本通过 Node 语法检查', () => { + for (const script of [ + 'scripts/profile-contract.mjs', + 'scripts/verify-profile-contract.mjs', + 'scripts/verify-mode1-profile-probe.mjs', + ]) { + const result = spawnSync(process.execPath, ['--check', script], { encoding: 'utf8' }) + assert.equal(result.status, 0, `${script} 语法错误:${result.stderr || result.stdout}`) + } +}) diff --git a/tests/architecture/release-pipeline.test.mjs b/tests/architecture/release-pipeline.test.mjs index 80518fb..017f9d9 100644 --- a/tests/architecture/release-pipeline.test.mjs +++ b/tests/architecture/release-pipeline.test.mjs @@ -138,7 +138,7 @@ test('树外 Bundle 的 Host 插件只从 Profile 解析正式入口,再由入 } }) -test('正式入口自身是唯一 Narratica Client loader,并以六个独立 Cordis Fiber 聚合现有实现', async () => { +test('正式入口自身是唯一 Narratica Client loader,并通过 Cordis Fiber 聚合现有实现', async () => { const entry = JSON.parse(await readFile('packages/bundle/narratica/package.json', 'utf8')) const patch = await readFile('packages/bundle/narratica/cordis.patch.yml', 'utf8') const client = await readFile('packages/bundle/narratica/src/client/entry.ts', 'utf8') @@ -159,15 +159,6 @@ test('正式入口自身是唯一 Narratica Client loader,并以六个独立 C assert.doesNotMatch(patch, new RegExp(`name: '${escapeRegExp(packageName)}'`)) } - for (const sourcePath of [ - 'client/runtime/src/client/entry.js', - 'client/layout/src/client/index.js', - 'client/workspace/src/client/entry.js', - 'client/story-library/src/client/index.js', - 'client/novel/src/client/index.js', - 'client/director/src/client/index.js', - ]) assert.ok(client.includes(sourcePath), `入口 Client 缺少子插件:${sourcePath}`) - assert.match(client, /for \(const plugin of clientPlugins\) await ctx\.plugin\(plugin\)/) assert.match(root.scripts['build:client:bundles'], /packages\/bundle\/narratica @narratica\/narratica/) }) @@ -190,12 +181,11 @@ test('Release 脚本必须把内部依赖锁到同版本,并为全部 tarball assert.match(publish, /入口包必须最后发布/) }) -test('本地 tarball 烟测必须保持真实的一包安装语义,不能用顶层内部依赖掩盖解析问题', async () => { +test('本地 tarball 烟测必须保持真实的一包安装语义,并复用发行态 Profile 契约', async () => { const smoke = await readFile('scripts/release/smoke-release.mjs', 'utf8') assert.match(smoke, /localOverrides/) assert.match(smoke, /add\(resolve\(releaseDir, entry\.tarball\)\)/) - assert.match(smoke, /narraticaDirectDependencies/) - assert.match(smoke, /Profile 顶层只能依赖正式入口包/) + assert.match(smoke, /await assertNarraticaProfileContract\(\{ profile, dump, distribution: true \}\)/) assert.match(smoke, /不得把内部包提升为 Profile 顶层依赖/) assert.doesNotMatch(smoke, /profile\.dependencies\[pkg\.name\]\s*=/) })