@dommaker/harness 1.10.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 (180) hide show
  1. package/CHANGELOG.md +31 -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 +36 -11
  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/commands/scaffold-templates.d.ts +1 -1
  29. package/dist/cli/commands/scaffold-templates.d.ts.map +1 -1
  30. package/dist/cli/commands/scaffold-templates.js +0 -16
  31. package/dist/cli/commands/scaffold-templates.js.map +1 -1
  32. package/dist/cli/state-io.d.ts +8 -1
  33. package/dist/cli/state-io.d.ts.map +1 -1
  34. package/dist/cli/state-io.js +2 -1
  35. package/dist/cli/state-io.js.map +1 -1
  36. package/dist/core/app-constraints-loader.d.ts +28 -0
  37. package/dist/core/app-constraints-loader.d.ts.map +1 -0
  38. package/dist/core/app-constraints-loader.js +187 -0
  39. package/dist/core/app-constraints-loader.js.map +1 -0
  40. package/dist/core/constraints/checker.js +1 -1
  41. package/dist/core/constraints/checker.js.map +1 -1
  42. package/dist/core/constraints/checkers/index.d.ts +20 -9
  43. package/dist/core/constraints/checkers/index.d.ts.map +1 -1
  44. package/dist/core/constraints/checkers/index.js +40 -11
  45. package/dist/core/constraints/checkers/index.js.map +1 -1
  46. package/dist/core/constraints/checkers/templated/file-exists.d.ts +16 -0
  47. package/dist/core/constraints/checkers/templated/file-exists.d.ts.map +1 -0
  48. package/dist/core/constraints/checkers/templated/file-exists.js +108 -0
  49. package/dist/core/constraints/checkers/templated/file-exists.js.map +1 -0
  50. package/dist/core/constraints/checkers/templated/glob.d.ts +10 -0
  51. package/dist/core/constraints/checkers/templated/glob.d.ts.map +1 -0
  52. package/dist/core/constraints/checkers/templated/glob.js +51 -0
  53. package/dist/core/constraints/checkers/templated/glob.js.map +1 -0
  54. package/dist/core/constraints/checkers/templated/index.d.ts +12 -0
  55. package/dist/core/constraints/checkers/templated/index.d.ts.map +1 -0
  56. package/dist/core/constraints/checkers/templated/index.js +17 -0
  57. package/dist/core/constraints/checkers/templated/index.js.map +1 -0
  58. package/dist/core/constraints/checkers/templated/regex-scan.d.ts +15 -0
  59. package/dist/core/constraints/checkers/templated/regex-scan.d.ts.map +1 -0
  60. package/dist/core/constraints/checkers/templated/regex-scan.js +129 -0
  61. package/dist/core/constraints/checkers/templated/regex-scan.js.map +1 -0
  62. package/dist/core/constraints/checkers/templates-registry.d.ts +17 -0
  63. package/dist/core/constraints/checkers/templates-registry.d.ts.map +1 -0
  64. package/dist/core/constraints/checkers/templates-registry.js +23 -0
  65. package/dist/core/constraints/checkers/templates-registry.js.map +1 -0
  66. package/dist/core/constraints/checkers/types.d.ts +14 -0
  67. package/dist/core/constraints/checkers/types.d.ts.map +1 -1
  68. package/dist/core/constraints/checkers/types.js.map +1 -1
  69. package/dist/core/constraints/definitions/guidelines.d.ts.map +1 -1
  70. package/dist/core/constraints/definitions/guidelines.js +0 -14
  71. package/dist/core/constraints/definitions/guidelines.js.map +1 -1
  72. package/dist/core/constraints/definitions/iron-laws.d.ts.map +1 -1
  73. package/dist/core/constraints/definitions/iron-laws.js +15 -0
  74. package/dist/core/constraints/definitions/iron-laws.js.map +1 -1
  75. package/dist/core/constraints/usage-report.d.ts +59 -7
  76. package/dist/core/constraints/usage-report.d.ts.map +1 -1
  77. package/dist/core/constraints/usage-report.js +62 -13
  78. package/dist/core/constraints/usage-report.js.map +1 -1
  79. package/dist/core/project-config-loader.d.ts +9 -6
  80. package/dist/core/project-config-loader.d.ts.map +1 -1
  81. package/dist/core/project-config-loader.js +18 -8
  82. package/dist/core/project-config-loader.js.map +1 -1
  83. package/dist/gates/command.d.ts.map +1 -1
  84. package/dist/gates/command.js +50 -0
  85. package/dist/gates/command.js.map +1 -1
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +14 -2
  89. package/dist/index.js.map +1 -1
  90. package/dist/knowledge/lifecycle.d.ts +14 -0
  91. package/dist/knowledge/lifecycle.d.ts.map +1 -1
  92. package/dist/knowledge/lifecycle.js +50 -0
  93. package/dist/knowledge/lifecycle.js.map +1 -1
  94. package/dist/knowledge/lint.d.ts +17 -0
  95. package/dist/knowledge/lint.d.ts.map +1 -1
  96. package/dist/knowledge/lint.js +52 -0
  97. package/dist/knowledge/lint.js.map +1 -1
  98. package/dist/knowledge/store.d.ts +7 -0
  99. package/dist/knowledge/store.d.ts.map +1 -1
  100. package/dist/knowledge/store.js +48 -13
  101. package/dist/knowledge/store.js.map +1 -1
  102. package/dist/knowledge/types.d.ts +5 -1
  103. package/dist/knowledge/types.d.ts.map +1 -1
  104. package/dist/knowledge/types.js +5 -1
  105. package/dist/knowledge/types.js.map +1 -1
  106. package/dist/presets/standard.d.ts +1 -1
  107. package/dist/presets/standard.d.ts.map +1 -1
  108. package/dist/presets/standard.js +2 -3
  109. package/dist/presets/standard.js.map +1 -1
  110. package/dist/types/constraint.d.ts +24 -0
  111. package/dist/types/constraint.d.ts.map +1 -1
  112. package/dist/types/constraint.js.map +1 -1
  113. package/package.json +1 -1
  114. package/src/__tests__/app-constraints-loader.test.ts +220 -0
  115. package/src/__tests__/iron-laws.test.ts +5 -5
  116. package/src/__tests__/public-exports.test.ts +7 -0
  117. package/src/__tests__/public-type-surface.test.ts +5 -0
  118. package/src/__tests__/usage-report-public-export.test.ts +21 -0
  119. package/src/cli/commands/CONTEXT.md +5 -4
  120. package/src/cli/commands/__tests__/check.test.ts +22 -4
  121. package/src/cli/commands/__tests__/constraints-pack-proposal.test.ts +142 -0
  122. package/src/cli/commands/__tests__/constraints-reactivate.test.ts +210 -0
  123. package/src/cli/commands/__tests__/constraints-report-json-flag.test.ts +101 -0
  124. package/src/cli/commands/__tests__/constraints-report.test.ts +175 -6
  125. package/src/cli/commands/__tests__/constraints-retire.test.ts +135 -10
  126. package/src/cli/commands/__tests__/init-ondisk.test.ts +1 -1
  127. package/src/cli/commands/__tests__/init.test.ts +1 -5
  128. package/src/cli/commands/__tests__/knowledge.test.ts +6 -16
  129. package/src/cli/commands/__tests__/numeric-flag-assembly.test.ts +1 -1
  130. package/src/cli/commands/__tests__/passes-gate-dead-options.test.ts +1 -1
  131. package/src/cli/commands/__tests__/registry.test.ts +1 -1
  132. package/src/cli/commands/__tests__/report-app-constraints.test.ts +67 -0
  133. package/src/cli/commands/constraints-pack-proposal.ts +172 -0
  134. package/src/cli/commands/constraints-reactivate.ts +197 -0
  135. package/src/cli/commands/constraints-report.ts +44 -2
  136. package/src/cli/commands/constraints-retire.ts +129 -36
  137. package/src/cli/commands/definitions.ts +36 -11
  138. package/src/cli/commands/knowledge-view.ts +9 -28
  139. package/src/cli/commands/report.ts +9 -5
  140. package/src/cli/commands/scaffold-templates.ts +0 -16
  141. package/src/cli/state-io.ts +8 -1
  142. package/src/core/CONTEXT.md +5 -4
  143. package/src/core/app-constraints-loader.ts +165 -0
  144. package/src/core/constraints/__tests__/collect-constraints.test.ts +3 -1
  145. package/src/core/constraints/__tests__/usage-report.test.ts +63 -0
  146. package/src/core/constraints/checker.ts +1 -1
  147. package/src/core/constraints/checkers/__tests__/templated-checkers.test.ts +180 -0
  148. package/src/core/constraints/checkers/__tests__/templates-registry.test.ts +81 -0
  149. package/src/core/constraints/checkers/index.ts +44 -10
  150. package/src/core/constraints/checkers/templated/file-exists.ts +75 -0
  151. package/src/core/constraints/checkers/templated/glob.ts +46 -0
  152. package/src/core/constraints/checkers/templated/index.ts +12 -0
  153. package/src/core/constraints/checkers/templated/regex-scan.ts +132 -0
  154. package/src/core/constraints/checkers/templates-registry.ts +22 -0
  155. package/src/core/constraints/checkers/types.ts +15 -0
  156. package/src/core/constraints/definitions/guidelines.ts +0 -15
  157. package/src/core/constraints/definitions/iron-laws.ts +16 -0
  158. package/src/core/constraints/usage-report.ts +129 -14
  159. package/src/core/project-config-loader.ts +19 -8
  160. package/src/gates/__tests__/command.test.ts +54 -0
  161. package/src/gates/command.ts +51 -0
  162. package/src/index.ts +20 -0
  163. package/src/knowledge/CONTEXT.md +7 -4
  164. package/src/knowledge/__tests__/lifecycle.test.ts +81 -0
  165. package/src/knowledge/__tests__/lint.test.ts +84 -0
  166. package/src/knowledge/__tests__/store.test.ts +70 -0
  167. package/src/knowledge/lifecycle.ts +49 -0
  168. package/src/knowledge/lint.ts +60 -1
  169. package/src/knowledge/store.ts +48 -11
  170. package/src/knowledge/types.ts +7 -1
  171. package/src/monitoring/CONTEXT.md +1 -1
  172. package/src/presets/__tests__/standard.test.ts +5 -4
  173. package/src/presets/standard.ts +2 -3
  174. package/src/types/constraint.ts +28 -0
  175. package/dist/cli/commands/posteval-plan.d.ts +0 -15
  176. package/dist/cli/commands/posteval-plan.d.ts.map +0 -1
  177. package/dist/cli/commands/posteval-plan.js +0 -86
  178. package/dist/cli/commands/posteval-plan.js.map +0 -1
  179. package/src/cli/commands/__tests__/posteval-plan.test.ts +0 -204
  180. package/src/cli/commands/posteval-plan.ts +0 -98
@@ -1,13 +1,19 @@
1
1
  /**
2
- * 约束检查器注册表(工单 21
2
+ * 约束检查器注册表(工单 21;ADR-0033 拆两层)
3
3
  *
4
- * checker.ts 编排层通过 getConstraintCheck(id) 查找实现。
4
+ * checker.ts 编排层通过 getConstraintCheck() 查找实现。
5
5
  *
6
- * 注册表闭环(ADR-0001):模块加载时校验全部 kind='check' 的内置约束
7
- * 都有已注册 checker,且注册表中没有无对应 check 定义的孤儿实现;
8
- * 任一不满足即抛错(加载期失败,不许静默 pass)。
6
+ * 两层注册表:
7
+ * - 内置 checker(registry,按约束 id):模块加载时校验全部 kind='check' 的内置约束
8
+ * 都有已注册 checker,且注册表中没有无对应 check 定义的孤儿实现;
9
+ * 任一不满足即抛错(加载期失败,不许静默 pass,ADR-0001)。
10
+ * - 模板 checker(TEMPLATES,按模板 id):填空式参数化模板,供应用层约束
11
+ * (source='app',`.harness/constraints.yml`)按 checker + params 实例化。
12
+ * 闭环语义扩展而非开口:应用层约束在加载期(app-constraints-loader)校验
13
+ * 模板 id 存在 + validateParams 通过;getConstraintCheck 对未注册模板 id 抛错。
9
14
  */
10
15
 
16
+ import type { Constraint } from '../../../types/constraint';
11
17
  import type { ConstraintCheck } from './types';
12
18
  import { getAllConstraints } from '../definitions';
13
19
 
@@ -20,14 +26,15 @@ import { contextDocSync } from './context-doc-sync';
20
26
  import { docsFreshness } from './docs-freshness';
21
27
  import { noHardcodedCredentials } from './no-hardcoded-credentials';
22
28
  import { governancePresence } from './governance-presence';
29
+ import { TEMPLATES } from './templates-registry';
23
30
 
24
31
  const CHECKS: ConstraintCheck[] = [
25
32
  // severity='error'
26
33
  noCompletionWithoutVerification,
27
34
  noTestSimplification,
28
35
  docsFreshness,
29
- // severity='warning'
30
36
  noHardcodedCredentials,
37
+ // severity='warning'
31
38
  capabilitySync,
32
39
  contextDocSync,
33
40
  governancePresence,
@@ -35,6 +42,14 @@ const CHECKS: ConstraintCheck[] = [
35
42
 
36
43
  const registry = new Map<string, ConstraintCheck>(CHECKS.map(c => [c.id, c]));
37
44
 
45
+ // ========================================
46
+ // 模板注册表(ADR-0033,应用层约束用)
47
+ // ========================================
48
+
49
+ // TEMPLATES 住在 ./templates-registry(独立模块防值级循环,见该文件头注释);
50
+ // 此处 re-export 供编排层与测试单口取数
51
+ export { TEMPLATES } from './templates-registry';
52
+
38
53
  // ========================================
39
54
  // 注册表闭环校验(加载期)
40
55
  // ========================================
@@ -60,11 +75,29 @@ for (const id of registry.keys()) {
60
75
  }
61
76
 
62
77
  /**
63
- * 按约束 ID 查找检查实现;未注册返回 undefined
64
- * (编排层对 kind='check' 未注册的情况抛错)
78
+ * 查找检查实现
79
+ *
80
+ * - 传字符串 id 或内置约束:按 id 查内置注册表,未注册返回 undefined
81
+ * (编排层对 kind='check' 未注册的情况抛错)
82
+ * - 传 source='app' 的约束:按 checker 模板 id + params 实例化;
83
+ * 模板 id 未注册 → 抛错(闭环保留,不许静默 pass)
65
84
  */
66
- export function getConstraintCheck(id: string): ConstraintCheck | undefined {
67
- return registry.get(id);
85
+ export function getConstraintCheck(idOrConstraint: string | Constraint): ConstraintCheck | undefined {
86
+ if (typeof idOrConstraint === 'string') {
87
+ return registry.get(idOrConstraint);
88
+ }
89
+ if (idOrConstraint.source === 'app') {
90
+ const templateId = idOrConstraint.checker;
91
+ const factory = templateId ? TEMPLATES.get(templateId) : undefined;
92
+ if (!factory) {
93
+ throw new Error(
94
+ `[harness] 约束注册表闭环校验失败:应用层约束 "${idOrConstraint.id}" 引用的 checker 模板 ` +
95
+ `"${templateId ?? '(未填写)'}" 未注册。请在 checkers/templated/ 中实现并注册。`
96
+ );
97
+ }
98
+ return factory.create(idOrConstraint.id, idOrConstraint.params ?? {});
99
+ }
100
+ return registry.get(idOrConstraint.id);
68
101
  }
69
102
 
70
103
  /**
@@ -77,6 +110,7 @@ export function registeredCheckCount(): number {
77
110
  export { buildCheckEnv, normalizeCheckOutcome } from './types';
78
111
  export type {
79
112
  ConstraintCheck,
113
+ TemplatedCheckerFactory,
80
114
  CheckEnv,
81
115
  CheckOutcome,
82
116
  CheckDetail,
@@ -0,0 +1,75 @@
1
+ /**
2
+ * file-exists 模板:文件存在性约束(ADR-0033,块 3 子项 2)
3
+ *
4
+ * 形态参照 context-doc-sync:存在性判定 + 证据行(fail 必须可归因,harness#119)。
5
+ * 与 context-doc-sync 的差别:目标由参数显式给出(加载期已校验),
6
+ * 无「约定未采用」三态,故永不 skip。
7
+ *
8
+ * 参数:
9
+ * - path(可选):相对项目根的单文件路径
10
+ * - glob(可选):文件集 glob(fast-glob 口径,dot: true 以覆盖 .harness 等点目录)
11
+ * - mustExist(可选,缺省 true):true = 必须存在;false = 必须不存在
12
+ * path 与 glob 至少填一个;都填 = 两者都要满足。
13
+ */
14
+
15
+ import { existsSync } from 'fs';
16
+ import { join } from 'path';
17
+ import * as glob from 'fast-glob';
18
+ import { formatEvidence, type ConstraintCheck, type TemplatedCheckerFactory } from '../types';
19
+
20
+ export const fileExists: TemplatedCheckerFactory = {
21
+ validateParams(params) {
22
+ const errors: string[] = [];
23
+ if (params.path !== undefined && typeof params.path !== 'string') {
24
+ errors.push('path 必须是字符串');
25
+ }
26
+ if (params.glob !== undefined && typeof params.glob !== 'string') {
27
+ errors.push('glob 必须是字符串');
28
+ }
29
+ const hasPath = typeof params.path === 'string' && params.path.length > 0;
30
+ const hasGlob = typeof params.glob === 'string' && params.glob.length > 0;
31
+ if (!hasPath && !hasGlob) {
32
+ errors.push('path 或 glob 至少填一个');
33
+ }
34
+ if (params.mustExist !== undefined && typeof params.mustExist !== 'boolean') {
35
+ errors.push('mustExist 必须是布尔值');
36
+ }
37
+ return errors;
38
+ },
39
+
40
+ create(id, params): ConstraintCheck {
41
+ const rel = params.path as string | undefined;
42
+ const globPattern = params.glob as string | undefined;
43
+ const mustExist = params.mustExist !== false;
44
+
45
+ return {
46
+ id,
47
+ evaluate(env) {
48
+ const pathHit = rel !== undefined && existsSync(join(env.projectPath, rel));
49
+ const globHits = globPattern !== undefined
50
+ ? glob.sync(globPattern, { cwd: env.projectPath, dot: true, onlyFiles: false })
51
+ : [];
52
+
53
+ if (mustExist) {
54
+ const missing: string[] = [];
55
+ if (rel !== undefined && !pathHit) missing.push(rel);
56
+ if (globPattern !== undefined && globHits.length === 0) missing.push(`${globPattern}(无匹配文件)`);
57
+ if (missing.length > 0) {
58
+ return { pass: false, evidence: formatEvidence('必需文件不存在', missing) };
59
+ }
60
+ return true;
61
+ }
62
+
63
+ // mustExist=false:任何目标存在即违规
64
+ const present: string[] = [
65
+ ...(pathHit ? [rel!] : []),
66
+ ...globHits,
67
+ ];
68
+ if (present.length > 0) {
69
+ return { pass: false, evidence: formatEvidence('禁存文件存在', present) };
70
+ }
71
+ return true;
72
+ },
73
+ };
74
+ },
75
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 模板侧轻量 glob 匹配(支持 `**`、`*`、`?`,纯字符串判定,不碰文件系统)
3
+ *
4
+ * 与 completion-checkers/glob-match 同一匹配语义;此处另写一份是因为分层
5
+ * (harness#88 参数化守卫)不许 core 值导入同层领域模块——约束域的模板
6
+ * 参数匹配不住 completion-checkers 的 commits 分类模块。
7
+ */
8
+
9
+ function escapeRegExp(s: string): string {
10
+ return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
11
+ }
12
+
13
+ /** glob → 锚定正则。前缀 `**` + `/` 匹配零或多段路径;`*` 不跨 `/`;`?` 匹配单个非 `/` 字符 */
14
+ function globToRegExp(glob: string): RegExp {
15
+ let re = '';
16
+ let i = 0;
17
+ while (i < glob.length) {
18
+ const c = glob[i];
19
+ if (c === '*') {
20
+ if (glob[i + 1] === '*') {
21
+ if (glob[i + 2] === '/') {
22
+ re += '(?:[^/]+/)*';
23
+ i += 3;
24
+ } else {
25
+ re += '.*';
26
+ i += 2;
27
+ }
28
+ } else {
29
+ re += '[^/]*';
30
+ i += 1;
31
+ }
32
+ } else if (c === '?') {
33
+ re += '[^/]';
34
+ i += 1;
35
+ } else {
36
+ re += escapeRegExp(c);
37
+ i += 1;
38
+ }
39
+ }
40
+ return new RegExp('^' + re + '$');
41
+ }
42
+
43
+ /** 单 glob 匹配(相对路径正斜杠口径) */
44
+ export function matchGlob(file: string, glob: string): boolean {
45
+ return globToRegExp(glob).test(file);
46
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 填空式 checker 模板(ADR-0033 两层约束模型,块 3 子项 2)
3
+ *
4
+ * 每个模板 = TemplatedCheckerFactory(validateParams + create),注册进
5
+ * checkers/index.ts 的 TEMPLATES 供应用层约束(`.harness/constraints.yml`)
6
+ * 按 checker + params 实例化。
7
+ *
8
+ * 模板只认参数不认应用语义(harness 零业务逻辑):约束条文、文案归应用仓。
9
+ */
10
+
11
+ export { regexScan } from './regex-scan';
12
+ export { fileExists } from './file-exists';
@@ -0,0 +1,132 @@
1
+ /**
2
+ * regex-scan 模板:正则扫描约束(ADR-0033,块 3 子项 2)
3
+ *
4
+ * 形态参照 no-hardcoded-credentials:scope='diff'(缺省)扫 staged diff 新增行,
5
+ * diff 为空/不可用时退化为变更文件内容扫描;scope='content' 直接扫变更文件内容。
6
+ * 命中即违规,证据行点名文件与命中行(harness#119:fail 必须可归因)。
7
+ *
8
+ * 参数:
9
+ * - pattern(必填):正则字符串
10
+ * - glob(可选):限定文件集(相对路径,支持 `**`/`*`/`?`)
11
+ * - scope(可选):'diff' | 'content',缺省 'diff'
12
+ */
13
+
14
+ import { readFileSync } from 'fs';
15
+ import { join } from 'path';
16
+ import { formatEvidence, type CheckDetail, type CheckEnv, type ConstraintCheck, type TemplatedCheckerFactory } from '../types';
17
+ import { matchGlob } from './glob';
18
+
19
+ /** 证据行单行长度上限(命中行可能是整行配置/长文本) */
20
+ const MAX_LINE = 120;
21
+
22
+ interface AddedLine {
23
+ file: string;
24
+ line: string;
25
+ }
26
+
27
+ /** 从 unified diff 中取新增行(按 +++ b/<path> 归属文件,glob 过滤文件集) */
28
+ function collectAddedLines(diff: string, inScope: (file: string) => boolean): AddedLine[] {
29
+ const out: AddedLine[] = [];
30
+ let file = '';
31
+ for (const raw of diff.split('\n')) {
32
+ if (raw.startsWith('+++ b/')) {
33
+ file = raw.slice('+++ b/'.length);
34
+ continue;
35
+ }
36
+ if (raw.startsWith('+++') || raw.startsWith('diff --git')) {
37
+ if (raw.startsWith('diff --git')) file = '';
38
+ continue;
39
+ }
40
+ if (raw.startsWith('+') && file && inScope(file)) {
41
+ out.push({ file, line: raw.slice(1) });
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function hitEvidence(pattern: RegExp, hits: string[]): CheckDetail {
48
+ return {
49
+ pass: false,
50
+ evidence: formatEvidence(`命中禁用模式 /${pattern.source}/`, hits),
51
+ };
52
+ }
53
+
54
+ function clip(line: string): string {
55
+ const t = line.trim();
56
+ return t.length > MAX_LINE ? `${t.slice(0, MAX_LINE)}…` : t;
57
+ }
58
+
59
+ export const regexScan: TemplatedCheckerFactory = {
60
+ validateParams(params) {
61
+ const errors: string[] = [];
62
+ const pattern = params.pattern;
63
+ if (typeof pattern !== 'string' || pattern.length === 0) {
64
+ errors.push('pattern 必填(正则字符串)');
65
+ } else {
66
+ try {
67
+ new RegExp(pattern);
68
+ } catch (err) {
69
+ errors.push(`pattern 不是合法正则:${err instanceof Error ? err.message : String(err)}`);
70
+ }
71
+ }
72
+ if (params.glob !== undefined && typeof params.glob !== 'string') {
73
+ errors.push('glob 必须是字符串');
74
+ }
75
+ if (params.scope !== undefined && params.scope !== 'diff' && params.scope !== 'content') {
76
+ errors.push("scope 必须是 'diff' 或 'content'");
77
+ }
78
+ return errors;
79
+ },
80
+
81
+ create(id, params): ConstraintCheck {
82
+ const pattern = new RegExp(params.pattern as string);
83
+ const glob = params.glob as string | undefined;
84
+ const scope = (params.scope as 'diff' | 'content' | undefined) ?? 'diff';
85
+ const inScope = (file: string) => !glob || matchGlob(file, glob);
86
+
87
+ const scanChangedFiles = (env: CheckEnv): string[] => {
88
+ const hits: string[] = [];
89
+ for (const file of (env.context.changedFiles ?? []).filter(inScope)) {
90
+ let content: string;
91
+ try {
92
+ content = readFileSync(join(env.projectPath, file), 'utf-8');
93
+ } catch {
94
+ continue; // 文件不可读(已删除/二进制)→ 跳过
95
+ }
96
+ for (const line of content.split('\n')) {
97
+ if (pattern.test(line)) hits.push(`${file}: ${clip(line)}`);
98
+ }
99
+ }
100
+ return hits;
101
+ };
102
+
103
+ return {
104
+ id,
105
+ async evaluate(env) {
106
+ if (scope === 'content') {
107
+ const hits = scanChangedFiles(env);
108
+ return hits.length > 0 ? hitEvidence(pattern, hits) : true;
109
+ }
110
+
111
+ // scope='diff':staged diff 新增行;diff 为空/不可用 → 退化变更文件内容扫描
112
+ let scanned = false;
113
+ try {
114
+ const diff = await env.stagedDiff();
115
+ const added = collectAddedLines(diff, inScope);
116
+ if (added.length > 0) {
117
+ scanned = true;
118
+ const hits = added.filter(a => pattern.test(a.line)).map(a => `${a.file}: ${clip(a.line)}`);
119
+ if (hits.length > 0) return hitEvidence(pattern, hits);
120
+ }
121
+ } catch {
122
+ // diff 不可用 → 退化为文件内容扫描
123
+ }
124
+ if (!scanned) {
125
+ const hits = scanChangedFiles(env);
126
+ if (hits.length > 0) return hitEvidence(pattern, hits);
127
+ }
128
+ return true;
129
+ },
130
+ };
131
+ },
132
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 填空式 checker 模板注册表(ADR-0033 两层约束模型)
3
+ *
4
+ * 独立成模块而非住在 checkers/index.ts:应用层加载器(core/app-constraints-loader)
5
+ * 要做加载期模板校验,若直接引 checkers/index.ts 会经 project-config-loader →
6
+ * 本注册表 → 内置 checker → project-config-loader 形成值级循环(checker 初始化时
7
+ * 拿到 undefined)。本模块不引内置 checker,无环。
8
+ *
9
+ * 闭环语义:模板 id 未注册即不可用——加载期由 app-constraints-loader 校验,
10
+ * 运行期由 checkers/index.ts 的 getConstraintCheck 抛错兜底。
11
+ *
12
+ * 首批模板:regex-scan(正则扫描)/ file-exists(文件存在性),块 3 子项 2。
13
+ */
14
+
15
+ import type { TemplatedCheckerFactory } from './types';
16
+ import { regexScan, fileExists } from './templated';
17
+
18
+ /** 模板注册表(模板 id → 工厂);测试可注册替身模板,用后须注销 */
19
+ export const TEMPLATES = new Map<string, TemplatedCheckerFactory>([
20
+ ['regex-scan', regexScan],
21
+ ['file-exists', fileExists],
22
+ ]);
@@ -148,6 +148,21 @@ export interface ConstraintCheck {
148
148
  evaluate(env: CheckEnv): Promise<CheckOutcome> | CheckOutcome;
149
149
  }
150
150
 
151
+ /**
152
+ * 填空式 checker 模板工厂(ADR-0033 两层约束模型)
153
+ *
154
+ * 每个模板 = validateParams + create:参数坏在加载期早报(validateParams),
155
+ * create 只在参数已校验后调用。注册点 = checkers/index.ts 的 TEMPLATES。
156
+ * 类型住在 types.ts(而非 index.ts)是为了模板实现文件只依赖本模块,
157
+ * 不与注册表形成值级循环导入。
158
+ */
159
+ export interface TemplatedCheckerFactory {
160
+ /** 参数校验:返回错误清单,空数组 = 通过(加载期跑,参数坏早报) */
161
+ validateParams(params: Record<string, unknown>): string[];
162
+ /** 按约束 id + 已校验参数实例化 checker */
163
+ create(id: string, params: Record<string, unknown>): ConstraintCheck;
164
+ }
165
+
151
166
  /**
152
167
  * 构造纯上下文标志检查(无 I/O 的轻量约束)
153
168
  */
@@ -10,21 +10,6 @@
10
10
  import type { Constraint } from '../../../types/constraint';
11
11
 
12
12
  export const WARNING_CONSTRAINTS: Record<string, Constraint> = {
13
- /**
14
- * 禁止硬编码凭证
15
- * 原因:密码/token/密钥泄露是最严重的安全漏洞,一旦提交到版本控制即不可逆
16
- */
17
- no_hardcoded_credentials: {
18
- id: 'no_hardcoded_credentials',
19
- kind: 'check',
20
- rule: 'NO HARDCODED PASSWORDS, TOKENS, SECRETS, OR CREDENTIALS IN ANY SOURCE FILE',
21
- message: '禁止在代码/文档/配置文件中硬编码密码、token、密钥、API key',
22
- severity: 'warning',
23
- trigger: ['code_implementation', 'doc_update', 'config_change', 'commit'],
24
- enforcement: 'credential-scan',
25
- description: `凭证(密码/token/密钥/API key)必须从环境变量或加密配置读取,严禁以明文形式出现在任何源代码文件中。包括但不限于:源码、Markdown 文档、YAML/JSON 配置、脚本文件。模板文件(.example/.sample/.template)允许占位符但不得包含真实值。建议配合 pre-commit hook 做自动扫描。`,
26
- },
27
-
28
13
  /**
29
14
  * 功能清单同步
30
15
  * 例外:内部重构、bug fix
@@ -25,6 +25,22 @@ export const ERROR_CONSTRAINTS: Record<string, Constraint> = {
25
25
  description: '在声明任何任务完成之前,必须运行新鲜的验证命令(项目声明的测试 + type check),以新鲜输出作为完成证据。验证命令集由项目自行声明,全量验证留给 CI 流程。',
26
26
  },
27
27
 
28
+ /**
29
+ * 禁止硬编码凭证
30
+ * 原因:密码/token/密钥泄露是最严重的安全漏洞,一旦提交到版本控制即不可逆。
31
+ * ADR-0032:安全底线类约束,warning 不阻断等于没有——升级为 error(违规即阻断)。
32
+ */
33
+ no_hardcoded_credentials: {
34
+ id: 'no_hardcoded_credentials',
35
+ kind: 'check',
36
+ rule: 'NO HARDCODED PASSWORDS, TOKENS, SECRETS, OR CREDENTIALS IN ANY SOURCE FILE',
37
+ message: '禁止在代码/文档/配置文件中硬编码密码、token、密钥、API key',
38
+ severity: 'error',
39
+ trigger: ['code_implementation', 'doc_update', 'config_change', 'commit'],
40
+ enforcement: 'credential-scan',
41
+ description: `凭证(密码/token/密钥/API key)必须从环境变量或加密配置读取,严禁以明文形式出现在任何源代码文件中。包括但不限于:源码、Markdown 文档、YAML/JSON 配置、脚本文件。模板文件(.example/.sample/.template)允许占位符但不得包含真实值。建议配合 pre-commit hook 做自动扫描。`,
42
+ },
43
+
28
44
  /**
29
45
  * 禁止简化测试
30
46
  * 原因:质量底线,测试困难必须解决
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * `harness constraints report` 与 `harness constraints retire`(交互模式)共用的
5
5
  * 数据层:读取项目 traces.log,与生效集(getEffectiveConstraints)对齐,
6
- * 产出 check 约束统计表与四类退役候选诊断。
6
+ * 产出 check 约束统计表、四类退役候选诊断与零拦截观察名单(ADR-0032/0033 块 3 子项 4:
7
+ * 零拦截先进观察名单挂一个季度,期满且样本足才转正式退役候选)。
7
8
  *
8
- * 只读:不创建目录、不写任何文件。
9
+ * 只读:不创建目录、不写任何文件。观察名单状态以快照入参进来、以 `nextWatchlist`
10
+ * 产物出去,持久化(`.harness/.state.json` 经 StateIO 接缝)是 cli 层调用方的职责。
9
11
  */
10
12
 
11
13
  import * as fs from 'fs';
@@ -92,6 +94,52 @@ export const DEFAULT_DIAGNOSE_THRESHOLDS: DiagnoseThresholds = {
92
94
  highNoiseMinEvaluated: 20,
93
95
  };
94
96
 
97
+ /**
98
+ * 观察名单状态条目(ADR-0032 口径,块 3 子项 4)
99
+ *
100
+ * 持久化在 `.harness/.state.json` 的 `constraintWatchlist` 段(StateIO 接缝,
101
+ * cli 层读-改-写);本层只认快照,不碰 fs。
102
+ */
103
+ export interface WatchlistStateEntry {
104
+ /** 列入观察名单时刻(ISO 串,与 HarnessState 其余时间字段同形) */
105
+ listedAt: string;
106
+ }
107
+
108
+ /** 观察名单状态(constraintId → 条目) */
109
+ export type ConstraintWatchlistState = Record<string, WatchlistStateEntry>;
110
+
111
+ /** 观察期时长(天):满一个季度且样本仍达标,零拦截才转正式退役候选 */
112
+ export const WATCHLIST_PERIOD_DAYS = 90;
113
+
114
+ const DAY_MS = 24 * 60 * 60 * 1000;
115
+
116
+ /**
117
+ * report 里单独分组展示的观察名单条目
118
+ *
119
+ * 列入即进 report(告知面),但不进退役候选;观察期内再次出现 fail / 样本
120
+ * 跌破阈值时条目自然消失(状态段里的列入记录保留,时钟不回拨)。
121
+ */
122
+ export interface WatchlistEntry {
123
+ id: string;
124
+ stats: ConstraintUsageStats;
125
+ /** 列入时刻(ISO 串) */
126
+ listedAt: string;
127
+ /** 剩余观察天数(观察期未满恒 > 0) */
128
+ remainingDays: number;
129
+ /** 人类可读的诊断说明(带证据数字) */
130
+ reason: string;
131
+ }
132
+
133
+ /**
134
+ * 候选诊断的观察名单上下文(不传 = 空名单 + 真实时钟)
135
+ */
136
+ export interface DiagnoseWatchOptions {
137
+ /** 观察名单状态快照(来自 `.harness/.state.json`,缺省空) */
138
+ watchlist?: ConstraintWatchlistState;
139
+ /** 当前时刻(Unix ms),测试注入用——别在测试里等 90 天 */
140
+ now?: number;
141
+ }
142
+
95
143
  /**
96
144
  * report 数据模型
97
145
  */
@@ -100,6 +148,13 @@ export interface ConstraintsUsageReport {
100
148
  stats: ConstraintUsageStats[];
101
149
  /** 退役候选诊断(按 zero_trigger → unevaluable → high_noise → zero_intercept 排序) */
102
150
  candidates: RetireCandidate[];
151
+ /** 零拦截观察名单(进 report 但不进退役候选;期满且样本足才转候选) */
152
+ watchlist: WatchlistEntry[];
153
+ /**
154
+ * 观察名单读-改-写产物:入参快照 + 本次新列入(只增不删)。
155
+ * 与入参快照键数不同(有新增)时,调用方应经 StateIO 写回 `.harness/.state.json`。
156
+ */
157
+ nextWatchlist: ConstraintWatchlistState;
103
158
  /** 配置健康诊断(unknownIds 等) */
104
159
  lint: EffectiveConfigLint;
105
160
  /** trace 文件是否存在 */
@@ -117,7 +172,7 @@ export interface ConstraintsUsageReport {
117
172
  *
118
173
  * 坏行策略:skip(原语义不变——report 只读,不因单行损坏失败);
119
174
  * 计数去向:透传——`skippedLines` 随本方法返回,进 `ConstraintsUsageReport.skippedLines`,
120
- * 消费面为 `constraints report` 的文本行 / `--json` 字段 / `--export` 摘要。
175
+ * 消费面为 `constraints report` 的文本行 / `--json-output` 字段 / `--export` 摘要。
121
176
  */
122
177
  export function readProjectTracesReport(projectRoot: string): { traces: ExecutionTrace[]; skippedLines: number } {
123
178
  const { records, skippedLines } = readJsonl<ExecutionTrace>(
@@ -176,18 +231,33 @@ function toStats(id: string, severity: Constraint['severity'], agg: { total: num
176
231
  };
177
232
  }
178
233
 
234
+ /** 诊断完整产物(candidates + watchlist + 待写回的状态) */
235
+ interface DiagnoseOutcome {
236
+ candidates: RetireCandidate[];
237
+ watchlist: WatchlistEntry[];
238
+ nextWatchlist: ConstraintWatchlistState;
239
+ }
240
+
179
241
  /**
180
242
  * 退役候选诊断(纯函数)
181
243
  *
182
244
  * 每条约束最多归入一个类别,优先级:
183
245
  * zero_trigger → unevaluable → high_noise → zero_intercept
246
+ *
247
+ * zero_intercept 走观察名单中间态(ADR-0032):命中且样本够 → 不在名单则列入
248
+ * (listedAt = now),进 watchlist 不进 candidates;已在名单且满 WATCHLIST_PERIOD_DAYS
249
+ * 且当前样本仍达标 → 转正式 zero_intercept 退役候选。其他三类不进观察名单。
184
250
  */
185
- export function diagnoseRetireCandidates(
251
+ function diagnose(
186
252
  stats: ConstraintUsageStats[],
187
- thresholds: Partial<DiagnoseThresholds> = {}
188
- ): RetireCandidate[] {
189
- const t = { ...DEFAULT_DIAGNOSE_THRESHOLDS, ...thresholds };
253
+ t: DiagnoseThresholds,
254
+ watch: DiagnoseWatchOptions
255
+ ): DiagnoseOutcome {
256
+ const now = watch.now ?? Date.now();
257
+ const watchlistState = watch.watchlist ?? {};
258
+ const nextWatchlist: ConstraintWatchlistState = { ...watchlistState };
190
259
  const candidates: RetireCandidate[] = [];
260
+ const watchlistEntries: WatchlistEntry[] = [];
191
261
 
192
262
  for (const s of stats) {
193
263
  if (s.total === 0) {
@@ -224,24 +294,66 @@ export function diagnoseRetireCandidates(
224
294
  }
225
295
 
226
296
  if (s.fail === 0 && s.evaluated >= t.zeroInterceptMinEvaluated) {
227
- candidates.push({
297
+ const listed = watchlistState[s.id];
298
+ const listedAtMs = listed ? Date.parse(listed.listedAt) : NaN;
299
+ // 列入时间损坏按未列入处理:重新列入、时钟重启(状态文件是人可手改的,不静默信脏值)
300
+ const isListed = listed !== undefined && Number.isFinite(listedAtMs);
301
+
302
+ if (isListed && now - listedAtMs >= WATCHLIST_PERIOD_DAYS * DAY_MS) {
303
+ candidates.push({
304
+ id: s.id,
305
+ kind: 'zero_intercept',
306
+ stats: s,
307
+ reason: `零拦截:观察期满(${listed.listedAt.slice(0, 10)} 列入,已满 ${WATCHLIST_PERIOD_DAYS} 天),${s.evaluated} 次评估从未 fail`,
308
+ });
309
+ continue;
310
+ }
311
+
312
+ const listedAt = isListed ? listed.listedAt : new Date(now).toISOString();
313
+ if (!isListed) {
314
+ nextWatchlist[s.id] = { listedAt };
315
+ }
316
+ watchlistEntries.push({
228
317
  id: s.id,
229
- kind: 'zero_intercept',
230
318
  stats: s,
319
+ listedAt,
320
+ remainingDays: isListed
321
+ ? Math.max(0, Math.ceil((listedAtMs + WATCHLIST_PERIOD_DAYS * DAY_MS - now) / DAY_MS))
322
+ : WATCHLIST_PERIOD_DAYS,
231
323
  reason: `零拦截:${s.evaluated} 次评估从未 fail`,
232
324
  });
233
325
  }
234
326
  }
235
327
 
236
- return candidates;
328
+ return { candidates, watchlist: watchlistEntries, nextWatchlist };
237
329
  }
238
330
 
239
331
  /**
240
- * 构建 report 数据模型(只读)
332
+ * 退役候选诊断(纯函数)——观察名单兼容包装
333
+ *
334
+ * 每条约束最多归入一个类别,优先级:
335
+ * zero_trigger → unevaluable → high_noise → zero_intercept
336
+ *
337
+ * 只返回退役候选;观察名单分组与待写回状态走 `buildConstraintsUsageReport`
338
+ * 的 `watchlist` / `nextWatchlist` 字段。zero_intercept 仅在观察期满且样本足时
339
+ * 才出现在本返回值里(ADR-0032),未期满的命中不再直接列候选。
340
+ */
341
+ export function diagnoseRetireCandidates(
342
+ stats: ConstraintUsageStats[],
343
+ thresholds: Partial<DiagnoseThresholds> = {},
344
+ watch: DiagnoseWatchOptions = {}
345
+ ): RetireCandidate[] {
346
+ const t = { ...DEFAULT_DIAGNOSE_THRESHOLDS, ...thresholds };
347
+ return diagnose(stats, t, watch).candidates;
348
+ }
349
+
350
+ /**
351
+ * 构建 report 数据模型(只读;观察名单状态以快照入、以 nextWatchlist 出,不写盘)
241
352
  */
242
353
  export function buildConstraintsUsageReport(
243
354
  projectRoot: string = process.cwd(),
244
- thresholds: Partial<DiagnoseThresholds> = {}
355
+ thresholds: Partial<DiagnoseThresholds> = {},
356
+ watch: DiagnoseWatchOptions = {}
245
357
  ): ConstraintsUsageReport {
246
358
  const effective = getEffectiveConstraints(projectRoot);
247
359
 
@@ -250,12 +362,15 @@ export function buildConstraintsUsageReport(
250
362
  const usage = collectUsageByConstraint(traces);
251
363
 
252
364
  const stats = effective.map(c => toStats(c.id, c.severity, usage.get(c.id)));
253
- const candidates = diagnoseRetireCandidates(stats, thresholds);
365
+ const t = { ...DEFAULT_DIAGNOSE_THRESHOLDS, ...thresholds };
366
+ const outcome = diagnose(stats, t, watch);
254
367
  const lint = lintEffectiveConfig(projectRoot);
255
368
 
256
369
  return {
257
370
  stats,
258
- candidates,
371
+ candidates: outcome.candidates,
372
+ watchlist: outcome.watchlist,
373
+ nextWatchlist: outcome.nextWatchlist,
259
374
  lint,
260
375
  traceFileExists: fs.existsSync(tracePath),
261
376
  skippedLines,