@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
@@ -14,6 +14,7 @@
14
14
 
15
15
  import * as fs from 'fs';
16
16
  import { captureIO, type CapturingIO } from '../../command-contract';
17
+ import * as os from 'os';
17
18
  import * as path from 'path';
18
19
  import { PassThrough, Writable } from 'stream';
19
20
  import * as yaml from 'js-yaml';
@@ -21,6 +22,7 @@ import { getConstraint } from '../../../core/constraints/definitions';
21
22
  import { getEffectiveConstraints } from '../../../core/effective-constraints';
22
23
  import { FileKnowledgeStore } from '../../../knowledge/store';
23
24
  import { retireConstraint, constraintsRetire, runRetireInteractive, printRetireResult } from '../constraints-retire';
25
+ import { openKnowledgeStore } from '../knowledge-view';
24
26
  import { createProjectFixture, writeProjectTraces } from '../../../test-setup/project-fixture';
25
27
 
26
28
  const FIXED_NOW = new Date('2026-08-08T12:00:00.000Z');
@@ -32,6 +34,12 @@ function readConfig(root: string): any {
32
34
  let io: CapturingIO;
33
35
  beforeEach(() => {
34
36
  io = captureIO();
37
+ // retire 的 KnowledgeStore 写口走统一解析点(harness#177):不隔离会写进真实用户主目录
38
+ process.env.KNOWLEDGE_BASE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-retire-kb-'));
39
+ });
40
+ afterEach(() => {
41
+ fs.rmSync(process.env.KNOWLEDGE_BASE_DIR!, { recursive: true, force: true });
42
+ delete process.env.KNOWLEDGE_BASE_DIR;
35
43
  });
36
44
 
37
45
  describe('retireConstraint 执行逻辑', () => {
@@ -49,7 +57,7 @@ describe('retireConstraint 执行逻辑', () => {
49
57
  });
50
58
 
51
59
  expect(result.status).toBe('retired');
52
- expect(result.isError).toBe(false);
60
+ expect(result.isError).toBe(true);
53
61
  expect(result.stats).toEqual({ total: 3, fail: 1, failRate: 1 / 3 });
54
62
 
55
63
  const config = readConfig(root);
@@ -77,7 +85,7 @@ describe('retireConstraint 执行逻辑', () => {
77
85
  expect(config.constraints.capability_sync.retired.reason).toBe('');
78
86
  });
79
87
 
80
- it('KnowledgeStore 写入退役记录:规则原文 + 原因 + 统计 + signal 模式', () => {
88
+ it('KnowledgeStore 写入退役记录:规则原文 + 原因 + 统计 + signal 模式,落点为 knowledge 读口同一解析根(harness#177)', () => {
81
89
  const root = createProjectFixture({
82
90
  name: 'harness-retire-test',
83
91
  traces: [{ constraintId: 'no_completion_without_verification', result: 'fail' }],
@@ -86,8 +94,12 @@ describe('retireConstraint 执行逻辑', () => {
86
94
  const result = retireConstraint(root, 'no_completion_without_verification', { reason: '流程已内置门禁', now: FIXED_NOW });
87
95
  expect(result.status).toBe('retired');
88
96
  expect(result.knowledgeEntryId).toBe('constraint-retired-no_completion_without_verification');
97
+ // 写口与读口同一解析点:KNOWLEDGE_BASE_DIR 覆盖对写口同步生效
98
+ expect(result.knowledgeBaseDir).toBe(process.env.KNOWLEDGE_BASE_DIR);
99
+ // 缺省解析(无 -p/--dir)构造的 store 直接可读——修复前写口硬编码 projectRoot,此处读不到
100
+ expect(openKnowledgeStore({}, io).get('constraint-retired-no_completion_without_verification')).toBeDefined();
89
101
 
90
- const store = new FileKnowledgeStore({ baseDir: path.join(root, '.harness', 'knowledge') });
102
+ const store = new FileKnowledgeStore({ baseDir: process.env.KNOWLEDGE_BASE_DIR! });
91
103
  const entry = store.get('constraint-retired-no_completion_without_verification');
92
104
  expect(entry).toBeDefined();
93
105
  expect(entry!.consumptionMode).toBe('signal');
@@ -104,7 +116,7 @@ describe('retireConstraint 执行逻辑', () => {
104
116
  expect(entry!.content).toContain(FIXED_NOW.toISOString());
105
117
  });
106
118
 
107
- it('退役结果打印含回滚语义提示(恢复 = config.yml 段)', () => {
119
+ it('退役结果打印含回滚语义提示(恢复 = constraints reactivate 命令)', () => {
108
120
  const root = createProjectFixture({ name: 'harness-retire-test' });
109
121
  const result = retireConstraint(root, 'capability_sync', { now: FIXED_NOW });
110
122
  expect(result.status).toBe('retired');
@@ -112,7 +124,38 @@ describe('retireConstraint 执行逻辑', () => {
112
124
  printRetireResult(result, io);
113
125
  const output = io.outText();
114
126
  expect(output).toContain('恢复方法');
115
- expect(output).toContain('constraints.capability_sync');
127
+ expect(output).toContain('harness constraints reactivate capability_sync');
128
+ });
129
+
130
+ it('退役结果打印:git 仓内附 commit 提示,非 git 目录不提示(票 02 断点 4)', () => {
131
+ const gitRoot = createProjectFixture({ name: 'harness-retire-test', files: { '.git/HEAD': 'ref: refs/heads/master\n' } });
132
+ const inGit = retireConstraint(gitRoot, 'capability_sync', { now: FIXED_NOW });
133
+ printRetireResult(inGit, io, gitRoot);
134
+ expect(io.outText()).toContain('git add .harness/config.yml');
135
+
136
+ const plainRoot = createProjectFixture({ name: 'harness-retire-test' });
137
+ const plainIo = captureIO();
138
+ const notInGit = retireConstraint(plainRoot, 'capability_sync', { now: FIXED_NOW });
139
+ printRetireResult(notInGit, plainIo, plainRoot);
140
+ expect(plainIo.outText()).not.toContain('git add');
141
+ });
142
+
143
+ it('裸 disable 不吞退休:enabled:false 无墓碑时 retire 照常落墓碑 + 沉淀(ADR-0032 决策 6.6,票 02 断点 6)', () => {
144
+ const root = createProjectFixture({
145
+ name: 'harness-retire-test',
146
+ config: 'constraints:\n capability_sync:\n enabled: false\n',
147
+ });
148
+
149
+ const result = retireConstraint(root, 'capability_sync', { reason: '升级裸 disable 为退休', now: FIXED_NOW });
150
+
151
+ expect(result.status).toBe('retired');
152
+ const config = readConfig(root);
153
+ expect(config.constraints.capability_sync.enabled).toBe(false);
154
+ expect(config.constraints.capability_sync.retired.at).toBe(FIXED_NOW.toISOString());
155
+ expect(config.constraints.capability_sync.retired.reason).toBe('升级裸 disable 为退休');
156
+ // 沉淀照写,不被裸 disable 吞掉
157
+ const store = new FileKnowledgeStore({ baseDir: process.env.KNOWLEDGE_BASE_DIR! });
158
+ expect(store.get('constraint-retired-capability_sync')).toBeDefined();
116
159
  });
117
160
 
118
161
  it('重复 retire:already_retired,不覆盖原 retired 元数据', () => {
@@ -229,14 +272,14 @@ describe('constraintsRetire 非交互直达', () => {
229
272
 
230
273
  it('--yes 直达 warning 级退役:不打印 error 级警示并落盘', async () => {
231
274
  const root = createProjectFixture({ name: 'harness-retire-test' });
232
- await constraintsRetire('no_hardcoded_credentials', { projectPath: root, reason: '直接退役', yes: true }, io);
275
+ await constraintsRetire('capability_sync', { projectPath: root, reason: '直接退役', yes: true }, io);
233
276
 
234
277
  const output = io.outText();
235
278
  expect(output).not.toContain('是一条 error 级约束');
236
279
  expect(output).toContain('已退役');
237
280
 
238
281
  const config = readConfig(root);
239
- expect(config.constraints.no_hardcoded_credentials.enabled).toBe(false);
282
+ expect(config.constraints.capability_sync.enabled).toBe(false);
240
283
  });
241
284
 
242
285
  it('--yes 直达未知 id:明确提示', async () => {
@@ -305,7 +348,7 @@ describe('runRetireInteractive 交互流程(注入 IO 流)', () => {
305
348
  ]);
306
349
  writeProjectTraces(root, healthy);
307
350
 
308
- const streams = makeIo(['no_hardcoded_credentials', '误报太多', 'y']);
351
+ const streams = makeIo(['capability_sync', '误报太多', 'y']);
309
352
  const printed = captureLog();
310
353
  await runRetireInteractive(root, streams);
311
354
  streams.done();
@@ -316,8 +359,8 @@ describe('runRetireInteractive 交互流程(注入 IO 流)', () => {
316
359
  expect(output).toContain('已退役');
317
360
 
318
361
  const config = readConfig(root);
319
- expect(config.constraints.no_hardcoded_credentials.enabled).toBe(false);
320
- expect(config.constraints.no_hardcoded_credentials.retired.reason).toBe('误报太多');
362
+ expect(config.constraints.capability_sync.enabled).toBe(false);
363
+ expect(config.constraints.capability_sync.retired.reason).toBe('误报太多');
321
364
  });
322
365
 
323
366
  it('坏行 fixture:候选列表前告知损坏行数(决策依据不完整不静默,harness#100)', async () => {
@@ -403,3 +446,85 @@ describe('runRetireInteractive 交互流程(注入 IO 流)', () => {
403
446
  expect(fs.existsSync(path.join(root, '.harness', 'config.yml'))).toBe(false);
404
447
  });
405
448
  });
449
+
450
+ describe('应用层约束退休(ADR-0033 块 3 子项 3)', () => {
451
+ const APP_YML = [
452
+ 'constraints:',
453
+ ' - id: app_no_internal_url',
454
+ ' rule: Web code must not contain internal URLs',
455
+ ' checker: regex-scan',
456
+ ' params:',
457
+ " pattern: 'https?://10\\.'",
458
+ ' severity: warning',
459
+ ' message: 检测到内网地址',
460
+ '',
461
+ ].join('\n');
462
+
463
+ function appFixture(configYml?: string): string {
464
+ return createProjectFixture({
465
+ name: 'harness-retire-test',
466
+ config: configYml,
467
+ files: { [path.join('.harness', 'constraints.yml')]: APP_YML },
468
+ });
469
+ }
470
+
471
+ it('墓碑写 config.yml 与内置同形(enabled:false + retired),constraints.yml 条文保留不删', () => {
472
+ const root = appFixture();
473
+ const ymlBefore = fs.readFileSync(path.join(root, '.harness', 'constraints.yml'), 'utf-8');
474
+
475
+ const result = retireConstraint(root, 'app_no_internal_url', { reason: '网关统一拦截', now: FIXED_NOW });
476
+
477
+ expect(result.status).toBe('retired');
478
+ expect(result.isError).toBe(false); // 应用层 severity=warning
479
+ const config = readConfig(root);
480
+ const entry = config.constraints.app_no_internal_url;
481
+ expect(entry.enabled).toBe(false);
482
+ expect(entry.retired.at).toBe(FIXED_NOW.toISOString());
483
+ expect(entry.retired.reason).toBe('网关统一拦截');
484
+ // 退休=停用不是删除:定义正本逐字节不动
485
+ expect(fs.readFileSync(path.join(root, '.harness', 'constraints.yml'), 'utf-8')).toBe(ymlBefore);
486
+ // 生效集排除
487
+ expect(getEffectiveConstraints(root).some(c => c.id === 'app_no_internal_url')).toBe(false);
488
+ });
489
+
490
+ it('沉淀条目复用 saveRetireKnowledge,tags 加 source:app', () => {
491
+ const root = appFixture();
492
+ const result = retireConstraint(root, 'app_no_internal_url', { now: FIXED_NOW });
493
+
494
+ expect(result.status).toBe('retired');
495
+ expect(result.knowledgeEntryId).toBe('constraint-retired-app_no_internal_url');
496
+ const store = new FileKnowledgeStore({ baseDir: process.env.KNOWLEDGE_BASE_DIR! });
497
+ const entry = store.get('constraint-retired-app_no_internal_url');
498
+ expect(entry).toBeDefined();
499
+ expect(entry!.tags).toContain('constraint-retired');
500
+ expect(entry!.tags).toContain('constraint:app_no_internal_url');
501
+ expect(entry!.tags).toContain('source:app');
502
+ expect(entry!.content).toContain('Web code must not contain internal URLs');
503
+ });
504
+
505
+ it('内置退休沉淀不带 source:app(tags 形状不漂移)', () => {
506
+ const root = createProjectFixture({ name: 'harness-retire-test' });
507
+ retireConstraint(root, 'capability_sync', { now: FIXED_NOW });
508
+ const store = new FileKnowledgeStore({ baseDir: process.env.KNOWLEDGE_BASE_DIR! });
509
+ expect(store.get('constraint-retired-capability_sync')!.tags).not.toContain('source:app');
510
+ });
511
+
512
+ it('重复退休应用层:already_retired 幂等,不覆盖原墓碑', () => {
513
+ const root = appFixture();
514
+ retireConstraint(root, 'app_no_internal_url', { reason: '第一次', now: FIXED_NOW });
515
+
516
+ const second = retireConstraint(root, 'app_no_internal_url', {
517
+ reason: '第二次',
518
+ now: new Date('2026-08-09T00:00:00.000Z'),
519
+ });
520
+ expect(second.status).toBe('already_retired');
521
+ expect(readConfig(root).constraints.app_no_internal_url.retired.reason).toBe('第一次');
522
+ });
523
+
524
+ it('constraints.yml 里的 id 与内置都找不到 → unknown_id', () => {
525
+ const root = appFixture();
526
+ const result = retireConstraint(root, 'app_ghost', { now: FIXED_NOW });
527
+ expect(result.status).toBe('unknown_id');
528
+ expect(fs.existsSync(path.join(root, '.harness', 'config.yml'))).toBe(false);
529
+ });
530
+ });
@@ -85,7 +85,8 @@ beforeEach(() => {
85
85
  });
86
86
 
87
87
  // 解析函数本体自 #133 起住 knowledge-view.ts(resolveKnowledgeBaseDir),行为面不变:
88
- // 这里经 knowledgeStats 观测 `-p` / KNOWLEDGE_BASE_DIR / 旧目录兜底三条路径的解析结果
88
+ // 这里经 knowledgeStats 观测 `-p` / KNOWLEDGE_BASE_DIR / 缺省目录三条路径的解析结果
89
+ // (ADR-0034:旧 ~/.studio/knowledge 兼容沿用已退役,不再属观测面)
89
90
  describe('getKnowledgeDir', () => {
90
91
  const fs = require('fs');
91
92
  const os = require('os');
@@ -119,34 +120,23 @@ describe('getKnowledgeDir', () => {
119
120
  }));
120
121
  });
121
122
 
122
- it('should default to ~/.harness/knowledge when no env var and no legacy data', async () => {
123
+ it('should default to ~/.harness/knowledge when no env var', async () => {
123
124
  await knowledgeStats({ json: true }, io);
124
125
  expect(storeCtorSpy).toHaveBeenCalledWith(expect.objectContaining({
125
126
  baseDir: path.join(tmpHome, '.harness', 'knowledge'),
126
127
  }));
127
128
  });
128
129
 
129
- it('should keep legacy ~/.studio/knowledge when it still has data, with deprecation notice shown once', async () => {
130
+ it('should NOT fall back to legacy ~/.studio/knowledge even when it has data (ADR-0034 目录收编:兼容沿用已退役)', async () => {
130
131
  const legacyDir = path.join(tmpHome, '.studio', 'knowledge');
131
132
  fs.mkdirSync(legacyDir, { recursive: true });
132
133
  fs.writeFileSync(path.join(legacyDir, 'guideline-x.md'), 'data');
133
134
  await knowledgeStats({ json: true }, io);
134
- expect(storeCtorSpy).toHaveBeenCalledWith(expect.objectContaining({
135
- baseDir: legacyDir,
136
- }));
137
- expect(io.errText()).toContain('旧目录');
138
- // 闩锁:同进程重复解析只提示一次
139
- await knowledgeStats({ json: true }, io);
140
- const notices = io.errLines().filter(line => line.includes('旧目录'));
141
- expect(notices).toHaveLength(1);
142
- });
143
-
144
- it('should ignore legacy dir when it exists but is empty', async () => {
145
- fs.mkdirSync(path.join(tmpHome, '.studio', 'knowledge'), { recursive: true });
146
- await knowledgeStats({ json: true }, io);
135
+ // 旧目录有数据也不再沿用:落缺省 ~/.harness/knowledge,无兼容提示
147
136
  expect(storeCtorSpy).toHaveBeenCalledWith(expect.objectContaining({
148
137
  baseDir: path.join(tmpHome, '.harness', 'knowledge'),
149
138
  }));
139
+ expect(io.errText()).not.toContain('旧目录');
150
140
  });
151
141
 
152
142
  it('should use projectPath when provided', async () => {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * report 命令生效集口径(ADR-0033 块 3 子项 3)
3
+ *
4
+ * 缺口修复回归:report 此前跑内置全集(CONSTRAINTS),config.yml 禁用与
5
+ * 应用层约束(.harness/constraints.yml)都进不了报告面。修复后 collectConstraints
6
+ * 传 merged config(与 check.ts 同一 getMergedConstraintsConfig 入口),
7
+ * 应用层条目进总数、违规如实上报。
8
+ */
9
+
10
+ import { describe, it, expect } from '@jest/globals';
11
+ import * as path from 'path';
12
+ import { report } from '../report';
13
+ import { captureIO } from '../../command-contract';
14
+ import { CONSTRAINTS } from '../../../core/constraints/definitions';
15
+ import { createProjectFixture } from '../../../test-setup/project-fixture';
16
+
17
+ const BUILTIN_TOTAL = Object.keys(CONSTRAINTS).length;
18
+
19
+ /** 应用层约束:file-exists 指向一个不存在的文件(warning 级,必违规) */
20
+ const APP_YML = [
21
+ 'constraints:',
22
+ ' - id: app_required_readme',
23
+ ' rule: Project must keep README.app.md',
24
+ ' checker: file-exists',
25
+ ' params:',
26
+ ' path: README.app.md',
27
+ ' severity: warning',
28
+ ' message: 缺少 README.app.md',
29
+ '',
30
+ ].join('\n');
31
+
32
+ function parseJsonReport(output: string) {
33
+ const start = output.indexOf('{');
34
+ const end = output.lastIndexOf('}');
35
+ return JSON.parse(output.slice(start, end + 1));
36
+ }
37
+
38
+ describe('report 生效集口径(含应用层)', () => {
39
+ it('应用层约束进报告:总数 +1,违规进 violations', async () => {
40
+ const root = createProjectFixture({
41
+ name: 'report-app-effective',
42
+ files: { [path.join('.harness', 'constraints.yml')]: APP_YML },
43
+ });
44
+ const io = captureIO();
45
+
46
+ await report({ format: 'json', projectPath: root }, io);
47
+
48
+ const data = parseJsonReport(io.outText());
49
+ expect(data.constraints.total).toBe(BUILTIN_TOTAL + 1);
50
+ const ids = data.constraints.violations.map((v: { id: string }) => v.id);
51
+ expect(ids).toContain('app_required_readme');
52
+ expect(data.constraints.warningViolations).toBeGreaterThanOrEqual(1);
53
+ });
54
+
55
+ it('config.yml 禁用内置约束后总数随生效集收窄(不再报内置全集)', async () => {
56
+ const root = createProjectFixture({
57
+ name: 'report-app-effective',
58
+ config: 'constraints:\n capability_sync:\n enabled: false\n',
59
+ });
60
+ const io = captureIO();
61
+
62
+ await report({ format: 'json', projectPath: root }, io);
63
+
64
+ const data = parseJsonReport(io.outText());
65
+ expect(data.constraints.total).toBe(BUILTIN_TOTAL - 1);
66
+ });
67
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * harness constraints pack-proposal <id> —— 升级提案材料打包(ADR-0033 决策 4,块 3 子项 5)
3
+ *
4
+ * 升级通道链路:studio 审卡发起/初审 → approve 后由本命令打包脱敏材料 →
5
+ * 人拿材料去 harness 仓开 issue(github 操作留给人,本命令不触网)。
6
+ *
7
+ * 材料内容:条文 + severity + checker 模板与参数 + traces.log 使用统计(只有计数)
8
+ * + 升级理由留白段。
9
+ *
10
+ * 脱敏口径(工具做路径脱敏,内容脱敏归人):
11
+ * - 项目根绝对路径一律替换为 `<repoRoot>`;
12
+ * - 统计只有计数与首末次日期,不带样本内容;
13
+ * - 应用层约束的 params(正则/glob)**原文带出**并标注"请人工确认无应用内部信息"——
14
+ * 打包工具是质量前置,不是保密闸门。
15
+ */
16
+
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import * as yaml from 'js-yaml';
20
+ import chalk from 'chalk';
21
+ import { getConstraint } from '../../core/constraints/definitions';
22
+ import { loadAppConstraints } from '../../core/app-constraints-loader';
23
+ import { collectUsageByConstraint, readProjectTraces } from '../../core/constraints/usage-report';
24
+ import type { Constraint } from '../../types/constraint';
25
+ import { log, logError, processIO, type CommandIO, type CommandResult } from '../command-contract';
26
+
27
+ export interface PackProposalOptions {
28
+ projectPath?: string;
29
+ /** 打印到 stdout 而不落盘 */
30
+ stdout?: boolean;
31
+ /** 日期注入(测试用;缺省真实时钟) */
32
+ now?: Date;
33
+ }
34
+
35
+ /** 提案材料的输入(查找结果 + 使用统计聚合) */
36
+ export interface ProposalMaterial {
37
+ constraint: Constraint;
38
+ stats: {
39
+ total: number;
40
+ pass: number;
41
+ fail: number;
42
+ skip: number;
43
+ evaluated: number;
44
+ firstAt?: number;
45
+ lastAt?: number;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * 查找约束定义(内置 + 应用层,与 findRetireTarget 同口径但带全量字段——
51
+ * checker/params 是提案材料的核心内容,RetireTargetInfo 不带)
52
+ */
53
+ function findProposalTarget(id: string, projectRoot: string): Constraint | undefined {
54
+ const builtIn = getConstraint(id);
55
+ if (builtIn) return builtIn;
56
+ return loadAppConstraints(projectRoot).find(c => c.id === id);
57
+ }
58
+
59
+ function formatDate(ts: number | undefined): string {
60
+ return ts === undefined ? '(无记录)' : new Date(ts).toISOString().slice(0, 10);
61
+ }
62
+
63
+ /**
64
+ * 渲染脱敏提案材料 markdown(纯函数,导出供测试)
65
+ *
66
+ * @param projectRoot 用于路径脱敏(材料中出现的项目根绝对路径替换为 `<repoRoot>`)
67
+ */
68
+ export function renderProposalMarkdown(
69
+ material: ProposalMaterial,
70
+ projectRoot: string,
71
+ now: Date = new Date()
72
+ ): string {
73
+ const c = material.constraint;
74
+ const s = material.stats;
75
+ const isApp = c.source === 'app';
76
+
77
+ const lines: string[] = [];
78
+ lines.push(`# 约束升级提案材料:${c.id}`);
79
+ lines.push('');
80
+ lines.push(`- 生成日期: ${now.toISOString().slice(0, 10)}`);
81
+ lines.push(`- 来源: ${isApp ? '应用层(.harness/constraints.yml)' : '内置'}`);
82
+ lines.push(`- severity: ${c.severity}`);
83
+ lines.push('');
84
+
85
+ lines.push('## 条文');
86
+ lines.push('');
87
+ lines.push(`rule: ${c.rule}`);
88
+ if (c.message && c.message !== c.rule) lines.push(`message: ${c.message}`);
89
+ if (c.description) lines.push(`description: ${c.description}`);
90
+ lines.push('');
91
+
92
+ lines.push('## checker');
93
+ lines.push('');
94
+ if (isApp) {
95
+ lines.push(`模板: \`${c.checker}\``);
96
+ lines.push('');
97
+ lines.push('参数(原文带出,**请人工确认无应用内部信息**——工具只做路径脱敏,内容脱敏归人):');
98
+ lines.push('');
99
+ lines.push('```yaml');
100
+ lines.push(yaml.dump(c.params ?? {}, { lineWidth: 120 }).trimEnd());
101
+ lines.push('```');
102
+ } else {
103
+ lines.push('内置定制 checker(按约束 id 注册,非参数化模板)。升级到通用层前需评审:');
104
+ lines.push('改写为模板 + 参数,或作为通用规则保留定制实现。');
105
+ }
106
+ lines.push('');
107
+
108
+ lines.push('## 使用统计(traces.log 聚合,只有计数)');
109
+ lines.push('');
110
+ lines.push(`- total: ${s.total}`);
111
+ lines.push(`- evaluated: ${s.evaluated}(pass ${s.pass} / fail ${s.fail} / skip ${s.skip})`);
112
+ lines.push(`- 首次触发: ${formatDate(s.firstAt)}`);
113
+ lines.push(`- 最近触发: ${formatDate(s.lastAt)}`);
114
+ lines.push('');
115
+
116
+ lines.push('## 升级理由');
117
+ lines.push('');
118
+ lines.push('> (请在此填写:为什么这条约束该进通用层——适用面、证据、与其他仓的相关性)');
119
+ lines.push('');
120
+
121
+ // 路径脱敏:项目根绝对路径一律替换
122
+ return lines.join('\n').split(projectRoot).join('<repoRoot>');
123
+ }
124
+
125
+ /**
126
+ * CLI handler: harness constraints pack-proposal <id>
127
+ */
128
+ export async function constraintsPackProposal(
129
+ id: string | undefined,
130
+ options: PackProposalOptions = {},
131
+ io: CommandIO = processIO
132
+ ): Promise<CommandResult> {
133
+ const projectRoot = options.projectPath || process.cwd();
134
+
135
+ if (!id) {
136
+ logError(io, '❌ 缺少约束 id:harness constraints pack-proposal <id>');
137
+ return { kind: 'usage-error', reason: '缺少约束 id' };
138
+ }
139
+
140
+ const constraint = findProposalTarget(id, projectRoot);
141
+ if (!constraint) {
142
+ logError(io, `❌ 未知约束 id: ${id}(内置与应用层 constraints.yml 均未找到),未做任何变更`);
143
+ return { kind: 'usage-error', reason: `未知约束 id: ${id}` };
144
+ }
145
+
146
+ const usage = collectUsageByConstraint(readProjectTraces(projectRoot)).get(id);
147
+ const stats = {
148
+ total: usage?.total ?? 0,
149
+ pass: usage?.pass ?? 0,
150
+ fail: usage?.fail ?? 0,
151
+ skip: usage?.skip ?? 0,
152
+ evaluated: (usage?.total ?? 0) - (usage?.skip ?? 0),
153
+ firstAt: usage?.firstAt,
154
+ lastAt: usage?.lastAt,
155
+ };
156
+
157
+ const now = options.now ?? new Date();
158
+ const markdown = renderProposalMarkdown({ constraint, stats }, projectRoot, now);
159
+
160
+ if (options.stdout) {
161
+ log(io, markdown);
162
+ return { kind: 'ok' };
163
+ }
164
+
165
+ const date = now.toISOString().slice(0, 10).replace(/-/g, '');
166
+ const outPath = path.join(projectRoot, '.harness', 'reports', `proposal-${id}-${date}.md`);
167
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
168
+ fs.writeFileSync(outPath, markdown, 'utf-8');
169
+ log(io, chalk.green(`✅ 提案材料已生成: ${outPath}`));
170
+ log(io, chalk.gray(' 下一步:人工确认 params 无应用内部信息后,拿材料去 harness 仓开 issue(不自动开)'));
171
+ return { kind: 'ok' };
172
+ }