@dommaker/harness 1.11.0 → 1.12.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 (137) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/cli/commands/constraints-pack-proposal.d.ts +48 -0
  3. package/dist/cli/commands/constraints-pack-proposal.d.ts.map +1 -0
  4. package/dist/cli/commands/constraints-pack-proposal.js +169 -0
  5. package/dist/cli/commands/constraints-pack-proposal.js.map +1 -0
  6. package/dist/cli/commands/constraints-reactivate.d.ts +55 -0
  7. package/dist/cli/commands/constraints-reactivate.d.ts.map +1 -0
  8. package/dist/cli/commands/constraints-reactivate.js +175 -0
  9. package/dist/cli/commands/constraints-reactivate.js.map +1 -0
  10. package/dist/cli/commands/constraints-report.d.ts +12 -1
  11. package/dist/cli/commands/constraints-report.d.ts.map +1 -1
  12. package/dist/cli/commands/constraints-report.js +35 -2
  13. package/dist/cli/commands/constraints-report.js.map +1 -1
  14. package/dist/cli/commands/constraints-retire.d.ts +49 -4
  15. package/dist/cli/commands/constraints-retire.d.ts.map +1 -1
  16. package/dist/cli/commands/constraints-retire.js +116 -34
  17. package/dist/cli/commands/constraints-retire.js.map +1 -1
  18. package/dist/cli/commands/definitions.d.ts.map +1 -1
  19. package/dist/cli/commands/definitions.js +31 -1
  20. package/dist/cli/commands/definitions.js.map +1 -1
  21. package/dist/cli/commands/knowledge-view.d.ts +6 -3
  22. package/dist/cli/commands/knowledge-view.d.ts.map +1 -1
  23. package/dist/cli/commands/knowledge-view.js +8 -26
  24. package/dist/cli/commands/knowledge-view.js.map +1 -1
  25. package/dist/cli/commands/report.d.ts.map +1 -1
  26. package/dist/cli/commands/report.js +9 -5
  27. package/dist/cli/commands/report.js.map +1 -1
  28. package/dist/cli/state-io.d.ts +8 -1
  29. package/dist/cli/state-io.d.ts.map +1 -1
  30. package/dist/cli/state-io.js +2 -1
  31. package/dist/cli/state-io.js.map +1 -1
  32. package/dist/core/app-constraints-loader.d.ts +28 -0
  33. package/dist/core/app-constraints-loader.d.ts.map +1 -0
  34. package/dist/core/app-constraints-loader.js +187 -0
  35. package/dist/core/app-constraints-loader.js.map +1 -0
  36. package/dist/core/constraints/checker.js +1 -1
  37. package/dist/core/constraints/checker.js.map +1 -1
  38. package/dist/core/constraints/checkers/index.d.ts +20 -9
  39. package/dist/core/constraints/checkers/index.d.ts.map +1 -1
  40. package/dist/core/constraints/checkers/index.js +40 -11
  41. package/dist/core/constraints/checkers/index.js.map +1 -1
  42. package/dist/core/constraints/checkers/templated/file-exists.d.ts +16 -0
  43. package/dist/core/constraints/checkers/templated/file-exists.d.ts.map +1 -0
  44. package/dist/core/constraints/checkers/templated/file-exists.js +108 -0
  45. package/dist/core/constraints/checkers/templated/file-exists.js.map +1 -0
  46. package/dist/core/constraints/checkers/templated/glob.d.ts +10 -0
  47. package/dist/core/constraints/checkers/templated/glob.d.ts.map +1 -0
  48. package/dist/core/constraints/checkers/templated/glob.js +51 -0
  49. package/dist/core/constraints/checkers/templated/glob.js.map +1 -0
  50. package/dist/core/constraints/checkers/templated/index.d.ts +12 -0
  51. package/dist/core/constraints/checkers/templated/index.d.ts.map +1 -0
  52. package/dist/core/constraints/checkers/templated/index.js +17 -0
  53. package/dist/core/constraints/checkers/templated/index.js.map +1 -0
  54. package/dist/core/constraints/checkers/templated/regex-scan.d.ts +15 -0
  55. package/dist/core/constraints/checkers/templated/regex-scan.d.ts.map +1 -0
  56. package/dist/core/constraints/checkers/templated/regex-scan.js +129 -0
  57. package/dist/core/constraints/checkers/templated/regex-scan.js.map +1 -0
  58. package/dist/core/constraints/checkers/templates-registry.d.ts +17 -0
  59. package/dist/core/constraints/checkers/templates-registry.d.ts.map +1 -0
  60. package/dist/core/constraints/checkers/templates-registry.js +23 -0
  61. package/dist/core/constraints/checkers/templates-registry.js.map +1 -0
  62. package/dist/core/constraints/checkers/types.d.ts +14 -0
  63. package/dist/core/constraints/checkers/types.d.ts.map +1 -1
  64. package/dist/core/constraints/checkers/types.js.map +1 -1
  65. package/dist/core/constraints/definitions/guidelines.d.ts.map +1 -1
  66. package/dist/core/constraints/definitions/guidelines.js +0 -14
  67. package/dist/core/constraints/definitions/guidelines.js.map +1 -1
  68. package/dist/core/constraints/definitions/iron-laws.d.ts.map +1 -1
  69. package/dist/core/constraints/definitions/iron-laws.js +15 -0
  70. package/dist/core/constraints/definitions/iron-laws.js.map +1 -1
  71. package/dist/core/constraints/usage-report.d.ts +58 -6
  72. package/dist/core/constraints/usage-report.d.ts.map +1 -1
  73. package/dist/core/constraints/usage-report.js +61 -12
  74. package/dist/core/constraints/usage-report.js.map +1 -1
  75. package/dist/core/project-config-loader.d.ts +9 -6
  76. package/dist/core/project-config-loader.d.ts.map +1 -1
  77. package/dist/core/project-config-loader.js +18 -8
  78. package/dist/core/project-config-loader.js.map +1 -1
  79. package/dist/gates/command.d.ts.map +1 -1
  80. package/dist/gates/command.js +50 -0
  81. package/dist/gates/command.js.map +1 -1
  82. package/dist/knowledge/lifecycle.d.ts +14 -0
  83. package/dist/knowledge/lifecycle.d.ts.map +1 -1
  84. package/dist/knowledge/lifecycle.js +50 -0
  85. package/dist/knowledge/lifecycle.js.map +1 -1
  86. package/dist/presets/standard.d.ts +1 -1
  87. package/dist/presets/standard.d.ts.map +1 -1
  88. package/dist/presets/standard.js +2 -3
  89. package/dist/presets/standard.js.map +1 -1
  90. package/dist/types/constraint.d.ts +24 -0
  91. package/dist/types/constraint.d.ts.map +1 -1
  92. package/dist/types/constraint.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/__tests__/app-constraints-loader.test.ts +220 -0
  95. package/src/__tests__/iron-laws.test.ts +5 -5
  96. package/src/cli/commands/CONTEXT.md +3 -2
  97. package/src/cli/commands/__tests__/check.test.ts +22 -4
  98. package/src/cli/commands/__tests__/constraints-pack-proposal.test.ts +142 -0
  99. package/src/cli/commands/__tests__/constraints-reactivate.test.ts +210 -0
  100. package/src/cli/commands/__tests__/constraints-report.test.ts +175 -6
  101. package/src/cli/commands/__tests__/constraints-retire.test.ts +135 -10
  102. package/src/cli/commands/__tests__/knowledge.test.ts +6 -16
  103. package/src/cli/commands/__tests__/report-app-constraints.test.ts +67 -0
  104. package/src/cli/commands/constraints-pack-proposal.ts +172 -0
  105. package/src/cli/commands/constraints-reactivate.ts +197 -0
  106. package/src/cli/commands/constraints-report.ts +44 -2
  107. package/src/cli/commands/constraints-retire.ts +129 -36
  108. package/src/cli/commands/definitions.ts +31 -1
  109. package/src/cli/commands/knowledge-view.ts +9 -28
  110. package/src/cli/commands/report.ts +9 -5
  111. package/src/cli/state-io.ts +8 -1
  112. package/src/core/CONTEXT.md +5 -4
  113. package/src/core/app-constraints-loader.ts +165 -0
  114. package/src/core/constraints/__tests__/collect-constraints.test.ts +3 -1
  115. package/src/core/constraints/__tests__/usage-report.test.ts +63 -0
  116. package/src/core/constraints/checker.ts +1 -1
  117. package/src/core/constraints/checkers/__tests__/templated-checkers.test.ts +180 -0
  118. package/src/core/constraints/checkers/__tests__/templates-registry.test.ts +81 -0
  119. package/src/core/constraints/checkers/index.ts +44 -10
  120. package/src/core/constraints/checkers/templated/file-exists.ts +75 -0
  121. package/src/core/constraints/checkers/templated/glob.ts +46 -0
  122. package/src/core/constraints/checkers/templated/index.ts +12 -0
  123. package/src/core/constraints/checkers/templated/regex-scan.ts +132 -0
  124. package/src/core/constraints/checkers/templates-registry.ts +22 -0
  125. package/src/core/constraints/checkers/types.ts +15 -0
  126. package/src/core/constraints/definitions/guidelines.ts +0 -15
  127. package/src/core/constraints/definitions/iron-laws.ts +16 -0
  128. package/src/core/constraints/usage-report.ts +128 -13
  129. package/src/core/project-config-loader.ts +19 -8
  130. package/src/gates/__tests__/command.test.ts +54 -0
  131. package/src/gates/command.ts +51 -0
  132. package/src/knowledge/CONTEXT.md +3 -1
  133. package/src/knowledge/__tests__/lifecycle.test.ts +81 -0
  134. package/src/knowledge/lifecycle.ts +49 -0
  135. package/src/presets/__tests__/standard.test.ts +5 -4
  136. package/src/presets/standard.ts +2 -3
  137. package/src/types/constraint.ts +28 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * harness constraints pack-proposal 测试(ADR-0033 决策 4,块 3 子项 5)
3
+ *
4
+ * 覆盖:材料内容形状(条文/severity/checker/统计/升级理由留白)、路径脱敏(<repoRoot>)、
5
+ * 应用层约束带模板 id 与 params 原文、未知 id → usage-error、--stdout、缺省落盘路径。
6
+ *
7
+ * 使用真实临时目录(loadAppConstraints / traces 读真实 fs)。
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { captureIO, type CapturingIO } from '../../command-contract';
13
+ import { constraintsPackProposal, renderProposalMarkdown } from '../constraints-pack-proposal';
14
+ import { createProjectFixture, writeProjectTraces } from '../../../test-setup/project-fixture';
15
+ import type { Constraint } from '../../../types/constraint';
16
+
17
+ const APP_YML = [
18
+ 'constraints:',
19
+ ' - id: app_no_internal_url',
20
+ ' rule: Web code must not contain internal URLs',
21
+ ' checker: regex-scan',
22
+ ' params:',
23
+ " pattern: 'https?://10\\.'",
24
+ " glob: 'apps/web/src/**'",
25
+ ' severity: warning',
26
+ ' message: 检测到内网地址,请改用环境变量配置',
27
+ '',
28
+ ].join('\n');
29
+
30
+ const NOW = new Date('2026-09-22T00:00:00.000Z');
31
+
32
+ let io: CapturingIO;
33
+ beforeEach(() => {
34
+ io = captureIO();
35
+ });
36
+
37
+ describe('constraintsPackProposal', () => {
38
+ it('内置约束:材料含条文/severity/统计/升级理由留白,缺省落 .harness/reports/proposal-<id>-<YYYYMMDD>.md', async () => {
39
+ const root = createProjectFixture({ name: 'pack-proposal-test' });
40
+ writeProjectTraces(root, [
41
+ { constraintId: 'no_hardcoded_credentials', result: 'pass', timestamp: 1700000000000 },
42
+ { constraintId: 'no_hardcoded_credentials', result: 'fail', timestamp: 1700000001000 },
43
+ { constraintId: 'no_hardcoded_credentials', result: 'skip', timestamp: 1700000002000 },
44
+ ]);
45
+
46
+ const result = await constraintsPackProposal('no_hardcoded_credentials', { projectPath: root, now: NOW }, io);
47
+
48
+ expect(result.kind).toBe('ok');
49
+ const outPath = path.join(root, '.harness', 'reports', 'proposal-no_hardcoded_credentials-20260922.md');
50
+ expect(fs.existsSync(outPath)).toBe(true);
51
+
52
+ const md = fs.readFileSync(outPath, 'utf-8');
53
+ expect(md).toContain('# 约束升级提案材料:no_hardcoded_credentials');
54
+ expect(md).toContain('- 生成日期: 2026-09-22');
55
+ expect(md).toContain('- 来源: 内置');
56
+ expect(md).toContain('- severity: error');
57
+ expect(md).toContain('## 条文');
58
+ expect(md).toContain('## checker');
59
+ expect(md).toContain('内置定制 checker');
60
+ // 统计:total=3,evaluated=2(skip 不计入),只有计数
61
+ expect(md).toContain('- total: 3');
62
+ expect(md).toContain('- evaluated: 2(pass 1 / fail 1 / skip 1)');
63
+ expect(md).toContain('- 首次触发: 2023-11-14');
64
+ expect(md).toContain('## 升级理由');
65
+ expect(md).toContain('请在此填写');
66
+ // 脱敏:材料不含项目根绝对路径
67
+ expect(md).not.toContain(root);
68
+ });
69
+
70
+ it('应用层约束:checker 段带模板 id 与 params 原文 + 人工确认标注', async () => {
71
+ const root = createProjectFixture({
72
+ name: 'pack-proposal-test',
73
+ files: { [path.join('.harness', 'constraints.yml')]: APP_YML },
74
+ });
75
+
76
+ const result = await constraintsPackProposal('app_no_internal_url', { projectPath: root, stdout: true, now: NOW }, io);
77
+
78
+ expect(result.kind).toBe('ok');
79
+ const out = io.outText();
80
+ expect(out).toContain('- 来源: 应用层(.harness/constraints.yml)');
81
+ expect(out).toContain('模板: `regex-scan`');
82
+ expect(out).toContain('pattern:');
83
+ expect(out).toContain('https?://10\\.');
84
+ expect(out).toContain("glob: apps/web/src/**");
85
+ expect(out).toContain('请人工确认无应用内部信息');
86
+ expect(out).toContain('message: 检测到内网地址,请改用环境变量配置');
87
+ // --stdout:不落盘
88
+ expect(fs.existsSync(path.join(root, '.harness', 'reports'))).toBe(false);
89
+ });
90
+
91
+ it('路径脱敏:params/条文中出现的项目根绝对路径替换为 <repoRoot>', () => {
92
+ const root = '/home/someone/my-app';
93
+ const constraint: Constraint = {
94
+ id: 'app_x',
95
+ kind: 'check',
96
+ rule: '不得引用 /home/someone/my-app/secrets 下的文件',
97
+ message: 'm',
98
+ severity: 'warning',
99
+ trigger: [],
100
+ enforcement: '',
101
+ source: 'app',
102
+ checker: 'file-exists',
103
+ params: { path: '/home/someone/my-app/.harness/config.yml' },
104
+ };
105
+
106
+ const md = renderProposalMarkdown(
107
+ { constraint, stats: { total: 0, pass: 0, fail: 0, skip: 0, evaluated: 0 } },
108
+ root,
109
+ NOW
110
+ );
111
+
112
+ expect(md).not.toContain(root);
113
+ expect(md).toContain('<repoRoot>/secrets');
114
+ expect(md).toContain('<repoRoot>/.harness/config.yml');
115
+ });
116
+
117
+ it('未知 id → usage-error,不落盘', async () => {
118
+ const root = createProjectFixture({ name: 'pack-proposal-test' });
119
+
120
+ const result = await constraintsPackProposal('ghost_constraint', { projectPath: root }, io);
121
+
122
+ expect(result.kind).toBe('usage-error');
123
+ expect(result.kind === 'usage-error' && result.reason).toContain('ghost_constraint');
124
+ expect(io.errText()).toContain('未知约束 id');
125
+ expect(fs.existsSync(path.join(root, '.harness', 'reports'))).toBe(false);
126
+ });
127
+
128
+ it('缺 id → usage-error', async () => {
129
+ const result = await constraintsPackProposal(undefined, { projectPath: '/nonexistent' }, io);
130
+ expect(result.kind).toBe('usage-error');
131
+ });
132
+
133
+ it('无 trace 时统计为零计数且不报错', async () => {
134
+ const root = createProjectFixture({ name: 'pack-proposal-test' });
135
+
136
+ await constraintsPackProposal('docs_freshness', { projectPath: root, stdout: true, now: NOW }, io);
137
+
138
+ const out = io.outText();
139
+ expect(out).toContain('- total: 0');
140
+ expect(out).toContain('(无记录)');
141
+ });
142
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * harness constraints reactivate 测试(ADR-0032 决策 6.5,票 02 断点 5)
3
+ *
4
+ * 执行逻辑(reactivateConstraint,纯函数化):墓碑段删除、复活沉淀条目、
5
+ * not_retired / unknown_id 保护、旧 retired 沉淀不改历史。
6
+ * CLI 直达(constraintsReactivate):--yes 人确认闸门。
7
+ *
8
+ * 使用真实临时目录。
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import { captureIO, type CapturingIO } from '../../command-contract';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ import * as yaml from 'js-yaml';
16
+ import { getEffectiveConstraints } from '../../../core/effective-constraints';
17
+ import { FileKnowledgeStore } from '../../../knowledge/store';
18
+ import { retireConstraint } from '../constraints-retire';
19
+ import { reactivateConstraint, constraintsReactivate, printReactivateResult } from '../constraints-reactivate';
20
+ import { createProjectFixture, writeProjectConfig } from '../../../test-setup/project-fixture';
21
+
22
+ const FIXED_NOW = new Date('2026-08-08T12:00:00.000Z');
23
+ const REACTIVATE_NOW = new Date('2026-09-01T08:00:00.000Z');
24
+
25
+ function readConfig(root: string): any {
26
+ return yaml.load(fs.readFileSync(path.join(root, '.harness', 'config.yml'), 'utf-8'));
27
+ }
28
+
29
+ let io: CapturingIO;
30
+ beforeEach(() => {
31
+ io = captureIO();
32
+ // 复活沉淀写口与 retire 同一解析点:不隔离会写进真实用户主目录
33
+ process.env.KNOWLEDGE_BASE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-reactivate-kb-'));
34
+ });
35
+ afterEach(() => {
36
+ fs.rmSync(process.env.KNOWLEDGE_BASE_DIR!, { recursive: true, force: true });
37
+ delete process.env.KNOWLEDGE_BASE_DIR;
38
+ });
39
+
40
+ describe('reactivateConstraint 执行逻辑', () => {
41
+ it('复活落盘:config.yml 墓碑段删除,约束回到生效集', () => {
42
+ const root = createProjectFixture({ name: 'harness-reactivate-test' });
43
+ retireConstraint(root, 'capability_sync', { reason: '先退', now: FIXED_NOW });
44
+ expect(getEffectiveConstraints(root).some(c => c.id === 'capability_sync')).toBe(false);
45
+
46
+ const result = reactivateConstraint(root, 'capability_sync', { reason: '误退', now: REACTIVATE_NOW });
47
+
48
+ expect(result.status).toBe('reactivated');
49
+ const config = readConfig(root);
50
+ expect(config.constraints?.capability_sync).toBeUndefined();
51
+ expect(getEffectiveConstraints(root).some(c => c.id === 'capability_sync')).toBe(true);
52
+ });
53
+
54
+ it('KnowledgeStore 写复活条目:引用原 retired 条目 + 复活原因 + signal 模式;旧沉淀不改历史', () => {
55
+ const root = createProjectFixture({ name: 'harness-reactivate-test' });
56
+ retireConstraint(root, 'capability_sync', { reason: '先退', now: FIXED_NOW });
57
+
58
+ const result = reactivateConstraint(root, 'capability_sync', { reason: '模型能力回退,仍需兜底', now: REACTIVATE_NOW });
59
+
60
+ expect(result.status).toBe('reactivated');
61
+ expect(result.knowledgeEntryId).toBe('constraint-reactivated-capability_sync');
62
+ expect(result.knowledgeBaseDir).toBe(process.env.KNOWLEDGE_BASE_DIR);
63
+
64
+ const store = new FileKnowledgeStore({ baseDir: process.env.KNOWLEDGE_BASE_DIR! });
65
+ const entry = store.get('constraint-reactivated-capability_sync');
66
+ expect(entry).toBeDefined();
67
+ expect(entry!.consumptionMode).toBe('signal');
68
+ expect(entry!.origin).toBe('human');
69
+ expect(entry!.tags).toContain('constraint-reactivated');
70
+ expect(entry!.tags).toContain('constraint:capability_sync');
71
+ expect(entry!.content).toContain('constraint-retired-capability_sync');
72
+ expect(entry!.content).toContain('模型能力回退,仍需兜底');
73
+ expect(entry!.content).toContain(REACTIVATE_NOW.toISOString());
74
+
75
+ // 不改历史:旧 retired 沉淀原样保留(原因仍是"先退")
76
+ const retired = store.get('constraint-retired-capability_sync');
77
+ expect(retired).toBeDefined();
78
+ expect(retired!.content).toContain('先退');
79
+ });
80
+
81
+ it('裸 disable(enabled:false 无 retired 墓碑):not_retired,不落盘不吞配置', () => {
82
+ const root = createProjectFixture({
83
+ name: 'harness-reactivate-test',
84
+ config: 'constraints:\n capability_sync:\n enabled: false\n',
85
+ });
86
+
87
+ const result = reactivateConstraint(root, 'capability_sync', { now: REACTIVATE_NOW });
88
+
89
+ expect(result.status).toBe('not_retired');
90
+ // 配置原样保留(裸 disable 的处置权在用户,reactivate 不代为"清理")
91
+ expect(readConfig(root).constraints.capability_sync.enabled).toBe(false);
92
+ const store = new FileKnowledgeStore({ baseDir: process.env.KNOWLEDGE_BASE_DIR! });
93
+ expect(store.get('constraint-reactivated-capability_sync')).toBeUndefined();
94
+ });
95
+
96
+ it('从未配置的约束:not_retired,不落盘任何文件', () => {
97
+ const root = createProjectFixture({ name: 'harness-reactivate-test' });
98
+
99
+ const result = reactivateConstraint(root, 'capability_sync', { now: REACTIVATE_NOW });
100
+
101
+ expect(result.status).toBe('not_retired');
102
+ expect(fs.existsSync(path.join(root, '.harness'))).toBe(false);
103
+ });
104
+
105
+ it('未知 id:unknown_id,不落盘任何文件', () => {
106
+ const root = createProjectFixture({ name: 'harness-reactivate-test' });
107
+ const result = reactivateConstraint(root, 'not_a_constraint', { now: REACTIVATE_NOW });
108
+ expect(result.status).toBe('unknown_id');
109
+ expect(fs.existsSync(path.join(root, '.harness'))).toBe(false);
110
+ });
111
+
112
+ it('复活结果打印:git 仓内附 commit 提示,非 git 目录不提示(票 02 断点 4)', () => {
113
+ const gitRoot = createProjectFixture({ name: 'harness-reactivate-test', files: { '.git/HEAD': 'ref: refs/heads/master\n' } });
114
+ retireConstraint(gitRoot, 'capability_sync', { now: FIXED_NOW });
115
+ const inGit = reactivateConstraint(gitRoot, 'capability_sync', { now: REACTIVATE_NOW });
116
+ printReactivateResult(inGit, io, gitRoot);
117
+ expect(io.outText()).toContain('git add .harness/config.yml');
118
+
119
+ const plainRoot = createProjectFixture({ name: 'harness-reactivate-test' });
120
+ retireConstraint(plainRoot, 'capability_sync', { now: FIXED_NOW });
121
+ const plainIo = captureIO();
122
+ const notInGit = reactivateConstraint(plainRoot, 'capability_sync', { now: REACTIVATE_NOW });
123
+ printReactivateResult(notInGit, plainIo, plainRoot);
124
+ expect(plainIo.outText()).not.toContain('git add');
125
+ });
126
+ });
127
+
128
+ describe('constraintsReactivate 非交互直达', () => {
129
+ it('无 --yes 直达:报错 + 非零退出码 + 不落盘任何文件(人确认闸门)', async () => {
130
+ const root = createProjectFixture({ name: 'harness-reactivate-test' });
131
+ retireConstraint(root, 'capability_sync', { now: FIXED_NOW });
132
+
133
+ const result = await constraintsReactivate('capability_sync', { projectPath: root, reason: '误退' }, io);
134
+
135
+ expect(io.errText()).toContain('--yes');
136
+ expect(result.kind).toBe('usage-error');
137
+ // 墓碑原样在,复活条目未写
138
+ expect(readConfig(root).constraints.capability_sync.retired).toBeDefined();
139
+ const store = new FileKnowledgeStore({ baseDir: process.env.KNOWLEDGE_BASE_DIR! });
140
+ expect(store.get('constraint-reactivated-capability_sync')).toBeUndefined();
141
+ });
142
+
143
+ it('--yes 直达复活:落盘并打印结果', async () => {
144
+ const root = createProjectFixture({ name: 'harness-reactivate-test' });
145
+ retireConstraint(root, 'capability_sync', { reason: '先退', now: FIXED_NOW });
146
+
147
+ const result = await constraintsReactivate('capability_sync', { projectPath: root, reason: '误退', yes: true }, io);
148
+
149
+ expect(result.kind).toBe('ok');
150
+ expect(io.outText()).toContain('已复活');
151
+ expect(readConfig(root).constraints?.capability_sync).toBeUndefined();
152
+ });
153
+
154
+ it('--yes 直达无墓碑:明确提示 not_retired 语义', async () => {
155
+ const root = createProjectFixture({ name: 'harness-reactivate-test' });
156
+ writeProjectConfig(root, 'constraints:\n capability_sync:\n enabled: false\n');
157
+
158
+ const result = await constraintsReactivate('capability_sync', { projectPath: root, yes: true }, io);
159
+
160
+ expect(result.kind).toBe('skip');
161
+ expect(io.outText()).toContain('无 retired 墓碑');
162
+ });
163
+
164
+ it('缺 id:usage-error 提示用法', async () => {
165
+ const result = await constraintsReactivate(undefined, { yes: true }, io);
166
+ expect(result.kind).toBe('usage-error');
167
+ expect(io.errText()).toContain('用法');
168
+ });
169
+ });
170
+
171
+ describe('应用层约束复活(ADR-0033 块 3 子项 3)', () => {
172
+ const APP_YML = [
173
+ 'constraints:',
174
+ ' - id: app_no_internal_url',
175
+ ' rule: Web code must not contain internal URLs',
176
+ ' checker: regex-scan',
177
+ ' params:',
178
+ " pattern: 'https?://10\\.'",
179
+ ' severity: warning',
180
+ '',
181
+ ].join('\n');
182
+
183
+ function appFixture(): string {
184
+ return createProjectFixture({
185
+ name: 'harness-reactivate-test',
186
+ files: { [path.join('.harness', 'constraints.yml')]: APP_YML },
187
+ });
188
+ }
189
+
190
+ it('退休后可复活:删 config.yml 墓碑,约束回生效集(constraints.yml 条文一直在)', () => {
191
+ const root = appFixture();
192
+ retireConstraint(root, 'app_no_internal_url', { reason: '先退', now: FIXED_NOW });
193
+ expect(getEffectiveConstraints(root).some(c => c.id === 'app_no_internal_url')).toBe(false);
194
+
195
+ const result = reactivateConstraint(root, 'app_no_internal_url', { reason: '误退', now: REACTIVATE_NOW });
196
+
197
+ expect(result.status).toBe('reactivated');
198
+ expect(readConfig(root).constraints?.app_no_internal_url).toBeUndefined();
199
+ expect(getEffectiveConstraints(root).some(c => c.id === 'app_no_internal_url')).toBe(true);
200
+ // 复活沉淀照写
201
+ const store = new FileKnowledgeStore({ baseDir: process.env.KNOWLEDGE_BASE_DIR! });
202
+ expect(store.get('constraint-reactivated-app_no_internal_url')).toBeDefined();
203
+ });
204
+
205
+ it('应用层未退休(无墓碑)→ not_retired', () => {
206
+ const root = appFixture();
207
+ const result = reactivateConstraint(root, 'app_no_internal_url', { now: REACTIVATE_NOW });
208
+ expect(result.status).toBe('not_retired');
209
+ });
210
+ });
@@ -15,7 +15,9 @@ import {
15
15
  buildConstraintsUsageReport,
16
16
  diagnoseRetireCandidates,
17
17
  readProjectTraces,
18
+ WATCHLIST_PERIOD_DAYS,
18
19
  } from '../../../core/constraints/usage-report';
20
+ import type { HarnessState, StateIO } from '../../state-io';
19
21
  import { constraintsReport, renderExportMarkdown } from '../constraints-report';
20
22
  import { createProjectFixture, writeProjectTraces } from '../../../test-setup/project-fixture';
21
23
 
@@ -126,13 +128,13 @@ describe('buildConstraintsUsageReport', () => {
126
128
  expect(noiseC?.kind).toBe('high_noise');
127
129
  expect(noiseC?.reason).toContain('83%');
128
130
 
129
- // 零拦截
130
- const zeroC = byId.get('no_hardcoded_credentials');
131
- expect(zeroC?.kind).toBe('zero_intercept');
132
- expect(zeroC?.reason).toContain('50 次评估');
131
+ // 零拦截:先进观察名单(ADR-0032 中间态),不直接进退役候选
132
+ expect(byId.get('no_hardcoded_credentials')).toBeUndefined();
133
+ const watched = report.watchlist.find(w => w.id === 'no_hardcoded_credentials');
134
+ expect(watched?.reason).toContain('50 次评估');
133
135
  });
134
136
 
135
- it('阈值可配:放宽零拦截样本阈值后 10 次全 pass 也成为候选', () => {
137
+ it('阈值可配:放宽零拦截样本阈值后 10 次全 pass 进观察名单(不直接进候选)', () => {
136
138
  const root = createProjectFixture({
137
139
  name: 'harness-report-test',
138
140
  traces: tracesOf('no_hardcoded_credentials', 'pass', 10),
@@ -140,9 +142,11 @@ describe('buildConstraintsUsageReport', () => {
140
142
 
141
143
  const strict = buildConstraintsUsageReport(root);
142
144
  expect(strict.candidates.find(c => c.id === 'no_hardcoded_credentials')).toBeUndefined();
145
+ expect(strict.watchlist.find(w => w.id === 'no_hardcoded_credentials')).toBeUndefined();
143
146
 
144
147
  const relaxed = buildConstraintsUsageReport(root, { zeroInterceptMinEvaluated: 10 });
145
- expect(relaxed.candidates.find(c => c.id === 'no_hardcoded_credentials')?.kind).toBe('zero_intercept');
148
+ expect(relaxed.candidates.find(c => c.id === 'no_hardcoded_credentials')).toBeUndefined();
149
+ expect(relaxed.watchlist.find(w => w.id === 'no_hardcoded_credentials')).toBeDefined();
146
150
  });
147
151
 
148
152
  it('diagnoseRetireCandidates 优先级:全 skip 不重复计入零拦截', () => {
@@ -286,3 +290,168 @@ describe('constraintsReport 坏行数透传(harness#100)', () => {
286
290
  expect(fs.readFileSync(path.join(clean, 'report.md'), 'utf-8')).not.toContain('损坏');
287
291
  });
288
292
  });
293
+
294
+ describe('零拦截观察名单(ADR-0032,块 3 子项 4)', () => {
295
+ const DAY_MS = 24 * 60 * 60 * 1000;
296
+ // 固定时钟:观察期判定全靠注入 now,不在测试里等 90 天
297
+ const NOW = 1700100000000;
298
+ const NOW_ISO = new Date(NOW).toISOString();
299
+
300
+ /** 内存 StateIO 假件(与 status/check 测试同形) */
301
+ function memoryStateIO(initial: HarnessState = {}): StateIO & { snapshot(): HarnessState } {
302
+ let state = initial;
303
+ return {
304
+ read: () => state,
305
+ write: (s: HarnessState) => { state = s; },
306
+ snapshot: () => state,
307
+ };
308
+ }
309
+
310
+ /** 50 次全 pass 的零拦截 fixture 项目 */
311
+ function zeroInterceptProject(id = 'no_hardcoded_credentials', n = 50): string {
312
+ return createProjectFixture({
313
+ name: 'harness-watchlist-test',
314
+ traces: tracesOf(id, 'pass', n, NOW - 10 * DAY_MS),
315
+ });
316
+ }
317
+
318
+ it('命中且不在名单:列入观察名单(记列入时间),进 watchlist 分组不进退役候选', () => {
319
+ const root = zeroInterceptProject();
320
+ const report = buildConstraintsUsageReport(root, {}, { now: NOW });
321
+
322
+ expect(report.candidates.some(c => c.id === 'no_hardcoded_credentials')).toBe(false);
323
+ const w = report.watchlist.find(x => x.id === 'no_hardcoded_credentials');
324
+ expect(w).toBeDefined();
325
+ expect(w!.listedAt).toBe(NOW_ISO);
326
+ expect(w!.remainingDays).toBe(WATCHLIST_PERIOD_DAYS);
327
+ expect(w!.reason).toContain('50 次评估');
328
+ // 待写回状态只增不删
329
+ expect(report.nextWatchlist['no_hardcoded_credentials']).toEqual({ listedAt: NOW_ISO });
330
+ });
331
+
332
+ it('观察期未满:仍留名单不转候选,剩余观察期按天折算', () => {
333
+ const root = zeroInterceptProject();
334
+ const listedAt = new Date(NOW - 30 * DAY_MS).toISOString();
335
+ const report = buildConstraintsUsageReport(root, {}, {
336
+ watchlist: { no_hardcoded_credentials: { listedAt } },
337
+ now: NOW,
338
+ });
339
+
340
+ expect(report.candidates.some(c => c.id === 'no_hardcoded_credentials')).toBe(false);
341
+ const w = report.watchlist.find(x => x.id === 'no_hardcoded_credentials');
342
+ expect(w!.listedAt).toBe(listedAt);
343
+ expect(w!.remainingDays).toBe(WATCHLIST_PERIOD_DAYS - 30);
344
+ // 已列入不重复写
345
+ expect(report.nextWatchlist).toEqual({ no_hardcoded_credentials: { listedAt } });
346
+ });
347
+
348
+ it('期满且样本够:转正式 zero_intercept 退役候选', () => {
349
+ const root = zeroInterceptProject();
350
+ const listedAt = new Date(NOW - WATCHLIST_PERIOD_DAYS * DAY_MS).toISOString();
351
+ const report = buildConstraintsUsageReport(root, {}, {
352
+ watchlist: { no_hardcoded_credentials: { listedAt } },
353
+ now: NOW,
354
+ });
355
+
356
+ const c = report.candidates.find(x => x.id === 'no_hardcoded_credentials');
357
+ expect(c?.kind).toBe('zero_intercept');
358
+ expect(c?.reason).toContain('观察期满');
359
+ expect(report.watchlist.some(w => w.id === 'no_hardcoded_credentials')).toBe(false);
360
+ });
361
+
362
+ it('期满但样本不够(评估数跌破阈值):不转候选', () => {
363
+ const root = zeroInterceptProject('no_hardcoded_credentials', 30);
364
+ const listedAt = new Date(NOW - 120 * DAY_MS).toISOString();
365
+ const report = buildConstraintsUsageReport(root, {}, {
366
+ watchlist: { no_hardcoded_credentials: { listedAt } },
367
+ now: NOW,
368
+ });
369
+
370
+ expect(report.candidates.some(c => c.id === 'no_hardcoded_credentials')).toBe(false);
371
+ });
372
+
373
+ it('其他三类候选不进观察名单(zero_trigger/unevaluable/high_noise 维持现状)', () => {
374
+ const root = createProjectFixture({ name: 'harness-watchlist-test' });
375
+ writeProjectTraces(root, [
376
+ ...tracesOf('capability_sync', 'skip', 5), // 不可评估
377
+ ...tracesOf('no_test_simplification', 'fail', 20), // 高噪
378
+ ...tracesOf('no_test_simplification', 'pass', 4, 1700001000000),
379
+ // docs_freshness 等零触发
380
+ ]);
381
+ const report = buildConstraintsUsageReport(root, {}, { now: NOW });
382
+
383
+ expect(report.candidates.length).toBeGreaterThan(0);
384
+ expect(report.watchlist).toEqual([]);
385
+ expect(report.nextWatchlist).toEqual({});
386
+ });
387
+
388
+ it('diagnoseRetireCandidates 兼容签名:只回候选,观察期内的零拦截不出现', () => {
389
+ const stats = [{
390
+ id: 'x', severity: 'warning' as const,
391
+ total: 50, pass: 50, fail: 0, skip: 0,
392
+ evaluated: 50, failRate: 0,
393
+ }];
394
+ expect(diagnoseRetireCandidates(stats)).toEqual([]);
395
+ const listedAt = new Date(NOW - 100 * DAY_MS).toISOString();
396
+ const converted = diagnoseRetireCandidates(stats, {}, {
397
+ watchlist: { x: { listedAt } },
398
+ now: NOW,
399
+ });
400
+ expect(converted.map(c => c.kind)).toEqual(['zero_intercept']);
401
+ });
402
+
403
+ it('列入时间损坏按未列入处理:重新列入、时钟重启', () => {
404
+ const root = zeroInterceptProject();
405
+ const report = buildConstraintsUsageReport(root, {}, {
406
+ watchlist: { no_hardcoded_credentials: { listedAt: 'not-a-date' } },
407
+ now: NOW,
408
+ });
409
+
410
+ expect(report.candidates.some(c => c.id === 'no_hardcoded_credentials')).toBe(false);
411
+ expect(report.nextWatchlist['no_hardcoded_credentials']).toEqual({ listedAt: NOW_ISO });
412
+ });
413
+
414
+ it('CLI 级:report 经 StateIO 持久化列入,第二次运行读回仍在名单', async () => {
415
+ const root = zeroInterceptProject();
416
+ const stateIO = memoryStateIO();
417
+
418
+ await constraintsReport({ projectPath: root, stateIO, now: NOW }, io);
419
+ expect(stateIO.snapshot().constraintWatchlist?.['no_hardcoded_credentials']).toEqual({ listedAt: NOW_ISO });
420
+ expect(io.outText()).toContain('观察名单');
421
+ expect(io.outText()).toContain('no_hardcoded_credentials');
422
+
423
+ // 第二次:名单状态经注入面读回,不重复写、不进候选
424
+ const io2 = captureIO();
425
+ await constraintsReport({ projectPath: root, stateIO, now: NOW + 10 * DAY_MS }, io2);
426
+ expect(io2.outText()).toContain('剩余观察 80 天');
427
+ expect(io2.outText()).not.toContain('[零拦截] no_hardcoded_credentials');
428
+
429
+ // 期满:同一条转退役候选
430
+ const io3 = captureIO();
431
+ await constraintsReport({ projectPath: root, stateIO, now: NOW + WATCHLIST_PERIOD_DAYS * DAY_MS }, io3);
432
+ expect(io3.outText()).toContain('[零拦截] no_hardcoded_credentials');
433
+ });
434
+
435
+ it('StateIO 写回不动其他状态字段(读-改-写)', async () => {
436
+ const root = zeroInterceptProject();
437
+ const stateIO = memoryStateIO({ shownHints: ['trace_50'], lastStatusRun: '2026-09-01T00:00:00.000Z' });
438
+
439
+ await constraintsReport({ projectPath: root, stateIO, now: NOW }, captureIO());
440
+
441
+ const s = stateIO.snapshot();
442
+ expect(s.shownHints).toEqual(['trace_50']);
443
+ expect(s.lastStatusRun).toBe('2026-09-01T00:00:00.000Z');
444
+ expect(Object.keys(s.constraintWatchlist ?? {})).toEqual(['no_hardcoded_credentials']);
445
+ });
446
+
447
+ it('--export 脱敏摘要带观察名单分组(列入时间 + 剩余观察期)', async () => {
448
+ const root = zeroInterceptProject();
449
+ await constraintsReport({ projectPath: root, export: 'report.md', now: NOW }, captureIO());
450
+
451
+ const md = fs.readFileSync(path.join(root, 'report.md'), 'utf-8');
452
+ expect(md).toContain('## 观察名单');
453
+ expect(md).toContain('no_hardcoded_credentials');
454
+ expect(md).toContain(`剩余观察 ${WATCHLIST_PERIOD_DAYS} 天`);
455
+ expect(md).not.toContain(root);
456
+ });
457
+ });