@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.
Files changed (146) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +5 -4
  3. package/bin/harness.js +9 -2
  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/update-user-model.d.ts +1 -0
  24. package/dist/cli/commands/update-user-model.d.ts.map +1 -1
  25. package/dist/cli/commands/update-user-model.js +12 -5
  26. package/dist/cli/commands/update-user-model.js.map +1 -1
  27. package/dist/cli/commands/validate.js +3 -3
  28. package/dist/cli/commands/validate.js.map +1 -1
  29. package/dist/cli/session-mining/text.js +1 -1
  30. package/dist/cli/session-mining/text.js.map +1 -1
  31. package/dist/core/constraints/agent-prompt-renderer.d.ts +32 -0
  32. package/dist/core/constraints/agent-prompt-renderer.d.ts.map +1 -0
  33. package/dist/core/constraints/agent-prompt-renderer.js +58 -0
  34. package/dist/core/constraints/agent-prompt-renderer.js.map +1 -0
  35. package/dist/core/constraints/check-cache.d.ts +48 -3
  36. package/dist/core/constraints/check-cache.d.ts.map +1 -1
  37. package/dist/core/constraints/check-cache.js +53 -5
  38. package/dist/core/constraints/check-cache.js.map +1 -1
  39. package/dist/core/constraints/definitions/iron-laws.js +2 -2
  40. package/dist/core/constraints/definitions/iron-laws.js.map +1 -1
  41. package/dist/core/constraints/index.d.ts +4 -0
  42. package/dist/core/constraints/index.d.ts.map +1 -1
  43. package/dist/core/constraints/index.js +7 -1
  44. package/dist/core/constraints/index.js.map +1 -1
  45. package/dist/core/constraints/injection-drift.d.ts +1 -1
  46. package/dist/core/constraints/injection-drift.d.ts.map +1 -1
  47. package/dist/core/constraints/injection-drift.js +1 -1
  48. package/dist/core/constraints/injection-drift.js.map +1 -1
  49. package/dist/core/effective-constraints.d.ts +2 -1
  50. package/dist/core/effective-constraints.d.ts.map +1 -1
  51. package/dist/core/effective-constraints.js +2 -1
  52. package/dist/core/effective-constraints.js.map +1 -1
  53. package/dist/core/project-config-loader.d.ts +2 -1
  54. package/dist/core/project-config-loader.d.ts.map +1 -1
  55. package/dist/core/project-config-loader.js +8 -2
  56. package/dist/core/project-config-loader.js.map +1 -1
  57. package/dist/hooks/bootstrap.js +1 -1
  58. package/dist/hooks/bootstrap.js.map +1 -1
  59. package/dist/index.d.ts +4 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +8 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/knowledge/store.js +1 -1
  64. package/dist/knowledge/store.js.map +1 -1
  65. package/dist/knowledge/types.d.ts +2 -0
  66. package/dist/knowledge/types.d.ts.map +1 -1
  67. package/dist/knowledge/types.js.map +1 -1
  68. package/dist/safety/output-guardrail.js +2 -2
  69. package/dist/safety/output-guardrail.js.map +1 -1
  70. package/dist/spec/annotation-checker.js +1 -1
  71. package/dist/spec/annotation-checker.js.map +1 -1
  72. package/dist/types/project-config.d.ts +15 -0
  73. package/dist/types/project-config.d.ts.map +1 -1
  74. package/dist/utils/file-walk.d.ts +13 -1
  75. package/dist/utils/file-walk.d.ts.map +1 -1
  76. package/dist/utils/file-walk.js +16 -3
  77. package/dist/utils/file-walk.js.map +1 -1
  78. package/package.json +9 -5
  79. package/src/__tests__/checkpoint.test.ts +2 -2
  80. package/src/__tests__/clean-state-extra.test.ts +1 -1
  81. package/src/__tests__/constraint-doctor.test.ts +1 -1
  82. package/src/__tests__/doctor-extra.test.ts +0 -9
  83. package/src/__tests__/effective-constraints.test.ts +21 -0
  84. package/src/__tests__/hooks-pipeline.test.ts +1 -1
  85. package/src/__tests__/iron-laws.test.ts +11 -0
  86. package/src/__tests__/passes-gate-extension.test.ts +3 -3
  87. package/src/__tests__/passes-gate-extra.test.ts +1 -1
  88. package/src/__tests__/performance-analyzer.test.ts +0 -2
  89. package/src/__tests__/traces.test.ts +1 -2
  90. package/src/architecture/cross-project-checker.ts +2 -2
  91. package/src/cli/commands/CONTEXT.md +1 -1
  92. package/src/cli/commands/__tests__/analyze-sessions.test.ts +178 -0
  93. package/src/cli/commands/__tests__/check-drift.test.ts +1 -1
  94. package/src/cli/commands/__tests__/check-skip-output.test.ts +0 -2
  95. package/src/cli/commands/__tests__/command.test.ts +1 -1
  96. package/src/cli/commands/__tests__/constraints-report.test.ts +1 -1
  97. package/src/cli/commands/__tests__/constraints-retire.test.ts +165 -4
  98. package/src/cli/commands/__tests__/failure.test.ts +0 -1
  99. package/src/cli/commands/__tests__/init.test.ts +40 -0
  100. package/src/cli/commands/__tests__/release.test.ts +162 -0
  101. package/src/cli/commands/__tests__/status-extra.test.ts +0 -2
  102. package/src/cli/commands/__tests__/status.test.ts +0 -2
  103. package/src/cli/commands/__tests__/sync-docs.test.ts +90 -0
  104. package/src/cli/commands/__tests__/update-user-model.test.ts +205 -0
  105. package/src/cli/commands/constraints-report.ts +2 -2
  106. package/src/cli/commands/constraints-retire.ts +114 -21
  107. package/src/cli/commands/doc-freshness-check.ts +1 -1
  108. package/src/cli/commands/init.ts +14 -14
  109. package/src/cli/commands/sync-docs/index.ts +20 -7
  110. package/src/cli/commands/sync-docs/project-reader.ts +10 -8
  111. package/src/cli/commands/update-user-model.ts +15 -5
  112. package/src/cli/commands/validate.ts +3 -3
  113. package/src/cli/session-mining/text.ts +1 -1
  114. package/src/context/__tests__/progressive-loader.test.ts +6 -6
  115. package/src/core/CONTEXT.md +2 -2
  116. package/src/core/constraints/__tests__/agent-prompt-renderer.test.ts +129 -0
  117. package/src/core/constraints/__tests__/check-cache.test.ts +227 -0
  118. package/src/core/constraints/__tests__/injection-drift.test.ts +1 -1
  119. package/src/core/constraints/__tests__/interceptor.test.ts +3 -3
  120. package/src/core/constraints/agent-prompt-renderer.ts +71 -0
  121. package/src/core/constraints/check-cache.ts +75 -3
  122. package/src/core/constraints/definitions/iron-laws.ts +2 -2
  123. package/src/core/constraints/index.ts +10 -0
  124. package/src/core/constraints/injection-drift.ts +1 -1
  125. package/src/core/effective-constraints.ts +2 -1
  126. package/src/core/project-config-loader.ts +8 -2
  127. package/src/gates/__tests__/acceptance.test.ts +0 -1
  128. package/src/gates/__tests__/command.test.ts +1 -1
  129. package/src/hooks/bootstrap.ts +1 -1
  130. package/src/index.ts +8 -0
  131. package/src/knowledge/__tests__/doctor.test.ts +2 -2
  132. package/src/knowledge/__tests__/ingest.test.ts +2 -2
  133. package/src/knowledge/__tests__/migration.test.ts +0 -6
  134. package/src/knowledge/__tests__/store.test.ts +14 -1
  135. package/src/knowledge/store.ts +1 -1
  136. package/src/knowledge/types.ts +2 -0
  137. package/src/llm/__tests__/adapter.test.ts +1 -1
  138. package/src/monitoring/__tests__/knowledge-doctor.test.ts +0 -1
  139. package/src/monitoring/__tests__/performance-collector.test.ts +1 -2
  140. package/src/safety/output-guardrail.ts +2 -2
  141. package/src/spec/__tests__/annotation-checker.test.ts +0 -1
  142. package/src/spec/annotation-checker.ts +1 -1
  143. package/src/types/project-config.ts +12 -0
  144. package/src/utils/__tests__/file-walk.test.ts +53 -0
  145. package/src/utils/file-walk.ts +23 -3
  146. package/src/verification/__tests__/loop.test.ts +1 -1
@@ -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 () => {
@@ -0,0 +1,162 @@
1
+ /**
2
+ * release 命令测试(O6)
3
+ *
4
+ * Seam:release(options) 公开入口。
5
+ * 隔离面:
6
+ * - child_process.execSync → 按命令分发的 fixture(git/npm/tsc 全部 mock,绝不真执行)
7
+ * - fs.existsSync / readFileSync → fixture 包(package.json + dist 关键文件)
8
+ * - process.exit → 抛异常断言化(exit code 可断言,且阻断后续真实副作用)
9
+ *
10
+ * 测试止于 dry-run 与「发布前闸门」(分支/同步/干净树/tag 冲突),
11
+ * 不覆盖真实 npm version/publish 路径——禁止真发版。
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import { execSync } from 'child_process';
16
+ import { release } from '../release';
17
+
18
+ jest.mock('child_process', () => ({
19
+ ...jest.requireActual('child_process'),
20
+ execSync: jest.fn(),
21
+ }));
22
+
23
+ jest.mock('fs', () => ({
24
+ ...jest.requireActual('fs'),
25
+ existsSync: jest.fn(),
26
+ readFileSync: jest.fn(),
27
+ }));
28
+
29
+ jest.mock('chalk', () => {
30
+ const id = (s: string) => s;
31
+ const chalkFn = Object.assign(id, {
32
+ gray: id,
33
+ blue: id,
34
+ green: id,
35
+ yellow: id,
36
+ cyan: id,
37
+ bold: id,
38
+ red: id,
39
+ });
40
+ return {
41
+ __esModule: true,
42
+ default: chalkFn,
43
+ gray: id,
44
+ blue: id,
45
+ green: id,
46
+ yellow: id,
47
+ cyan: id,
48
+ bold: id,
49
+ red: id,
50
+ };
51
+ });
52
+
53
+ const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
54
+ const mockFs = fs as jest.Mocked<typeof fs>;
55
+
56
+ const PKG_JSON = JSON.stringify({ name: '@dommaker/harness', version: '0.18.0' });
57
+
58
+ describe('release command', () => {
59
+ let consoleSpy: jest.SpyInstance;
60
+ let errorSpy: jest.SpyInstance;
61
+ let exitSpy: jest.SpyInstance;
62
+ let pkgExists: boolean;
63
+ let tagListResult: string;
64
+
65
+ beforeEach(() => {
66
+ jest.clearAllMocks();
67
+ pkgExists = true;
68
+ tagListResult = '';
69
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
70
+ errorSpy = jest.spyOn(console, 'error').mockImplementation();
71
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
72
+ throw new Error(`__exit_${code}__`);
73
+ });
74
+
75
+ mockFs.existsSync.mockImplementation((p) => {
76
+ if (String(p).endsWith('package.json')) return pkgExists;
77
+ return true; // dist 关键文件视为存在
78
+ });
79
+ mockFs.readFileSync.mockReturnValue(PKG_JSON);
80
+
81
+ mockExecSync.mockImplementation((cmd: string) => {
82
+ const c = String(cmd);
83
+ if (c.startsWith('git rev-parse --abbrev-ref')) return 'master';
84
+ if (c.startsWith('git rev-list --count')) return '0';
85
+ if (c.startsWith('git status --porcelain')) return '';
86
+ if (c.startsWith('git tag -l')) return tagListResult;
87
+ if (c.startsWith('npx tsc')) return 'build ok';
88
+ return '';
89
+ });
90
+ });
91
+
92
+ afterEach(() => {
93
+ consoleSpy.mockRestore();
94
+ errorSpy.mockRestore();
95
+ exitSpy.mockRestore();
96
+ });
97
+
98
+ test('非包目录:exit 1 并提示 Not a package', async () => {
99
+ pkgExists = false;
100
+
101
+ await expect(release({})).rejects.toThrow('__exit_1__');
102
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Not a package'));
103
+ });
104
+
105
+ test('非 master/main 分支:exit 1 并提示', async () => {
106
+ mockExecSync.mockImplementation((cmd: string) =>
107
+ String(cmd).startsWith('git rev-parse --abbrev-ref') ? 'feat/x' : '');
108
+
109
+ await expect(release({})).rejects.toThrow('__exit_1__');
110
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Must be on master or main branch'));
111
+ });
112
+
113
+ test('落后于远程:exit 1 并提示 behind', async () => {
114
+ mockExecSync.mockImplementation((cmd: string) => {
115
+ const c = String(cmd);
116
+ if (c.startsWith('git rev-parse --abbrev-ref')) return 'master';
117
+ if (c.startsWith('git rev-list --count')) return '3';
118
+ return '';
119
+ });
120
+
121
+ await expect(release({})).rejects.toThrow('__exit_1__');
122
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('behind origin'));
123
+ });
124
+
125
+ test('工作树不干净:exit 1 并提示 Uncommitted changes', async () => {
126
+ mockExecSync.mockImplementation((cmd: string) => {
127
+ const c = String(cmd);
128
+ if (c.startsWith('git rev-parse --abbrev-ref')) return 'master';
129
+ if (c.startsWith('git rev-list --count')) return '0';
130
+ if (c.startsWith('git status --porcelain')) return ' M package.json';
131
+ return '';
132
+ });
133
+
134
+ await expect(release({})).rejects.toThrow('__exit_1__');
135
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Uncommitted changes'));
136
+ });
137
+
138
+ test('dry-run patch:计算目标版本 0.18.1,exit 0', async () => {
139
+ await expect(release({ bumpType: 'patch', dryRun: 'true' })).rejects.toThrow('__exit_0__');
140
+ expect(consoleSpy).toHaveBeenCalledWith(
141
+ expect.stringContaining('Dry-run complete. Would publish: @dommaker/harness@0.18.1'),
142
+ );
143
+ });
144
+
145
+ test('dry-run minor:计算目标版本 0.19.0', async () => {
146
+ await expect(release({ bumpType: 'minor', dryRun: 'true' })).rejects.toThrow('__exit_0__');
147
+ expect(consoleSpy).toHaveBeenCalledWith(
148
+ expect.stringContaining('Dry-run complete. Would publish: @dommaker/harness@0.19.0'),
149
+ );
150
+ });
151
+
152
+ test('目标 tag 已存在:发布前闸门 exit 1(不触 npm version)', async () => {
153
+ tagListResult = 'v0.18.1';
154
+
155
+ await expect(release({ bumpType: 'patch' })).rejects.toThrow('__exit_1__');
156
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
157
+ // 闸门阻断:npm version / npm publish 从未执行
158
+ const commands = mockExecSync.mock.calls.map(c => String(c[0]));
159
+ expect(commands.some(c => c.startsWith('npm version'))).toBe(false);
160
+ expect(commands.some(c => c.startsWith('npm publish'))).toBe(false);
161
+ });
162
+ });
@@ -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');
@@ -0,0 +1,205 @@
1
+ /**
2
+ * update-user-model 命令测试(O1:--days flag + --json/--dry-run 兼容)
3
+ *
4
+ * Seam:updateUserModel(options) 公开入口。
5
+ * 隔离面:
6
+ * - os.homedir → 测试临时目录(state/profile 落 TEST_HOME,不触碰真实 ~/.claude)
7
+ * - readTranscriptSessions → fixture 会话(extractCorrectionMatches 等纯函数保持真实)
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { updateUserModel } from '../update-user-model';
13
+ import { readTranscriptSessions, type MinedSession } from '../../session-mining';
14
+
15
+ const TEST_HOME = '/tmp/harness-uum-test-home';
16
+
17
+ jest.mock('os', () => ({
18
+ ...jest.requireActual('os'),
19
+ homedir: () => '/tmp/harness-uum-test-home',
20
+ }));
21
+
22
+ jest.mock('../../session-mining', () => ({
23
+ ...jest.requireActual('../../session-mining'),
24
+ readTranscriptSessions: jest.fn(),
25
+ }));
26
+
27
+ jest.mock('chalk', () => {
28
+ const id = (s: string) => s;
29
+ const chalkFn = Object.assign(id, {
30
+ gray: id,
31
+ blue: id,
32
+ green: id,
33
+ yellow: id,
34
+ cyan: id,
35
+ bold: id,
36
+ red: id,
37
+ });
38
+ return {
39
+ __esModule: true,
40
+ default: chalkFn,
41
+ gray: id,
42
+ blue: id,
43
+ green: id,
44
+ yellow: id,
45
+ cyan: id,
46
+ bold: id,
47
+ red: id,
48
+ };
49
+ });
50
+
51
+ const mockReadTranscriptSessions = readTranscriptSessions as jest.MockedFunction<
52
+ typeof readTranscriptSessions
53
+ >;
54
+
55
+ const STATE_FILE = path.join(TEST_HOME, '.claude', 'user-model-state.json');
56
+
57
+ function todayStr(offsetDays = 0): string {
58
+ return new Date(Date.now() - offsetDays * 86_400_000).toISOString().slice(0, 10);
59
+ }
60
+
61
+ function mkSession(partial: Partial<MinedSession> = {}): MinedSession {
62
+ return {
63
+ id: 'session-1',
64
+ date: todayStr(),
65
+ mtimeMs: Date.now(),
66
+ turns: [
67
+ { role: 'user', content: '数据库迁移方案需要执行' },
68
+ { role: 'assistant', content: '好的' },
69
+ ],
70
+ toolCalls: ['Read'],
71
+ ...partial,
72
+ };
73
+ }
74
+
75
+ function lastJsonOutput(consoleSpy: jest.SpyInstance): Record<string, unknown> {
76
+ const jsonLine = consoleSpy.mock.calls.map(c => c[0]).join('\n');
77
+ return JSON.parse(jsonLine);
78
+ }
79
+
80
+ describe('update-user-model command', () => {
81
+ let consoleSpy: jest.SpyInstance;
82
+
83
+ beforeEach(() => {
84
+ jest.clearAllMocks();
85
+ fs.rmSync(TEST_HOME, { recursive: true, force: true });
86
+ fs.mkdirSync(path.join(TEST_HOME, '.claude', 'projects', '-root-projects', 'memory'), { recursive: true });
87
+ process.env.CLAUDE_TRANSCRIPTS_DIR = path.join(TEST_HOME, 'transcripts');
88
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
89
+ });
90
+
91
+ afterEach(() => {
92
+ consoleSpy.mockRestore();
93
+ delete process.env.CLAUDE_TRANSCRIPTS_DIR;
94
+ });
95
+
96
+ test('无新会话:提示 No new sessions to process', async () => {
97
+ mockReadTranscriptSessions.mockReturnValue([]);
98
+
99
+ await updateUserModel({});
100
+
101
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No new sessions to process'));
102
+ expect(fs.existsSync(STATE_FILE)).toBe(false);
103
+ });
104
+
105
+ test('--json:输出 newSessions 与 new_pattern 变化', async () => {
106
+ mockReadTranscriptSessions.mockReturnValue([
107
+ mkSession({ id: 'session-a' }),
108
+ mkSession({ id: 'session-b' }),
109
+ ]);
110
+
111
+ await updateUserModel({ json: true, dryRun: true });
112
+
113
+ const output = lastJsonOutput(consoleSpy);
114
+ expect(output.newSessions).toBe(2);
115
+ expect(output.changes).toEqual(
116
+ expect.arrayContaining([
117
+ expect.objectContaining({
118
+ type: 'new_pattern',
119
+ key: '数据库迁移方案需要执行',
120
+ }),
121
+ ]),
122
+ );
123
+ });
124
+
125
+ test('--dry-run:只展示变化,不写 state 文件', async () => {
126
+ mockReadTranscriptSessions.mockReturnValue([mkSession({ id: 'session-a' })]);
127
+
128
+ await updateUserModel({ dryRun: true });
129
+
130
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Processed 1 new sessions'));
131
+ expect(fs.existsSync(STATE_FILE)).toBe(false);
132
+ });
133
+
134
+ test('默认(非 dry-run):落盘 state 并记录已处理会话', async () => {
135
+ mockReadTranscriptSessions.mockReturnValue([mkSession({ id: 'session-a' })]);
136
+
137
+ await updateUserModel({});
138
+
139
+ expect(fs.existsSync(STATE_FILE)).toBe(true);
140
+ const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
141
+ expect(state.sessionsProcessed).toContain('session-a');
142
+ });
143
+
144
+ test('--days 1:只处理最近 1 天的会话', async () => {
145
+ mockReadTranscriptSessions.mockReturnValue([
146
+ mkSession({ id: 'today' }),
147
+ mkSession({
148
+ id: 'three-days-ago',
149
+ date: todayStr(3),
150
+ turns: [
151
+ { role: 'user', content: '旧会话概念内容测试' },
152
+ { role: 'assistant', content: '' },
153
+ ],
154
+ }),
155
+ ]);
156
+
157
+ await updateUserModel({ json: true, dryRun: true, days: 1 });
158
+
159
+ const output = lastJsonOutput(consoleSpy);
160
+ expect(output.newSessions).toBe(1);
161
+ });
162
+
163
+ test('缺省 --days:处理全部未处理会话(向后兼容)', async () => {
164
+ mockReadTranscriptSessions.mockReturnValue([
165
+ mkSession({ id: 'today' }),
166
+ mkSession({
167
+ id: 'three-days-ago',
168
+ date: todayStr(3),
169
+ turns: [
170
+ { role: 'user', content: '旧会话概念内容测试' },
171
+ { role: 'assistant', content: '' },
172
+ ],
173
+ }),
174
+ ]);
175
+
176
+ await updateUserModel({ json: true, dryRun: true });
177
+
178
+ const output = lastJsonOutput(consoleSpy);
179
+ expect(output.newSessions).toBe(2);
180
+ });
181
+
182
+ test('sessionsProcessed 去重:已处理会话不重复计入(与 --days 正交)', async () => {
183
+ fs.writeFileSync(
184
+ STATE_FILE,
185
+ JSON.stringify({
186
+ lastUpdated: '',
187
+ sessionsProcessed: ['session-a'],
188
+ patterns: {},
189
+ lensWeights: {},
190
+ principleWeights: {},
191
+ evolutionLog: [],
192
+ }),
193
+ 'utf-8',
194
+ );
195
+ mockReadTranscriptSessions.mockReturnValue([
196
+ mkSession({ id: 'session-a' }),
197
+ mkSession({ id: 'session-b' }),
198
+ ]);
199
+
200
+ await updateUserModel({ json: true, dryRun: true, days: 7 });
201
+
202
+ const output = lastJsonOutput(consoleSpy);
203
+ expect(output.newSessions).toBe(1);
204
+ });
205
+ });
@@ -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
  }