diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index 0ae34aebc0..f6ace06890 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.i18n.yaml @@ -9,8 +9,8 @@ en: cc0f91dc4bb82605 zh: 81d5031752c6ea74 /vendored-package-rescope/what-the-rename-does-not-touch: - en: e781d7e1b46c0974 - zh: 9796fb884c25d850 + en: 9bb7d09f8cccf695 + zh: 2a8f3f60ae5116f3 /vendored-package-rescope/what-your-code-has-to-change: en: 50a48dfda6cea731 zh: 55028c5c04270eff diff --git a/docs/rescope.md b/docs/rescope.md index 0fcbdd476a..2e25989742 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -28,7 +28,9 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@ - **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`. - **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`. - **Upstream runtime identifiers**, such as Schemastery's `Symbol.for('schemastery')` and its `vendor:` metadata field. -- **Prose outside `docs/`.** `vendor/*/README.md`, package READMEs, and Agent Notes keep the names they were written with; a bare `cordis` there can also be the Python SDK's option name or an agent-preset id. Inside `docs/`, prose and every Markdown fence follow the rename. +- **Product identifiers.** Files in `GENERIC_SKIPS` keep preset IDs, event names, directory group names, and locale or input-source keys for the listed upstream name. Source imports, exports, module declarations and JSDoc import types still change in both directions, including in JavaScript/TypeScript fences. YAML plugin `name:` values also change. Other string uses of the listed name stay intact; other library names remain checked. +- **Rewrite fixtures.** `scripts/rescope-vendor.spec.ts` and `scripts/gen-third-party-notices.spec.ts` keep both package spellings as test data. +- **Prose outside `docs/`.** `vendor/*/README.md`, package READMEs, and Agent Notes keep the names they were written with; a bare `cordis` there can also be the Python SDK's option name or an agent-preset id. Inside `docs/`, prose and Markdown fences follow the token rule except for the audited identifiers above. ## What your code has to change diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index bd6e3f233b..7b888ede0c 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -28,7 +28,9 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R - **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。 - **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。 - **上游运行时标识符**,例如 Schemastery 的 `Symbol.for('schemastery')` 及其 `vendor:` 元数据字段。 -- **`docs/` 之外的散文。** `vendor/*/README.md`、各包 README 与 Agent Note 保留写作当时的名字;那里的裸 `cordis` 也可能是 Python SDK 的选项名或某个 agent-preset 的 id。`docs/` 之内,散文与所有 Markdown 围栏都跟着改。 +- **产品标识符。** `GENERIC_SKIPS` 列出的文件保留所列上游名称对应的 preset ID、事件名、目录分组名,以及 locale 或输入来源键。同一文件中的 import、export、模块声明和 JSDoc import 类型仍会双向改名,JavaScript/TypeScript 围栏内也如此。YAML 插件的 `name:` 值同样改名。所列名称的其他字符串用法保持不变,其他库名仍受检查。 +- **改名测试夹具。** `scripts/rescope-vendor.spec.ts` 和 `scripts/gen-third-party-notices.spec.ts` 保留两种包名拼写作为测试数据。 +- **`docs/` 之外的散文。** `vendor/*/README.md`、各包 README 与 Agent Note 保留写作当时的名字;那里的裸 `cordis` 也可能是 Python SDK 的选项名或某个 agent-preset 的 id。`docs/` 之内的散文和 Markdown 围栏按 token 规则改名,上述经过核对的标识符除外。 ## 你的代码要改什么 diff --git a/scripts/rescope-vendor.spec.ts b/scripts/rescope-vendor.spec.ts index f05658ce64..18755ce397 100644 --- a/scripts/rescope-vendor.spec.ts +++ b/scripts/rescope-vendor.spec.ts @@ -1,11 +1,18 @@ /** Recorded npm evidence stays intact while authored files and exact edits remain checked. */ +import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { exactEditState, isRescopeExcluded } from './rescope-vendor.ts' +import { exactEditState, isRescopeExcluded, rescopeText } from './rescope-vendor.ts' const ANCHOR = '\n## Sync procedure' const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}` +function expectRoundTrip(file: string, text: string, scoped: string, lines: number): void { + expect(rescopeText(text, file)).toEqual({ text: scoped, lines }) + expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 }) + expect(rescopeText(scoped, file, true)).toEqual({ text, lines }) +} + describe('rescope file selection', () => { it('preserves the recorded npm resolution', () => { expect(isRescopeExcluded('scripts/dependency-catalog/package-lock.json')).toBe(true) @@ -51,3 +58,141 @@ describe('exactEditState', () => { expect(exactEditState('x\n', 'a = 1', 'b = 2', 1)).toBe('invalid') }) }) + +describe('product identifiers', () => { + it.each([ + 'apps/cli/tests/optional-bundle-transitions.e2e.ts', + 'apps/web/tests/agent-preset-selection.e2e.ts', + 'apps/web/tests/developer-tools-settings.e2e.ts', + 'docs/subsystems/schedule.md', + 'docs/subsystems/schedule.zh.md', + 'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.md', + 'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.zh.md', + 'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md', + 'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md', + 'docs/user/guide/schedule.md', + 'docs/user/guide/schedule.zh.md', + 'packages/bundle/web-app/cordis.patch.yml', + 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx', + 'packages/client/ui-agent-preset/src/client/analytics.ts', + 'packages/client/ui-agent-preset/tests/analytics.client.spec.ts', + 'packages/client/ui-agent-preset/tests/components.client.spec.tsx', + 'packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx', + 'packages/client/ui-agent-preset/tests/section-store.client.spec.ts', + 'packages/client/ui-conversation/src/client/input/submission-analytics.ts', + 'packages/client/ui-conversation/tests/service-orchestration.client.spec.ts', + 'packages/client/ui-entry-customization/src/client/index.ts', + 'packages/client/ui-entry-customization/tests/menu.client.spec.tsx', + 'packages/client/ui-workspace/tests/workspaces-service.client.spec.ts', + 'packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts', + 'snapshots/session/cordis-inspect-liveness/client-fixture.mjs', + 'snapshots/session/cordis-inspect-timeout/client-fixture.mjs', + ])('preserves the product values in %s', (file) => { + const text = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8') + expect(text).toMatch(/(['"`])cordis(?:\/inspect-query(?:-resolved)?)?\1/) + expect(isRescopeExcluded(file)).toBe(false) + expect(rescopeText(text, file)).toEqual({ text, lines: 0 }) + }) + + it('still renames other libraries in a preset consumer and reverses them', () => { + const file = 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx' + const text = "const preset = 'cordis'\nimport utils from 'cosmokit'\nimport Schema from 'schemastery'\n" + const scoped = "const preset = 'cordis'\nimport utils from '@deepseek-ai/cosmokit'\nimport Schema from '@deepseek-ai/schemastery'\n" + expect(rescopeText(text, file)).toEqual({ text: scoped, lines: 2 }) + expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 }) + expect(rescopeText(scoped, file, true)).toEqual({ text, lines: 2 }) + }) + + it('keeps unlisted package references subject to residue detection', () => { + const file = 'packages/example/src/index.ts' + const text = "import { Context } from 'cordis'\nimport type Schema from 'schemastery'\n" + const scoped = "import { Context } from '@deepseek-ai/cordis'\nimport type Schema from '@deepseek-ai/schemastery'\n" + expect(rescopeText(text, file)).toEqual({ text: scoped, lines: 2 }) + expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 }) + expect(rescopeText(scoped, file, true)).toEqual({ text, lines: 2 }) + expect(rescopeText("ctx.emit('cordis/inspect-query')", file).text).toBe("ctx.emit('@deepseek-ai/cordis/inspect-query')") + }) + + it.each([ + ['import', "import { Context } from 'cordis'", "import { Context } from '@deepseek-ai/cordis'"], + ['multiline import', "import {\n Context,\n} from 'cordis'", "import {\n Context,\n} from '@deepseek-ai/cordis'"], + ['side-effect import', "import 'cordis'", "import '@deepseek-ai/cordis'"], + ['export', "export { Context } from 'cordis/subpath'", "export { Context } from '@deepseek-ai/cordis/subpath'"], + ['import equals', "import Cordis = require('cordis')", "import Cordis = require('@deepseek-ai/cordis')"], + ['dynamic import', 'const cordis = import(`cordis`)', 'const cordis = import(`@deepseek-ai/cordis`)'], + ['require', 'const cordis = require("cordis/subpath")', 'const cordis = require("@deepseek-ai/cordis/subpath")'], + ['import type', "type Context = import('cordis').Context", "type Context = import('@deepseek-ai/cordis').Context"], + ['module declaration', "declare module 'cordis' {}", "declare module '@deepseek-ai/cordis' {}"], + ['JSDoc type', "/** @param {import('cordis').Context} ctx */\nfunction apply(ctx) {}", "/** @param {import('@deepseek-ai/cordis').Context} ctx */\nfunction apply(ctx) {}"], + ['JSDoc import', "/** @import { Context } from 'cordis' */\nfunction apply(ctx) {}", "/** @import { Context } from '@deepseek-ai/cordis' */\nfunction apply(ctx) {}"], + ['same-line imports', "import 'cordis'; import 'cordis/subpath'", "import '@deepseek-ai/cordis'; import '@deepseek-ai/cordis/subpath'"], + ])('rewrites %s without changing same-file product data', (_label, source, target) => { + const file = 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx' + const product = "const preset = 'cordis'\nctx.emit('cordis/inspect-query')\nconst fixture = \"import 'cordis'\"\n" + const text = `${product}${source}\n` + const scoped = `${product}${target}\n` + expectRoundTrip(file, text, scoped, 1) + }) + + it.each([ + ['apps/cli/tests/optional-bundle-transitions.e2e.ts', 1], + ['packages/client/ui-conversation/src/client/input/submission-analytics.ts', 1], + ['packages/client/ui-conversation/tests/service-orchestration.client.spec.ts', 1], + ['packages/client/ui-entry-customization/src/client/index.ts', 1], + ['packages/client/ui-entry-customization/tests/menu.client.spec.tsx', 1], + ['packages/client/ui-workspace/tests/workspaces-service.client.spec.ts', 1], + ['packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts', 2], + ['snapshots/session/cordis-inspect-liveness/client-fixture.mjs', 1], + ['snapshots/session/cordis-inspect-timeout/client-fixture.mjs', 2], + ] as const)('reverses real package references in %s and restores them', (file, lines) => { + const scoped = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8') + const text = scoped.replaceAll("'@deepseek-ai/cordis'", "'cordis'") + expect(text).not.toBe(scoped) + expectRoundTrip(file, text, scoped, lines) + expect(rescopeText(text, file, true)).toEqual({ text, lines: 0 }) + }) + + it('rewrites multiline fenced imports while preserving prose and fenced product data', () => { + const file = 'docs/subsystems/schedule.md' + const text = "The `cordis` preset.\n```ts\nconst preset = 'cordis'\nimport {\n Context,\n} from 'cordis'\n```\n" + const scoped = "The `cordis` preset.\n```ts\nconst preset = 'cordis'\nimport {\n Context,\n} from '@deepseek-ai/cordis'\n```\n" + expectRoundTrip(file, text, scoped, 1) + }) + + it.each(['cordis', "'cordis'", '"cordis/subpath"'])('rewrites the YAML name %s without changing product comments', (name) => { + const text = `# The 'cordis' preset.\n- name: ${name} # 'cordis' stays product data\n` + const scoped = `# The 'cordis' preset.\n- name: ${name.replace('cordis', '@deepseek-ai/cordis')} # 'cordis' stays product data\n` + for (const [file, wrap] of [ + ['packages/bundle/web-app/cordis.patch.yml', (value: string) => value], + ['docs/subsystems/schedule.md', (value: string) => `The \`cordis\` preset.\n\`\`\`yaml\n${value}\`\`\`\n`], + ] as const) { + expectRoundTrip(file, wrap(text), wrap(scoped), 1) + } + }) + + it.each(['scripts/rescope-vendor.spec.ts', 'scripts/gen-third-party-notices.spec.ts'])('keeps both package spellings in the fixture owner %s', (file) => { + const text = "import 'cordis'\nimport '@deepseek-ai/cordis'\nconst fixture = \"import 'cosmokit'\"\n" + expect(rescopeText(text, file)).toEqual({ text, lines: 0 }) + expect(rescopeText(text, file, true)).toEqual({ text, lines: 0 }) + }) + + it('keeps schema runtime identifiers while rewriting their same-file import', () => { + const file = 'vendor/loader/src/config/diff.ts' + const text = "const key = Symbol.for('schemastery')\nconst metadata = { vendor: 'schemastery' }\nimport Schema from 'schemastery'\n" + const scoped = "const key = Symbol.for('schemastery')\nconst metadata = { vendor: 'schemastery' }\nimport Schema from '@deepseek-ai/schemastery'\n" + expectRoundTrip(file, text, scoped, 1) + }) + + it('rewrites declarations in an older event-name exception', () => { + const file = 'packages/extensions/cordis-host-runner/src/index.ts' + const text = "ctx.emit('cordis/inspect-query')\nimport { Context } from 'cordis'\ndeclare module 'cordis' {}\n" + const scoped = "ctx.emit('cordis/inspect-query')\nimport { Context } from '@deepseek-ai/cordis'\ndeclare module '@deepseek-ai/cordis' {}\n" + expectRoundTrip(file, text, scoped, 2) + }) + + it.each(['```ts\n```\n', "```ts\nimport 'cordis'\n"])('keeps fence line counts without requiring a closing fence', (text) => { + const file = 'docs/subsystems/schedule.md' + const scoped = text.replace("import 'cordis'", "import '@deepseek-ai/cordis'") + expectRoundTrip(file, text, scoped, scoped === text ? 0 : 1) + }) +}) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index f3abe4844d..ac9cd7153e 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -12,9 +12,9 @@ * `cordis.yml`, the Loader's `cordis:` builtin prefix, `cordis-config-entry`, * `@deepseek-ai/dsh-tool-cordis`, and `cordiverse/cordis`, and makes the * rewrite idempotent because the scoped name's `cordis` is preceded by `/`. - * Markdown follows the rename inside every fence, and in `docs/` prose too: - * a tutorial that teaches an unresolvable name is wrong, while prose elsewhere - * records what was true when it was written. + * Markdown follows the token rule in fences and `docs/` prose. Audited product + * names stay intact; their source module references and YAML plugin names still + * follow the rename. Prose elsewhere records what was true when it was written. * * Sites the token rule cannot express (dot-notation access, unquoted object * keys, regex literals, the vendored-manifest table) are listed in @@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import ts from 'typescript' const root = resolve(import.meta.dirname, '..') @@ -66,13 +67,14 @@ interface ExactEdit { } /** - * A file where an upstream name also appears as a vendor DIRECTORY name or an - * upstream runtime identifier: the generic pass is disabled for the listed - * names and {@link EXACT_EDITS} renames the real package-name occurrences. + * A file where an upstream name also identifies runtime metadata, directories, + * presets, events or locale keys. Its module references and YAML plugin names + * still change; fixture owners retain both package spellings throughout. */ interface GenericSkip { readonly file: string readonly upstream: readonly string[] + readonly fixture?: true } const GENERIC_SKIPS: readonly GenericSkip[] = [ @@ -83,8 +85,10 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // Native schema detection and its fixture share the vendored runtime's Symbol.for identifier. { file: 'packages/boot/app-boot/src/config-schema/native.ts', upstream: ['schemastery'] }, { file: 'packages/boot/app-boot/tests/config-schema.spec.ts', upstream: ['schemastery'] }, + // The codemod's positive and negative fixtures contain both package spellings. + { file: 'scripts/rescope-vendor.spec.ts', upstream: RENAMES.map(rename => rename.upstream), fixture: true }, // Asserts the vendored-manifest table, which gains an upstream-name column. - { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) }, + { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream), fixture: true }, // `cordis` is also an agent-preset id, so in these files the bare name is // product data, not a package reference. Renaming it changed which preset // the creator flow stages and which id the roster reports. @@ -98,6 +102,30 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ { file: 'apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs', upstream: ['cordis'] }, { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] }, { file: 'packages/preset/agent-preset-registry/tests/session.spec.ts', upstream: ['cordis'] }, + // Preset selection, reminder policy and UI copy use the same product id. + { file: 'apps/cli/tests/optional-bundle-transitions.e2e.ts', upstream: ['cordis'] }, + { file: 'apps/web/tests/agent-preset-selection.e2e.ts', upstream: ['cordis'] }, + { file: 'apps/web/tests/developer-tools-settings.e2e.ts', upstream: ['cordis'] }, + { file: 'docs/subsystems/schedule.md', upstream: ['cordis'] }, + { file: 'docs/subsystems/schedule.zh.md', upstream: ['cordis'] }, + { file: 'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.md', upstream: ['cordis'] }, + { file: 'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.zh.md', upstream: ['cordis'] }, + { file: 'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md', upstream: ['cordis'] }, + { file: 'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md', upstream: ['cordis'] }, + { file: 'docs/user/guide/schedule.md', upstream: ['cordis'] }, + { file: 'docs/user/guide/schedule.zh.md', upstream: ['cordis'] }, + { file: 'packages/bundle/web-app/cordis.patch.yml', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/src/client/analytics.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/analytics.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/components.client.spec.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/section-store.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-conversation/src/client/input/submission-analytics.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-conversation/tests/service-orchestration.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-entry-customization/src/client/index.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-entry-customization/tests/menu.client.spec.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-workspace/tests/workspaces-service.client.spec.ts', upstream: ['cordis'] }, // The preset-roster loop names the `cordis` preset id, not a package. { file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] }, // GROUP_ORDER holds `packages//` directory names, not package names. @@ -124,6 +152,10 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ { file: 'packages/extensions/tool-cordis/src/providers.ts', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/index.ts', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/inventory.ts', upstream: ['cordis'] }, + // Inspector fixtures use the same extension event domain. + { file: 'packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts', upstream: ['cordis'] }, + { file: 'snapshots/session/cordis-inspect-liveness/client-fixture.mjs', upstream: ['cordis'] }, + { file: 'snapshots/session/cordis-inspect-timeout/client-fixture.mjs', upstream: ['cordis'] }, // `cordis/tree` is an Inspector observation topic, not a package subpath. { file: 'packages/experimental/inspector/src/shared/bridge/messages/cordis.ts', upstream: ['cordis'] }, { file: 'packages/experimental/inspector/tests/cordis-query.host.spec.ts', upstream: ['cordis'] }, @@ -470,7 +502,7 @@ function patterns(reverse: boolean): Pattern[] { .map(rename => ({ ...rename, token: new RegExp(`(['"\`])${escapeRegExp(rename.from)}((?:/[^'"\`\\s]*)?)\\1`, 'g'), - yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)${escapeRegExp(rename.from)}([ \\t]*(?:#.*)?)$`, 'gm'), + yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)(['"]?)${escapeRegExp(rename.from)}((?:/[^\\s'"]*)?)\\2([ \\t]*(?:#.*)?)$`, 'gm'), })) } @@ -478,45 +510,109 @@ function skipped(file: string, pattern: Pattern): boolean { return GENERIC_SKIPS.some(skip => skip.file === file && skip.upstream.includes(pattern.upstream)) } -function rewriteLine(line: string, file: string, all: readonly Pattern[]): string { +function rewriteLine(line: string, file: string, all: readonly Pattern[], yaml = false): string { let out = line for (const pattern of all) { - if (skipped(file, pattern)) continue - out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`) - out = out.replace(pattern.yamlName, (_match, prefix: string, suffix: string) => `${prefix}${pattern.to}${suffix}`) + const preserve = skipped(file, pattern) + if (!preserve) out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`) + if (!preserve || yaml) { + out = out.replace(pattern.yamlName, (_match, prefix: string, quote: string, subpath: string, suffix: string) => `${prefix}${quote}${pattern.to}${subpath}${quote}${suffix}`) + } + } + return out +} + +/** Rewrite only module literals whose other same-file uses are product data. */ +function rewriteModules(text: string, file: string, all: readonly Pattern[], sourceName = file): string { + const skip = GENERIC_SKIPS.find(entry => entry.file === file) + if (!skip || skip.fixture) return text + const active = all.filter(pattern => skip.upstream.includes(pattern.upstream)) + const source = ts.createSourceFile(sourceName, text, ts.ScriptTarget.Latest, true) + const edits = new Map() + const visited = new Set() + const visit = (node: ts.Node): void => { + if (visited.has(node)) return + visited.add(node) + let literal: ts.Node | undefined + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || ts.isJSDocImportTag(node)) { + literal = node.moduleSpecifier + } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { + literal = node.moduleReference.expression + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + literal = node.argument.literal + } else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) { + literal = node.arguments[0] + } else if (ts.isModuleDeclaration(node)) { + literal = node.name + } + if (literal && ts.isStringLiteralLike(literal)) { + const start = literal.getStart(source) + 1 + const end = literal.getEnd() - 1 + const value = text.slice(start, end) + const pattern = active.find(entry => value === entry.from || value.startsWith(`${entry.from}/`)) + if (pattern) edits.set(start, { end, value: pattern.to + value.slice(pattern.from.length) }) + } + ts.forEachChild(node, visit) + for (const doc of ts.getJSDocCommentsAndTags(node)) visit(doc) + } + visit(source) + let out = text + for (const [start, edit] of [...edits].sort(([left], [right]) => right - left)) { + out = out.slice(0, start) + edit.value + out.slice(edit.end) } return out } /** - * Rewrite a file's eligible lines. - * - * Markdown splits in two. Every fence is code a reader copies or a - * configuration they mount, so every fence follows the rename regardless of its - * info string. Prose follows it only under `docs/`, where a sentence quoting - * `` `cordis` `` teaches an unresolved package name; elsewhere - * prose is a record of what was true when it was written, and the same spelling - * can mean something else entirely — the Python SDK's `cordis` option, or the - * unvendored `@cordisjs/plugin-http`. + * Rewrite eligible source, fences and docs prose. Audited names in source and + * TypeScript/JavaScript fences change only in module syntax; YAML names remain + * eligible too. Other names retain the token rule. Prose outside docs is kept. */ function rewrite(text: string, file: string, all: readonly Pattern[]): { text: string; lines: number } { const markdown = file.endsWith('.md') const prose = markdown && file.startsWith('docs/') - let insideFence = false - let lines = 0 - const out = text.split('\n').map((line) => { + const original = text.split('\n') + let source = text + if (!markdown && /\.(ts|tsx|js|mjs|cjs|tpl)$/.test(file)) source = rewriteModules(text, file, all) + let fenceStart: number | undefined + let language = '' + const out = source.split('\n') + const finishFence = (end: number): void => { + if (fenceStart === undefined || fenceStart === end || !/^(?:[cm]?[jt]sx?|typescript|javascript)?$/.test(language)) return + const extension = language === 'jsx' || language === 'tsx' ? 'tsx' : language === 'js' || language === 'javascript' || language === 'mjs' || language === 'cjs' ? 'js' : 'ts' + const body = out.slice(fenceStart, end).join('\n') + out.splice(fenceStart, end - fenceStart, ...rewriteModules(body, file, all, `fence.${extension}`).split('\n')) + } + for (const [index, line] of out.entries()) { if (markdown) { - if (/^\s*```/.test(line)) { - insideFence = !insideFence - return line + const fence = /^\s*```(\S*)/.exec(line) + if (fence) { + if (fenceStart === undefined) { + fenceStart = index + 1 + language = fence[1] ?? '' + } else { + finishFence(index) + fenceStart = undefined + } + continue } - if (!insideFence && !prose) return line + if (fenceStart === undefined && !prose) continue } - const next = rewriteLine(line, file, all) - if (next !== line) lines += 1 - return next - }) - return { text: out.join('\n'), lines } + out[index] = rewriteLine(line, file, all, /\.ya?ml$/.test(file) || (fenceStart !== undefined && (language === 'yaml' || language === 'yml'))) + } + if (fenceStart !== undefined) finishFence(out.length) + return { text: out.join('\n'), lines: out.filter((line, index) => line !== original[index]).length } +} + +/** + * Apply the CLI's generic package rename with the audited name exceptions. + * @param text - Complete source text. + * @param file - Repository-relative path with forward slash separators. + * @param reverse - Restore upstream package names when true. + * @returns Rewritten text and the number of changed lines. + */ +export function rescopeText(text: string, file: string, reverse = false): { text: string; lines: number } { + return rewrite(text, file, patterns(reverse)) } function classify(file: string): string {