@dommaker/harness 0.17.1 → 0.18.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -4
  3. package/bin/harness.js +3 -1
  4. package/bin/install-precommit-hook.sh +76 -0
  5. package/dist/architecture/cross-project-checker.js +2 -2
  6. package/dist/architecture/cross-project-checker.js.map +1 -1
  7. package/dist/cli/commands/constraints-report.js +2 -2
  8. package/dist/cli/commands/constraints-report.js.map +1 -1
  9. package/dist/cli/commands/constraints-retire.d.ts +24 -6
  10. package/dist/cli/commands/constraints-retire.d.ts.map +1 -1
  11. package/dist/cli/commands/constraints-retire.js +96 -19
  12. package/dist/cli/commands/constraints-retire.js.map +1 -1
  13. package/dist/cli/commands/doc-freshness-check.js +1 -1
  14. package/dist/cli/commands/doc-freshness-check.js.map +1 -1
  15. package/dist/cli/commands/init.js +14 -14
  16. package/dist/cli/commands/init.js.map +1 -1
  17. package/dist/cli/commands/sync-docs/index.d.ts.map +1 -1
  18. package/dist/cli/commands/sync-docs/index.js +14 -6
  19. package/dist/cli/commands/sync-docs/index.js.map +1 -1
  20. package/dist/cli/commands/sync-docs/project-reader.d.ts.map +1 -1
  21. package/dist/cli/commands/sync-docs/project-reader.js +9 -7
  22. package/dist/cli/commands/sync-docs/project-reader.js.map +1 -1
  23. package/dist/cli/commands/validate.js +3 -3
  24. package/dist/cli/commands/validate.js.map +1 -1
  25. package/dist/cli/session-mining/text.js +1 -1
  26. package/dist/cli/session-mining/text.js.map +1 -1
  27. package/dist/core/constraints/definitions/iron-laws.js +2 -2
  28. package/dist/core/constraints/definitions/iron-laws.js.map +1 -1
  29. package/dist/core/constraints/injection-drift.d.ts +1 -1
  30. package/dist/core/constraints/injection-drift.d.ts.map +1 -1
  31. package/dist/core/constraints/injection-drift.js +1 -1
  32. package/dist/core/constraints/injection-drift.js.map +1 -1
  33. package/dist/core/effective-constraints.d.ts +2 -1
  34. package/dist/core/effective-constraints.d.ts.map +1 -1
  35. package/dist/core/effective-constraints.js +2 -1
  36. package/dist/core/effective-constraints.js.map +1 -1
  37. package/dist/core/project-config-loader.d.ts +2 -1
  38. package/dist/core/project-config-loader.d.ts.map +1 -1
  39. package/dist/core/project-config-loader.js +8 -2
  40. package/dist/core/project-config-loader.js.map +1 -1
  41. package/dist/hooks/bootstrap.js +1 -1
  42. package/dist/hooks/bootstrap.js.map +1 -1
  43. package/dist/knowledge/store.js +1 -1
  44. package/dist/knowledge/store.js.map +1 -1
  45. package/dist/knowledge/types.d.ts +2 -0
  46. package/dist/knowledge/types.d.ts.map +1 -1
  47. package/dist/knowledge/types.js.map +1 -1
  48. package/dist/safety/output-guardrail.js +2 -2
  49. package/dist/safety/output-guardrail.js.map +1 -1
  50. package/dist/spec/annotation-checker.js +1 -1
  51. package/dist/spec/annotation-checker.js.map +1 -1
  52. package/dist/types/project-config.d.ts +15 -0
  53. package/dist/types/project-config.d.ts.map +1 -1
  54. package/dist/utils/file-walk.d.ts +13 -1
  55. package/dist/utils/file-walk.d.ts.map +1 -1
  56. package/dist/utils/file-walk.js +16 -3
  57. package/dist/utils/file-walk.js.map +1 -1
  58. package/package.json +6 -2
  59. package/src/__tests__/checkpoint.test.ts +2 -2
  60. package/src/__tests__/clean-state-extra.test.ts +1 -1
  61. package/src/__tests__/constraint-doctor.test.ts +1 -1
  62. package/src/__tests__/doctor-extra.test.ts +0 -9
  63. package/src/__tests__/effective-constraints.test.ts +21 -0
  64. package/src/__tests__/hooks-pipeline.test.ts +1 -1
  65. package/src/__tests__/iron-laws.test.ts +11 -0
  66. package/src/__tests__/passes-gate-extension.test.ts +3 -3
  67. package/src/__tests__/passes-gate-extra.test.ts +1 -1
  68. package/src/__tests__/performance-analyzer.test.ts +0 -2
  69. package/src/__tests__/traces.test.ts +1 -2
  70. package/src/architecture/cross-project-checker.ts +2 -2
  71. package/src/cli/commands/CONTEXT.md +1 -1
  72. package/src/cli/commands/__tests__/check-drift.test.ts +1 -1
  73. package/src/cli/commands/__tests__/check-skip-output.test.ts +0 -2
  74. package/src/cli/commands/__tests__/command.test.ts +1 -1
  75. package/src/cli/commands/__tests__/constraints-report.test.ts +1 -1
  76. package/src/cli/commands/__tests__/constraints-retire.test.ts +165 -4
  77. package/src/cli/commands/__tests__/failure.test.ts +0 -1
  78. package/src/cli/commands/__tests__/init.test.ts +40 -0
  79. package/src/cli/commands/__tests__/status-extra.test.ts +0 -2
  80. package/src/cli/commands/__tests__/status.test.ts +0 -2
  81. package/src/cli/commands/__tests__/sync-docs.test.ts +90 -0
  82. package/src/cli/commands/constraints-report.ts +2 -2
  83. package/src/cli/commands/constraints-retire.ts +114 -21
  84. package/src/cli/commands/doc-freshness-check.ts +1 -1
  85. package/src/cli/commands/init.ts +14 -14
  86. package/src/cli/commands/sync-docs/index.ts +20 -7
  87. package/src/cli/commands/sync-docs/project-reader.ts +10 -8
  88. package/src/cli/commands/validate.ts +3 -3
  89. package/src/cli/session-mining/text.ts +1 -1
  90. package/src/context/__tests__/progressive-loader.test.ts +6 -6
  91. package/src/core/CONTEXT.md +1 -1
  92. package/src/core/constraints/__tests__/injection-drift.test.ts +1 -1
  93. package/src/core/constraints/__tests__/interceptor.test.ts +3 -3
  94. package/src/core/constraints/definitions/iron-laws.ts +2 -2
  95. package/src/core/constraints/injection-drift.ts +1 -1
  96. package/src/core/effective-constraints.ts +2 -1
  97. package/src/core/project-config-loader.ts +8 -2
  98. package/src/gates/__tests__/acceptance.test.ts +0 -1
  99. package/src/gates/__tests__/command.test.ts +1 -1
  100. package/src/hooks/bootstrap.ts +1 -1
  101. package/src/knowledge/__tests__/doctor.test.ts +2 -2
  102. package/src/knowledge/__tests__/ingest.test.ts +2 -2
  103. package/src/knowledge/__tests__/migration.test.ts +0 -6
  104. package/src/knowledge/__tests__/store.test.ts +14 -1
  105. package/src/knowledge/store.ts +1 -1
  106. package/src/knowledge/types.ts +2 -0
  107. package/src/llm/__tests__/adapter.test.ts +1 -1
  108. package/src/monitoring/__tests__/knowledge-doctor.test.ts +0 -1
  109. package/src/monitoring/__tests__/performance-collector.test.ts +1 -2
  110. package/src/safety/output-guardrail.ts +2 -2
  111. package/src/spec/__tests__/annotation-checker.test.ts +0 -1
  112. package/src/spec/annotation-checker.ts +1 -1
  113. package/src/types/project-config.ts +12 -0
  114. package/src/utils/__tests__/file-walk.test.ts +53 -0
  115. package/src/utils/file-walk.ts +23 -3
  116. package/src/verification/__tests__/loop.test.ts +1 -1
@@ -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
- const result = await pipeline.run('before', ctx);
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, TaskTestResult } from '../types/passes-gate';
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 (workDir) => ({
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 (workDir, task) => {
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, existsSync, mkdir } from 'fs';
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
- const result = collector.cleanupOldFiles(30);
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(([_, deps]) => deps.includes(project))
327
- .map(([name, _]) => 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`(交互选择 + 人确认退役,config.yml retired 元数据 + KnowledgeStore 沉淀 + CLAUDE.md 注入段同步)。
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/` 约束引擎
@@ -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, DEFAULT_COMMAND_BLACKLIST } from '../../../gates';
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
- it('iron 直达退役时打印额外警示', async () => {
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
- await constraintsRetire('docs_freshness', { projectPath: root, reason: '直接退役' });
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('未知 id 直达:明确提示', async () => {
329
+ it('--yes 直达非 iron(guideline)退役:不打印 iron 警示并落盘', async () => {
203
330
  const root = makeTmpProject();
204
- await constraintsRetire('ghost', { projectPath: root });
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: {
@@ -111,6 +111,8 @@ describe('init command', () => {
111
111
  it('应该输出代码片段模式', async () => {
112
112
  await init({ preset: 'standard', printSnippets: true });
113
113
  expect(consoleSpy).toHaveBeenCalled();
114
+ // 打印版与落盘版 plan 匹配模式须一致(#35:模板字面量 \. 运行期退化为 .,防两副本 drift)
115
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("grep -E 'plans/.*\\.md$|\\.plan\\.md$'"));
114
116
  });
115
117
  });
116
118
 
@@ -132,6 +134,8 @@ describe('init command', () => {
132
134
 
133
135
  await init({ preset: 'standard', projectPath: PROJECT });
134
136
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('pre-commit 已存在'));
137
+ // 手动添加提示的片段同样 scoped 化(#36)
138
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('npx @dommaker/harness check --staged'));
135
139
  });
136
140
 
137
141
  it('应该创建 pre-commit hook', async () => {
@@ -143,6 +147,23 @@ describe('init command', () => {
143
147
  await init({ preset: 'standard', projectPath: PROJECT });
144
148
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('已创建 .git/hooks/pre-commit'));
145
149
  });
150
+
151
+ it('落盘 pre-commit 使用 scoped 包名(#36)', async () => {
152
+ mockFs.mkdir.mockResolvedValue(undefined);
153
+ mockFs.writeFile.mockResolvedValue(undefined);
154
+ existingFiles.add(`${PROJECT}/.git`);
155
+ existingFiles.add(`${PROJECT}/.git/hooks`);
156
+
157
+ await init({ preset: 'standard', projectPath: PROJECT });
158
+ const writeCalls = mockFs.writeFile.mock.calls;
159
+ const hookCall = writeCalls.find((c: any[]) => String(c[0]).includes('pre-commit'));
160
+ expect(hookCall).toBeDefined();
161
+ expect(hookCall![1]).toContain('npx @dommaker/harness check --staged');
162
+ expect(hookCall![1]).toContain('npx @dommaker/harness posteval-plan');
163
+ expect(hookCall![1]).not.toMatch(/npx harness /);
164
+ // plan 匹配模式须为转义点(#35:模板字面量 \. 运行期退化为 .,与打印版防 drift)
165
+ expect(hookCall![1]).toContain("grep -E 'plans/.*\\.md$|\\.plan\\.md$'");
166
+ });
146
167
  });
147
168
 
148
169
  describe('GitHub Actions', () => {
@@ -155,6 +176,20 @@ describe('init command', () => {
155
176
  await init({ preset: 'standard', projectPath: PROJECT });
156
177
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('已存在的 CI 配置'));
157
178
  });
179
+
180
+ it('落盘 harness-check.yml 使用 scoped 包名(#36)', async () => {
181
+ mockFs.mkdir.mockResolvedValue(undefined);
182
+ mockFs.writeFile.mockResolvedValue(undefined);
183
+
184
+ await init({ preset: 'standard', projectPath: PROJECT });
185
+ const writeCalls = mockFs.writeFile.mock.calls;
186
+ const workflowCall = writeCalls.find((c: any[]) => String(c[0]).includes('harness-check.yml'));
187
+ expect(workflowCall).toBeDefined();
188
+ expect(workflowCall![1]).toContain('npx @dommaker/harness check');
189
+ expect(workflowCall![1]).toContain('npx @dommaker/harness validate');
190
+ expect(workflowCall![1]).toContain('npx @dommaker/harness passes-gate');
191
+ expect(workflowCall![1]).not.toMatch(/npx harness /);
192
+ });
158
193
  });
159
194
 
160
195
  describe('CAPABILITIES.md', () => {
@@ -258,6 +293,10 @@ describe('init command', () => {
258
293
  expect(workflowCall).toBeDefined();
259
294
  expect(workflowCall![1]).toContain('Harness Governance');
260
295
  expect(workflowCall![1]).toContain('harness check');
296
+ // scoped 包名(#36)
297
+ expect(workflowCall![1]).toContain('npx @dommaker/harness check');
298
+ expect(workflowCall![1]).toContain('npx @dommaker/harness passes-gate');
299
+ expect(workflowCall![1]).not.toMatch(/npx harness /);
261
300
  });
262
301
 
263
302
  it('应该跳过治理 CI workflow 当已存在', async () => {
@@ -277,6 +316,7 @@ describe('init command', () => {
277
316
  const writeCalls = mockFs.writeFile.mock.calls;
278
317
  const workflowCall = writeCalls.find((c: any[]) => String(c[0]).includes('harness-governance.yml'));
279
318
  expect(workflowCall![1]).toContain('sync-docs');
319
+ expect(workflowCall![1]).toContain('npx @dommaker/harness sync-docs --check');
280
320
  });
281
321
 
282
322
  it('minimal 治理不应在 CI 中包含 docs check', async () => {
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { status } from '../status';
6
6
  import * as fs from 'fs';
7
- import { TraceCollector } from '../../../monitoring/traces';
8
7
  import { TraceAnalyzer } from '../../../monitoring/trace-analyzer';
9
8
 
10
9
  // Mock fs
@@ -41,7 +40,6 @@ jest.mock('chalk', () => ({
41
40
  }));
42
41
 
43
42
  const mockFs = fs as jest.Mocked<typeof fs>;
44
- const MockTraceCollector = TraceCollector as jest.MockedClass<typeof TraceCollector>;
45
43
  const MockTraceAnalyzer = TraceAnalyzer as jest.MockedClass<typeof TraceAnalyzer>;
46
44
 
47
45
  describe('status command - 补充覆盖', () => {
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { status } from '../status';
6
6
  import * as fs from 'fs';
7
- import { TraceCollector } from '../../../monitoring/traces';
8
7
  import { TraceAnalyzer } from '../../../monitoring/trace-analyzer';
9
8
 
10
9
  // Mock fs
@@ -41,7 +40,6 @@ jest.mock('chalk', () => ({
41
40
  }));
42
41
 
43
42
  const mockFs = fs as jest.Mocked<typeof fs>;
44
- const MockTraceCollector = TraceCollector as jest.MockedClass<typeof TraceCollector>;
45
43
  const MockTraceAnalyzer = TraceAnalyzer as jest.MockedClass<typeof TraceAnalyzer>;
46
44
 
47
45
  describe('status command', () => {
@@ -406,6 +406,96 @@ describe('sync-docs command', () => {
406
406
  fs.rmSync(testDir, { recursive: true, force: true });
407
407
  });
408
408
 
409
+ it('.tsx 登记行指向真实文件时不得误判为已删除(#33),--check 收敛', async () => {
410
+ const testDir = path.join(tempDir, 'tsx-row');
411
+ const srcDir = path.join(testDir, 'src', 'components');
412
+ fs.mkdirSync(srcDir, { recursive: true });
413
+ // 复刻 studio 场景:同目录混合 .ts 与 .tsx
414
+ fs.writeFileSync(path.join(srcDir, 'statusClasses.ts'), 'export const statuses = {};');
415
+ fs.writeFileSync(
416
+ path.join(srcDir, 'DistillProposalCard.tsx'),
417
+ 'export const DistillProposalCard = () => null;'
418
+ );
419
+
420
+ // 手工登记一行 .tsx 模块(表格格式承诺支持 ts|tsx|js|jsx)
421
+ fs.writeFileSync(
422
+ path.join(testDir, 'CAPABILITIES.md'),
423
+ [
424
+ '# CAPABILITIES.md', '',
425
+ '> 最后更新: 2026-01-01', '',
426
+ '| 模块 | 文件 | 说明 |', '|------|------|------|',
427
+ '| statusClasses | src/components/statusClasses.ts | 状态类 |',
428
+ '| DistillProposalCard | src/components/DistillProposalCard.tsx | 人审卡 |',
429
+ '',
430
+ ].join('\n')
431
+ );
432
+
433
+ await syncDocs({ projectPath: testDir });
434
+ const content = fs.readFileSync(path.join(testDir, 'CAPABILITIES.md'), 'utf-8');
435
+ expect(content).toContain('DistillProposalCard.tsx');
436
+
437
+ // 修复前:--check 每跑必报「包含已删除的模块」不收敛
438
+ consoleSpy.mockClear();
439
+ const check = await syncDocs({ projectPath: testDir, check: true });
440
+ expect(check).toBe(true);
441
+
442
+ fs.rmSync(testDir, { recursive: true, force: true });
443
+ });
444
+
445
+ it('未登记的 .tsx 源文件应进入 added,写入模式生成表格行', async () => {
446
+ const testDir = path.join(tempDir, 'tsx-added');
447
+ const srcDir = path.join(testDir, 'src');
448
+ fs.mkdirSync(srcDir, { recursive: true });
449
+ fs.writeFileSync(path.join(srcDir, 'util.ts'), 'export const util = 1;');
450
+ fs.writeFileSync(path.join(srcDir, 'Card.tsx'), 'export const Card = () => null;');
451
+
452
+ fs.writeFileSync(
453
+ path.join(testDir, 'CAPABILITIES.md'),
454
+ '# Capabilities\n\n| 模块 | 文件 | 说明 |\n|------|------|------|\n| util | src/util.ts | util |'
455
+ );
456
+
457
+ const check = await syncDocs({ projectPath: testDir, check: true });
458
+ expect(check).toBe(false);
459
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Card.tsx'));
460
+
461
+ await syncDocs({ projectPath: testDir });
462
+ const content = fs.readFileSync(path.join(testDir, 'CAPABILITIES.md'), 'utf-8');
463
+ expect(content).toContain('Card.tsx');
464
+
465
+ fs.rmSync(testDir, { recursive: true, force: true });
466
+ });
467
+
468
+ it('真实存在的非扫描扩展名登记行(.js/.jsx)也不得误删(存在性过滤兜底)', async () => {
469
+ const testDir = path.join(tempDir, 'js-row');
470
+ const srcDir = path.join(testDir, 'src');
471
+ fs.mkdirSync(srcDir, { recursive: true });
472
+ fs.writeFileSync(path.join(srcDir, 'helper.js'), 'module.exports = {};');
473
+ fs.writeFileSync(path.join(srcDir, 'Helper.jsx'), 'export default () => null;');
474
+
475
+ fs.writeFileSync(
476
+ path.join(testDir, 'CAPABILITIES.md'),
477
+ [
478
+ '# CAPABILITIES.md', '',
479
+ '> 最后更新: 2026-01-01', '',
480
+ '| 模块 | 文件 | 说明 |', '|------|------|------|',
481
+ '| helper | src/helper.js | 手工登记 |',
482
+ '| Helper | src/Helper.jsx | 手工登记 |',
483
+ '',
484
+ ].join('\n')
485
+ );
486
+
487
+ await syncDocs({ projectPath: testDir });
488
+ const content = fs.readFileSync(path.join(testDir, 'CAPABILITIES.md'), 'utf-8');
489
+ expect(content).toContain('src/helper.js');
490
+ expect(content).toContain('src/Helper.jsx');
491
+
492
+ consoleSpy.mockClear();
493
+ const check = await syncDocs({ projectPath: testDir, check: true });
494
+ expect(check).toBe(true);
495
+
496
+ fs.rmSync(testDir, { recursive: true, force: true });
497
+ });
498
+
409
499
  it('basename 碰撞的幽灵条目(文件已删但同名文件存在)也应被移除', async () => {
410
500
  const testDir = path.join(tempDir, 'ghost-basename-collision');
411
501
  const srcA = path.join(testDir, 'src', 'a');
@@ -118,9 +118,9 @@ export function renderExportMarkdown(
118
118
  export function renderInjectionDriftLines(drift: InjectionDrift): string[] {
119
119
  const lines: string[] = [];
120
120
  if (drift.notInjected) {
121
- lines.push(' CLAUDE.md 无约束注入标记段(未注入,不算漂移);可运行 npx harness init 注入');
121
+ lines.push(' CLAUDE.md 无约束注入标记段(未注入,不算漂移);可运行 npx @dommaker/harness init 注入');
122
122
  if (drift.duplicateHeading) {
123
- lines.push(' 另检测到多个 "## Governance Rules" 标题(历史遗留重复章节),建议手工清理后重跑 npx harness init');
123
+ lines.push(' 另检测到多个 "## Governance Rules" 标题(历史遗留重复章节),建议手工清理后重跑 npx @dommaker/harness init');
124
124
  }
125
125
  return lines;
126
126
  }