@dommaker/harness 0.17.1 → 0.19.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 +31 -0
- package/README.md +5 -4
- package/bin/harness.js +9 -2
- package/bin/install-precommit-hook.sh +76 -0
- package/dist/architecture/cross-project-checker.js +2 -2
- package/dist/architecture/cross-project-checker.js.map +1 -1
- package/dist/cli/commands/constraints-report.js +2 -2
- package/dist/cli/commands/constraints-report.js.map +1 -1
- package/dist/cli/commands/constraints-retire.d.ts +24 -6
- package/dist/cli/commands/constraints-retire.d.ts.map +1 -1
- package/dist/cli/commands/constraints-retire.js +96 -19
- package/dist/cli/commands/constraints-retire.js.map +1 -1
- package/dist/cli/commands/doc-freshness-check.js +1 -1
- package/dist/cli/commands/doc-freshness-check.js.map +1 -1
- package/dist/cli/commands/init.js +14 -14
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/sync-docs/index.d.ts.map +1 -1
- package/dist/cli/commands/sync-docs/index.js +14 -6
- package/dist/cli/commands/sync-docs/index.js.map +1 -1
- package/dist/cli/commands/sync-docs/project-reader.d.ts.map +1 -1
- package/dist/cli/commands/sync-docs/project-reader.js +9 -7
- package/dist/cli/commands/sync-docs/project-reader.js.map +1 -1
- package/dist/cli/commands/update-user-model.d.ts +1 -0
- package/dist/cli/commands/update-user-model.d.ts.map +1 -1
- package/dist/cli/commands/update-user-model.js +12 -5
- package/dist/cli/commands/update-user-model.js.map +1 -1
- package/dist/cli/commands/validate.js +3 -3
- package/dist/cli/commands/validate.js.map +1 -1
- package/dist/cli/session-mining/text.js +1 -1
- package/dist/cli/session-mining/text.js.map +1 -1
- package/dist/core/constraints/agent-prompt-renderer.d.ts +32 -0
- package/dist/core/constraints/agent-prompt-renderer.d.ts.map +1 -0
- package/dist/core/constraints/agent-prompt-renderer.js +58 -0
- package/dist/core/constraints/agent-prompt-renderer.js.map +1 -0
- package/dist/core/constraints/check-cache.d.ts +48 -3
- package/dist/core/constraints/check-cache.d.ts.map +1 -1
- package/dist/core/constraints/check-cache.js +53 -5
- package/dist/core/constraints/check-cache.js.map +1 -1
- package/dist/core/constraints/definitions/iron-laws.js +2 -2
- package/dist/core/constraints/definitions/iron-laws.js.map +1 -1
- package/dist/core/constraints/index.d.ts +4 -0
- package/dist/core/constraints/index.d.ts.map +1 -1
- package/dist/core/constraints/index.js +7 -1
- package/dist/core/constraints/index.js.map +1 -1
- package/dist/core/constraints/injection-drift.d.ts +1 -1
- package/dist/core/constraints/injection-drift.d.ts.map +1 -1
- package/dist/core/constraints/injection-drift.js +1 -1
- package/dist/core/constraints/injection-drift.js.map +1 -1
- package/dist/core/effective-constraints.d.ts +2 -1
- package/dist/core/effective-constraints.d.ts.map +1 -1
- package/dist/core/effective-constraints.js +2 -1
- package/dist/core/effective-constraints.js.map +1 -1
- package/dist/core/project-config-loader.d.ts +2 -1
- package/dist/core/project-config-loader.d.ts.map +1 -1
- package/dist/core/project-config-loader.js +8 -2
- package/dist/core/project-config-loader.js.map +1 -1
- package/dist/hooks/bootstrap.js +1 -1
- package/dist/hooks/bootstrap.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/knowledge/store.js +1 -1
- package/dist/knowledge/store.js.map +1 -1
- package/dist/knowledge/types.d.ts +2 -0
- package/dist/knowledge/types.d.ts.map +1 -1
- package/dist/knowledge/types.js.map +1 -1
- package/dist/safety/output-guardrail.js +2 -2
- package/dist/safety/output-guardrail.js.map +1 -1
- package/dist/spec/annotation-checker.js +1 -1
- package/dist/spec/annotation-checker.js.map +1 -1
- package/dist/types/project-config.d.ts +15 -0
- package/dist/types/project-config.d.ts.map +1 -1
- package/dist/utils/file-walk.d.ts +13 -1
- package/dist/utils/file-walk.d.ts.map +1 -1
- package/dist/utils/file-walk.js +16 -3
- package/dist/utils/file-walk.js.map +1 -1
- package/package.json +9 -5
- package/src/__tests__/checkpoint.test.ts +2 -2
- package/src/__tests__/clean-state-extra.test.ts +1 -1
- package/src/__tests__/constraint-doctor.test.ts +1 -1
- package/src/__tests__/doctor-extra.test.ts +0 -9
- package/src/__tests__/effective-constraints.test.ts +21 -0
- package/src/__tests__/hooks-pipeline.test.ts +1 -1
- package/src/__tests__/iron-laws.test.ts +11 -0
- package/src/__tests__/passes-gate-extension.test.ts +3 -3
- package/src/__tests__/passes-gate-extra.test.ts +1 -1
- package/src/__tests__/performance-analyzer.test.ts +0 -2
- package/src/__tests__/traces.test.ts +1 -2
- package/src/architecture/cross-project-checker.ts +2 -2
- package/src/cli/commands/CONTEXT.md +1 -1
- package/src/cli/commands/__tests__/analyze-sessions.test.ts +178 -0
- package/src/cli/commands/__tests__/check-drift.test.ts +1 -1
- package/src/cli/commands/__tests__/check-skip-output.test.ts +0 -2
- package/src/cli/commands/__tests__/command.test.ts +1 -1
- package/src/cli/commands/__tests__/constraints-report.test.ts +1 -1
- package/src/cli/commands/__tests__/constraints-retire.test.ts +165 -4
- package/src/cli/commands/__tests__/failure.test.ts +0 -1
- package/src/cli/commands/__tests__/init.test.ts +40 -0
- package/src/cli/commands/__tests__/release.test.ts +162 -0
- package/src/cli/commands/__tests__/status-extra.test.ts +0 -2
- package/src/cli/commands/__tests__/status.test.ts +0 -2
- package/src/cli/commands/__tests__/sync-docs.test.ts +90 -0
- package/src/cli/commands/__tests__/update-user-model.test.ts +205 -0
- package/src/cli/commands/constraints-report.ts +2 -2
- package/src/cli/commands/constraints-retire.ts +114 -21
- package/src/cli/commands/doc-freshness-check.ts +1 -1
- package/src/cli/commands/init.ts +14 -14
- package/src/cli/commands/sync-docs/index.ts +20 -7
- package/src/cli/commands/sync-docs/project-reader.ts +10 -8
- package/src/cli/commands/update-user-model.ts +15 -5
- package/src/cli/commands/validate.ts +3 -3
- package/src/cli/session-mining/text.ts +1 -1
- package/src/context/__tests__/progressive-loader.test.ts +6 -6
- package/src/core/CONTEXT.md +2 -2
- package/src/core/constraints/__tests__/agent-prompt-renderer.test.ts +129 -0
- package/src/core/constraints/__tests__/check-cache.test.ts +227 -0
- package/src/core/constraints/__tests__/injection-drift.test.ts +1 -1
- package/src/core/constraints/__tests__/interceptor.test.ts +3 -3
- package/src/core/constraints/agent-prompt-renderer.ts +71 -0
- package/src/core/constraints/check-cache.ts +75 -3
- package/src/core/constraints/definitions/iron-laws.ts +2 -2
- package/src/core/constraints/index.ts +10 -0
- package/src/core/constraints/injection-drift.ts +1 -1
- package/src/core/effective-constraints.ts +2 -1
- package/src/core/project-config-loader.ts +8 -2
- package/src/gates/__tests__/acceptance.test.ts +0 -1
- package/src/gates/__tests__/command.test.ts +1 -1
- package/src/hooks/bootstrap.ts +1 -1
- package/src/index.ts +8 -0
- package/src/knowledge/__tests__/doctor.test.ts +2 -2
- package/src/knowledge/__tests__/ingest.test.ts +2 -2
- package/src/knowledge/__tests__/migration.test.ts +0 -6
- package/src/knowledge/__tests__/store.test.ts +14 -1
- package/src/knowledge/store.ts +1 -1
- package/src/knowledge/types.ts +2 -0
- package/src/llm/__tests__/adapter.test.ts +1 -1
- package/src/monitoring/__tests__/knowledge-doctor.test.ts +0 -1
- package/src/monitoring/__tests__/performance-collector.test.ts +1 -2
- package/src/safety/output-guardrail.ts +2 -2
- package/src/spec/__tests__/annotation-checker.test.ts +0 -1
- package/src/spec/annotation-checker.ts +1 -1
- package/src/types/project-config.ts +12 -0
- package/src/utils/__tests__/file-walk.test.ts +53 -0
- package/src/utils/file-walk.ts +23 -3
- package/src/verification/__tests__/loop.test.ts +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 目标:覆盖 autoCommit、detectBugs 完整流程、异常分支
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { describe, it, expect, beforeAll, afterAll
|
|
7
|
+
import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
|
|
8
8
|
import { CleanStateManager } from '../core/session/clean-state';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { describe, it, expect, beforeAll, afterAll, jest } from '@jest/globals';
|
|
6
6
|
import { ConstraintDoctor } from '../monitoring/constraint-doctor';
|
|
7
7
|
import type { TraceAnomaly } from '../types/trace';
|
|
8
|
-
import type { LLMAdapter, Message, LLMOptions
|
|
8
|
+
import type { LLMAdapter, Message, LLMOptions } from '../llm/types';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
|
|
@@ -5,25 +5,16 @@
|
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import * as os from 'os';
|
|
8
|
-
import { TraceCollector } from '../monitoring/traces';
|
|
9
|
-
import { TraceAnalyzer } from '../monitoring/trace-analyzer';
|
|
10
8
|
import { ConstraintDoctor, createDoctor } from '../monitoring/constraint-doctor';
|
|
11
9
|
import type { ExecutionTrace, TraceAnomaly } from '../types/trace';
|
|
12
10
|
|
|
13
11
|
describe('ConstraintDoctor', () => {
|
|
14
12
|
let tempDir: string;
|
|
15
|
-
let collector: TraceCollector;
|
|
16
|
-
let analyzer: TraceAnalyzer;
|
|
17
13
|
let doctor: ConstraintDoctor;
|
|
18
14
|
|
|
19
15
|
beforeEach(() => {
|
|
20
16
|
tempDir = path.join(os.tmpdir(), `harness-traces-${Date.now()}`);
|
|
21
17
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
22
|
-
collector = new TraceCollector({
|
|
23
|
-
traceFile: path.join(tempDir, 'execution.log'),
|
|
24
|
-
enabled: true,
|
|
25
|
-
});
|
|
26
|
-
analyzer = new TraceAnalyzer(collector);
|
|
27
18
|
doctor = createDoctor();
|
|
28
19
|
});
|
|
29
20
|
|
|
@@ -85,6 +85,27 @@ constraints:
|
|
|
85
85
|
expect(constraints.length).toBe(BUILTIN_TOTAL - SCENE_PROMPTS.length - 2);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
it('config.yml 禁用:custom 约束同样从生效集移除(retire 落 config.yml 的退役路径)', () => {
|
|
89
|
+
const dir = setupProject(
|
|
90
|
+
`
|
|
91
|
+
constraints:
|
|
92
|
+
my_project_rule:
|
|
93
|
+
enabled: false
|
|
94
|
+
`,
|
|
95
|
+
`
|
|
96
|
+
custom_constraints:
|
|
97
|
+
my_project_rule:
|
|
98
|
+
level: iron_law
|
|
99
|
+
rule: MY RULE
|
|
100
|
+
message: 项目自定义
|
|
101
|
+
`
|
|
102
|
+
);
|
|
103
|
+
const constraints = getEffectiveConstraints(dir);
|
|
104
|
+
const ids = constraints.map(c => c.id);
|
|
105
|
+
|
|
106
|
+
expect(ids).not.toContain('my_project_rule');
|
|
107
|
+
});
|
|
108
|
+
|
|
88
109
|
it('preset: relaxed 裁剪生效集', () => {
|
|
89
110
|
const dir = setupProject(`preset: relaxed\n`);
|
|
90
111
|
const constraints = getEffectiveConstraints(dir);
|
|
@@ -206,7 +206,7 @@ describe('HookPipeline', () => {
|
|
|
206
206
|
registry.register(makeHook('enabled'));
|
|
207
207
|
registry.register(makeHook('disabled', { enabled: false }));
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
await pipeline.run('before', ctx);
|
|
210
210
|
|
|
211
211
|
expect(ctx.calls).toEqual(['enabled']);
|
|
212
212
|
expect(ctx.calls).not.toContain('disabled');
|
|
@@ -82,6 +82,17 @@ describe('Constraint System', () => {
|
|
|
82
82
|
expect(PROMPTS['no_skill_without_test'].appliesTo).toEqual(['agent-skill']);
|
|
83
83
|
expect(PROMPTS['no_model_for_deterministic'].appliesTo).toEqual(['llm-app']);
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
it('no_completion_without_verification 不硬编码具体命令示例(#25:口径由项目声明)', () => {
|
|
87
|
+
const law = IRON_LAWS['no_completion_without_verification'];
|
|
88
|
+
expect(law.promptInjection).not.toMatch(/npm test|npm run build/);
|
|
89
|
+
expect(law.description).not.toMatch(/npm test|npm run build/);
|
|
90
|
+
expect(law.promptInjection).not.toContain('完整');
|
|
91
|
+
expect(law.description).not.toContain('完整');
|
|
92
|
+
expect(law.promptInjection).toContain('项目声明的测试');
|
|
93
|
+
expect(law.description).toContain('项目声明的测试');
|
|
94
|
+
expect(law.promptInjection).toContain('不得凭记忆声称完成');
|
|
95
|
+
});
|
|
85
96
|
});
|
|
86
97
|
|
|
87
98
|
describe('注册表闭环', () => {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
8
8
|
import { PassesGate, createPassesGate } from '../core/validators/passes-gate';
|
|
9
|
-
import type { PassesGateExtension
|
|
9
|
+
import type { PassesGateExtension } from '../types/passes-gate';
|
|
10
10
|
|
|
11
11
|
describe('PassesGate Extension', () => {
|
|
12
12
|
let passesGate: PassesGate;
|
|
@@ -100,7 +100,7 @@ describe('PassesGate Extension', () => {
|
|
|
100
100
|
const extension: PassesGateExtension = {
|
|
101
101
|
name: 'puppeteer',
|
|
102
102
|
description: 'Puppeteer E2E tests',
|
|
103
|
-
run: async (
|
|
103
|
+
run: async (_workDir) => ({
|
|
104
104
|
passed: true,
|
|
105
105
|
command: 'puppeteer',
|
|
106
106
|
output: 'E2E passed',
|
|
@@ -116,7 +116,7 @@ describe('PassesGate Extension', () => {
|
|
|
116
116
|
it('扩展 run 方法应返回 TaskTestResult', async () => {
|
|
117
117
|
const extension: PassesGateExtension = {
|
|
118
118
|
name: 'mock',
|
|
119
|
-
run: async (
|
|
119
|
+
run: async (_workDir, _task) => {
|
|
120
120
|
// 模拟测试运行
|
|
121
121
|
return {
|
|
122
122
|
passed: true,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
|
6
6
|
import { PassesGate, createPassesGate } from '../core/validators/passes-gate';
|
|
7
|
-
import { mkdirSync, rmSync, writeFileSync
|
|
7
|
+
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
|
|
10
10
|
describe('PassesGate - 补充覆盖', () => {
|
|
@@ -6,7 +6,6 @@ import { PerformanceAnalyzer, createPerformanceAnalyzer } from '../monitoring/pe
|
|
|
6
6
|
import { PerformanceCollector } from '../monitoring/performance-collector';
|
|
7
7
|
import type { PerformanceTrace, PerformanceSummary } from '../types/performance';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
|
-
import * as path from 'path';
|
|
10
9
|
|
|
11
10
|
// Mock fs
|
|
12
11
|
jest.mock('fs', () => ({
|
|
@@ -25,7 +24,6 @@ jest.mock('../monitoring/performance-collector', () => ({
|
|
|
25
24
|
}));
|
|
26
25
|
|
|
27
26
|
const mockFs = fs as jest.Mocked<typeof fs>;
|
|
28
|
-
const MockPerformanceCollector = PerformanceCollector as jest.MockedClass<typeof PerformanceCollector>;
|
|
29
27
|
|
|
30
28
|
describe('PerformanceAnalyzer', () => {
|
|
31
29
|
let analyzer: PerformanceAnalyzer;
|
|
@@ -7,7 +7,6 @@ import * as path from 'path';
|
|
|
7
7
|
import * as os from 'os';
|
|
8
8
|
import { TraceCollector } from '../monitoring/traces';
|
|
9
9
|
import { TraceAnalyzer } from '../monitoring/trace-analyzer';
|
|
10
|
-
import type { ExecutionTrace } from '../types/trace';
|
|
11
10
|
|
|
12
11
|
describe('TraceCollector', () => {
|
|
13
12
|
let tempDir: string;
|
|
@@ -175,7 +174,7 @@ describe('TraceCollector', () => {
|
|
|
175
174
|
});
|
|
176
175
|
|
|
177
176
|
test('should return 0 when backup dir does not exist', () => {
|
|
178
|
-
|
|
177
|
+
collector.cleanupOldFiles(30);
|
|
179
178
|
// Dir exists (created by constructor), so this won't hit line 259.
|
|
180
179
|
// Create a collector with a path in non-existent dir to test that branch.
|
|
181
180
|
const tmpRoot = path.join(os.tmpdir(), `harness-traces-nonexistent-${Date.now()}`);
|
|
@@ -323,8 +323,8 @@ function isBreakingChange(change: ApiChange): boolean {
|
|
|
323
323
|
|
|
324
324
|
function getDependents(project: string, config: CrossProjectConfig): string[] {
|
|
325
325
|
return Object.entries(config.dependencies)
|
|
326
|
-
.filter(([
|
|
327
|
-
.map(([name
|
|
326
|
+
.filter(([, deps]) => deps.includes(project))
|
|
327
|
+
.map(([name]) => name);
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
interface ApiChange {
|
|
@@ -6,7 +6,7 @@ CLI 子命令实现:24 个顶层命令 + constraints 治理子命令 report/re
|
|
|
6
6
|
## 核心导出(24 顶层命令)
|
|
7
7
|
check / validate / passes-gate / init / report / status / spec / acceptance / performance / security / contract / review / command / sync-docs / knowledge / sdd / failure / posteval-plan / release / analyze-sessions / update-user-model / constraints / doc-freshness-check / spec-baseline-check
|
|
8
8
|
|
|
9
|
-
`constraints` 下挂治理子命令:`constraints report`(使用统计 + 退役候选诊断 + 配置健康 + 注入漂移,`--export` 脱敏)、`constraints retire`(交互选择 +
|
|
9
|
+
`constraints` 下挂治理子命令:`constraints report`(使用统计 + 退役候选诊断 + 配置健康 + 注入漂移,`--export` 脱敏)、`constraints retire`(交互选择 + 人确认退役;带 id 直达需显式 `--yes`(#24 人确认闸门),无 `--yes` 报错 + 非零退出码且不落盘;内置落 config.yml retired 元数据,custom 落 custom-constraints.yml 条目 retired 段(#82 D6 一处真相)+ KnowledgeStore 沉淀 + CLAUDE.md 注入段同步)。
|
|
10
10
|
|
|
11
11
|
## 依赖关系
|
|
12
12
|
- 依赖 `src/core/constraints/` 约束引擎
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* analyze-sessions 命令测试(O6)
|
|
3
|
+
*
|
|
4
|
+
* Seam:analyzeSessions(options) 公开入口。
|
|
5
|
+
* 隔离面:
|
|
6
|
+
* - os.homedir → 测试临时目录(memory 规则库落 TEST_HOME)
|
|
7
|
+
* - readTranscriptSessions → fixture 会话(extractCorrectionMatches/tokenize 等纯函数保持真实)
|
|
8
|
+
* - CLAUDE_TRANSCRIPTS_DIR → 测试临时目录(决定 transcripts 目录是否存在)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { analyzeSessions } from '../analyze-sessions';
|
|
14
|
+
import { readTranscriptSessions, type MinedSession } from '../../session-mining';
|
|
15
|
+
|
|
16
|
+
const TEST_HOME = '/tmp/harness-analyze-test-home';
|
|
17
|
+
|
|
18
|
+
jest.mock('os', () => ({
|
|
19
|
+
...jest.requireActual('os'),
|
|
20
|
+
homedir: () => '/tmp/harness-analyze-test-home',
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
jest.mock('../../session-mining', () => ({
|
|
24
|
+
...jest.requireActual('../../session-mining'),
|
|
25
|
+
readTranscriptSessions: jest.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.mock('chalk', () => {
|
|
29
|
+
const id = (s: string) => s;
|
|
30
|
+
const chalkFn = Object.assign(id, {
|
|
31
|
+
gray: id,
|
|
32
|
+
blue: id,
|
|
33
|
+
green: id,
|
|
34
|
+
yellow: id,
|
|
35
|
+
cyan: id,
|
|
36
|
+
bold: id,
|
|
37
|
+
red: id,
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
__esModule: true,
|
|
41
|
+
default: chalkFn,
|
|
42
|
+
gray: id,
|
|
43
|
+
blue: id,
|
|
44
|
+
green: id,
|
|
45
|
+
yellow: id,
|
|
46
|
+
cyan: id,
|
|
47
|
+
bold: id,
|
|
48
|
+
red: id,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const mockReadTranscriptSessions = readTranscriptSessions as jest.MockedFunction<
|
|
53
|
+
typeof readTranscriptSessions
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
const TRANSCRIPTS_DIR = path.join(TEST_HOME, 'transcripts');
|
|
57
|
+
|
|
58
|
+
function mkSession(partial: Partial<MinedSession> = {}): MinedSession {
|
|
59
|
+
return {
|
|
60
|
+
id: 'session-1',
|
|
61
|
+
date: '2026-08-15',
|
|
62
|
+
mtimeMs: Date.now(),
|
|
63
|
+
turns: [
|
|
64
|
+
{ role: 'user', content: '我不是说要用中文吗' },
|
|
65
|
+
{ role: 'assistant', content: '收到,改用中文' },
|
|
66
|
+
],
|
|
67
|
+
toolCalls: [],
|
|
68
|
+
...partial,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lastJsonOutput(consoleSpy: jest.SpyInstance): Record<string, unknown> {
|
|
73
|
+
const jsonLine = consoleSpy.mock.calls.map(c => c[0]).join('\n');
|
|
74
|
+
return JSON.parse(jsonLine);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('analyze-sessions command', () => {
|
|
78
|
+
let consoleSpy: jest.SpyInstance;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
jest.clearAllMocks();
|
|
82
|
+
fs.rmSync(TEST_HOME, { recursive: true, force: true });
|
|
83
|
+
fs.mkdirSync(path.join(TEST_HOME, '.claude', 'projects', '-root-projects', 'memory'), { recursive: true });
|
|
84
|
+
fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
|
|
85
|
+
process.env.CLAUDE_TRANSCRIPTS_DIR = TRANSCRIPTS_DIR;
|
|
86
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
consoleSpy.mockRestore();
|
|
91
|
+
delete process.env.CLAUDE_TRANSCRIPTS_DIR;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('transcripts 目录不存在:提示 No transcripts directory found', async () => {
|
|
95
|
+
process.env.CLAUDE_TRANSCRIPTS_DIR = path.join(TEST_HOME, 'missing-dir');
|
|
96
|
+
|
|
97
|
+
await analyzeSessions({});
|
|
98
|
+
|
|
99
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No transcripts directory found'));
|
|
100
|
+
expect(mockReadTranscriptSessions).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('窗口内无会话:提示 No sessions found(默认最近 7 天)', async () => {
|
|
104
|
+
mockReadTranscriptSessions.mockReturnValue([
|
|
105
|
+
mkSession({ id: 'old', mtimeMs: Date.now() - 30 * 86_400_000 }),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
await analyzeSessions({});
|
|
109
|
+
|
|
110
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No sessions found in the last 7 days'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('--days 1:只统计最近 1 天(mtimeMs 窗口过滤)', async () => {
|
|
114
|
+
mockReadTranscriptSessions.mockReturnValue([
|
|
115
|
+
mkSession({ id: 'today' }),
|
|
116
|
+
mkSession({ id: 'two-days-ago', mtimeMs: Date.now() - 2 * 86_400_000 }),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
await analyzeSessions({ days: 1 });
|
|
120
|
+
|
|
121
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Analyzing 1 sessions (last 1 days)'));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('缺省 --days:窗口为 7 天,两天前会话仍计入', async () => {
|
|
125
|
+
mockReadTranscriptSessions.mockReturnValue([
|
|
126
|
+
mkSession({ id: 'today' }),
|
|
127
|
+
mkSession({ id: 'two-days-ago', mtimeMs: Date.now() - 2 * 86_400_000 }),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
await analyzeSessions({});
|
|
131
|
+
|
|
132
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Analyzing 2 sessions (last 7 days)'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('--json:纠正句跨 3 会话聚合为 correction 候选', async () => {
|
|
136
|
+
mockReadTranscriptSessions.mockReturnValue([
|
|
137
|
+
mkSession({ id: 's1' }),
|
|
138
|
+
mkSession({ id: 's2' }),
|
|
139
|
+
mkSession({ id: 's3' }),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
await analyzeSessions({ json: true });
|
|
143
|
+
|
|
144
|
+
const output = lastJsonOutput(consoleSpy);
|
|
145
|
+
expect(output.sessions).toBe(3);
|
|
146
|
+
expect(output.corrections).toBe(3);
|
|
147
|
+
expect(output.candidates).toEqual(
|
|
148
|
+
expect.arrayContaining([
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
source: 'correction',
|
|
151
|
+
frequency: 3,
|
|
152
|
+
pattern: '我不是说',
|
|
153
|
+
}),
|
|
154
|
+
]),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('--json:跨会话重复概念产出 ngram 候选', async () => {
|
|
159
|
+
const ngramText = { role: 'user', content: '数据库迁移方案的详细讨论' };
|
|
160
|
+
mockReadTranscriptSessions.mockReturnValue([
|
|
161
|
+
mkSession({
|
|
162
|
+
id: 's1',
|
|
163
|
+
turns: [ngramText, { role: 'assistant', content: '好的' }],
|
|
164
|
+
}),
|
|
165
|
+
mkSession({
|
|
166
|
+
id: 's2',
|
|
167
|
+
turns: [ngramText, { role: 'assistant', content: '明白' }],
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
await analyzeSessions({ json: true });
|
|
172
|
+
|
|
173
|
+
const output = lastJsonOutput(consoleSpy);
|
|
174
|
+
const ngramCandidates = (output.candidates as Array<{ source: string }>)
|
|
175
|
+
.filter(c => c.source === 'ngram');
|
|
176
|
+
expect(ngramCandidates.length).toBeGreaterThan(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -82,7 +82,7 @@ describe('check 命令注入漂移警告', () => {
|
|
|
82
82
|
expect(output).toContain('⚠️⚠️');
|
|
83
83
|
expect(output).toContain('agent 上下文中的规则与已安装 harness 版本不一致');
|
|
84
84
|
expect(output).toContain('0.0.1-old');
|
|
85
|
-
expect(output).toContain('npx harness init');
|
|
85
|
+
expect(output).toContain('npx @dommaker/harness init');
|
|
86
86
|
// 不阻断:检查仍通过、未调用 process.exit
|
|
87
87
|
expect(output).toContain('约束检查通过');
|
|
88
88
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { check } from '../check';
|
|
8
|
-
import * as fs from 'fs';
|
|
9
8
|
import { constraintChecker } from '../../../core/constraints/checker';
|
|
10
|
-
import { ProjectConfigLoader } from '../../../core/project-config-loader';
|
|
11
9
|
|
|
12
10
|
// Mock fs
|
|
13
11
|
jest.mock('fs', () => ({
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { executeCommand } from '../command';
|
|
6
|
-
import { createCommandGate, getCommandRiskLevel
|
|
6
|
+
import { createCommandGate, getCommandRiskLevel } from '../../../gates';
|
|
7
7
|
|
|
8
8
|
jest.mock('../../../gates', () => ({
|
|
9
9
|
createCommandGate: jest.fn(),
|
|
@@ -290,7 +290,7 @@ describe('constraintsReport 注入漂移小节(ADR-0001 决策 7)', () => {
|
|
|
290
290
|
expect(output).toContain('0.0.1-old');
|
|
291
291
|
expect(output).toContain(`缺失: ${originalLine}`);
|
|
292
292
|
expect(output).toContain(`多余: ${editedLine}`);
|
|
293
|
-
expect(output).toContain('npx harness init');
|
|
293
|
+
expect(output).toContain('npx @dommaker/harness init');
|
|
294
294
|
});
|
|
295
295
|
|
|
296
296
|
it('重复章节:输出重复章节提示', async () => {
|
|
@@ -40,6 +40,24 @@ function readConfig(root: string): any {
|
|
|
40
40
|
return yaml.load(fs.readFileSync(path.join(root, '.harness', 'config.yml'), 'utf-8'));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
const CUSTOM_YML = `
|
|
44
|
+
custom_constraints:
|
|
45
|
+
my_custom_rule:
|
|
46
|
+
level: iron_law
|
|
47
|
+
rule: 禁止引入 X
|
|
48
|
+
message: X 已由平台能力替代
|
|
49
|
+
promptInjection: 禁止引入 X
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
function writeCustom(root: string, yml: string): void {
|
|
53
|
+
fs.mkdirSync(path.join(root, '.harness'), { recursive: true });
|
|
54
|
+
fs.writeFileSync(path.join(root, '.harness', 'custom-constraints.yml'), yml, 'utf-8');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readCustom(root: string): any {
|
|
58
|
+
return yaml.load(fs.readFileSync(path.join(root, '.harness', 'custom-constraints.yml'), 'utf-8'));
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
let logSpy: jest.SpyInstance;
|
|
44
62
|
|
|
45
63
|
beforeEach(() => {
|
|
@@ -184,12 +202,121 @@ describe('retireConstraint 执行逻辑', () => {
|
|
|
184
202
|
retireConstraint(root, 'capability_sync', { now: FIXED_NOW });
|
|
185
203
|
expect(getEffectiveConstraints(root).some(c => c.id === 'capability_sync')).toBe(false);
|
|
186
204
|
});
|
|
205
|
+
|
|
206
|
+
it('custom 约束退役:落 custom-constraints.yml retired 段(不写 config.yml),生效集与注入段移除', () => {
|
|
207
|
+
const root = makeTmpProject();
|
|
208
|
+
writeCustom(root, CUSTOM_YML);
|
|
209
|
+
const before = renderConstraintsSection(getEffectiveConstraints(root), '0.0.0-test');
|
|
210
|
+
expect(before).toContain('**my_custom_rule**');
|
|
211
|
+
fs.writeFileSync(path.join(root, 'CLAUDE.md'), `# 项目\n\n## Governance Rules\n${before}\n其他内容\n`, 'utf-8');
|
|
212
|
+
|
|
213
|
+
const result = retireConstraint(root, 'my_custom_rule', { reason: '作用对象已从代码库消失', now: FIXED_NOW });
|
|
214
|
+
|
|
215
|
+
expect(result.status).toBe('retired');
|
|
216
|
+
expect(result.landing).toBe('custom-constraints.yml');
|
|
217
|
+
expect(result.claudeMdSynced).toBe(true);
|
|
218
|
+
// config.yml 不产生该 id 段
|
|
219
|
+
expect(fs.existsSync(path.join(root, '.harness', 'config.yml'))).toBe(false);
|
|
220
|
+
// yml 保留规则原文 + retired 元数据
|
|
221
|
+
const custom = readCustom(root).custom_constraints.my_custom_rule;
|
|
222
|
+
expect(custom.rule).toBe('禁止引入 X');
|
|
223
|
+
expect(custom.promptInjection).toBe('禁止引入 X');
|
|
224
|
+
expect(custom.retired.at).toBe(FIXED_NOW.toISOString());
|
|
225
|
+
expect(custom.retired.reason).toBe('作用对象已从代码库消失');
|
|
226
|
+
// 生效集移除
|
|
227
|
+
expect(getEffectiveConstraints(root).some(c => c.id === 'my_custom_rule')).toBe(false);
|
|
228
|
+
// 注入段同步移除
|
|
229
|
+
const after = fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf-8');
|
|
230
|
+
expect(after).not.toContain('**my_custom_rule**');
|
|
231
|
+
expect(after).toContain('其他内容');
|
|
232
|
+
// 打印的恢复提示指向 yml
|
|
233
|
+
printRetireResult(result);
|
|
234
|
+
const output = logSpy.mock.calls.map(c => String(c[0])).join('\n');
|
|
235
|
+
expect(output).toContain('custom_constraints.my_custom_rule.retired');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('custom 重复退役:yml 已带 retired → already_retired,不覆盖原元数据', () => {
|
|
239
|
+
const root = makeTmpProject();
|
|
240
|
+
writeCustom(root, CUSTOM_YML);
|
|
241
|
+
const first = retireConstraint(root, 'my_custom_rule', { reason: '第一次', now: FIXED_NOW });
|
|
242
|
+
expect(first.status).toBe('retired');
|
|
243
|
+
|
|
244
|
+
const later = new Date('2026-08-09T00:00:00.000Z');
|
|
245
|
+
const second = retireConstraint(root, 'my_custom_rule', { reason: '第二次', now: later });
|
|
246
|
+
expect(second.status).toBe('already_retired');
|
|
247
|
+
|
|
248
|
+
const custom = readCustom(root).custom_constraints.my_custom_rule;
|
|
249
|
+
expect(custom.retired.at).toBe(FIXED_NOW.toISOString());
|
|
250
|
+
expect(custom.retired.reason).toBe('第一次');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('custom 历史落点(config.yml enabled:false)仍判定 already_retired', () => {
|
|
254
|
+
const root = makeTmpProject();
|
|
255
|
+
writeCustom(root, CUSTOM_YML);
|
|
256
|
+
fs.writeFileSync(path.join(root, '.harness', 'config.yml'), 'constraints:\n my_custom_rule:\n enabled: false\n', 'utf-8');
|
|
257
|
+
const result = retireConstraint(root, 'my_custom_rule', { now: FIXED_NOW });
|
|
258
|
+
expect(result.status).toBe('already_retired');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('custom 退役 KnowledgeStore 记录包含 promptInjection(规则原文完整)', () => {
|
|
262
|
+
const root = makeTmpProject();
|
|
263
|
+
writeCustom(root, CUSTOM_YML);
|
|
264
|
+
const result = retireConstraint(root, 'my_custom_rule', { reason: '由新机制覆盖', now: FIXED_NOW });
|
|
265
|
+
expect(result.status).toBe('retired');
|
|
266
|
+
|
|
267
|
+
const store = new FileKnowledgeStore({ baseDir: path.join(root, '.harness', 'knowledge') });
|
|
268
|
+
const entry = store.get('constraint-retired-my_custom_rule');
|
|
269
|
+
expect(entry).toBeDefined();
|
|
270
|
+
expect(entry!.content).toContain('禁止引入 X');
|
|
271
|
+
expect(entry!.content).toContain('promptInjection: 禁止引入 X');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('custom 恢复:删除 yml retired 段后回到生效集', () => {
|
|
275
|
+
const root = makeTmpProject();
|
|
276
|
+
writeCustom(root, CUSTOM_YML);
|
|
277
|
+
retireConstraint(root, 'my_custom_rule', { now: FIXED_NOW });
|
|
278
|
+
expect(getEffectiveConstraints(root).some(c => c.id === 'my_custom_rule')).toBe(false);
|
|
279
|
+
|
|
280
|
+
const custom = readCustom(root);
|
|
281
|
+
delete custom.custom_constraints.my_custom_rule.retired;
|
|
282
|
+
fs.writeFileSync(path.join(root, '.harness', 'custom-constraints.yml'), yaml.dump(custom), 'utf-8');
|
|
283
|
+
|
|
284
|
+
expect(getEffectiveConstraints(root).some(c => c.id === 'my_custom_rule')).toBe(true);
|
|
285
|
+
});
|
|
187
286
|
});
|
|
188
287
|
|
|
189
288
|
describe('constraintsRetire 非交互直达', () => {
|
|
190
|
-
|
|
289
|
+
let originalExitCode: typeof process.exitCode;
|
|
290
|
+
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
originalExitCode = process.exitCode;
|
|
293
|
+
process.exitCode = 0;
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
afterEach(() => {
|
|
297
|
+
process.exitCode = originalExitCode;
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('无 --yes 直达:报错 + 非零退出码 + 不落盘任何文件(#24 人确认闸门)', async () => {
|
|
191
301
|
const root = makeTmpProject();
|
|
192
|
-
|
|
302
|
+
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
303
|
+
try {
|
|
304
|
+
await constraintsRetire('docs_freshness', { projectPath: root, reason: '直接退役' });
|
|
305
|
+
|
|
306
|
+
const output = errSpy.mock.calls.map(c => String(c[0])).join('\n');
|
|
307
|
+
expect(output).toContain('--yes');
|
|
308
|
+
expect(output).toContain('交互');
|
|
309
|
+
expect(process.exitCode).toBe(1);
|
|
310
|
+
// 无副作用:任何文件都不落盘
|
|
311
|
+
expect(fs.existsSync(path.join(root, '.harness'))).toBe(false);
|
|
312
|
+
} finally {
|
|
313
|
+
errSpy.mockRestore();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('--yes 直达 iron 退役:打印额外警示并落盘', async () => {
|
|
318
|
+
const root = makeTmpProject();
|
|
319
|
+
await constraintsRetire('docs_freshness', { projectPath: root, reason: '直接退役', yes: true });
|
|
193
320
|
|
|
194
321
|
const output = logSpy.mock.calls.map(c => String(c[0])).join('\n');
|
|
195
322
|
expect(output).toContain('Iron Law');
|
|
@@ -199,9 +326,21 @@ describe('constraintsRetire 非交互直达', () => {
|
|
|
199
326
|
expect(config.constraints.docs_freshness.enabled).toBe(false);
|
|
200
327
|
});
|
|
201
328
|
|
|
202
|
-
it('
|
|
329
|
+
it('--yes 直达非 iron(guideline)退役:不打印 iron 警示并落盘', async () => {
|
|
203
330
|
const root = makeTmpProject();
|
|
204
|
-
await constraintsRetire('
|
|
331
|
+
await constraintsRetire('no_hardcoded_credentials', { projectPath: root, reason: '直接退役', yes: true });
|
|
332
|
+
|
|
333
|
+
const output = logSpy.mock.calls.map(c => String(c[0])).join('\n');
|
|
334
|
+
expect(output).not.toContain('Iron Law');
|
|
335
|
+
expect(output).toContain('已退役');
|
|
336
|
+
|
|
337
|
+
const config = readConfig(root);
|
|
338
|
+
expect(config.constraints.no_hardcoded_credentials.enabled).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('--yes 直达未知 id:明确提示', async () => {
|
|
342
|
+
const root = makeTmpProject();
|
|
343
|
+
await constraintsRetire('ghost', { projectPath: root, yes: true });
|
|
205
344
|
const output = logSpy.mock.calls.map(c => String(c[0])).join('\n');
|
|
206
345
|
expect(output).toContain('约束不存在');
|
|
207
346
|
});
|
|
@@ -264,6 +403,28 @@ describe('runRetireInteractive 交互流程(注入 IO 流)', () => {
|
|
|
264
403
|
expect(config.constraints.no_hardcoded_credentials.retired.reason).toBe('误报太多');
|
|
265
404
|
});
|
|
266
405
|
|
|
406
|
+
it('无候选 → 手动输入 custom id → 确认执行 → 落 custom-constraints.yml', async () => {
|
|
407
|
+
const root = makeTmpProject();
|
|
408
|
+
writeCustom(root, CUSTOM_YML);
|
|
409
|
+
// 所有 check 约束给少量健康数据(低于一切候选阈值)
|
|
410
|
+
const healthy = getEffectiveConstraints(root)
|
|
411
|
+
.filter(c => c.kind === 'check')
|
|
412
|
+
.flatMap(c => [
|
|
413
|
+
{ constraintId: c.id, result: 'pass' as const },
|
|
414
|
+
{ constraintId: c.id, result: 'pass' as const, timestamp: 1700000001000 },
|
|
415
|
+
{ constraintId: c.id, result: 'fail' as const, timestamp: 1700000002000 },
|
|
416
|
+
]);
|
|
417
|
+
writeTraces(root, healthy);
|
|
418
|
+
|
|
419
|
+
const io = makeIo(['my_custom_rule', '作用对象消失', 'y']);
|
|
420
|
+
await runRetireInteractive(root, io);
|
|
421
|
+
io.done();
|
|
422
|
+
|
|
423
|
+
const custom = readCustom(root).custom_constraints.my_custom_rule;
|
|
424
|
+
expect(custom.retired.reason).toBe('作用对象消失');
|
|
425
|
+
expect(fs.existsSync(path.join(root, '.harness', 'config.yml'))).toBe(false);
|
|
426
|
+
});
|
|
427
|
+
|
|
267
428
|
it('无候选 → 手动输入未知 id → 提示不存在并取消', async () => {
|
|
268
429
|
const root = makeTmpProject();
|
|
269
430
|
const healthy = getEffectiveConstraints(root)
|
|
@@ -13,7 +13,6 @@ jest.mock('../../../failure/recorder');
|
|
|
13
13
|
|
|
14
14
|
describe('failure CLI commands', () => {
|
|
15
15
|
const tempDir = path.join(process.cwd(), 'temp-test-failure-cli');
|
|
16
|
-
const logFilePath = path.join(tempDir, '.harness', 'failures', 'failures.log');
|
|
17
16
|
let consoleSpy: jest.SpyInstance;
|
|
18
17
|
let consoleErrorSpy: jest.SpyInstance;
|
|
19
18
|
let mockRecorderInstance: {
|