dsh-scout 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,12 +91,13 @@ test('renders a sourced claim and preserves a VERIFY decision', () => {
91
91
  const report = renderReport(scoutCase)
92
92
 
93
93
  assert.equal(scoutCase.decision, 'VERIFY')
94
- assert.match(report, /Example Co — HR Head/)
94
+ assert.match(report, /^# Example Co$/m)
95
+ assert.match(report, /^> 岗位:HR Head/m)
95
96
  assert.match(report, /job-posting/)
96
97
  assert.match(report, /Confirm the mandate/)
97
- assert.match(report, /Blocking or unresolved risks/)
98
- assert.match(report, /Role task and authority hypotheses/)
99
- assert.match(report, /Interview questions/)
98
+ assert.match(report, /## 3\. 线索与待确认/)
99
+ assert.match(report, /## 4\. 风险与红旗/)
100
+ assert.match(report, /## 5\. 面试问题/)
100
101
  })
101
102
 
102
103
  test('requires an E3 source to verify company identity', () => {
@@ -234,8 +235,8 @@ test('replays the Snapmaker fixture offline with a stable VERIFY result', () =>
234
235
 
235
236
  const report = renderReport(scoutCase)
236
237
  assert.equal(scoutCase.decision, fixture.expectedDecision)
237
- assert.match(report, /历史材料不能替代运行时实时核验|公司主体仍未核验/)
238
- assert.match(report, /Interview questions/)
238
+ assert.match(report, /公司主体还没有核验/)
239
+ assert.match(report, /## 5\. 面试问题/)
239
240
  assert.equal(scoutCase.interviewQuestions.length, 5)
240
241
  })
241
242
 
@@ -373,13 +374,11 @@ test('renders an evidence summary, source URLs, and a prioritized verification c
373
374
  })
374
375
  const report = renderReport(scoutCase)
375
376
 
376
- assert.match(report, /证据概况:2 reported/)
377
+ assert.match(report, /## 3\. 线索与待确认/)
378
+ assert.match(report, /The role exists\./)
379
+ assert.match(report, /## 4\. 风险与红旗[\s\S]*A litigation risk is reported/)
377
380
  assert.match(report, /https:\/\/example\.com\/job/)
378
- assert.match(report, /## Verification checklist/)
379
- const checklistStart = report.indexOf('## Verification checklist')
380
- const checklist = report.slice(checklistStart, report.indexOf('## Claim ledger'))
381
- assert.ok(checklist.indexOf('[blocking]') < checklist.indexOf('[material]'), 'blocking items rank before material')
382
- assert.match(checklist, /→ 下一步:Confirm the mandate/)
381
+ assert.match(report, /## 5\. 面试问题/)
383
382
  })
384
383
 
385
384
  test('round-trips a case through the five-file export', () => {
@@ -418,7 +417,7 @@ test('round-trips a case through the five-file export', () => {
418
417
 
419
418
  const files = exportCaseFiles(scoutCase)
420
419
  assert.deepEqual(Object.keys(files).sort(), ['case.json', 'claims.json', 'events.jsonl', 'report.md', 'sources.json'])
421
- assert.match(files['report.md'], /## Verification checklist/)
420
+ assert.match(files['report.md'], /## 5\. 面试问题/)
422
421
  assert.match(files['events.jsonl'], /"type":"case_started"/)
423
422
 
424
423
  const restored = importCaseFromFiles(files)
@@ -463,6 +462,7 @@ test('generates a deduplicated, prioritized interview question list', () => {
463
462
  sourceIds: ['job-posting'],
464
463
  confidenceNote: 'A source describes the role.',
465
464
  nextAction: 'Confirm the mandate in the interview.',
465
+ interviewQuestion: 'Who owns the people roadmap in this role?',
466
466
  })
467
467
  scoutCase = addClaim(scoutCase, {
468
468
  claimId: 'claim-reporting',
@@ -474,15 +474,20 @@ test('generates a deduplicated, prioritized interview question list', () => {
474
474
  sourceIds: [],
475
475
  confidenceNote: 'No source discloses the reporting line.',
476
476
  nextAction: 'Ask who this role reports to.',
477
+ interviewQuestion: 'Who would this role report to on day one?',
477
478
  })
478
479
 
479
480
  const questions = generateInterviewQuestions(scoutCase)
480
- assert.ok(questions.includes('Confirm the mandate in the interview.'))
481
- assert.ok(questions.includes('Ask who this role reports to.'))
481
+ // 0.9.4: only explicit interviewQuestion values feed the head; nextAction
482
+ // (a research instruction) must never become an interview question.
483
+ assert.ok(questions.includes('Who owns the people roadmap in this role?'))
484
+ assert.ok(questions.includes('Who would this role report to on day one?'))
485
+ assert.ok(!questions.includes('Confirm the mandate in the interview.'))
486
+ assert.ok(!questions.includes('Ask who this role reports to.'))
482
487
  // missing verified mandate dimension gets its default question
483
488
  const mandateQuestion = questions.findIndex(q => q.includes('最终决定权'))
484
489
  assert.ok(mandateQuestion >= 0)
485
- // 0.9.1 ordering: untouched dimensions rank after the missing required ones
490
+ // untouched dimensions rank after the missing required ones
486
491
  const untouchedQuestion = questions.findIndex(q => q.includes('法定名称'))
487
492
  assert.ok(
488
493
  untouchedQuestion > mandateQuestion,
@@ -718,63 +723,146 @@ function legacyCase() {
718
723
  return decideCase(scoutCase)
719
724
  }
720
725
 
721
- // Captured verbatim from dsh-scout 0.9.0 (HEAD 3406a4e) before the 0.9.1
722
- // Coverage block existed. Only that block may be inserted: any other byte
723
- // change to the zh-CN report is a regression.
724
- const LEGACY_REPORT_GOLDEN = [
725
- "# Legacy Co — HR Head",
726
+ // Captured from dsh-scout 0.9.3 plus the 0.9.4 report changes (verified
727
+ // subject block, source capture dates, derived interview questions). Any byte
728
+ // change to the zh-CN report must update this baseline deliberately, together
729
+ // with CHANGELOG.
730
+ const REPORT_GOLDEN = [
731
+ "# Legacy Co",
726
732
  "",
727
- "- 判断:**VERIFY**",
728
- "- 理由:公司主体仍未核验,不能直接推进。",
729
- "- 目标:判断是否值得进入下一轮,并准备面试反问",
730
- "- 证据概况:1 reported",
733
+ "> 岗位:HR Head | 地点:未指定 | 证据截至:2026-08-16 | 判断:继续核验(VERIFY)",
734
+ "> 目标:判断是否值得进入下一轮,并准备面试反问",
735
+ "> 阅读顺序:30 秒结论 → 主体 → 已确认事实 → 待确认 → 风险 → 面试问题 → 附录(证据台账)",
736
+ "> 可信度:已核实=多来源一致;有报道=单一来源;推断=由事实推出;未知=没查到;未找到证据=不代表不存在",
731
737
  "",
732
- "## Key supporting evidence (up to 3)",
738
+ "## 30 秒结论",
733
739
  "",
734
- "- 暂无已核验的支持证据",
740
+ "**继续核验**:公司主体还没有核验,现在还不能决定是否推进。",
735
741
  "",
736
- "## Blocking or unresolved risks (up to 3)",
742
+ "- 主体:Legacy Co——尚未核验。",
743
+ "- 业务:公开信息未形成可核实的产品描述。",
744
+ "- 资本:未找到公开融资信息。",
737
745
  "",
738
- "- **reported / E1 / blocking** The role may own the people roadmap.(维度:mandate;来源:job-posting;置信说明:Only the job posting says so.;下一步:Confirm the mandate in the interview.)",
746
+ "**最该先确认的 3 件事**",
739
747
  "",
740
- "## Role task and authority hypotheses",
748
+ "1. 取得官方登记证据(营业执照或工商公示页),确认合同主体与信用代码",
749
+ "2. 确认岗位是否真实在招、汇报给谁、有多大授权(只能见面问)",
741
750
  "",
742
- "- **reported / E1 / blocking** The role may own the people roadmap.(维度:mandate;来源:job-posting;置信说明:Only the job posting says so.;下一步:Confirm the mandate in the interview.)",
751
+ "## 1. 公司与主体(已确认)",
743
752
  "",
744
- "## Verification checklist",
753
+ "| 项目 | 内容 |",
754
+ "|---|---|",
755
+ "| 法定主体 | 未知 |",
756
+ "| 统一社会信用代码 | 未知 |",
757
+ "| 注册地 | 未知 |",
758
+ "| 法定代表人 | 未知 |",
759
+ "| 品牌关系 | 未知 |",
760
+ "| 核验等级 | 未核验:尚无足够来源确认主体。 |",
745
761
  "",
746
- "- [blocking] claim-1: The role may own the people roadmap. → 下一步:Confirm the mandate in the interview.",
762
+ "**这意味着**:可以按该主体继续尽调与判断,但把它视为“未拿到官方盖章的事实”,关键动作(签约、背调、开票)前需复核。",
747
763
  "",
748
- "## Claim ledger",
764
+ "## 2. 已确认的事实(阶段:可直接使用)",
749
765
  "",
750
- "- **reported / E1 / blocking** The role may own the people roadmap.(维度:mandate;来源:job-posting;置信说明:Only the job posting says so.;下一步:Confirm the mandate in the interview.)",
766
+ "- 暂无已核实的事实",
751
767
  "",
752
- "## Sources",
768
+ "## 3. 线索与待确认(阶段:先别当事实)",
753
769
  "",
754
- "- job-posting: Legacy job posting [job_posting / E1] — https://example.com/job",
770
+ "### 还能通过公开渠道再核实",
755
771
  "",
756
- "## Interview questions",
772
+ "- The role may own the people roadmap.",
773
+ " - 转述 · 单方说法 · 下一步:Confirm the mandate in the interview.",
757
774
  "",
775
+ "## 4. 风险与红旗(阶段:已识别)",
776
+ "",
777
+ "- 暂无已识别风险",
778
+ "",
779
+ "## 5. 面试问题(按主题,最多 12 条)",
780
+ "",
781
+ "### 岗位与团队",
782
+ "",
783
+ "- 该岗位当前是否真实在招?招聘状态与到岗时间如何?",
784
+ "- 该岗位直接向谁汇报?虚线/实线如何划分?",
785
+ "- 该岗位在哪些事项上拥有最终决定权?团队与预算规模如何?",
786
+ "- 创始团队背景与股权结构如何?关键决策人是谁?",
758
787
  "- 这个岗位在前三个月最重要的业务结果是什么?",
759
788
  "- 该岗位直接向谁汇报,在哪些事项上拥有最终决定权?",
760
789
  "- 现有团队、预算和外部供应商资源分别是什么?",
761
790
  "- 当前最影响业务增长的组织问题是什么?",
762
791
  "- 六个月后,哪些可观察结果代表这个岗位取得成功?",
763
792
  "",
793
+ "### 产品与市场",
794
+ "",
795
+ "- 核心产品当前处于什么阶段?技术壁垒与依赖是什么?",
796
+ "- 目标市场与主要竞争对手是谁?差异化优势如何维持?",
797
+ "",
798
+ "### 通用",
799
+ "",
800
+ "- 公司主体的法定名称、注册号与注册地是什么?如何与品牌对应?",
801
+ "",
802
+ "## 6. 距离下一步还差什么",
803
+ "",
804
+ "- **为什么现在还是「继续核验」**:公司主体还没有核验,现在还不能决定是否推进。",
805
+ "- **补齐后可以进入「可以推进」**:",
806
+ " - 主体:取得官方登记页/营业执照(或明确接受“多源交叉核验”的残余风险)",
807
+ " - 授权与职责:The role may own the people roadmap.",
808
+ "- **最小行动清单**:",
809
+ " 1. 取得官方登记证据(营业执照或工商公示页),确认合同主体与信用代码",
810
+ " 2. 确认岗位是否真实在招、汇报给谁、有多大授权(只能见面问)",
811
+ "",
812
+ "## 附录 A · 来源表",
813
+ "",
814
+ "| 编号 | 来源 | 类型 | 可信度 | 采集日期 |",
815
+ "|---|---|---|---|---|",
816
+ "| job-posting | Legacy job posting | 招聘平台 | 单方说法 | 2026-08-16 |",
817
+ "- job-posting: https://example.com/job",
818
+ "",
819
+ "## 附录 B · 主张台账(技术可追溯)",
820
+ "",
821
+ "| 结论摘要 | 阶段/证据 | 影响 | 来源 | 下一步 |",
822
+ "|---|---|---|---|---|",
823
+ "| The role may own the people roadmap. | 转述 · 单方说法 | 阻断级 | job-posting | Confirm the mandate in th… |",
824
+ "",
825
+ "## 附录 C · 调查覆盖度",
826
+ "",
827
+ "- 主体身份:未调查",
828
+ "- 岗位真实性:未调查",
829
+ "- 汇报关系:未调查",
830
+ "- 授权与职责:仅 1 条待确认线索",
831
+ "- 公司与创始人:未调查",
832
+ "- 产品与技术:未调查",
833
+ "- 市场与竞争:未调查",
834
+ "- 融资与商业化:未调查",
835
+ "- 组织与人力:未调查",
836
+ "- 风险:未调查",
837
+ "- 岗位匹配度:未调查",
838
+ "",
839
+ "## 附录 D · 术语表",
840
+ "",
841
+ "| 代码 | 面向读者的说法 | 含义 |",
842
+ "|---|---|---|",
843
+ "| E3 | 官方/权威来源 | 政府登记、监管、官方文件;可直接引用 |",
844
+ "| E2 | 独立来源 | 独立媒体、公开数据库、招聘平台;可交叉核对 |",
845
+ "| E1 | 单方说法 | 公司自述、招聘信息、匿名评价、用户提供 |",
846
+ "| E0 | 未找到证据 | 没找到证据,不等于不存在 |",
847
+ "| verified | 已核验 | 多来源一致 |",
848
+ "| reported | 转述 | 单一来源转述,未交叉 |",
849
+ "| inferred | 推断 | 由事实推出,不是直接证据 |",
850
+ "| contradicted | 矛盾 | 两个来源口径不一致 |",
851
+ "| unknown | 未知 | 没查到,需要补 |",
852
+ "| needs_verification | 待核验 | 有线索但未核实 |",
853
+ "",
764
854
  "> 本报告只表达当前证据边界内的判断,不替代法律、投资或医疗意见。",
765
855
  "",
766
856
  ].join('\n')
767
857
 
768
- test('keeps the zh-CN report byte-identical apart from the Coverage block', () => {
858
+ test('matches the 0.9.4 zh-CN report baseline', () => {
769
859
  const report = renderReport(legacyCase())
770
- const lines = report.split('\n')
771
- const start = lines.indexOf('## Coverage')
772
- assert.ok(start > 0, 'the Coverage block is present')
773
- let end = start + 1
774
- while (end < lines.length && !lines[end].startsWith('## ')) end += 1
775
- const withoutCoverage = [...lines.slice(0, start), ...lines.slice(end)].join('\n')
776
- assert.equal(withoutCoverage, LEGACY_REPORT_GOLDEN)
777
- assert.match(report, /## Coverage\n\n- identity/)
860
+ assert.equal(report, REPORT_GOLDEN)
861
+ // the blocks added in 0.9.4 are part of the baseline, not optional extras
862
+ assert.match(report, /## 1\. 公司与主体(已确认)/)
863
+ assert.match(report, /\| job-posting \| Legacy job posting \| 招聘平台 \| 单方说法 \| 2026-08-16 \|/)
864
+ assert.match(report, /## 5\. 面试问题(按主题,最多 12 条)/)
865
+ assert.match(report, /## 附录 D · 术语表/)
778
866
  })
779
867
 
780
868
  test('renders an English report and English questions, defaulting to the case language', () => {
@@ -796,24 +884,26 @@ test('renders an English report and English questions, defaulting to the case la
796
884
  scoutCase = decideCase(scoutCase)
797
885
 
798
886
  const report = renderReport(scoutCase)
799
- assert.match(report, /- Decision: \*\*VERIFY\*\*/)
800
- assert.match(report, /- Reason: The company identity is not verified yet/)
801
- assert.match(report, /- identity \(Company identity\): verified 0 \/ open 0 \/ untouched: yes/)
802
- assert.match(report, /- mandate \(Mandate and authority\): verified 0 \/ open 1 \/ untouched: no/)
803
- assert.match(report, /Untouched dimensions \(10\):/)
804
- assert.match(report, /The role may own the people roadmap\. \(dimension: mandate; sources: job-posting/)
887
+ assert.match(report, /^> Role: CTO \| Location: unspecified \| Evidence as of: 2026-08-16 \| Decision: verify \(VERIFY\)$/m)
888
+ assert.match(report, /## 30-second verdict/)
889
+ assert.match(report, /## 1\. The company and its legal subject \(confirmed\)/)
890
+ assert.match(report, /## 4\. Risks and red flags/)
891
+ assert.match(report, /## 5\. Interview questions \(by theme, up to 12\)/)
892
+ assert.match(report, /reported · Single-party account/)
805
893
  assert.doesNotMatch(report, /[\u4e00-\u9fff]/, 'the English report contains no CJK characters')
806
894
 
807
895
  const questions = generateInterviewQuestions(scoutCase)
808
896
  assert.equal(questions.length, 12)
809
- assert.equal(questions[0], 'Confirm the mandate in the interview.')
810
- assert.ok(questions.includes('Is this role actually open right now, and what are the hiring status and start date?'))
897
+ // 0.9.4: nextAction no longer feeds questions; missing dimensions lead with
898
+ // their own default question (role existence first in REQUIRED order).
899
+ assert.equal(questions[0], 'Is this role actually open right now, and what are the hiring status and start date?')
900
+ assert.ok(!questions.includes('Confirm the mandate in the interview.'))
811
901
  assert.ok(questions.every(question => !/[\u4e00-\u9fff]/.test(question)))
812
902
 
813
903
  // an explicit option overrides the case language in either direction
814
904
  const zhReport = renderReport(scoutCase, { language: 'zh-CN' })
815
- assert.match(zhReport, /- 判断:\*\*VERIFY\*\*/)
816
- assert.doesNotMatch(zhReport, /- Decision:/)
905
+ assert.match(zhReport, /^> 岗位:CTO .*判断:继续核验(VERIFY)$/m)
906
+ assert.doesNotMatch(zhReport, /Decision:/)
817
907
  const zhQuestions = generateInterviewQuestions(scoutCase, { language: 'zh-CN' })
818
908
  assert.ok(zhQuestions.includes('公司主体的法定名称、注册号与注册地是什么?如何与品牌对应?'))
819
909
  // the comparison report follows the same language resolution
@@ -864,14 +954,12 @@ test('computes coverage for all 11 dimensions, including untouched ones', () =>
864
954
  })
865
955
 
866
956
  const report = renderReport(scoutCase)
867
- assert.match(report, /## Coverage/)
868
- assert.ok(report.indexOf('## Coverage') < report.indexOf('## Key supporting evidence'))
869
- assert.match(report, /- identity(主体身份):已核验 1 \/ 待核验 0 \/ 未调查:否/)
870
- assert.match(report, /- mandate(授权与职责):已核验 0 \/ 待核验 0 \/ 未调查:是/)
871
- assert.match(
872
- report,
873
- /未调查维度(9):role_existence、reporting_line、mandate、company_and_founder、product_and_technology、market_and_competition、financing_and_commercialization、organization_and_hr、role_fit/,
874
- )
957
+ assert.match(report, /## 附录 C · 调查覆盖度/)
958
+ assert.ok(report.indexOf('## 附录 C') < report.indexOf('## 附录 D'))
959
+ assert.match(report, /- 主体身份:1 条已核实/)
960
+ assert.match(report, /- 授权与职责:未调查/)
961
+ assert.match(report, /- 岗位真实性:未调查/)
962
+ assert.match(report, /- 市场与竞争:未调查/)
875
963
  // coverage drives the question list
876
964
  assert.ok(generateInterviewQuestions(scoutCase)
877
965
  .includes('该岗位当前是否真实在招?招聘状态与到岗时间如何?'))
@@ -996,7 +1084,7 @@ test('imports a legacy five-file export without the new optional fields', () =>
996
1084
  assert.equal(restored.decisionReasonCode, undefined)
997
1085
  assert.equal('decisionReasonCode' in restored, false)
998
1086
  assert.deepEqual(restored.interviewQuestions, ['第一个问题?'])
999
- assert.match(renderReport(restored), /## Coverage/)
1087
+ assert.match(renderReport(restored), /## 附录 C · 调查覆盖度/)
1000
1088
 
1001
1089
  // a payload that predates the language field still imports as zh-CN
1002
1090
  const withoutLanguage = importCaseFromFiles({
@@ -1074,13 +1162,13 @@ test('renders the exported report.md in the requested language', () => {
1074
1162
  const zhCase = legacyCase()
1075
1163
  const zhFiles = exportCaseFiles(zhCase)
1076
1164
  assert.equal(zhFiles['report.md'], renderReport(zhCase))
1077
- assert.match(zhFiles['report.md'], /^- 判断:\*\*VERIFY\*\*$/m)
1165
+ assert.match(zhFiles['report.md'], /^> 岗位:HR Head .*判断:继续核验(VERIFY)$/m)
1078
1166
  const enFiles = exportCaseFiles(zhCase, { language: 'en' })
1079
1167
  assert.equal(enFiles['report.md'], renderReport(zhCase, { language: 'en' }))
1080
1168
  // the framework text follows the requested language; stored case data
1081
1169
  // (claim text, decisionReason, curated questions) is not translated
1082
- assert.match(enFiles['report.md'], /^- Decision: \*\*VERIFY\*\*$/m)
1083
- assert.match(enFiles['report.md'], /^Untouched dimensions \(10\):/m)
1170
+ assert.match(enFiles['report.md'], /^> Role: HR Head \| Location: unspecified \| Evidence as of: 2026-08-16 \| Decision: verify \(VERIFY\)$/m)
1171
+ assert.match(enFiles['report.md'], /## Appendix C - Investigation coverage/)
1084
1172
  assert.doesNotMatch(enFiles['report.md'], /- 判断:/)
1085
1173
  assert.equal(
1086
1174
  JSON.parse(enFiles['case.json']).language,
@@ -1137,3 +1225,147 @@ test('keeps the ingest draft defaults in both language tables', () => {
1137
1225
  assert.doesNotMatch(renderReport(ingestCase), /[\u4e00-\u9fff]/)
1138
1226
  assert.ok(generateInterviewQuestions(ingestCase).every(question => !/[\u4e00-\u9fff]/.test(question)))
1139
1227
  })
1228
+
1229
+ test('a corroborated identity still blocks PROCEED until an E3 registration source exists', () => {
1230
+ let scoutCase = createCase({ caseId: 'gate', companyName: 'Example Co', roleTitle: 'HR Head' })
1231
+ scoutCase = addSource(scoutCase, {
1232
+ sourceId: 'agg-a', type: 'public_database', title: 'Aggregator A',
1233
+ url: 'https://www.qixin.com/brand/gate', capturedAt: '2026-08-14T00:00:00.000Z',
1234
+ evidenceLevel: 'E2', status: 'captured',
1235
+ })
1236
+ scoutCase = addSource(scoutCase, {
1237
+ sourceId: 'agg-b', type: 'company_official', title: 'App Store listing',
1238
+ url: 'https://apps.apple.com/cn/app/gate/id1', capturedAt: '2026-08-14T00:00:00.000Z',
1239
+ evidenceLevel: 'E2', status: 'captured',
1240
+ })
1241
+ scoutCase = verifyIdentity(scoutCase, {
1242
+ legalEntity: 'Example Co Ltd',
1243
+ registrationNumber: 'REG-1',
1244
+ registeredRegion: 'Shenzhen',
1245
+ legalRepresentative: 'Example Person',
1246
+ brandRelationship: 'Operating entity',
1247
+ sourceIds: ['agg-a', 'agg-b'],
1248
+ fieldSources: {
1249
+ legalEntity: ['agg-a'],
1250
+ registrationNumber: ['agg-a', 'agg-b'],
1251
+ registeredRegion: ['agg-a'],
1252
+ legalRepresentative: ['agg-b'],
1253
+ brandRelationship: ['agg-b'],
1254
+ },
1255
+ })
1256
+ assert.equal(scoutCase.subject.identityStatus, 'corroborated')
1257
+
1258
+ // Cover every dimension with verified, informational claims so the only
1259
+ // remaining blocker is the identity tier itself.
1260
+ const dimensions = [
1261
+ 'identity', 'role_existence', 'reporting_line', 'mandate', 'company_and_founder',
1262
+ 'product_and_technology', 'market_and_competition', 'financing_and_commercialization',
1263
+ 'organization_and_hr', 'risk', 'role_fit',
1264
+ ]
1265
+ dimensions.forEach((dimension, index) => {
1266
+ scoutCase = addClaim(scoutCase, {
1267
+ claimId: `gate-${index}`,
1268
+ text: `Verified ${dimension} claim.`,
1269
+ status: 'verified',
1270
+ evidenceLevel: 'E2',
1271
+ dimension,
1272
+ impact: 'informational',
1273
+ sourceIds: ['agg-a'],
1274
+ confidenceNote: 'Two independent sources agree.',
1275
+ nextAction: 'None.',
1276
+ })
1277
+ })
1278
+ scoutCase = decideCase(scoutCase)
1279
+ assert.equal(scoutCase.decision, 'VERIFY')
1280
+ assert.equal(scoutCase.decisionReasonCode, 'identity')
1281
+ assert.match(scoutCase.decisionReason, /多源交叉核验/)
1282
+ assert.doesNotMatch(scoutCase.decisionReason, /尚有未核验结论/, 'nothing else is open in this case')
1283
+ })
1284
+
1285
+ test('new source-type hints and URL normalization behave as documented', () => {
1286
+ // 0.9.4 inference: first-party app stores and Chinese registry aggregators are
1287
+ // no longer lumped into `other`.
1288
+ assert.deepEqual(inferSourceType('https://apps.apple.com/cn/app/x/id1'), { type: 'company_official', inferred: true })
1289
+ assert.deepEqual(inferSourceType('https://play.google.com/store/apps/details?id=x'), { type: 'company_official', inferred: true })
1290
+ assert.deepEqual(inferSourceType('https://www.qixin.com/brand/x'), { type: 'public_database', inferred: true })
1291
+ assert.deepEqual(inferSourceType('https://m-harmony.qcc.com/pl/x.html'), { type: 'public_database', inferred: true })
1292
+ // Campaign parameters do not change source identity, content-selectors do.
1293
+ assert.equal(
1294
+ normalizeSourceUrl('https://example.com/a?utm_source=x&hmsr=y#frag'),
1295
+ 'https://example.com/a',
1296
+ )
1297
+ assert.equal(
1298
+ normalizeSourceUrl('https://example.com/a?id=42&utm_medium=z'),
1299
+ 'https://example.com/a?id=42',
1300
+ )
1301
+ })
1302
+
1303
+ test('verifyIdentity rejects field provenance that points outside sourceIds', () => {
1304
+ let scoutCase = createCase({ caseId: 'provenance', companyName: 'Example Co', roleTitle: 'HR Head' })
1305
+ scoutCase = addSource(scoutCase, {
1306
+ sourceId: 'a', type: 'public_database', title: 'A', url: 'https://www.qixin.com/brand/a',
1307
+ capturedAt: '2026-08-14T00:00:00.000Z', evidenceLevel: 'E2', status: 'captured',
1308
+ })
1309
+ scoutCase = addSource(scoutCase, {
1310
+ sourceId: 'b', type: 'company_official', title: 'B', url: 'https://apps.apple.com/cn/app/b/id1',
1311
+ capturedAt: '2026-08-14T00:00:00.000Z', evidenceLevel: 'E2', status: 'captured',
1312
+ })
1313
+ assert.throws(
1314
+ () => verifyIdentity(scoutCase, {
1315
+ legalEntity: 'Example Co Ltd',
1316
+ registrationNumber: 'REG-1',
1317
+ registeredRegion: 'Shenzhen',
1318
+ legalRepresentative: 'P',
1319
+ brandRelationship: 'brand',
1320
+ sourceIds: ['a', 'b'],
1321
+ fieldSources: { legalEntity: ['a'], registrationNumber: ['a', 'b'], registeredRegion: ['a'], legalRepresentative: ['b'], brandRelationship: ['missing'] },
1322
+ }),
1323
+ /Unknown source for identity field brandRelationship: missing/,
1324
+ )
1325
+ })
1326
+
1327
+ test('the reader-facing report body stays free of internal codes and keeps the stage order', () => {
1328
+ const report = renderReport(legacyCase())
1329
+ const appendixStart = report.indexOf('## 附录 A · 来源表')
1330
+ assert.ok(appendixStart > 0, 'the audit appendix exists')
1331
+ const body = report.slice(0, appendixStart)
1332
+
1333
+ // The readability contract: readers never see evidence codes, source ids, or
1334
+ // internal status/impact enum values in the narrative; those live in the
1335
+ // appendices with the glossary.
1336
+ assert.doesNotMatch(body, /\bE[0-3]\b/, 'no evidence codes in the reader-facing body')
1337
+ assert.doesNotMatch(body, /src-\d+/, 'no source ids in the reader-facing body')
1338
+ assert.doesNotMatch(
1339
+ body,
1340
+ /\b(verified|reported|inferred|contradicted|needs_verification|blocking|material|informational)\b/i,
1341
+ 'no internal enum values in the reader-facing body',
1342
+ )
1343
+ assert.match(body, /已核验|有报道|推断|未知/)
1344
+
1345
+ // Stage order is the navigation contract: verdict -> subject -> confirmed ->
1346
+ // open -> risks -> questions -> gap -> appendices.
1347
+ const stageHeadings = [
1348
+ '## 30 秒结论',
1349
+ '## 1. 公司与主体(已确认)',
1350
+ '## 2. 已确认的事实(阶段:可直接使用)',
1351
+ '## 3. 线索与待确认(阶段:先别当事实)',
1352
+ '## 4. 风险与红旗(阶段:已识别)',
1353
+ '## 5. 面试问题(按主题,最多 12 条)',
1354
+ '## 6. 距离下一步还差什么',
1355
+ '## 附录 A · 来源表',
1356
+ ]
1357
+ const positions = stageHeadings.map(heading => report.indexOf(heading))
1358
+ assert.ok(positions.every(position => position >= 0), 'every stage heading is present')
1359
+ assert.deepEqual([...positions].sort((left, right) => left - right), positions, 'stages render in order')
1360
+
1361
+ // The English framework keeps the same contract (case-authored prose is
1362
+ // deliberately not translated, so only heading/label checks apply here).
1363
+ const enReport = renderReport(createCase({
1364
+ caseId: 'readable-en', companyName: 'Readable Co', roleTitle: 'CTO', language: 'en',
1365
+ }))
1366
+ const enBody = enReport.slice(0, enReport.indexOf('## Appendix A - Sources'))
1367
+ assert.ok(enBody.length > 0, 'the English appendix boundary exists')
1368
+ assert.doesNotMatch(enBody, /\bE[0-3]\b/)
1369
+ assert.doesNotMatch(enBody, /src-\d+/)
1370
+ assert.match(enBody, /## 30-second verdict/)
1371
+ })