diff --git a/packages/adapter-wordpress/src/index.ts b/packages/adapter-wordpress/src/index.ts index 484a48b..45d666b 100644 --- a/packages/adapter-wordpress/src/index.ts +++ b/packages/adapter-wordpress/src/index.ts @@ -1,3 +1,4 @@ export * from './limits.js' export * from './parse/php-unserialize.js' export * from './parse/wxr-stream.js' +export * from './map/item-to-row.js' diff --git a/packages/adapter-wordpress/src/map/item-to-row.ts b/packages/adapter-wordpress/src/map/item-to-row.ts new file mode 100644 index 0000000..2ac09f9 --- /dev/null +++ b/packages/adapter-wordpress/src/map/item-to-row.ts @@ -0,0 +1,198 @@ +// WxrItem → UniversalRow 映射器(Plan 2 Task 4):纯函数——单条 WxrItem 进、MappedResult +// 出;不访问文件/网络/DB,不跨 item 看世界。悬空 attachmentRef(如 mini.xml 的 203/204) +// 原样保留:引用是否可解析是入库阶段(阶段 3)以全量数据才能裁决的事,本层不猜。 +// +// Plan 1 移交责任在此落实: +// - canonical status 值域:STATUS_MAP 冻结表,未知值 → 'unknown' + issue(不猜语义); +// - property 断言:产出 row 满足 isUniversalRow 且 sourceId/sourceType 非空、refs 元素 +// 形状合法(测试钉死;Task 6 的 validateUniversalRow 再做加法式复检,不动 contracts)。 +// +// 字段提升纪律(有意不提前):title/publishedAt/modifiedAt 留在 knownFields(键名 +// title/postDate/postModified,值为 WXR 原串);UniversalRow 顶层同名字段的提升与 +// 日期格式化是阶段 3 导入映射的事——那时任何字段变更须过契约升版评审。 +// +// taxonomyRefs 锚点设计决定(Task 8 实数据核对后冻结):item 内 只携带 +// domain/nicename,不带 wp:term 的 term_id(mapItem 只见单条 item,无法查 term 表)。 +// 真实 WP 中 category @nicename 即 term slug,故 sourceTermId 暂以 nicename 为锚, +// 由下游以 wp:term 事件(slug → term_id)解析——锚点若须换,只改本文件与测试。 + +import type { UniversalRow } from '@sf/contracts' +import { phpUnserialize, phpValueToJson } from '../parse/php-unserialize.js' +import type { WxrItem } from '../parse/wxr-stream.js' + +/** 默认类型路由:WXR post_type → UniversalRow.sourceType(冻结;真实基线仅此四类产 row) */ +export const DEFAULT_TYPE_ROUTES: Readonly> = Object.freeze({ + post: 'wp:post', + page: 'wp:page', + product: 'wp:product', + attachment: 'wp:attachment', +}) + +/** + * canonical status 值域映射表(冻结,Plan 1 移交):已知值原样透传; + * 不在表内的值 → {@link UNKNOWN_STATUS} 且结果带 issue。 + */ +export const STATUS_MAP: Readonly> = Object.freeze({ + publish: 'publish', + draft: 'draft', + future: 'future', + pending: 'pending', + private: 'private', + trash: 'trash', + inherit: 'inherit', +}) + +/** 未知 status 的统一落点(报告层按 unknown 汇总,不猜具体语义) */ +export const UNKNOWN_STATUS = 'unknown' + +/** + * meta 分流键(冻结):键名来自真实 hesupipe WXR(Task 8 实数据核对)。 + * thumbnail → attachmentRefs{role:'featured'};gallery(逗号分隔 id)→ {role:'gallery'}; + * seoTitle/seoDescription → seo.title/description;wppopSections → PHP 解析(见 mapWppop); + * 其余 meta 一律原样进 extras。 + */ +export const META_KEYS = Object.freeze({ + thumbnail: '_thumbnail_id', + gallery: '_product_image_gallery', + seoTitle: '_yoast_wpseo_title', + seoDescription: '_yoast_wpseo_metadesc', + wppopSections: 'wppop_sections', +} as const) + +/** mapItem 配置 */ +export interface MapConfig { + /** 站点 locale,原样写入 row.locale(空串与否由调用方自证) */ + locale: string + /** + * post_type → sourceType 路由;缺省 {@link DEFAULT_TYPE_ROUTES}。 + * 传则**整体替换**(非合并)——需增改请展开默认表:`{ ...DEFAULT_TYPE_ROUTES, ... }`。 + */ + typeRoutes?: Readonly> +} + +/** + * 映射结果三分支: + * - row:产出 UniversalRow(issues 为该 item 的非致命问题清单,可空) + * - unsupported:post_type 未配置路由(sourceType 按 `wp:` 约定命名,仅为 + * 报告可读性,不代表已配置路由)——分流不产 row + * - error:数据无法建立稳定身份(post_id 缺失/非法)——不产 row 不猜 + */ +export type MappedResult = + | { kind: 'row'; row: UniversalRow; issues: string[] } + | { kind: 'unsupported'; sourceType: string; reason: string; rawLocation: string } + | { kind: 'error'; issue: string; rawLocation: string } + +/** + * wppop_sections 分流:PHP 串经 phpUnserialize + phpValueToJson 进 extras(Map 陷阱 + * 防线:phpValueToJson 前的 Map 不可 JSON.stringify,会静默变 {});任何解析失败 + * (PhpParseError/PhpLimitError)不崩不丢——原串存 extras.phpRaw 并记 issue。 + */ +function mapWppop(raw: string, extras: Record, issues: string[]): void { + try { + extras[META_KEYS.wppopSections] = phpValueToJson(phpUnserialize(raw)) + } catch (err) { + extras.phpRaw = raw + const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err) + issues.push(`meta ${META_KEYS.wppopSections} PHP 解析失败(${detail}),原串保留于 extras.phpRaw`) + } +} + +/** + * 单条 WxrItem → UniversalRow / unsupported / error(纯函数)。 + * + * 决策顺序:post_id 有效性先于路由(无稳定身份的一切输出都是猜测);路由先于字段 + * 映射(unsupported 不必再解析 meta)。sourceId = String(postId):负值原样('-7'), + * -0 归一为 '0'。空串字段(link/postName/contentEncoded 等)不写入 row——契约的可选 + * 字段以"缺席"表达"源无此值",而非空串。 + */ +export function mapItem(item: WxrItem, config: MapConfig): MappedResult { + // Task 3 审查移交策略:post_id 是 sourceId 唯一来源,缺失/非法即无法建立稳定身份 + if (item.postId === null) { + return { kind: 'error', issue: 'post_id 缺失或非法', rawLocation: item.rawLocation } + } + + const routes = config.typeRoutes ?? DEFAULT_TYPE_ROUTES + const sourceType = routes[item.postType] + if (sourceType === undefined) { + return { + kind: 'unsupported', + sourceType: `wp:${item.postType}`, + reason: 'post_type 未配置映射', + rawLocation: item.rawLocation, + } + } + + const issues: string[] = [] + const knownStatus = STATUS_MAP[item.status] + const status = knownStatus ?? UNKNOWN_STATUS + if (knownStatus === undefined) { + issues.push(`status 值未识别:${JSON.stringify(item.status)},已映射为 '${UNKNOWN_STATUS}'`) + } + + // taxonomyRefs:锚点=nicename(见模块头设计决定);缺 domain/nicename 的引用跳过并记 + // issue——空锚点在下游必然失配,不如显式报告 + const taxonomyRefs: UniversalRow['taxonomyRefs'] = [] + for (const category of item.categories) { + if (category.domain === '' || category.nicename === '') { + issues.push(`category 引用缺 domain/nicename(value=${JSON.stringify(category.value)}),已跳过`) + continue + } + taxonomyRefs.push({ taxonomy: category.domain, sourceTermId: category.nicename }) + } + + const attachmentRefs: UniversalRow['attachmentRefs'] = [] + const seo: Record = {} + const extras: Record = {} + for (const meta of item.metas) { + if (meta.key === META_KEYS.thumbnail) { + const sourceId = meta.value.trim() + if (sourceId === '') { + // 畸形:meta 存在但值为空(对照:空 gallery 值 = 无图库,合法) + issues.push(`meta ${META_KEYS.thumbnail} 值为空,featured 引用已跳过`) + continue + } + attachmentRefs.push({ role: 'featured', sourceId }) + } else if (meta.key === META_KEYS.gallery) { + for (const part of meta.value.split(',')) { + const sourceId = part.trim() + if (sourceId !== '') attachmentRefs.push({ role: 'gallery', sourceId }) + } + } else if (meta.key === META_KEYS.seoTitle) { + seo.title = meta.value + } else if (meta.key === META_KEYS.seoDescription) { + seo.description = meta.value + } else if (meta.key === META_KEYS.wppopSections) { + mapWppop(meta.value, extras, issues) + } else { + // 未识别 meta:原样进 extras(同 key 重复时后者覆盖前者,文档序) + extras[meta.key] = meta.value + } + } + + // knownFields(阶段 3 提升纪律见模块头):空串不写入——键缺席即源无此值 + const knownFields: Record = {} + if (item.title !== '') knownFields.title = item.title + if (item.postDate !== '') knownFields.postDate = item.postDate + if (item.postModified !== '') knownFields.postModified = item.postModified + if (item.excerptEncoded !== '') knownFields.excerpt = item.excerptEncoded + if (item.creator !== '') knownFields.creator = item.creator + if (item.attachmentUrl !== '') knownFields.attachmentUrl = item.attachmentUrl + + const row: UniversalRow = { + sourceType, + sourceId: String(item.postId), + status, + locale: config.locale, + taxonomyRefs, + attachmentRefs, + seo, + knownFields, + extras, + rawLocation: item.rawLocation, + } + if (item.link !== '') row.sourceUrl = item.link + if (item.postName !== '') row.slug = item.postName + if (item.contentEncoded !== '') row.bodyHtml = item.contentEncoded // 原文透传,清洗是 Task 5 + + return { kind: 'row', row, issues } +} diff --git a/packages/adapter-wordpress/test/item-to-row.test.ts b/packages/adapter-wordpress/test/item-to-row.test.ts new file mode 100644 index 0000000..9b45df9 --- /dev/null +++ b/packages/adapter-wordpress/test/item-to-row.test.ts @@ -0,0 +1,350 @@ +import { createReadStream } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { isUniversalRow, type UniversalRow } from '@sf/contracts' +import { parseWxrStream, type WxrItem } from '../src/parse/wxr-stream.js' +import { + DEFAULT_TYPE_ROUTES, + mapItem, + META_KEYS, + STATUS_MAP, + UNKNOWN_STATUS, + type MapConfig, + type MappedResult, +} from '../src/map/item-to-row.js' + +// mini.xml(Plan 1 夹具):5 item(post 101 / product 111 / page 121 / attachment 201,202) +// + 2 term;product 的图库 203/204 有意悬空(夹具中不存在对应 attachment item)。 +const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url)) + +const CONFIG: MapConfig = { locale: 'zh-CN' } + +/** 经 parseWxrStream 喂 mini.xml 取真实 item 事件(端到端:解析产物即映射输入) */ +async function miniItems(): Promise { + const out: WxrItem[] = [] + for await (const ev of parseWxrStream(createReadStream(FIXTURE))) { + if (ev.kind === 'item') out.push(ev.item) + } + return out +} + +function byPostId(items: WxrItem[], postId: number): WxrItem { + const found = items.find((it) => it.postId === postId) + if (found === undefined) throw new Error(`mini.xml 中找不到 post ${postId}`) + return found +} + +/** 手构等值 WxrItem(任务说明允许:wxr-stream 完整字段形状,缺省为最平凡合法值) */ +function makeItem(overrides: Partial = {}): WxrItem { + return { + rawLocation: 'wxr://item/handmade', + postId: 1, + postType: 'post', + status: 'publish', + postName: 'handmade', + link: 'https://example.com/handmade.html', + title: 'Handmade', + contentEncoded: '

handmade body

', + excerptEncoded: '', + postDate: '2026-01-01 00:00:00', + postModified: '2026-01-02 00:00:00', + attachmentUrl: '', + creator: 'admin', + metas: [], + categories: [], + ...overrides, + } +} + +/** 断言结果为 row 并取出( narrowing 辅助;非 row 即失败) */ +function rowOf(result: MappedResult): { row: UniversalRow; issues: string[] } { + if (result.kind !== 'row') throw new Error(`期望 kind:'row',实际 ${result.kind}`) + return { row: result.row, issues: result.issues } +} + +/** Plan 1 移交 property 断言:产出 row 的通用不变量(守卫 + 非空 + refs 形状) */ +function expectRowInvariants(row: UniversalRow): void { + expect(isUniversalRow(row)).toBe(true) + expect(row.sourceType).not.toBe('') + expect(row.sourceId).not.toBe('') + expect(row.rawLocation).not.toBe('') + for (const ref of row.taxonomyRefs) { + expect(ref.taxonomy).not.toBe('') + expect(ref.sourceTermId).not.toBe('') + } + for (const ref of row.attachmentRefs) { + expect(ref.role).not.toBe('') + // 本映射器产出的 ref 一律以 sourceId 锚定(不产 sourceUrl 型 ref) + expect(typeof ref.sourceId === 'string' && ref.sourceId !== '').toBe(true) + } +} + +describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => { + it('post(101):全字段映射(路由/status/locale/slug/seo/taxonomyRefs/featured/knownFields/bodyHtml 原文)', async () => { + const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 101), CONFIG)) + expect(issues).toStrictEqual([]) + expect(row).toMatchObject({ + sourceType: 'wp:post', + sourceId: '101', + status: 'publish', + locale: 'zh-CN', + sourceUrl: 'https://fixture.example/news/fixture-news-a.html', + slug: 'fixture-news-a', + // 原文透传(清洗是 Task 5 的职责) + bodyHtml: + '

Body with link and

', + rawLocation: 'wxr://item/1', + }) + // 设计决定(JSDoc 钉死):sourceTermId 暂以 category @nicename 为锚 + // (= 真实 WP 的 term slug;mini.xml 中 term 31 的 slug 恰为 'company',Task 8 实数据核对后冻结) + expect(row.taxonomyRefs).toStrictEqual([{ taxonomy: 'category', sourceTermId: 'company' }]) + expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '201' }]) + expect(row.seo).toStrictEqual({ + title: 'Fixture SEO Title A', + description: 'Fixture SEO description A.', + }) + // 阶段 3 提升纪律:title/日期暂留 knownFields(键名冻结为 title/postDate/postModified) + expect(row.knownFields).toStrictEqual({ + title: 'Fixture News A', + postDate: '2026-10-05 09:00:00', + postModified: '2026-10-06 10:00:00', + excerpt: 'Fixture excerpt A', + creator: 'editor', + }) + // 101 的 3 条 meta 全部被分流(thumbnail/yoast×2)——extras 无未识别残余 + expect(row.extras).toStrictEqual({}) + expectRowInvariants(row) + }) + + it('product(111):featured 202 + 悬空 gallery 203/204 原样保留(歧义不猜);extras.wppop_sections 为解析产物', async () => { + const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 111), CONFIG)) + expect(issues).toStrictEqual([]) + expect(row.sourceType).toBe('wp:product') + expect(row.sourceId).toBe('111') + // 悬空 203/204 原样保留:是否存在对应媒体是入库阶段以全量数据才能裁决的事 + expect(row.attachmentRefs).toStrictEqual([ + { role: 'featured', sourceId: '202' }, + { role: 'gallery', sourceId: '203' }, + { role: 'gallery', sourceId: '204' }, + ]) + // Map 陷阱防线:必须是 phpValueToJson 后的普通对象(可 JSON.stringify,非 Map) + expect(row.extras.wppop_sections).not.toBeInstanceOf(Map) + expect(row.extras.wppop_sections).toStrictEqual([{ title: 'SpecsTab', content: '

Tab

' }]) + expect(JSON.stringify(row.extras.wppop_sections)).toBe('[{"title":"SpecsTab","content":"

Tab

"}]') + expectRowInvariants(row) + }) + + it('page(121):sourceType wp:page,seo/refs 为空', async () => { + const { row } = rowOf(mapItem(byPostId(await miniItems(), 121), CONFIG)) + expect(row.sourceType).toBe('wp:page') + expect(row.sourceId).toBe('121') + expect(row.taxonomyRefs).toStrictEqual([]) + expect(row.attachmentRefs).toStrictEqual([]) + expect(row.seo).toStrictEqual({}) + expectRowInvariants(row) + }) + + it('attachment(201):sourceType wp:attachment、status inherit 原样、knownFields.attachmentUrl', async () => { + const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 201), CONFIG)) + expect(issues).toStrictEqual([]) + expect(row.sourceType).toBe('wp:attachment') + expect(row.sourceId).toBe('201') + expect(row.status).toBe('inherit') + expect(row.knownFields.attachmentUrl).toBe( + 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png', + ) + expectRowInvariants(row) + }) + + it('property 断言(Plan 1 移交):mini.xml 全部 5 item 均产合法 row(含 attachment 202)', async () => { + const items = await miniItems() + expect(items).toHaveLength(5) + for (const item of items) { + const { row } = rowOf(mapItem(item, CONFIG)) + expectRowInvariants(row) + } + }) +}) + +describe('mapItem:unsupported 分流(不产 row)', () => { + it.each(['nav_menu_item', 'wpcf7_contact_form', 'elementor_library', 'wp_navigation'])( + 'post_type %s → {kind:unsupported, sourceType:wp:%s},不产 row', + (postType) => { + const result = mapItem(makeItem({ postType }), CONFIG) + expect(result).toStrictEqual({ + kind: 'unsupported', + sourceType: `wp:${postType}`, + reason: 'post_type 未配置映射', + rawLocation: 'wxr://item/handmade', + }) + expect('row' in result).toBe(false) + }, + ) + + it('typeRoutes 自定义:整体替换语义(post 走自定义路由;product 无路由 → unsupported)', () => { + const custom: MapConfig = { locale: 'zh-CN', typeRoutes: { post: 'site:article' } } + const post = rowOf(mapItem(makeItem({ postType: 'post' }), custom)) + expect(post.row.sourceType).toBe('site:article') + expect(mapItem(makeItem({ postType: 'product' }), custom).kind).toBe('unsupported') + }) +}) + +describe('mapItem:status 值域映射(Plan 1 移交,表驱动)', () => { + it.each(['publish', 'draft', 'future', 'pending', 'private', 'trash', 'inherit'])( + '已知 status %s → 原样', + (status) => { + const { row, issues } = rowOf(mapItem(makeItem({ status }), CONFIG)) + expect(row.status).toBe(status) + expect(issues).toStrictEqual([]) + }, + ) + + it("未知 status('weird'/空串)→ 'unknown' + issue(不猜不丢)", () => { + const weird = rowOf(mapItem(makeItem({ status: 'weird' }), CONFIG)) + expect(weird.row.status).toBe('unknown') + expect(weird.issues).toStrictEqual([`status 值未识别:"weird",已映射为 'unknown'`]) + const empty = rowOf(mapItem(makeItem({ status: '' }), CONFIG)) + expect(empty.row.status).toBe('unknown') + expect(empty.issues).toHaveLength(1) + }) + + it('STATUS_MAP/DEFAULT_TYPE_ROUTES/META_KEYS 冻结且值域钉死', () => { + expect(Object.isFrozen(STATUS_MAP)).toBe(true) + expect(Object.isFrozen(DEFAULT_TYPE_ROUTES)).toBe(true) + expect(Object.isFrozen(META_KEYS)).toBe(true) + expect(STATUS_MAP).toStrictEqual({ + publish: 'publish', + draft: 'draft', + future: 'future', + pending: 'pending', + private: 'private', + trash: 'trash', + inherit: 'inherit', + }) + expect(UNKNOWN_STATUS).toBe('unknown') + expect(DEFAULT_TYPE_ROUTES).toStrictEqual({ + post: 'wp:post', + page: 'wp:page', + product: 'wp:product', + attachment: 'wp:attachment', + }) + expect(META_KEYS).toStrictEqual({ + thumbnail: '_thumbnail_id', + gallery: '_product_image_gallery', + seoTitle: '_yoast_wpseo_title', + seoDescription: '_yoast_wpseo_metadesc', + wppopSections: 'wppop_sections', + }) + }) +}) + +describe('mapItem:postId 异常策略(Task 3 审查移交)', () => { + it('postId null → {kind:error}(不产 row 不猜)', () => { + expect(mapItem(makeItem({ postId: null }), CONFIG)).toStrictEqual({ + kind: 'error', + issue: 'post_id 缺失或非法', + rawLocation: 'wxr://item/handmade', + }) + }) + + it("postId -7(wxr-stream 严格解析 '-7' 的产物)→ sourceId '-7' 原样", () => { + const { row } = rowOf(mapItem(makeItem({ postId: -7 }), CONFIG)) + expect(row.sourceId).toBe('-7') + }) + + it("postId -0 / 0 → sourceId '0'(-0 归一,String(-0)==='0')", () => { + const item = makeItem({ postId: -0 }) + expect(Object.is(item.postId, -0)).toBe(true) // 确证喂入的确实是 -0(未被字面量归一) + expect(rowOf(mapItem(item, CONFIG)).row.sourceId).toBe('0') + expect(rowOf(mapItem(makeItem({ postId: 0 }), CONFIG)).row.sourceId).toBe('0') + }) + + it('post_id 缺失的 unsupported 类型:data 有效性先行于路由(error 而非 unsupported)', () => { + const result = mapItem(makeItem({ postId: null, postType: 'nav_menu_item' }), CONFIG) + expect(result.kind).toBe('error') + }) +}) + +describe('mapItem:meta 分流表', () => { + it('未识别 meta → extras 原样(字符串键值;同 key 后者覆盖前者)', () => { + const { row, issues } = rowOf( + mapItem( + makeItem({ + metas: [ + { key: 'custom_a', value: 'va' }, + { key: '_thumbnail_id', value: '301' }, + { key: 'custom_a', value: 'va-2' }, + { key: 'custom_b', value: 'vb' }, + ], + }), + CONFIG, + ), + ) + expect(issues).toStrictEqual([]) + expect(row.extras).toStrictEqual({ custom_a: 'va-2', custom_b: 'vb' }) + expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '301' }]) + }) + + it('thumbnail/gallery 容忍空白(trim);空 gallery 值不产 ref;空 thumbnail 记 issue', () => { + const ok = rowOf( + mapItem( + makeItem({ + metas: [ + { key: '_thumbnail_id', value: ' 401 ' }, + { key: '_product_image_gallery', value: '402 , 403' }, + ], + }), + CONFIG, + ), + ) + expect(ok.row.attachmentRefs).toStrictEqual([ + { role: 'featured', sourceId: '401' }, + { role: 'gallery', sourceId: '402' }, + { role: 'gallery', sourceId: '403' }, + ]) + // WooCommerce 语义:空 _product_image_gallery = 无图库(不报 issue);空 _thumbnail_id 为畸形(记 issue) + const emptyGallery = rowOf( + mapItem(makeItem({ metas: [{ key: '_product_image_gallery', value: '' }] }), CONFIG), + ) + expect(emptyGallery.row.attachmentRefs).toStrictEqual([]) + expect(emptyGallery.issues).toStrictEqual([]) + const emptyThumb = rowOf( + mapItem(makeItem({ metas: [{ key: '_thumbnail_id', value: ' ' }] }), CONFIG), + ) + expect(emptyThumb.row.attachmentRefs).toStrictEqual([]) + expect(emptyThumb.issues).toStrictEqual(['meta _thumbnail_id 值为空,featured 引用已跳过']) + }) + + it('wppop 解析失败路径:extras.phpRaw 存原串 + issue(不崩不丢,row 照常产出)', () => { + const raw = 'a:1:{i:0;s:5:"ab' // 截断串 → PhpParseError + const { row, issues } = rowOf( + mapItem(makeItem({ metas: [{ key: 'wppop_sections', value: raw }] }), CONFIG), + ) + expect(row.extras.phpRaw).toBe(raw) + expect(row.extras.wppop_sections).toBeUndefined() + expect(issues).toHaveLength(1) + expect(issues[0]).toContain('wppop_sections') + expect(issues[0]).toContain('phpRaw') + expectRowInvariants(row) + }) +}) + +describe('mapItem:category 引用形状防线', () => { + it('category 缺 domain 或 nicename → 跳过该 ref 并记 issue(不产空锚点)', () => { + const { row, issues } = rowOf( + mapItem( + makeItem({ + categories: [ + { domain: 'category', nicename: '', value: 'X' }, + { domain: '', nicename: 'slug', value: 'Y' }, + { domain: 'post_tag', nicename: 'tag-a', value: 'TagA' }, + ], + }), + CONFIG, + ), + ) + expect(row.taxonomyRefs).toStrictEqual([{ taxonomy: 'post_tag', sourceTermId: 'tag-a' }]) + expect(issues).toHaveLength(2) + expectRowInvariants(row) + }) +}) diff --git a/packages/adapter-wordpress/test/php-unserialize.test.ts b/packages/adapter-wordpress/test/php-unserialize.test.ts index d6e3567..22c8056 100644 --- a/packages/adapter-wordpress/test/php-unserialize.test.ts +++ b/packages/adapter-wordpress/test/php-unserialize.test.ts @@ -213,6 +213,40 @@ describe('错误信息上下文摘录(offset ±20 码元窗口、控制字符 }) }) +// Task 2 审查移交补测(Plan 2 Task 4 落实):四例边界/形状/性能钉死 +describe('Task 2 审查移交补测:数组键类型、NUL、数字串键形状、1MB 性能烟雾', () => { + it('d: 作数组键 → 拒绝(数组键只认 i:/s:,浮点键是 PHP 序列化产物中不存在的形状)', () => { + const e = capture(() => phpUnserialize('a:1:{d:1.5;i:0;}')) as PhpParseError + expect(e).toBeInstanceOf(PhpParseError) + expect(e.message).toContain('数组键') + }) + + it('s: 内容含 NUL(\\u0000)原样往返,不截断不报错(单指针扫描无 C 式终止符语义)', () => { + expect(phpUnserialize('s:3:"a\0b";')).toBe('a\0b') + // NUL 作键亦不炸:Map 保留该键 + expect(phpUnserialize('a:1:{s:3:"a\0b";i:1;}')).toStrictEqual(new Map([['a\0b', 1]])) + }) + + it('数字串键 s:1:"0" → Map(密集数组判定用严格 ===,字符串键永不伪装整数键 0)', () => { + expect(phpUnserialize('a:1:{s:1:"0";i:5;}')).toStrictEqual(new Map([['0', 5]])) + expect(JSON.stringify(phpValueToJson(phpUnserialize('a:1:{s:1:"0";i:5;}')))).toBe('{"0":5}') + }) + + it('1MB 输入性能烟雾:<50ms(防 O(n²) 回归——单指针扫描应线性)', () => { + const parts: string[] = [] + for (let i = 0; i < 400; i++) parts.push(`i:${i};s:2500:"${'x'.repeat(2500)}";`) + const input = `a:400:{${parts.join('')}}` + expect(input.length).toBeGreaterThan(1_000_000) // 确证规模 ≥1MB(纯 ASCII:码元数=字节数) + // 元素数 = 400 键 + 400 值 + 1 数组自身 = 801 ≤ 默认 phpMaxElements=1000(不经限额捷径) + const t0 = performance.now() + const value = phpUnserialize(input) + const elapsed = performance.now() - t0 + expect(Array.isArray(value)).toBe(true) + expect(value).toHaveLength(400) + expect(elapsed).toBeLessThan(50) + }) +}) + describe('错误类型层级:PhpError 公共基类', () => { it('PhpParseError 与 PhpLimitError 均为 PhpError 子类,且互不为对方子类', () => { const parse = capture(() => phpUnserialize('x:1:irrelevant;')) as PhpParseError diff --git a/packages/adapter-wordpress/tsconfig.build.json b/packages/adapter-wordpress/tsconfig.build.json index 3afbf38..1252a32 100644 --- a/packages/adapter-wordpress/tsconfig.build.json +++ b/packages/adapter-wordpress/tsconfig.build.json @@ -2,7 +2,15 @@ "extends": "./tsconfig.json", "compilerOptions": { "rootDir": ".", - "noEmit": false // 根 tsconfig 经 extends 链漏下 noEmit:true,build 须翻回 + "noEmit": false, // 根 tsconfig 经 extends 链漏下 noEmit:true,build 须翻回 + // Task 4 起 src 引用 @sf/contracts(type-only):emit 程序不得含包外源文件 + // (rootDir 下 TS6059),故 build 侧改经 contracts 预构建 dist 声明解析(先 + // `pnpm --filter @sf/contracts build`);typecheck/vitest/tsx 仍走根 paths 的 + // src 直连。src-vs-dist 的最终统一归 ADR-016(Task 9)。paths 相对继承的根 + // baseUrl(仓库根)解析。 + "paths": { + "@sf/contracts": ["./packages/contracts/dist/src/index.d.ts"] + } }, "exclude": ["test"] }