Archived
与 refersTo 目标检查同款:代码错是必然的错,而必然的错只有在装的时候被拦下才算好查。 台账地址 / 列名 / 模型名 / 视图模版 / submit 的验收清单逐条核; analyze 的出参没人消费报 warning —— 那是白烧一次模型调用。 顺带(R8,Task 7 评审挖出):全 auto 的步没有 guide 是正常形态, guide.min(1) 放宽为 z.string() + superRefine —— 没 auto 又不写 guide 照旧拒装。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
464 lines
19 KiB
TypeScript
464 lines
19 KiB
TypeScript
/**
|
|
* 判据谓词的名字必须在台账上 —— 安装期就挡下,别等跑到放行那一刻才炸。
|
|
*
|
|
* 这条门是拿血换来的:A2/A3/A4/A6/A7 五个活动的机检判据全都引用了
|
|
* 台账里根本不存在的名字(`candidatesConfirmed` / `unquoted` / `unpriced`
|
|
* / `missingQuoteFields` / `reasonLogged` ……)。S-FEEL 对未绑定的名字求值为假,
|
|
* 于是这些判据**永远达不了标**:Agent 把活办得再干净也过不了 submit 门,
|
|
* 连拒五拍熔断 —— MVP 整条流三轮都卡死在 A2,而清单本身语法挑不出毛病。
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { ActivityManifest } from '@contracts';
|
|
|
|
/* 两条审读的入参已在 domain 收窄到字段级 —— 这里直接用它,不拼假清单 */
|
|
type PredicateInput = Parameters<typeof acceptanceLevelFindings>[0];
|
|
type LedgerTable = ActivityManifest['declarations']['ledger'][number];
|
|
type Deliverable = ActivityManifest['contract']['requiredDeliverables'][number];
|
|
import { acceptanceLevelFindings } from '../internal/domain/static-audit.ts';
|
|
|
|
/** 一份最小可用清单 —— 只摆本测试关心的三处:判据 / 台账 / 案卷 */
|
|
function manifest(over: {
|
|
predicate?: string | undefined;
|
|
level?: 'L1' | 'L2' | 'L3';
|
|
fields?: string[];
|
|
caseItems?: string[];
|
|
inputProps?: string[];
|
|
}): PredicateInput {
|
|
const fields = over.fields ?? ['line_total'];
|
|
const caseItems = over.caseItems ?? [];
|
|
const inputProps = over.inputProps ?? [];
|
|
return {
|
|
contract: {
|
|
input: {
|
|
data: {
|
|
type: 'object',
|
|
properties: Object.fromEntries(inputProps.map((k) => [k, { type: 'string' }])),
|
|
},
|
|
},
|
|
acceptanceCriteria: [
|
|
{
|
|
key: 'k',
|
|
label: '测试判据',
|
|
level: over.level ?? 'L2',
|
|
...(over.predicate === undefined ? {} : { predicate: over.predicate }),
|
|
},
|
|
],
|
|
},
|
|
declarations: {
|
|
ledger: [
|
|
{
|
|
domain: 'd',
|
|
object: 'record',
|
|
kind: 'record' as const,
|
|
caseKey: 'rfq_ref',
|
|
label: '表',
|
|
fields: fields.map((name) => ({
|
|
name,
|
|
type: 'integer' as const,
|
|
primaryKey: false,
|
|
masked: false,
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
assets: {
|
|
businessModel: {
|
|
cmmnXml: caseItems.map((id) => `<caseFileItem id="${id}" name="x"/>`).join(''),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
const codes = (m: PredicateInput) => acceptanceLevelFindings(m).map((f) => f.code);
|
|
|
|
describe('判据谓词的名字必须绑得上', () => {
|
|
it('台账有这一列 —— 放行', () => {
|
|
expect(codes(manifest({ predicate: 'line_total > 0' }))).toEqual([]);
|
|
});
|
|
|
|
it('台账没有这个名字 —— 拒装', () => {
|
|
const found = acceptanceLevelFindings(manifest({ predicate: 'candidatesConfirmed >= 2' }));
|
|
expect(found.map((f) => f.code)).toContain('acceptance-predicate-unbound');
|
|
// 报错要指名道姓:说清是哪个名字绑不上,不然改的人还得自己猜
|
|
expect(found[0]?.message).toContain('candidatesConfirmed');
|
|
});
|
|
|
|
it('案卷项也算绑得上 —— 案卷项 id 就是任务视图里的键名', () => {
|
|
expect(codes(manifest({ predicate: 'benchmark_total > 0', caseItems: ['benchmark_total'] }))).toEqual([]);
|
|
});
|
|
|
|
it('起单入参也算绑得上', () => {
|
|
expect(codes(manifest({ predicate: 'eau > 0', inputProps: ['eau'] }))).toEqual([]);
|
|
});
|
|
|
|
it('两个名字里错一个,也要报出那一个', () => {
|
|
const found = acceptanceLevelFindings(
|
|
manifest({ predicate: 'alt_request_total = type_c_total', fields: ['alt_request_total'] }),
|
|
);
|
|
expect(found).toHaveLength(1);
|
|
expect(found[0]?.message).toContain('type_c_total');
|
|
});
|
|
|
|
it('保留字与字面量不当作台账名', () => {
|
|
expect(codes(manifest({ predicate: 'need_sourcing = true and line_total > 0', fields: ['need_sourcing', 'line_total'] }))).toEqual([]);
|
|
});
|
|
|
|
it('字符串字面量里的词不当作台账名', () => {
|
|
expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]);
|
|
});
|
|
|
|
it('`distinct` 声明出的名字也绑得上 —— 覆盖闭合判据的落点(2026-08-24)', () => {
|
|
const bound = manifest({ predicate: 'd_record_distinct_customer_code = d_record_rows', fields: ['customer_code'] });
|
|
(bound.declarations.ledger[0]!.fields[0] as { distinct?: boolean }).distinct = true;
|
|
expect(codes(bound)).toEqual([]);
|
|
// 没标 distinct,这个名字就不存在 —— 拒装,别让判据指着一个永远为假的读数
|
|
const unbound = manifest({ predicate: 'd_record_distinct_customer_code = d_record_rows', fields: ['customer_code'] });
|
|
expect(codes(unbound)).toEqual(['acceptance-predicate-unbound']);
|
|
});
|
|
|
|
it('L3 不机检 —— 没谓词也不报', () => {
|
|
expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]);
|
|
});
|
|
|
|
it('L2 没谓词照旧报等级谎报,且不误报绑定', () => {
|
|
const found = codes(manifest({ level: 'L2', predicate: undefined }));
|
|
expect(found).toEqual(['acceptance-level-misreported']);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 必交付物必须给产出地址 —— 不给的话放行门永远拒。
|
|
*
|
|
* 预检数的是「本单在这张表上写了几行」;地址不给,它退回拿 key 去任务视图里找,
|
|
* 而 key 是 `benchmark-table` 这样的短横线别名 —— 视图里永远没有这个键。
|
|
* A2/A3/A4/A6/A7 五个活动全没给,活办完了也放不了行(实测 A2 死于此)。
|
|
*/
|
|
import { deliverableAddressFindings } from '../internal/domain/static-audit.ts';
|
|
|
|
type DeliverableInput = Parameters<typeof deliverableAddressFindings>[0];
|
|
|
|
const LEDGER: LedgerTable[] = [
|
|
{ domain: 'quote_alternative', object: 'benchmark', kind: 'record' as const, caseKey: 'rfq_ref', label: '表', fields: [] },
|
|
];
|
|
|
|
function withDeliverables(items: Deliverable[]): DeliverableInput {
|
|
return { contract: { requiredDeliverables: items }, declarations: { ledger: LEDGER } };
|
|
}
|
|
|
|
describe('必交付物的产出地址', () => {
|
|
it('地址指向真实台账表 —— 放行', () => {
|
|
const found = deliverableAddressFindings(
|
|
withDeliverables([{ key: 'benchmark-table', label: '对标表', ledger: 'quote_alternative.benchmark' }]),
|
|
);
|
|
expect(found).toEqual([]);
|
|
});
|
|
|
|
it('没给地址 —— 拒装', () => {
|
|
const found = deliverableAddressFindings(withDeliverables([{ key: 'benchmark-table', label: '对标表' }]));
|
|
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
|
|
expect(found[0]?.message).toContain('对标表');
|
|
});
|
|
|
|
it('地址指向不存在的表 —— 拒装,并把声明过的表摆出来', () => {
|
|
const found = deliverableAddressFindings(
|
|
withDeliverables([{ key: 'k', label: '对标表', ledger: 'quote_alternative.nope' }]),
|
|
);
|
|
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
|
|
expect(found[0]?.message).toContain('quote_alternative.benchmark');
|
|
});
|
|
|
|
it('多件里错一件,只报那一件', () => {
|
|
const found = deliverableAddressFindings(
|
|
withDeliverables([
|
|
{ key: 'a', label: '对标表', ledger: 'quote_alternative.benchmark' },
|
|
{ key: 'b', label: '初筛清单' },
|
|
]),
|
|
);
|
|
expect(found).toHaveLength(1);
|
|
expect(found[0]?.message).toContain('初筛清单');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 台账性质要核得住 —— 这条门是拿一次真实的数据丢失换来的。
|
|
*
|
|
* A4 的报价草案本该一单一份,主键却只有 `pn`:第二单报同一个料号就 upsert 覆盖上一单。
|
|
* 跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
|
|
* 而主键带单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少。
|
|
* **作者没决定这张表是单据还是主数据,主键就写歪了** —— 而清单语法挑不出毛病。
|
|
*/
|
|
import { ledgerKindFindings } from '../internal/domain/static-audit.ts';
|
|
|
|
type LedgerInput = Parameters<typeof ledgerKindFindings>[0];
|
|
type Kind = 'record' | 'master' | 'derived';
|
|
|
|
function table(over: { kind?: Kind; caseKey?: string; pk?: string[]; cols?: string[] }): LedgerInput {
|
|
const cols = over.cols ?? ['rfq_ref', 'pn'];
|
|
const pk = over.pk ?? ['rfq_ref', 'pn'];
|
|
return {
|
|
declarations: {
|
|
ledger: [
|
|
{
|
|
domain: 'd',
|
|
object: 'o',
|
|
label: '表',
|
|
kind: over.kind ?? 'record',
|
|
...(over.caseKey === undefined ? {} : { caseKey: over.caseKey }),
|
|
fields: cols.map((name) => ({
|
|
name,
|
|
type: 'text' as const,
|
|
primaryKey: pk.includes(name),
|
|
masked: false,
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
const kindCodes = (m: LedgerInput) => ledgerKindFindings(m).map((f) => f.code);
|
|
|
|
describe('台账性质', () => {
|
|
it('单据:指明单标识且它在主键里 —— 放行', () => {
|
|
expect(kindCodes(table({ kind: 'record', caseKey: 'rfq_ref' }))).toEqual([]);
|
|
});
|
|
|
|
/* 这一条就是 A4 那次数据丢失的机检形态 */
|
|
it('单据没指明单标识 —— 拒装', () => {
|
|
const found = ledgerKindFindings(table({ kind: 'record', cols: ['pn'], pk: ['pn'] }));
|
|
expect(found.map((f) => f.code)).toContain('ledger-kind-invalid');
|
|
expect(found[0]?.message).toContain('caseKey');
|
|
});
|
|
|
|
it('单标识不在主键里 —— 拒装(单标识不进主键等于没有)', () => {
|
|
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'rfq_ref', pk: ['pn'] }));
|
|
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
|
|
expect(found[0]?.message).toContain('不是主键的一部分');
|
|
});
|
|
|
|
it('单标识不在自己字段里 —— 拒装', () => {
|
|
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'nope' }));
|
|
expect(found[0]?.message).toContain('不在它自己的字段里');
|
|
});
|
|
|
|
it('主数据:按业务实体分行,不要单标识 —— 放行', () => {
|
|
expect(kindCodes(table({ kind: 'master', cols: ['customer_code'], pk: ['customer_code'] }))).toEqual([]);
|
|
});
|
|
|
|
it('主数据却带着单标识 —— 拒装:它多半其实是单据,归错了类', () => {
|
|
const found = ledgerKindFindings(table({ kind: 'master', caseKey: 'rfq_ref' }));
|
|
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
|
|
expect(found[0]?.message).toContain('归错了类');
|
|
});
|
|
|
|
it('派生:按周期分行,不要单标识 —— 放行', () => {
|
|
expect(kindCodes(table({ kind: 'derived', cols: ['period'], pk: ['period'] }))).toEqual([]);
|
|
});
|
|
|
|
it('一个主键列都没有 —— 拒装:合并与去重无从谈起', () => {
|
|
const found = ledgerKindFindings(table({ kind: 'master', cols: ['a'], pk: [] }));
|
|
expect(found[0]?.message).toContain('主键列');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 行数读数 `域_对象_rows` —— 基座数出来的那一边。
|
|
*
|
|
* 它不是活动写得出的列,却必须是**绑得上的名字**:判据的两边不能都由活动自报,
|
|
* 否则活动数错一次就两边一起错、一起「对上」(实测:条目数报 9、表里躺着 10,
|
|
* 派单数与三类条数同源同错,`2 = 2` 顺利过关)。
|
|
* 但也不能一律放行 —— 对象名手滑写错,照旧要在装的时候拒。
|
|
*/
|
|
describe('行数读数认得出,写错的照旧拒装', () => {
|
|
it('声明过的表 → `域_对象_rows` 绑得上', () => {
|
|
expect(codes(manifest({ predicate: 'd_record_rows = line_total' }))).toEqual([]);
|
|
});
|
|
|
|
it('没声明过的表 → 照旧拒装(手滑一个字母,判据就永远达不了标)', () => {
|
|
expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **工具调用读数也算绑得上**(2026-08-03)——
|
|
* 基座按留痕数「这一单成功调了几次某工具」(`tool_<工具名去符号>_calls`),
|
|
* 声明过的工具一律先铺 0。判据的两边不能都由活动自报:A2 实测约十次一次,
|
|
* 判稳那一步压根没调行情工具就把料判成「稳」转 A,自报的依据列却写得漂亮。
|
|
*/
|
|
describe('工具调用读数绑得上', () => {
|
|
const withTool = (predicate: string) =>
|
|
acceptanceLevelFindings({
|
|
contract: {
|
|
acceptanceCriteria: [{ key: 'k', label: 'l', level: 'L2', predicate }],
|
|
input: { data: { type: 'object', properties: {} } },
|
|
},
|
|
declarations: { ledger: [], tools: [{ name: 'crm.history-price-read' }] },
|
|
assets: { businessModel: { cmmnXml: '' } },
|
|
} as never);
|
|
|
|
it('声明过的工具 → `tool_..._calls` 绑得上', () => {
|
|
expect(withTool('tool_crm_history_price_read_calls > 0')).toEqual([]);
|
|
});
|
|
|
|
it('没声明的工具照旧拒装 —— 声明面就是授权面,写错一个字当场拦下', () => {
|
|
expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
/* ── refersTo 的引用目标必须声明过(2026-08-24)──────────────────────── */
|
|
import { referenceFindings } from '../internal/domain/static-audit.ts';
|
|
|
|
const REF_LEDGER = (refersTo: string) => ({
|
|
declarations: {
|
|
ledger: [
|
|
{
|
|
domain: 'crm_coverage',
|
|
object: 'plan',
|
|
kind: 'record' as const,
|
|
caseKey: 'round_ref',
|
|
label: '计划',
|
|
fields: [
|
|
{ name: 'round_ref', type: 'text' as const, primaryKey: true, masked: false },
|
|
{ name: 'contact_code', type: 'text' as const, primaryKey: true, masked: false, refersTo },
|
|
],
|
|
},
|
|
{
|
|
domain: 'customer',
|
|
object: 'contact',
|
|
kind: 'master' as const,
|
|
label: '联系人',
|
|
fields: [{ name: 'contact_code', type: 'text' as const, primaryKey: true, masked: false }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
describe('refersTo 目标要在本活动的声明里 —— 写错的地址会变成永远查无此值的死墙', () => {
|
|
it('指向声明过的对象与列 —— 放行', () => {
|
|
expect(referenceFindings(REF_LEDGER('customer.contact.contact_code'))).toEqual([]);
|
|
});
|
|
|
|
it('表名写错 —— 拒装', () => {
|
|
const found = referenceFindings(REF_LEDGER('customer.contacts.contact_code'));
|
|
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
|
|
});
|
|
|
|
it('列名写错 —— 拒装', () => {
|
|
const found = referenceFindings(REF_LEDGER('customer.contact.code'));
|
|
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* ── 配方静态审(2026-08-29)──────────────────────────────────────────
|
|
*
|
|
* `autoRecipeFindings` 读的是**整份** `ActivityManifest`(不像本文件其余审读只收窄到
|
|
* 字段级),所以这里不能再用局部的 `manifest()` / `table()` 这类窄类型 helper ——
|
|
* 借 `activityManifestSchema.parse()` 把六区的默认值一次性填满,只手写真正必填的那几处。
|
|
*/
|
|
import { activityManifestSchema } from '@contracts';
|
|
import { autoRecipeFindings } from '../internal/domain/static-audit.ts';
|
|
|
|
/** 台账地址与列名全部真实声明过 —— `demo.record` 只有 `case_ref` / `note` 两列 */
|
|
function manifestFixture(): ActivityManifest {
|
|
return activityManifestSchema.parse({
|
|
identity: {
|
|
activityId: 'demo-activity',
|
|
name: '演示活动',
|
|
packageVersion: '1.0.0',
|
|
category: '演示',
|
|
sdkVersion: '1.0.0',
|
|
checksum: 'chk',
|
|
},
|
|
contract: {
|
|
activityId: 'demo-activity',
|
|
version: '1.0.0',
|
|
input: { data: { type: 'object', properties: {} } },
|
|
output: {},
|
|
authorization: { humanCallBudget: 0 },
|
|
resources: { budget: { steps: 10, tokens: 10000, activeMinutes: 10, humanCalls: 0 } },
|
|
},
|
|
declarations: {
|
|
models: ['m'],
|
|
ledger: [
|
|
{
|
|
domain: 'demo',
|
|
object: 'record',
|
|
kind: 'record',
|
|
caseKey: 'case_ref',
|
|
label: '演示记录',
|
|
fields: [
|
|
{ name: 'case_ref', type: 'text', primaryKey: true },
|
|
{ name: 'note', type: 'text' },
|
|
],
|
|
},
|
|
],
|
|
harness: {},
|
|
},
|
|
assets: {
|
|
businessModel: {
|
|
cmmnXml: '<caseFileItem id="S1" name="x"/>',
|
|
narrative: '演示说明书',
|
|
dmn: { drd: [], tables: [] },
|
|
steps: [],
|
|
},
|
|
},
|
|
samples: [{ name: '样例', input: {}, expect: { ports: ['done'] } }],
|
|
entry: { file: 'index.js', checksum: 'chk' },
|
|
});
|
|
}
|
|
|
|
describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () => {
|
|
const withRecipe = (auto: unknown) => {
|
|
const base = manifestFixture();
|
|
return {
|
|
...base,
|
|
assets: {
|
|
...base.assets,
|
|
businessModel: {
|
|
...base.assets.businessModel,
|
|
steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
|
|
},
|
|
},
|
|
} as never;
|
|
};
|
|
|
|
it('台账地址不在 declarations.ledger 里 → error', () => {
|
|
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }));
|
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true);
|
|
});
|
|
|
|
it('列名不在该对象的声明里 → error(列名写错 = 那一格永远是空的)', () => {
|
|
const findings = autoRecipeFindings(
|
|
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { 不存在的列: 'x' } }] }),
|
|
);
|
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('不存在的列'))).toBe(true);
|
|
});
|
|
|
|
it('each / lookup 的地址同样要在声明里', () => {
|
|
const findings = autoRecipeFindings(
|
|
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'mine:nope.nope' }, fields: {} }] }),
|
|
);
|
|
expect(findings.some((f) => f.message.includes('nope.nope'))).toBe(true);
|
|
});
|
|
|
|
it('in: 与 result: 不是台账地址,不该被当成台账来审', () => {
|
|
const findings = autoRecipeFindings(
|
|
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'in:pns' }, fields: {} }] }),
|
|
);
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
it('analyze 的出参没人消费 → warning(白烧一次模型调用)', () => {
|
|
const findings = autoRecipeFindings(
|
|
withRecipe({ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { once: true }, fields: {} }, output: { type: 'object' } }] }),
|
|
);
|
|
expect(findings.some((f) => f.severity === 'warn' && f.message.includes('items'))).toBe(true);
|
|
});
|
|
|
|
it('submit 那一笔所在的步没有人工验收清单 → error(系统提交不比模型提交宽松)', () => {
|
|
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'submit', rationale: 'x' }] }));
|
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('验收清单'))).toBe(true);
|
|
});
|
|
});
|