rescope-vendor.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771
  1. /**
  2. * Rescope the vendored Cordis packages into the `@deepseek-ai` scope, and undo
  3. * that rescope with `--reverse`. Every harness package declares `cordis` as a
  4. * peer dependency, so publication carries this framework layer too; publishing
  5. * it under the upstream names would squat them on the registry
  6. * ([rationale](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md),
  7. * [name mapping](../docs/rescope.md)).
  8. *
  9. * The generic pass rewrites ONLY delimited, complete package-name tokens:
  10. * `'old'` / `"old"` / `` `old` `` / `'old/subpath'`, plus a YAML `name: old`
  11. * scalar. A match needs a quote (or `name: `) immediately left and the matching
  12. * quote — optionally after a `/subpath` — immediately right, which excludes
  13. * `cordis.yml`, the Loader's `cordis:` builtin prefix, `cordis-config-entry`,
  14. * `@deepseek-ai/dsh-tool-cordis`, and `cordiverse/cordis`, and makes the
  15. * rewrite idempotent because the scoped name's `cordis` is preceded by `/`.
  16. * Markdown follows the rename inside every fence, and in `docs/` prose too:
  17. * a tutorial that teaches an unresolvable name is wrong, while prose elsewhere
  18. * records what was true when it was written.
  19. *
  20. * Sites the token rule cannot express (dot-notation access, unquoted object
  21. * keys, regex literals, the vendored-manifest table) are listed in
  22. * {@link EXACT_EDITS} with an exact hit count, so an upstream change to one of
  23. * them fails loudly instead of being silently skipped.
  24. *
  25. * Usage: `pnpm run rescope-vendor [--apply|--check] [--reverse]`. Without a
  26. * mode it reports what would change. `--check` asserts the post-state: no
  27. * residue, every exact edit landed, every postcondition holds, and a second
  28. * `--apply` would be a no-op.
  29. */
  30. import { execFileSync } from 'node:child_process'
  31. import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
  32. import { resolve } from 'node:path'
  33. import { fileURLToPath } from 'node:url'
  34. const root = resolve(import.meta.dirname, '..')
  35. /** One vendored package's directory, upstream npm name, and rescoped name. */
  36. interface Rename {
  37. readonly directory: string
  38. readonly upstream: string
  39. readonly scoped: string
  40. }
  41. /** The mapping this codemod applies; `vendor/README.md` carries the same table. */
  42. const RENAMES: readonly Rename[] = [
  43. { directory: 'cordis', upstream: 'cordis', scoped: '@deepseek-ai/cordis' },
  44. { directory: 'cosmokit', upstream: 'cosmokit', scoped: '@deepseek-ai/cosmokit' },
  45. { directory: 'schemastery', upstream: 'schemastery', scoped: '@deepseek-ai/schemastery' },
  46. { directory: 'loader', upstream: '@cordisjs/plugin-loader', scoped: '@deepseek-ai/cordis-plugin-loader' },
  47. { directory: 'include', upstream: '@cordisjs/plugin-include', scoped: '@deepseek-ai/cordis-plugin-include' },
  48. { directory: 'group', upstream: '@cordisjs/plugin-group', scoped: '@deepseek-ai/cordis-plugin-group' },
  49. { directory: 'timer', upstream: '@cordisjs/plugin-timer', scoped: '@deepseek-ai/cordis-plugin-timer' },
  50. { directory: 'hmr', upstream: '@cordisjs/plugin-hmr', scoped: '@deepseek-ai/cordis-plugin-hmr' },
  51. { directory: 'logger-console', upstream: '@cordisjs/plugin-logger-console', scoped: '@deepseek-ai/cordis-plugin-logger-console' },
  52. ]
  53. const EXTENSIONS = ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.tpl', '.json', '.yml', '.yaml', '.md'] as const
  54. /** An exact-string edit the token rule cannot express, with its required hit count. */
  55. interface ExactEdit {
  56. readonly id: string
  57. readonly file: string
  58. readonly find: string
  59. readonly replace: string
  60. readonly expect: number
  61. }
  62. /**
  63. * A file where an upstream name also appears as a vendor DIRECTORY name or an
  64. * upstream runtime identifier: the generic pass is disabled for the listed
  65. * names and {@link EXACT_EDITS} renames the real package-name occurrences.
  66. */
  67. interface GenericSkip {
  68. readonly file: string
  69. readonly upstream: readonly string[]
  70. }
  71. const GENERIC_SKIPS: readonly GenericSkip[] = [
  72. // `vendorPackages` lists vendor/ directory names, joined with 'vendor' below it.
  73. { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', upstream: ['cordis', 'cosmokit', 'schemastery'] },
  74. // Mixes join(root, 'vendor', 'cordis') paths with real manifest names.
  75. { file: 'packages/scaffold/helper/tests/documents.spec.ts', upstream: ['cordis'] },
  76. // `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers.
  77. { file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] },
  78. // Asserts the vendored-manifest table, which gains an upstream-name column.
  79. { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) },
  80. // `cordis` is also an agent-preset id — the directory name under
  81. // apps/cli/config/agent-presets/ — so in these files the bare name is
  82. // product data, not a package reference. Renaming it changed which preset
  83. // the creator flow stages and which id the roster reports.
  84. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] },
  85. { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] },
  86. { file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', upstream: ['cordis'] },
  87. { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', upstream: ['cordis'] },
  88. { file: 'packages/client/ui-agent-preset/tests/section.spec.tsx', upstream: ['cordis'] },
  89. { file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] },
  90. { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] },
  91. { file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] },
  92. // The preset's own composition: its header comment and its system prompt name
  93. // the preset a model mounts, so the scoped name would send the model after an
  94. // id no roster reports.
  95. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] },
  96. // GROUP_ORDER holds `packages/<group>/` directory names, not package names.
  97. { file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] },
  98. { file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] },
  99. ]
  100. /** A string that must appear exactly `count` times once the rescope has run. */
  101. interface PostCondition {
  102. readonly file: string
  103. readonly text: string
  104. readonly count: number
  105. }
  106. const POSTCONDITIONS: readonly PostCondition[] = [
  107. { file: 'vendor/cordis/package.json', text: '"name": "@deepseek-ai/cordis"', count: 1 },
  108. { file: 'vendor/hmr/package.json', text: '"name": "@deepseek-ai/cordis-plugin-hmr"', count: 1 },
  109. { file: 'scripts/cordis-walk.ts', text: '@deepseek-ai\\/cordis', count: 1 },
  110. { file: 'scripts/cordis-walk.ts', text: '!== \'@deepseek-ai/cordis\'', count: 1 },
  111. { file: 'scripts/gen-scoped-events.ts', text: '=== \'@deepseek-ai/cordis\'', count: 1 },
  112. { file: 'packages/typert/generator/src/analyzer.ts', text: '!== \'@deepseek-ai/cordis\'', count: 2 },
  113. { file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 },
  114. { file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts', text: '\'@deepseek-ai/cordis\': \'^4.0.0-rc.7\'', count: 1 },
  115. { file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts', text: '\'@deepseek-ai/cordis\': cordisSpec', count: 2 },
  116. { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 },
  117. { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 },
  118. // One insertion, once: a duplicated log entry is what a non-idempotent apply produced.
  119. { file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 },
  120. { file: 'knip.json', text: '@cordisjs', count: 0 },
  121. { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 },
  122. // The preset ids in this table are product data, not package names.
  123. { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 },
  124. // The preset id the shipped composition documents to its own model.
  125. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 },
  126. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 },
  127. // The vendor-directory paths in these fixtures must survive the rename.
  128. { file: 'packages/scaffold/helper/tests/documents.spec.ts', text: 'join(root, \'vendor\', \'cordis\')', count: 2 },
  129. { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', text: '\'cordis\', \'loader\', \'include\', \'timer\', \'hmr\', \'logger-console\',', count: 1 },
  130. ]
  131. /**
  132. * Every exact edit, in application order. Each `find` is written against the
  133. * PRE-rename text because these run before the generic pass, so no `find` may
  134. * quote a neighbouring line the generic pass would rewrite.
  135. */
  136. const EXACT_EDITS: readonly ExactEdit[] = [
  137. {
  138. id: 'cordis-walk-merge-head',
  139. file: 'scripts/cordis-walk.ts',
  140. find: 'const MERGE_HEAD = /declare module [\'"](?:cordis|\\.\\/context\\.ts)[\'"]/',
  141. replace: 'const MERGE_HEAD = /declare module [\'"](?:@deepseek-ai\\/cordis|\\.\\/context\\.ts)[\'"]/',
  142. expect: 1,
  143. },
  144. {
  145. id: 'constraints-manifest-lookup',
  146. file: 'scripts/check-workspace-constraints.ts',
  147. find: ` const peer = manifest.peerDependencies?.cordis
  148. const dev = manifest.devDependencies?.cordis
  149. if (!peer) errors.push(\`\${label}: cordis must be a peerDependency\`)
  150. if (!dev) errors.push(\`\${label}: cordis must also be a devDependency\`)
  151. if (peer && dev && peer !== dev) {
  152. errors.push(\`\${label}: cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`,
  153. replace: ` const peer = manifest.peerDependencies?.['@deepseek-ai/cordis']
  154. const dev = manifest.devDependencies?.['@deepseek-ai/cordis']
  155. if (!peer) errors.push(\`\${label}: @deepseek-ai/cordis must be a peerDependency\`)
  156. if (!dev) errors.push(\`\${label}: @deepseek-ai/cordis must also be a devDependency\`)
  157. if (peer && dev && peer !== dev) {
  158. errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`,
  159. expect: 1,
  160. },
  161. {
  162. id: 'scaffold-dependency-policy',
  163. file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts',
  164. find: ' cordis: \'^4.0.0-rc.7\',',
  165. replace: ' \'@deepseek-ai/cordis\': \'^4.0.0-rc.7\',',
  166. expect: 1,
  167. },
  168. {
  169. id: 'scaffold-plugin-blueprint',
  170. file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts',
  171. find: ` cordis: cordisSpec,
  172. },
  173. devDependencies: {
  174. cordis: cordisSpec,
  175. },`,
  176. replace: ` '@deepseek-ai/cordis': cordisSpec,
  177. },
  178. devDependencies: {
  179. '@deepseek-ai/cordis': cordisSpec,
  180. },`,
  181. expect: 1,
  182. },
  183. {
  184. id: 'scaffold-link-workspace-lookup',
  185. file: 'packages/scaffold/create-sdk/tests/link-workspace.e2e.ts',
  186. find: 'manifest.dependencies.cordis',
  187. replace: 'manifest.dependencies[\'@deepseek-ai/cordis\']',
  188. expect: 1,
  189. },
  190. {
  191. id: 'documents-spec-manifest-name',
  192. file: 'packages/scaffold/helper/tests/documents.spec.ts',
  193. find: 'JSON.stringify({ name: \'cordis\' })',
  194. replace: 'JSON.stringify({ name: \'@deepseek-ai/cordis\' })',
  195. expect: 1,
  196. },
  197. {
  198. id: 'documents-spec-peer-key',
  199. file: 'packages/scaffold/helper/tests/documents.spec.ts',
  200. find: 'peerDependencies: { cordis: \'^4\' },',
  201. replace: 'peerDependencies: { \'@deepseek-ai/cordis\': \'^4\' },',
  202. expect: 1,
  203. },
  204. {
  205. id: 'documents-spec-closure-order',
  206. file: 'packages/scaffold/helper/tests/documents.spec.ts',
  207. find: ' \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\', \'cordis\',',
  208. replace: ' \'@deepseek-ai/cordis\', \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\',',
  209. expect: 1,
  210. },
  211. {
  212. id: 'documents-spec-lookups',
  213. file: 'packages/scaffold/helper/tests/documents.spec.ts',
  214. find: ` expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/)
  215. expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false')
  216. expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis'))
  217. expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')`,
  218. replace: ` expect(manifest.npmDependency('@deepseek-ai/cordis')?.spec).toMatch(/^link:/)
  219. expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false')
  220. expect(workspace.packageDirectory('@deepseek-ai/cordis')).toBe(join(root, 'vendor', 'cordis'))
  221. expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis')`,
  222. expect: 1,
  223. },
  224. {
  225. id: 'documents-spec-policy-lookup',
  226. file: 'packages/scaffold/helper/tests/documents.spec.ts',
  227. find: ' expect(resolveNpmDependency(\'cordis\', \'devDependencies\', \'0.0.1\')).toEqual({',
  228. replace: ' expect(resolveNpmDependency(\'@deepseek-ai/cordis\', \'devDependencies\', \'0.0.1\')).toEqual({',
  229. expect: 1,
  230. },
  231. {
  232. // The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it.
  233. id: 'knip-logger-console',
  234. file: 'knip.json',
  235. find: ` "ignoreDependencies": [
  236. "@cordisjs/plugin-logger-console",
  237. "@deepseek-ai/.+"
  238. ]
  239. },
  240. "packages/util/home": {`,
  241. replace: ` "ignoreDependencies": [
  242. "@deepseek-ai/.+"
  243. ]
  244. },
  245. "packages/util/home": {`,
  246. expect: 1,
  247. },
  248. {
  249. id: 'knip-bundle-base',
  250. file: 'knip.json',
  251. find: ` "packages/bundle/base": {
  252. "ignoreDependencies": [
  253. "@deepseek-ai/.+",
  254. "@cordisjs/.+"
  255. ]`,
  256. replace: ` "packages/bundle/base": {
  257. "ignoreDependencies": [
  258. "@deepseek-ai/.+"
  259. ]`,
  260. expect: 1,
  261. },
  262. {
  263. // Rescoped packages are never fetched from a registry, so the exclusion is dead config.
  264. id: 'pnpm-release-age',
  265. file: 'pnpm-workspace.yaml',
  266. find: `minimumReleaseAgeExclude:
  267. # Cordis release candidates are source-vendored and pinned in vendor/README.md
  268. # during the same-day sync that updates package manifests and the lockfile.
  269. - '@cordisjs/plugin-loader@1.0.0-rc.5'
  270. - cordis@4.0.0-rc.7
  271. `,
  272. replace: 'minimumReleaseAgeExclude:\n',
  273. expect: 1,
  274. },
  275. {
  276. id: 'publication-set-scope-assertion',
  277. file: 'scripts/publish-npm-baseline.ts',
  278. find: ' if (!isVendored && !name.startsWith(\'@deepseek-ai/\')) {',
  279. replace: ` // Vendored packages are rescoped too (vendor/README.md), so publication
  280. // never carries an upstream name that would squat it on the registry.
  281. if (!name.startsWith('@deepseek-ai/')) {`,
  282. expect: 1,
  283. },
  284. {
  285. id: 'vendor-readme-preamble',
  286. file: 'vendor/README.md',
  287. find: 'All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names.',
  288. replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.',
  289. expect: 1,
  290. },
  291. {
  292. id: 'vendor-readme-schemastery-note',
  293. file: 'vendor/README.md',
  294. find: 'whose lazy `require(\'cosmokit\')` can race',
  295. replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race',
  296. expect: 1,
  297. },
  298. {
  299. id: 'vendor-readme-table-head',
  300. file: 'vendor/README.md',
  301. find: '| Directory | npm name | Version | Upstream repo | Commit |\n|---|---|---|---|---|',
  302. replace: '| Directory | npm name | Upstream name | Version | Upstream repo | Commit |\n|---|---|---|---|---|---|',
  303. expect: 1,
  304. },
  305. {
  306. id: 'vendor-readme-local-modification-log',
  307. file: 'vendor/README.md',
  308. find: '\n## Sync procedure',
  309. replace: '17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure',
  310. expect: 1,
  311. },
  312. {
  313. // A plain fence listing the bundle's mounted tree: a bare token, no quotes.
  314. id: 'agent-spine-demo-mounted-tree',
  315. file: 'packages/examples/agent-spine-demo/README.md',
  316. find: '@cordisjs/plugin-timer timer service',
  317. replace: '@deepseek-ai/cordis-plugin-timer timer service',
  318. expect: 1,
  319. },
  320. {
  321. id: 'agent-spine-demo-mounted-tree-zh',
  322. file: 'packages/examples/agent-spine-demo/README.zh.md',
  323. find: '@cordisjs/plugin-timer timer service',
  324. replace: '@deepseek-ai/cordis-plugin-timer timer service',
  325. expect: 1,
  326. },
  327. {
  328. // The root contract claimed vendored packages keep their upstream names.
  329. id: 'root-agents-vendored-name-contract',
  330. file: 'AGENTS.md',
  331. find: 'vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.',
  332. replace: 'vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package.',
  333. expect: 1,
  334. },
  335. {
  336. // The client purity gate reads `@deepseek-ai/` as "another plugin package".
  337. // The rescope moves the vendored framework and its libraries into that
  338. // namespace, where the gate would reject the library imports client
  339. // bundles have always inlined, so it needs their names.
  340. id: 'client-purity-vendored-libraries',
  341. file: 'packages/client/tsdown.client.ts',
  342. find: '/** Generated descriptor/codec contribution with no shared runtime identity. */',
  343. replace: `/**
  344. * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
  345. * would read them as plugin packages. They carry no cross-plugin runtime
  346. * identity to share — the framework itself is a platform module (external),
  347. * while these are ordinary libraries a browser bundle inlines.
  348. */
  349. const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
  350. /** Generated descriptor/codec contribution with no shared runtime identity. */`,
  351. expect: 1,
  352. },
  353. {
  354. id: 'client-purity-vendored-libraries-predicate',
  355. file: 'packages/client/tsdown.client.ts',
  356. find: ' if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point',
  357. replace: ` if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
  358. if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point`,
  359. expect: 1,
  360. },
  361. {
  362. // The step-1 file tree told the reader to keep the upstream name, one
  363. // paragraph above the invariant that says to rescope it.
  364. id: 'vendoring-cookbook-tree-comment',
  365. file: 'docs/cookbook/adding-a-vendored-package.md',
  366. find: ' package.json # from upstream; set "private": true, keep name/exports/type',
  367. replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type',
  368. expect: 1,
  369. },
  370. {
  371. id: 'vendoring-cookbook-tree-comment-zh',
  372. file: 'docs/cookbook/adding-a-vendored-package.zh.md',
  373. find: ' package.json # from upstream; set "private": true, keep name/exports/type',
  374. replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type',
  375. expect: 1,
  376. },
  377. {
  378. // The checklist told the next vendoring to keep upstream's name.
  379. id: 'vendoring-cookbook-name-invariant',
  380. file: 'docs/cookbook/adding-a-vendored-package.md',
  381. find: "keep upstream's `name`/`version`/`exports`/`type`",
  382. replace: "rescope the `name` ([mapping](../rescope.md)) while keeping upstream's `version`/`exports`/`type`",
  383. expect: 1,
  384. },
  385. {
  386. id: 'vendoring-cookbook-name-invariant-zh',
  387. file: 'docs/cookbook/adding-a-vendored-package.zh.md',
  388. find: '保留上游的 `name`/`version`/`exports`/`type`',
  389. replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`',
  390. expect: 1,
  391. },
  392. {
  393. // The real package references in files whose other `cordis` strings are preset ids.
  394. id: 'agent-preset-spec-framework-import',
  395. file: 'packages/client/ui-agent-preset/tests/apply.spec.ts',
  396. find: "import { Context } from 'cordis'",
  397. replace: "import { Context } from '@deepseek-ai/cordis'",
  398. expect: 1,
  399. },
  400. {
  401. id: 'web-agent-presets-e2e-framework-import',
  402. file: 'apps/cli/tests/web-agent-presets.e2e.ts',
  403. find: "import { Context } from 'cordis'",
  404. replace: "import { Context } from '@deepseek-ai/cordis'",
  405. expect: 1,
  406. },
  407. {
  408. id: 'notices-vendored-row-type',
  409. file: 'scripts/gen-third-party-notices.ts',
  410. find: `export interface VendoredRow {
  411. npmName: string
  412. upstream: string
  413. }`,
  414. replace: `export interface VendoredRow {
  415. npmName: string
  416. /** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */
  417. upstreamName: string
  418. upstream: string
  419. }`,
  420. expect: 1,
  421. },
  422. {
  423. id: 'notices-vendored-row-parse',
  424. file: 'scripts/gen-third-party-notices.ts',
  425. find: ` const match = /^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| (https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$/.exec(line)
  426. if (match === null) continue
  427. const [, npmName, upstream] = match
  428. if (npmName === undefined || upstream === undefined) continue
  429. rows.push({ npmName, upstream })`,
  430. replace: ` const match = new RegExp(String.raw\`^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| \`
  431. + String.raw\`(https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$\`).exec(line)
  432. if (match === null) continue
  433. const [, npmName, upstreamName, upstream] = match
  434. if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue
  435. rows.push({ npmName, upstreamName, upstream })`,
  436. expect: 1,
  437. },
  438. {
  439. id: 'notices-vendored-section',
  440. file: 'scripts/gen-third-party-notices.ts',
  441. find: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed',
  442. replace: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \\`@deepseek-ai\\` scope. All are MIT-licensed',
  443. expect: 1,
  444. },
  445. {
  446. id: 'notices-vendored-table',
  447. file: 'scripts/gen-third-party-notices.ts',
  448. find: `| Package | Upstream | License |
  449. | --- | --- | --- |
  450. \${vendored.map(row => \`| \\\`\${row.npmName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`,
  451. replace: `| Package | Upstream name | Upstream | License |
  452. | --- | --- | --- | --- |
  453. \${vendored.map(row => \`| \\\`\${row.npmName}\\\` | \\\`\${row.upstreamName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`,
  454. expect: 1,
  455. },
  456. {
  457. id: 'notices-spec-row-fixture',
  458. file: 'scripts/gen-third-party-notices.spec.ts',
  459. find: ' expect(rows).toContainEqual({ npmName: \'cordis\', upstream: \'https://github.com/cordiverse/cordis\' })',
  460. replace: ` expect(rows).toContainEqual({
  461. npmName: '@deepseek-ai/cordis',
  462. upstreamName: 'cordis',
  463. upstream: 'https://github.com/cordiverse/cordis',
  464. })`,
  465. expect: 1,
  466. },
  467. {
  468. id: 'notices-spec-shape-fixture',
  469. file: 'scripts/gen-third-party-notices.spec.ts',
  470. find: 'parseVendoredRows(\'| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')',
  471. replace: 'parseVendoredRows(\'| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')',
  472. expect: 1,
  473. },
  474. {
  475. // The framework peer is no longer a registry name, so the rehearsal must install this
  476. // repository's vendored copies; cosmokit comes along as cordis's own dependency.
  477. id: 'packed-install-vendored-peer',
  478. file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
  479. find: ` 'packages/support/invariants',
  480. ]`,
  481. replace: ` 'packages/support/invariants',
  482. // The framework and the vendored packages the closure declares outright:
  483. // rescoped into @deepseek-ai, so the consumer installs this repository's
  484. // copies. Schemastery is a hard dependency of three members above, not a
  485. // peer, so npm resolves it while installing them.
  486. 'vendor/cordis',
  487. 'vendor/cosmokit',
  488. 'vendor/schemastery',
  489. ]`,
  490. expect: 1,
  491. },
  492. {
  493. id: 'packed-install-registry-spec',
  494. file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
  495. find: ` // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional
  496. // dependencies because the launcher selects its OS/CPU package through one.
  497. writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
  498. const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], {`,
  499. replace: ` // Peer ranges resolve to the tarballs, the framework peer included. Do not omit optional
  500. // dependencies because the launcher selects its OS/CPU package through one.
  501. writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
  502. const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], {`,
  503. expect: 1,
  504. },
  505. {
  506. id: 'packed-install-module-doc',
  507. file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
  508. find: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current
  509. * repository's Landlock entry/platform packages, then installs those exact tarballs in an external
  510. * plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy,
  511. * tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost
  512. * executable modes.`,
  513. replace: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework
  514. * peer, and the current repository's Landlock entry/platform packages, then installs those exact
  515. * tarballs in an external plain-Node consumer. The host launcher comes from the exact local tarballs,
  516. * so no registry copy, tsx, path mapping, or workspace resolution can hide missing files, dependency
  517. * errors, or lost executable modes.`,
  518. expect: 1,
  519. },
  520. // The manifest table's name column plus the new upstream-name column, one edit per row.
  521. ...RENAMES.map(rename => ({
  522. id: `vendor-readme-row-${rename.directory}`,
  523. file: 'vendor/README.md',
  524. find: `| \`${rename.directory}/\` | \`${rename.upstream}\` | `,
  525. replace: `| \`${rename.directory}/\` | \`${rename.scoped}\` | \`${rename.upstream}\` | `,
  526. expect: 1,
  527. })),
  528. ]
  529. /** Files the rescope must never rewrite. */
  530. function excluded(file: string): boolean {
  531. if (file === 'scripts/rescope-vendor.ts') return true // the mapping itself
  532. if (file.startsWith('.agents/notes/')) return true // notes record what was true when written
  533. // Recorded model payloads quote documentation verbatim, so they must mirror the
  534. // sources on disk — including the notes this rescope leaves alone.
  535. if (file.startsWith('scripts/snapshots/')) return true
  536. // The mapping documents state both names on purpose.
  537. if (file === 'docs/rescope.md' || file === 'docs/rescope.zh.md') return true
  538. if (file.endsWith('.i18n.yaml')) return true // blob-hash records, re-recorded by the pairing gate
  539. if (file === 'pnpm-lock.yaml') return true // regenerated by pnpm install
  540. if (/^vendor\/[^/]+\/(README\.md|LICENSE)$/.test(file)) return true // upstream files kept verbatim
  541. return !EXTENSIONS.some(extension => file.endsWith(extension))
  542. }
  543. function escapeRegExp(value: string): string {
  544. return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  545. }
  546. /** One name's rewrite, precompiled for both delimited forms. */
  547. interface Pattern {
  548. readonly upstream: string
  549. readonly from: string
  550. readonly to: string
  551. readonly token: RegExp
  552. readonly yamlName: RegExp
  553. }
  554. function patterns(reverse: boolean): Pattern[] {
  555. return RENAMES
  556. .map(rename => ({
  557. upstream: rename.upstream,
  558. from: reverse ? rename.scoped : rename.upstream,
  559. to: reverse ? rename.upstream : rename.scoped,
  560. }))
  561. .sort((left, right) => right.from.length - left.from.length)
  562. .map(rename => ({
  563. ...rename,
  564. token: new RegExp(`(['"\`])${escapeRegExp(rename.from)}((?:/[^'"\`\\s]*)?)\\1`, 'g'),
  565. yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)${escapeRegExp(rename.from)}([ \\t]*(?:#.*)?)$`, 'gm'),
  566. }))
  567. }
  568. function skipped(file: string, pattern: Pattern): boolean {
  569. return GENERIC_SKIPS.some(skip => skip.file === file && skip.upstream.includes(pattern.upstream))
  570. }
  571. function rewriteLine(line: string, file: string, all: readonly Pattern[]): string {
  572. let out = line
  573. for (const pattern of all) {
  574. if (skipped(file, pattern)) continue
  575. out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`)
  576. out = out.replace(pattern.yamlName, (_match, prefix: string, suffix: string) => `${prefix}${pattern.to}${suffix}`)
  577. }
  578. return out
  579. }
  580. /**
  581. * Rewrite a file's eligible lines.
  582. *
  583. * Markdown splits in two. Every fence is code a reader copies or a
  584. * configuration they mount, so every fence follows the rename regardless of its
  585. * info string. Prose follows it only under `docs/`, where a sentence quoting
  586. * `` `cordis` `` teaches a name this repository no longer resolves; elsewhere
  587. * prose is a record of what was true when it was written, and the same spelling
  588. * can mean something else entirely — the Python SDK's `cordis` option, or the
  589. * unvendored `@cordisjs/plugin-http`.
  590. */
  591. function rewrite(text: string, file: string, all: readonly Pattern[]): { text: string; lines: number } {
  592. const markdown = file.endsWith('.md')
  593. const prose = markdown && file.startsWith('docs/')
  594. let insideFence = false
  595. let lines = 0
  596. const out = text.split('\n').map((line) => {
  597. if (markdown) {
  598. if (/^\s*```/.test(line)) {
  599. insideFence = !insideFence
  600. return line
  601. }
  602. if (!insideFence && !prose) return line
  603. }
  604. const next = rewriteLine(line, file, all)
  605. if (next !== line) lines += 1
  606. return next
  607. })
  608. return { text: out.join('\n'), lines }
  609. }
  610. function classify(file: string): string {
  611. if (/^vendor\/[^/]+\/package\.json$/.test(file)) return 'vendor manifest name'
  612. if (file.endsWith('package.json')) return 'package.json dependencies'
  613. if (/\.(ts|tsx|js|mjs|cjs|tpl)$/.test(file)) return 'code specifiers'
  614. if (/\.(yml|yaml)$/.test(file)) return 'YAML plugin names'
  615. if (file.endsWith('.json')) return 'JSON configuration'
  616. return 'Markdown fences and docs prose'
  617. }
  618. /**
  619. * One exact edit's state in the text it targets. `pending` means the source
  620. * form is present and the target form absent; `applied` means the reverse;
  621. * anything else — a partial application, a moved site, or a DUPLICATED
  622. * insertion — is `invalid`, so it fails the run instead of being applied again.
  623. */
  624. export type ExactEditState = 'pending' | 'applied' | 'invalid'
  625. /**
  626. * Classify one exact edit against its target text.
  627. *
  628. * An insertion keeps its anchor (`replace` contains `find`) and a deletion
  629. * keeps its remainder (`find` contains `replace`), so neither can be judged by
  630. * the source form alone: the surviving side counts the target form instead.
  631. * @param text - the complete current text of the edited file.
  632. * @param find - the source form, already oriented for the running direction.
  633. * @param replace - the target form, already oriented for the running direction.
  634. * @param expect - how many occurrences one complete application produces.
  635. * @returns Whether the edit is pending, already applied, or invalid.
  636. */
  637. export function exactEditState(text: string, find: string, replace: string, expect: number): ExactEditState {
  638. const hits = text.split(find).length - 1
  639. const landed = text.split(replace).length - 1
  640. if (replace.includes(find)) {
  641. if (landed === expect) return 'applied'
  642. return landed === 0 && hits === expect ? 'pending' : 'invalid'
  643. }
  644. if (find.includes(replace)) {
  645. if (hits === 0) return landed === expect ? 'applied' : 'invalid'
  646. return hits === expect ? 'pending' : 'invalid'
  647. }
  648. if (hits === 0 && landed === expect) return 'applied'
  649. return hits === expect && landed === 0 ? 'pending' : 'invalid'
  650. }
  651. function main(): void {
  652. const args = process.argv.slice(2)
  653. const mode = args.includes('--apply') ? 'apply' : args.includes('--check') ? 'check' : 'dry'
  654. const reverse = args.includes('--reverse')
  655. const all = patterns(reverse)
  656. const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
  657. .split('\0')
  658. .filter(file => file !== '' && !excluded(file))
  659. const counts = new Map<string, { files: number; lines: number }>()
  660. const failures: string[] = []
  661. const outstanding: string[] = []
  662. // Classify every exact edit before writing anything: a single invalid site
  663. // means the mapping and the tree disagree, and a half-applied tree is worse
  664. // than an untouched one.
  665. const planned: { edit: ExactEdit; path: string; find: string; replace: string }[] = []
  666. for (const edit of EXACT_EDITS) {
  667. const path = resolve(root, edit.file)
  668. const before = readFileSync(path, 'utf8')
  669. const find = reverse ? edit.replace : edit.find
  670. const replace = reverse ? edit.find : edit.replace
  671. const state = exactEditState(before, find, replace, edit.expect)
  672. if (state === 'invalid') {
  673. failures.push(`exact edit ${edit.id}: ${edit.file} is neither pending nor cleanly applied (duplicated, partial, or moved)`)
  674. continue
  675. }
  676. if (mode === 'check') {
  677. if (state !== 'applied') failures.push(`exact edit ${edit.id} did not land in ${edit.file}`)
  678. continue
  679. }
  680. if (state === 'pending') planned.push({ edit, path, find, replace })
  681. }
  682. if (failures.length > 0) {
  683. for (const failure of failures) console.error(`rescope-vendor: ${failure}`)
  684. console.error(`rescope-vendor: ${String(failures.length)} problem(s); nothing was written.`)
  685. process.exitCode = 1
  686. return
  687. }
  688. if (mode === 'apply') {
  689. // Re-read per edit: two edits can target one file, and a stale snapshot
  690. // would let the second write discard the first.
  691. for (const { path, find, replace } of planned) {
  692. writeFileSync(path, readFileSync(path, 'utf8').split(find).join(replace))
  693. }
  694. }
  695. for (const file of files) {
  696. const path = resolve(root, file)
  697. const before = readFileSync(path, 'utf8')
  698. const { text: after, lines } = rewrite(before, file, all)
  699. if (after === before) continue
  700. outstanding.push(file)
  701. const kind = classify(file)
  702. const current = counts.get(kind) ?? { files: 0, lines: 0 }
  703. counts.set(kind, { files: current.files + 1, lines: current.lines + lines })
  704. if (mode === 'apply') writeFileSync(path, after)
  705. }
  706. console.log(`rescope-vendor: ${mode}${reverse ? ' --reverse' : ''} over ${String(files.length)} tracked files`)
  707. for (const kind of [...counts.keys()].sort()) {
  708. const { files: count, lines } = counts.get(kind) ?? { files: 0, lines: 0 }
  709. console.log(` ${kind.padEnd(24)} ${String(count).padStart(4)} file(s), ${String(lines)} line(s)`)
  710. }
  711. if (mode !== 'dry') {
  712. for (const check of POSTCONDITIONS) {
  713. if (reverse) break
  714. const path = resolve(root, check.file)
  715. const hits = existsSync(path) ? readFileSync(path, 'utf8').split(check.text).length - 1 : -1
  716. if (hits !== check.count) {
  717. failures.push(`postcondition: ${check.file} has ${String(hits)} occurrence(s) of ${JSON.stringify(check.text)}, expected ${String(check.count)}`)
  718. }
  719. }
  720. // The generic pass above already told us which files would still change,
  721. // which in check mode is exactly the residue-and-idempotency signal.
  722. if (mode === 'check') {
  723. for (const file of outstanding) failures.push(`residue: ${file} still carries a pre-rescope name token`)
  724. }
  725. }
  726. if (failures.length > 0) {
  727. for (const failure of failures) console.error(`rescope-vendor: ${failure}`)
  728. console.error(`rescope-vendor: ${String(failures.length)} problem(s); the mapping or an upstream site moved.`)
  729. process.exitCode = 1
  730. } else if (mode === 'check') {
  731. console.log('rescope-vendor: post-state verified — no residue, every exact edit landed, idempotent.')
  732. } else if (mode === 'apply') {
  733. console.log('rescope-vendor: applied. Run `pnpm install`, `pnpm run gen-third-party-notices`, and re-record the touched bilingual pairs.')
  734. }
  735. }
  736. // Importing this module for its exported classifier must not run the codemod.
  737. if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
  738. main()
  739. }