@dommaker/harness 1.0.0 → 1.1.0
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.
- package/CHANGELOG.md +12 -13
- package/dist/cli/commands/sync-docs/agents-syncer.d.ts.map +1 -1
- package/dist/cli/commands/sync-docs/agents-syncer.js +25 -4
- package/dist/cli/commands/sync-docs/agents-syncer.js.map +1 -1
- package/dist/completion-checkers/classify.d.ts +23 -0
- package/dist/completion-checkers/classify.d.ts.map +1 -0
- package/dist/completion-checkers/classify.js +37 -0
- package/dist/completion-checkers/classify.js.map +1 -0
- package/dist/completion-checkers/contract-presence.d.ts +12 -0
- package/dist/completion-checkers/contract-presence.d.ts.map +1 -0
- package/dist/completion-checkers/contract-presence.js +36 -0
- package/dist/completion-checkers/contract-presence.js.map +1 -0
- package/dist/completion-checkers/glob-match.d.ts +13 -0
- package/dist/completion-checkers/glob-match.d.ts.map +1 -0
- package/dist/completion-checkers/glob-match.js +58 -0
- package/dist/completion-checkers/glob-match.js.map +1 -0
- package/dist/completion-checkers/index.d.ts +19 -0
- package/dist/completion-checkers/index.d.ts.map +1 -0
- package/dist/completion-checkers/index.js +47 -0
- package/dist/completion-checkers/index.js.map +1 -0
- package/dist/completion-checkers/phase-format.d.ts +15 -0
- package/dist/completion-checkers/phase-format.d.ts.map +1 -0
- package/dist/completion-checkers/phase-format.js +42 -0
- package/dist/completion-checkers/phase-format.js.map +1 -0
- package/dist/completion-checkers/tdd-chain.d.ts +16 -0
- package/dist/completion-checkers/tdd-chain.d.ts.map +1 -0
- package/dist/completion-checkers/tdd-chain.js +57 -0
- package/dist/completion-checkers/tdd-chain.js.map +1 -0
- package/dist/completion-checkers/types.d.ts +77 -0
- package/dist/completion-checkers/types.d.ts.map +1 -0
- package/dist/completion-checkers/types.js +10 -0
- package/dist/completion-checkers/types.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/pretool-use-hook.d.ts +16 -0
- package/dist/pretool-use-hook.d.ts.map +1 -0
- package/dist/pretool-use-hook.js +68 -0
- package/dist/pretool-use-hook.js.map +1 -0
- package/package.json +1 -1
- package/src/CONTEXT.md +1 -0
- package/src/__tests__/pretool-use-hook.test.ts +82 -0
- package/src/cli/commands/__tests__/sync-docs-agents.test.ts +55 -0
- package/src/cli/commands/sync-docs/agents-syncer.ts +26 -6
- package/src/completion-checkers/CONTEXT.md +25 -0
- package/src/completion-checkers/__tests__/contract-presence.test.ts +44 -0
- package/src/completion-checkers/__tests__/phase-format.test.ts +60 -0
- package/src/completion-checkers/__tests__/tdd-chain.test.ts +99 -0
- package/src/completion-checkers/classify.ts +47 -0
- package/src/completion-checkers/contract-presence.ts +40 -0
- package/src/completion-checkers/glob-match.ts +54 -0
- package/src/completion-checkers/index.ts +19 -0
- package/src/completion-checkers/phase-format.ts +48 -0
- package/src/completion-checkers/tdd-chain.ts +63 -0
- package/src/completion-checkers/types.ts +84 -0
- package/src/index.ts +7 -0
- package/src/pretool-use-hook.ts +68 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verifyTddChain 测试(studio#160 验收:伪造引用三形态 / Tests: none waiver / 免检分类)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { verifyTddChain } from '../tdd-chain';
|
|
6
|
+
import type { CommitInput } from '../types';
|
|
7
|
+
|
|
8
|
+
const sha = (n: number) => n.toString(16).padStart(40, '0');
|
|
9
|
+
|
|
10
|
+
function commit(partial: Partial<CommitInput> & { sha: string }): CommitInput {
|
|
11
|
+
return { subject: 'phase(impl): x', body: '', files: ['src/a.ts'], ...partial };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('verifyTddChain', () => {
|
|
15
|
+
it('合法引用链 → pass(测试 commit 在前,实现 commit 引用它)', () => {
|
|
16
|
+
const commits = [
|
|
17
|
+
commit({ sha: sha(1), files: ['src/a.test.ts'], subject: 'phase(test): add test' }),
|
|
18
|
+
commit({ sha: sha(2), files: ['src/a.ts'], body: 'impl\n\nTested-By: ' + sha(1) }),
|
|
19
|
+
];
|
|
20
|
+
const result = verifyTddChain(commits);
|
|
21
|
+
expect(result.verdict).toBe('pass');
|
|
22
|
+
expect(result.commits.map((c) => c.verdict)).toEqual(['skip', 'pass']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('短 sha(7 位前缀)引用可命中', () => {
|
|
26
|
+
const commits = [
|
|
27
|
+
commit({ sha: sha(1), files: ['__tests__/a.ts'] }),
|
|
28
|
+
commit({ sha: sha(2), body: 'Tested-By: ' + sha(1).slice(0, 7) }),
|
|
29
|
+
];
|
|
30
|
+
expect(verifyTddChain(commits).verdict).toBe('pass');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('伪造引用:sha 不在提交集内 → violation', () => {
|
|
34
|
+
const commits = [commit({ sha: sha(2), body: 'Tested-By: ' + sha(9) })];
|
|
35
|
+
const result = verifyTddChain(commits);
|
|
36
|
+
expect(result.verdict).toBe('violation');
|
|
37
|
+
expect(result.commits[0].reason).toContain('不在本 WU 提交集内');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('伪造引用:被引 commit 位置在本 commit 之后 → violation', () => {
|
|
41
|
+
const commits = [
|
|
42
|
+
commit({ sha: sha(2), body: 'Tested-By: ' + sha(1) }),
|
|
43
|
+
commit({ sha: sha(1), files: ['src/a.test.ts'] }),
|
|
44
|
+
];
|
|
45
|
+
const result = verifyTddChain(commits);
|
|
46
|
+
expect(result.verdict).toBe('violation');
|
|
47
|
+
expect(result.commits[0].reason).toContain('位置不在本 commit 之前');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('伪造引用:被引 commit 文件清单不含测试文件 → violation', () => {
|
|
51
|
+
const commits = [
|
|
52
|
+
commit({ sha: sha(1), files: ['src/b.ts'] }),
|
|
53
|
+
commit({ sha: sha(2), body: 'Tested-By: ' + sha(1) }),
|
|
54
|
+
];
|
|
55
|
+
const result = verifyTddChain(commits);
|
|
56
|
+
expect(result.verdict).toBe('violation');
|
|
57
|
+
expect(result.commits[1].reason).toContain('未命中 test_globs');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('缺 Tested-By trailer → violation', () => {
|
|
61
|
+
const result = verifyTddChain([commit({ sha: sha(1) })]);
|
|
62
|
+
expect(result.verdict).toBe('violation');
|
|
63
|
+
expect(result.commits[0].reason).toContain('缺 Tested-By trailer');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('Tests: none trailer → waiver(放行,不算 violation)', () => {
|
|
67
|
+
const result = verifyTddChain([commit({ sha: sha(1), body: 'chore\n\nTests: none' })]);
|
|
68
|
+
expect(result.verdict).toBe('pass');
|
|
69
|
+
expect(result.commits[0].verdict).toBe('waiver');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('纯测试 commit 与纯非代码 commit 天然免检', () => {
|
|
73
|
+
const commits = [
|
|
74
|
+
commit({ sha: sha(1), files: ['src/a.test.ts'] }),
|
|
75
|
+
commit({ sha: sha(2), files: ['README.md', 'docs/guide.md'] }),
|
|
76
|
+
];
|
|
77
|
+
const result = verifyTddChain(commits);
|
|
78
|
+
expect(result.verdict).toBe('pass');
|
|
79
|
+
expect(result.commits.every((c) => c.verdict === 'skip')).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('混合 commit(代码+测试同 commit)仍需引用链', () => {
|
|
83
|
+
const commits = [commit({ sha: sha(1), files: ['src/a.ts', 'src/a.test.ts'] })];
|
|
84
|
+
expect(verifyTddChain(commits).verdict).toBe('violation');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('自定义 test_globs / noncode_globs 生效', () => {
|
|
88
|
+
const commits = [commit({ sha: sha(1), files: ['assets/logo.png'] })];
|
|
89
|
+
const withGlob = verifyTddChain(commits, { noncodeGlobs: ['**/*.png'] });
|
|
90
|
+
expect(withGlob.commits[0].verdict).toBe('skip');
|
|
91
|
+
expect(verifyTddChain(commits).commits[0].verdict).toBe('violation');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('checker 关闭 / 总开关关闭 → skip', () => {
|
|
95
|
+
const commits = [commit({ sha: sha(1) })];
|
|
96
|
+
expect(verifyTddChain(commits, { checkers: { tddChain: false } }).verdict).toBe('skip');
|
|
97
|
+
expect(verifyTddChain(commits, { enabled: false }).verdict).toBe('skip');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commit 文件分类(tdd-chain 与 phase-format 共享同一份分类结果,Q4 定稿)
|
|
3
|
+
*
|
|
4
|
+
* - 纯非代码(全部命中 noncode_globs)与纯测试(全部命中 test_globs)commit 天然免检
|
|
5
|
+
* - 混合 commit 只要触到代码文件即需走引用链
|
|
6
|
+
* - 空文件清单按免检处理
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CommitInput, CompletionCheckersConfig } from './types';
|
|
10
|
+
import { DEFAULT_NONCODE_GLOBS, DEFAULT_TEST_GLOBS, matchAnyGlob } from './glob-match';
|
|
11
|
+
|
|
12
|
+
/** 单 commit 文件分类结果 */
|
|
13
|
+
export interface CommitFileClassification {
|
|
14
|
+
/** 无代码文件(纯测试 / 纯非代码 / 空清单)→ 天然免检 */
|
|
15
|
+
exempt: boolean;
|
|
16
|
+
/** 文件清单命中 test_globs(被引 commit 须满足) */
|
|
17
|
+
hasTests: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 从配置取 glob(缺省落默认) */
|
|
21
|
+
export function resolveGlobs(config: CompletionCheckersConfig): { testGlobs: string[]; noncodeGlobs: string[] } {
|
|
22
|
+
return {
|
|
23
|
+
testGlobs: config.testGlobs ?? DEFAULT_TEST_GLOBS,
|
|
24
|
+
noncodeGlobs: config.noncodeGlobs ?? DEFAULT_NONCODE_GLOBS,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 分类单 commit 的文件清单 */
|
|
29
|
+
export function classifyCommitFiles(
|
|
30
|
+
commit: CommitInput,
|
|
31
|
+
testGlobs: string[],
|
|
32
|
+
noncodeGlobs: string[],
|
|
33
|
+
): CommitFileClassification {
|
|
34
|
+
if (commit.files.length === 0) {
|
|
35
|
+
return { exempt: true, hasTests: false };
|
|
36
|
+
}
|
|
37
|
+
let hasTests = false;
|
|
38
|
+
let hasCode = false;
|
|
39
|
+
for (const file of commit.files) {
|
|
40
|
+
if (matchAnyGlob(file, testGlobs)) {
|
|
41
|
+
hasTests = true;
|
|
42
|
+
} else if (!matchAnyGlob(file, noncodeGlobs)) {
|
|
43
|
+
hasCode = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { exempt: !hasCode, hasTests };
|
|
47
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contract-presence:通用契约在场引擎(Q5 定稿)
|
|
3
|
+
*
|
|
4
|
+
* - 按 yml contracts 类型清单查表:类型不在清单内 = skip(不算违规)
|
|
5
|
+
* - 类型 → 判定方法的映射是代码不是配置(CONTRACT_JUDGMENTS);
|
|
6
|
+
* 首个活跃条目 review → context.reviewReport 在场(studio agent-loop 已解析字段,不重复解析)
|
|
7
|
+
* - 类型在清单内但无判定方法注册 = violation(暴露配置与代码失配)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CompletionCheckersConfig, ContractPresenceContext, ContractPresenceResult } from './types';
|
|
11
|
+
|
|
12
|
+
/** 类型 → 判定方法映射(代码,不是配置)。新增契约类型 = 在此加一条 + 测试 */
|
|
13
|
+
const CONTRACT_JUDGMENTS: Record<string, (context: ContractPresenceContext) => boolean> = {
|
|
14
|
+
review: (context) => context.reviewReport != null,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** 契约在场判定 */
|
|
18
|
+
export function verifyContractPresence(
|
|
19
|
+
type: string,
|
|
20
|
+
context: ContractPresenceContext,
|
|
21
|
+
config: CompletionCheckersConfig = {},
|
|
22
|
+
): ContractPresenceResult {
|
|
23
|
+
if (config.enabled === false || config.checkers?.contractPresence === false) {
|
|
24
|
+
return { checker: 'contract-presence', verdict: 'skip', detail: 'checker 已禁用' };
|
|
25
|
+
}
|
|
26
|
+
if (!(config.contracts ?? []).includes(type)) {
|
|
27
|
+
return { checker: 'contract-presence', verdict: 'skip', detail: `类型 ${type} 无 contracts 表项` };
|
|
28
|
+
}
|
|
29
|
+
const judge = CONTRACT_JUDGMENTS[type];
|
|
30
|
+
if (!judge) {
|
|
31
|
+
return {
|
|
32
|
+
checker: 'contract-presence',
|
|
33
|
+
verdict: 'violation',
|
|
34
|
+
detail: `类型 ${type} 已在 contracts 声明但无判定方法注册(配置与代码失配)`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return judge(context)
|
|
38
|
+
? { checker: 'contract-presence', verdict: 'pass' }
|
|
39
|
+
: { checker: 'contract-presence', verdict: 'violation', detail: `类型 ${type} 契约标记缺失` };
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量 glob 匹配(支持 `**`、`*`、`?`),用于 commits 文件清单分类。
|
|
3
|
+
* 不匹配文件系统,纯字符串判定。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 默认测试文件 glob(Q2 定稿) */
|
|
7
|
+
export const DEFAULT_TEST_GLOBS = ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**'];
|
|
8
|
+
|
|
9
|
+
/** 默认非代码文件 glob(兜底;权威清单由 yml noncode_globs 供给) */
|
|
10
|
+
export const DEFAULT_NONCODE_GLOBS = ['**/*.md', '**/*.mdx', '**/*.txt', 'docs/**'];
|
|
11
|
+
|
|
12
|
+
function escapeRegExp(s: string): string {
|
|
13
|
+
return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** glob → 锚定正则。前缀 `**` + `/` 匹配零或多段路径;`*` 不跨 `/`;`?` 匹配单个非 `/` 字符 */
|
|
17
|
+
function globToRegExp(glob: string): RegExp {
|
|
18
|
+
let re = '';
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < glob.length) {
|
|
21
|
+
const c = glob[i];
|
|
22
|
+
if (c === '*') {
|
|
23
|
+
if (glob[i + 1] === '*') {
|
|
24
|
+
if (glob[i + 2] === '/') {
|
|
25
|
+
re += '(?:[^/]+/)*';
|
|
26
|
+
i += 3;
|
|
27
|
+
} else {
|
|
28
|
+
re += '.*';
|
|
29
|
+
i += 2;
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
re += '[^/]*';
|
|
33
|
+
i += 1;
|
|
34
|
+
}
|
|
35
|
+
} else if (c === '?') {
|
|
36
|
+
re += '[^/]';
|
|
37
|
+
i += 1;
|
|
38
|
+
} else {
|
|
39
|
+
re += escapeRegExp(c);
|
|
40
|
+
i += 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return new RegExp('^' + re + '$');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 单 glob 匹配 */
|
|
47
|
+
export function matchGlob(file: string, glob: string): boolean {
|
|
48
|
+
return globToRegExp(glob).test(file);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 命中任一 glob */
|
|
52
|
+
export function matchAnyGlob(file: string, globs: string[]): boolean {
|
|
53
|
+
return globs.some((g) => matchGlob(file, g));
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completion Checkers(T7-E1,studio#160)
|
|
3
|
+
*
|
|
4
|
+
* WU 收尾软观测三件套——纯判定函数库:
|
|
5
|
+
* - verifyTddChain:实现 commit 的 Tested-By 引用链验证
|
|
6
|
+
* - verifyPhaseFormat:phase(...) subject 结构验证
|
|
7
|
+
* - verifyContractPresence:通用契约在场引擎
|
|
8
|
+
*
|
|
9
|
+
* 与 ConstraintCheck 闭环注册表无关:直接 export,不注册、不碰 checkers/index.ts。
|
|
10
|
+
* 函数不碰文件系统与 git;commits 由调用方(studio 第四段守卫,T7-E2)供给。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export * from './types';
|
|
14
|
+
export { DEFAULT_TEST_GLOBS, DEFAULT_NONCODE_GLOBS, matchGlob, matchAnyGlob } from './glob-match';
|
|
15
|
+
export { classifyCommitFiles, resolveGlobs } from './classify';
|
|
16
|
+
export type { CommitFileClassification } from './classify';
|
|
17
|
+
export { verifyTddChain, TESTED_BY_RE, TESTS_NONE_RE } from './tdd-chain';
|
|
18
|
+
export { verifyPhaseFormat, PHASE_SUBJECT_RE } from './phase-format';
|
|
19
|
+
export { verifyContractPresence } from './contract-presence';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phase-format:WU 提交 subject 的 phase 结构验证(Q4 定稿)
|
|
3
|
+
*
|
|
4
|
+
* 协议(正则写死在函数里,不进配置):
|
|
5
|
+
* - 全部非 merge commit 的 subject 命中 `^phase\([a-z0-9-]+\):\s+\S`
|
|
6
|
+
* - merge commit 出现即记违规
|
|
7
|
+
* - 阶段名不维护词表不查
|
|
8
|
+
* - 文件分类口径与 tdd-chain 共享(classify.ts),本 checker 不做免检——subject 结构要求覆盖全部非 merge commit
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CommitInput, CommitVerdict, CompletionCheckersConfig, PhaseFormatResult } from './types';
|
|
12
|
+
|
|
13
|
+
/** phase subject 结构(写死) */
|
|
14
|
+
export const PHASE_SUBJECT_RE = /^phase\([a-z0-9-]+\):\s+\S/;
|
|
15
|
+
|
|
16
|
+
/** merge commit 的 subject 启发式(调用方未显式给 isMerge 时兜底) */
|
|
17
|
+
const MERGE_SUBJECT_RE = /^Merge\s/;
|
|
18
|
+
|
|
19
|
+
/** 判定 merge commit:优先 isMerge 显式字段,缺省按 subject 启发式 */
|
|
20
|
+
function isMergeCommit(commit: CommitInput): boolean {
|
|
21
|
+
return commit.isMerge ?? MERGE_SUBJECT_RE.test(commit.subject);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** phase 格式验证 */
|
|
25
|
+
export function verifyPhaseFormat(
|
|
26
|
+
commits: CommitInput[],
|
|
27
|
+
config: CompletionCheckersConfig = {},
|
|
28
|
+
): PhaseFormatResult {
|
|
29
|
+
if (config.enabled === false || config.checkers?.phaseFormat === false) {
|
|
30
|
+
return { checker: 'phase-format', verdict: 'skip', commits: [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const verdicts: CommitVerdict[] = commits.map((commit) => {
|
|
34
|
+
if (isMergeCommit(commit)) {
|
|
35
|
+
return { sha: commit.sha, verdict: 'violation', reason: 'WU 提交集内出现 merge commit' };
|
|
36
|
+
}
|
|
37
|
+
if (!PHASE_SUBJECT_RE.test(commit.subject)) {
|
|
38
|
+
return { sha: commit.sha, verdict: 'violation', reason: `subject 不合规:${commit.subject}` };
|
|
39
|
+
}
|
|
40
|
+
return { sha: commit.sha, verdict: 'pass' };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
checker: 'phase-format',
|
|
45
|
+
verdict: verdicts.some((v) => v.verdict === 'violation') ? 'violation' : 'pass',
|
|
46
|
+
commits: verdicts,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tdd-chain:实现 commit 的测试引用链验证(Q2/Q3 定稿)
|
|
3
|
+
*
|
|
4
|
+
* 协议(写死为机制本体,不进配置):
|
|
5
|
+
* - 实现 commit 必须带 trailer `Tested-By: <sha>`
|
|
6
|
+
* - 被引 sha 须 a) 在本 WU 提交集内 b) 序列位置在本 commit 之前(比位置不比时间戳)c) 其文件清单命中 test_globs
|
|
7
|
+
* - 豁免:trailer `Tests: none` → waiver(放行记台账);纯非代码/纯测试 commit 天然免检
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CommitInput, CommitVerdict, CompletionCheckersConfig, TddChainResult } from './types';
|
|
11
|
+
import { classifyCommitFiles, resolveGlobs } from './classify';
|
|
12
|
+
|
|
13
|
+
/** Tested-By 引用协议(写死) */
|
|
14
|
+
export const TESTED_BY_RE = /^Tested-By:\s*([0-9a-f]{7,40})\s*$/gim;
|
|
15
|
+
|
|
16
|
+
/** Tests: none 豁免协议(写死) */
|
|
17
|
+
export const TESTS_NONE_RE = /^Tests:\s*none\s*$/im;
|
|
18
|
+
|
|
19
|
+
/** 引用链验证:伪造引用(不存在 / 位置在后 / 不含测试文件)一律记 violation */
|
|
20
|
+
export function verifyTddChain(
|
|
21
|
+
commits: CommitInput[],
|
|
22
|
+
config: CompletionCheckersConfig = {},
|
|
23
|
+
): TddChainResult {
|
|
24
|
+
if (config.enabled === false || config.checkers?.tddChain === false) {
|
|
25
|
+
return { checker: 'tdd-chain', verdict: 'skip', commits: [] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { testGlobs, noncodeGlobs } = resolveGlobs(config);
|
|
29
|
+
// 文件分类一次性算好,与同批 commit 的其他 checker 共享口径
|
|
30
|
+
const classifications = commits.map((c) => classifyCommitFiles(c, testGlobs, noncodeGlobs));
|
|
31
|
+
|
|
32
|
+
const verdicts: CommitVerdict[] = commits.map((commit, i) => {
|
|
33
|
+
if (classifications[i].exempt) {
|
|
34
|
+
return { sha: commit.sha, verdict: 'skip', reason: '纯测试/纯非代码 commit,天然免检' };
|
|
35
|
+
}
|
|
36
|
+
if (TESTS_NONE_RE.test(commit.body)) {
|
|
37
|
+
return { sha: commit.sha, verdict: 'waiver', reason: 'Tests: none 显式豁免' };
|
|
38
|
+
}
|
|
39
|
+
TESTED_BY_RE.lastIndex = 0;
|
|
40
|
+
const m = TESTED_BY_RE.exec(commit.body);
|
|
41
|
+
if (!m) {
|
|
42
|
+
return { sha: commit.sha, verdict: 'violation', reason: '缺 Tested-By trailer 且未声明 Tests: none' };
|
|
43
|
+
}
|
|
44
|
+
const ref = m[1].toLowerCase();
|
|
45
|
+
const j = commits.findIndex((c) => c.sha.toLowerCase().startsWith(ref));
|
|
46
|
+
if (j === -1) {
|
|
47
|
+
return { sha: commit.sha, verdict: 'violation', reason: `Tested-By 引用 ${ref} 不在本 WU 提交集内` };
|
|
48
|
+
}
|
|
49
|
+
if (j >= i) {
|
|
50
|
+
return { sha: commit.sha, verdict: 'violation', reason: `Tested-By 引用 ${ref} 位置不在本 commit 之前` };
|
|
51
|
+
}
|
|
52
|
+
if (!classifications[j].hasTests) {
|
|
53
|
+
return { sha: commit.sha, verdict: 'violation', reason: `被引 commit ${ref} 文件清单未命中 test_globs` };
|
|
54
|
+
}
|
|
55
|
+
return { sha: commit.sha, verdict: 'pass' };
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
checker: 'tdd-chain',
|
|
60
|
+
verdict: verdicts.some((v) => v.verdict === 'violation') ? 'violation' : 'pass',
|
|
61
|
+
commits: verdicts,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completion Checkers 类型定义(T7-E1,studio#160)
|
|
3
|
+
*
|
|
4
|
+
* 三个纯判定函数共享的输入/输出/配置类型。
|
|
5
|
+
* 与 ConstraintCheck 闭环注册表无关:直接 export,不注册、不碰 checkers/index.ts。
|
|
6
|
+
* 函数不碰文件系统与 git,commits 由调用方(studio 侧 git log)供给。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** WU 提交输入(有序,base..HEAD 升序) */
|
|
10
|
+
export interface CommitInput {
|
|
11
|
+
/** 完整 sha */
|
|
12
|
+
sha: string;
|
|
13
|
+
/** commit subject(第一行) */
|
|
14
|
+
subject: string;
|
|
15
|
+
/** commit body(trailer 所在) */
|
|
16
|
+
body: string;
|
|
17
|
+
/** 本 commit 触碰的文件清单(相对路径) */
|
|
18
|
+
files: string[];
|
|
19
|
+
/**
|
|
20
|
+
* 是否 merge commit。调用方有 git 数据时应显式供给(如 %P 父数 > 1);
|
|
21
|
+
* 缺省时按 subject 启发式判定(`/^Merge\s/`)。
|
|
22
|
+
*/
|
|
23
|
+
isMerge?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 判定结论:pass / violation / waiver(豁免放行,记台账)/ skip(不适用,不记台账) */
|
|
27
|
+
export type CheckerVerdict = 'pass' | 'violation' | 'waiver' | 'skip';
|
|
28
|
+
|
|
29
|
+
/** 单 commit 判定明细 */
|
|
30
|
+
export interface CommitVerdict {
|
|
31
|
+
sha: string;
|
|
32
|
+
verdict: CheckerVerdict;
|
|
33
|
+
reason?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** verifyTddChain 结果。整体 verdict 只取 pass/violation/skip;waiver 是 commit 级结论 */
|
|
37
|
+
export interface TddChainResult {
|
|
38
|
+
checker: 'tdd-chain';
|
|
39
|
+
verdict: 'pass' | 'violation' | 'skip';
|
|
40
|
+
commits: CommitVerdict[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** verifyPhaseFormat 结果 */
|
|
44
|
+
export interface PhaseFormatResult {
|
|
45
|
+
checker: 'phase-format';
|
|
46
|
+
verdict: 'pass' | 'violation' | 'skip';
|
|
47
|
+
commits: CommitVerdict[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** verifyContractPresence 上下文(类型→字段的判定方法映射是代码不是配置) */
|
|
51
|
+
export interface ContractPresenceContext {
|
|
52
|
+
/** review 类型契约:studio agent-loop 已解析的 metadata.reviewReport */
|
|
53
|
+
reviewReport?: unknown;
|
|
54
|
+
[key: string]: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** verifyContractPresence 结果 */
|
|
58
|
+
export interface ContractPresenceResult {
|
|
59
|
+
checker: 'contract-presence';
|
|
60
|
+
verdict: 'pass' | 'violation' | 'skip';
|
|
61
|
+
detail?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* completion_checkers 配置(对应 yml 顶层键 `completion_checkers:`,studio 侧自解)。
|
|
66
|
+
* 只配开关/glob/契约类型清单;协议格式(Tested-By、Tests: none、phase 结构)写死为机制本体。
|
|
67
|
+
* 缺省 = 全开 + 默认 glob。
|
|
68
|
+
*/
|
|
69
|
+
export interface CompletionCheckersConfig {
|
|
70
|
+
/** 总开关,缺省 true */
|
|
71
|
+
enabled?: boolean;
|
|
72
|
+
/** 各 checker 开关,缺省全开 */
|
|
73
|
+
checkers?: {
|
|
74
|
+
tddChain?: boolean;
|
|
75
|
+
phaseFormat?: boolean;
|
|
76
|
+
contractPresence?: boolean;
|
|
77
|
+
};
|
|
78
|
+
/** 测试文件 glob,缺省 DEFAULT_TEST_GLOBS */
|
|
79
|
+
testGlobs?: string[];
|
|
80
|
+
/** 非代码文件 glob,缺省 DEFAULT_NONCODE_GLOBS */
|
|
81
|
+
noncodeGlobs?: string[];
|
|
82
|
+
/** 契约类型清单:类型在清单内才判定,无表项 = skip */
|
|
83
|
+
contracts?: string[];
|
|
84
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,13 @@ export * from './safety';
|
|
|
66
66
|
// ========================================
|
|
67
67
|
export * from './verification';
|
|
68
68
|
|
|
69
|
+
// ========================================
|
|
70
|
+
// Completion Checkers 导出(T7-E1,studio#160)
|
|
71
|
+
// WU 收尾软观测三纯判定函数:tdd-chain / phase-format / contract-presence。
|
|
72
|
+
// 纯函数直接 export,不进 ConstraintCheck 闭环注册表。
|
|
73
|
+
// ========================================
|
|
74
|
+
export * from './completion-checkers';
|
|
75
|
+
|
|
69
76
|
// ========================================
|
|
70
77
|
// Dashboard 数据导出
|
|
71
78
|
// ========================================
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PreToolUse 执法脚本 — command-gate hook 固化版(studio#153)
|
|
3
|
+
*
|
|
4
|
+
* 内容 = studio-agent provider-hooks.buildHookScriptContent 生成脚本(#147)的固化版,
|
|
5
|
+
* 归属归位:谁的东西谁发货——CommandGate 是 harness 的,hook 脚本随 harness 包出厂。
|
|
6
|
+
* 编译产物 = 包内 dist/pretool-use-hook.js,provider hook 配置(codex hooks.json /
|
|
7
|
+
* kimi config.toml)直接指向 require.resolve('@dommaker/harness') 同目录的该文件,
|
|
8
|
+
* 不再由 studio-agent 按 worktree 生成、不再内嵌绝对路径。
|
|
9
|
+
*
|
|
10
|
+
* 语义(与生成版一致):
|
|
11
|
+
* - stdin 收 provider PreToolUse JSON(tool_input.command);
|
|
12
|
+
* - CommandGate.isAllowed 判定 block 级黑名单,命中 → stderr 写原因 + exit 2
|
|
13
|
+
* (codex/kimi 阻断语义:exit 2 + stderr 阻断,其余 exit code fail-open);
|
|
14
|
+
* - fail-open:stdin 非 JSON / 缺字段 / CommandGate 异常一律放行——宁可漏拦
|
|
15
|
+
* 也不全体 Bash 秒断,拦截层只是纵深防御的一道;
|
|
16
|
+
* - warn/audit 级命中同样放行(isAllowed 只看 block 级),与生成版行为一致。
|
|
17
|
+
*/
|
|
18
|
+
import { CommandGate } from './gates/command';
|
|
19
|
+
|
|
20
|
+
/** hook 标识:与 studio-agent 生成版同一 marker(配置幂等检测共用口径) */
|
|
21
|
+
export const HOOK_MARKER = 'harness-command-gate';
|
|
22
|
+
|
|
23
|
+
interface PreToolUseInput {
|
|
24
|
+
tool_input?: { command?: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 判定一次 PreToolUse 是否放行。
|
|
29
|
+
* block 级命中 → allowed=false;warn/audit/干净命令/坏输入一律 allowed=true。
|
|
30
|
+
*/
|
|
31
|
+
export function decidePreToolUse(rawStdin: string): { allowed: boolean; command: string } {
|
|
32
|
+
let input: PreToolUseInput = {};
|
|
33
|
+
try {
|
|
34
|
+
input = JSON.parse(rawStdin || '{}') as PreToolUseInput;
|
|
35
|
+
} catch {
|
|
36
|
+
// 非 JSON stdin:放行
|
|
37
|
+
}
|
|
38
|
+
const command = (input.tool_input && input.tool_input.command) || '';
|
|
39
|
+
try {
|
|
40
|
+
const gate = new CommandGate();
|
|
41
|
+
return { allowed: gate.isAllowed(command), command };
|
|
42
|
+
} catch {
|
|
43
|
+
// CommandGate 加载/判定异常:fail-open
|
|
44
|
+
return { allowed: true, command };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 执行一次 hook:返回进程 exit code(2 = 阻断,0 = 放行)。
|
|
50
|
+
* 阻断时原因写 stderr(provider 把 stderr 作为阻断原因回填模型)。
|
|
51
|
+
*/
|
|
52
|
+
export function runPreToolUseHook(rawStdin: string): number {
|
|
53
|
+
const { allowed, command } = decidePreToolUse(rawStdin);
|
|
54
|
+
if (!allowed) {
|
|
55
|
+
console.error(`[${HOOK_MARKER}] blocked: ${command}`);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* istanbul ignore next -- 进程入口仅做 stdin 收集 + exit,逻辑由 runPreToolUseHook 单测覆盖 */
|
|
62
|
+
if (require.main === module) {
|
|
63
|
+
let raw = '';
|
|
64
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
65
|
+
process.stdin.on('end', () => {
|
|
66
|
+
process.exit(runPreToolUseHook(raw));
|
|
67
|
+
});
|
|
68
|
+
}
|