fix(tooling): 保留产品标识并修复 vendor 重命名检查 (#6716)

* fix(tooling): 保留产品标识并校验 vendored 包引用

* fix(tooling): preserve module rewrites in product-name exceptions
This commit is contained in:
Magolor authored and GitHub committed 2026-10-21 20:42:35 +08:00
1 parent dd466092de
commit f879017105
5 files changed
+283 -38

No files matched your search

+2 -2
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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 规则改名,上述经过核对的标识符除外。
## 你的代码要改什么
+146 -1
View File
@@ -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)
})
})
+129 -33
View File
@@ -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/<group>/` 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<number, { end: number; value: string }>()
const visited = new Set<ts.Node>()
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 {