@principles/pd-cli 1.133.0 → 1.135.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 (215) hide show
  1. package/dist/commands/__tests__/intent-flag-wiring.test.js +74 -3
  2. package/dist/commands/__tests__/intent-flag-wiring.test.js.map +1 -1
  3. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.d.ts +2 -0
  4. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.d.ts.map +1 -0
  5. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.js +60 -0
  6. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.js.map +1 -0
  7. package/dist/commands/__tests__/rulecode-flag-wiring.test.js +11 -4
  8. package/dist/commands/__tests__/rulecode-flag-wiring.test.js.map +1 -1
  9. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js +9 -0
  10. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js.map +1 -1
  11. package/dist/commands/admission-gate.d.ts +24 -0
  12. package/dist/commands/admission-gate.d.ts.map +1 -0
  13. package/dist/commands/admission-gate.js +29 -0
  14. package/dist/commands/admission-gate.js.map +1 -0
  15. package/dist/commands/candidate.d.ts.map +1 -1
  16. package/dist/commands/candidate.js +72 -6
  17. package/dist/commands/candidate.js.map +1 -1
  18. package/dist/commands/console.d.ts.map +1 -1
  19. package/dist/commands/console.js +42 -0
  20. package/dist/commands/console.js.map +1 -1
  21. package/dist/commands/demo-story-a.d.ts.map +1 -1
  22. package/dist/commands/demo-story-a.js +4 -2
  23. package/dist/commands/demo-story-a.js.map +1 -1
  24. package/dist/commands/diagnose.d.ts.map +1 -1
  25. package/dist/commands/diagnose.js +155 -8
  26. package/dist/commands/diagnose.js.map +1 -1
  27. package/dist/commands/errors-list.d.ts +25 -0
  28. package/dist/commands/errors-list.d.ts.map +1 -0
  29. package/dist/commands/errors-list.js +368 -0
  30. package/dist/commands/errors-list.js.map +1 -0
  31. package/dist/commands/health-codex.d.ts +7 -0
  32. package/dist/commands/health-codex.d.ts.map +1 -0
  33. package/dist/commands/health-codex.js +209 -0
  34. package/dist/commands/health-codex.js.map +1 -0
  35. package/dist/commands/health.d.ts +24 -0
  36. package/dist/commands/health.d.ts.map +1 -1
  37. package/dist/commands/health.js +48 -0
  38. package/dist/commands/health.js.map +1 -1
  39. package/dist/commands/intent.d.ts +21 -4
  40. package/dist/commands/intent.d.ts.map +1 -1
  41. package/dist/commands/intent.js +88 -27
  42. package/dist/commands/intent.js.map +1 -1
  43. package/dist/commands/pain-evidence.d.ts +6 -0
  44. package/dist/commands/pain-evidence.d.ts.map +1 -1
  45. package/dist/commands/pain-evidence.js +6 -0
  46. package/dist/commands/pain-evidence.js.map +1 -1
  47. package/dist/commands/pain-record.d.ts.map +1 -1
  48. package/dist/commands/pain-record.js +21 -0
  49. package/dist/commands/pain-record.js.map +1 -1
  50. package/dist/commands/pain-retry.d.ts +4 -0
  51. package/dist/commands/pain-retry.d.ts.map +1 -1
  52. package/dist/commands/pain-retry.js +327 -32
  53. package/dist/commands/pain-retry.js.map +1 -1
  54. package/dist/commands/proven-channel-baseline.d.ts.map +1 -1
  55. package/dist/commands/proven-channel-baseline.js +4 -2
  56. package/dist/commands/proven-channel-baseline.js.map +1 -1
  57. package/dist/commands/runtime-activation.d.ts +30 -0
  58. package/dist/commands/runtime-activation.d.ts.map +1 -1
  59. package/dist/commands/runtime-activation.js +567 -11
  60. package/dist/commands/runtime-activation.js.map +1 -1
  61. package/dist/commands/runtime-canary.d.ts.map +1 -1
  62. package/dist/commands/runtime-canary.js +32 -7
  63. package/dist/commands/runtime-canary.js.map +1 -1
  64. package/dist/commands/runtime-health-snapshot.d.ts.map +1 -1
  65. package/dist/commands/runtime-health-snapshot.js +4 -2
  66. package/dist/commands/runtime-health-snapshot.js.map +1 -1
  67. package/dist/commands/runtime-init.d.ts +43 -0
  68. package/dist/commands/runtime-init.d.ts.map +1 -0
  69. package/dist/commands/runtime-init.js +264 -0
  70. package/dist/commands/runtime-init.js.map +1 -0
  71. package/dist/commands/runtime-internalization-context-trace.d.ts +25 -0
  72. package/dist/commands/runtime-internalization-context-trace.d.ts.map +1 -0
  73. package/dist/commands/runtime-internalization-context-trace.js +374 -0
  74. package/dist/commands/runtime-internalization-context-trace.js.map +1 -0
  75. package/dist/commands/runtime-internalization-enqueue-successors.d.ts.map +1 -1
  76. package/dist/commands/runtime-internalization-enqueue-successors.js +3 -1
  77. package/dist/commands/runtime-internalization-enqueue-successors.js.map +1 -1
  78. package/dist/commands/runtime-internalization-integrity.d.ts.map +1 -1
  79. package/dist/commands/runtime-internalization-integrity.js +4 -2
  80. package/dist/commands/runtime-internalization-integrity.js.map +1 -1
  81. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  82. package/dist/commands/runtime-internalization-run-once.js +20 -5
  83. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  84. package/dist/commands/runtime-internalization-run-rulehost.d.ts +1 -0
  85. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  86. package/dist/commands/runtime-internalization-run-rulehost.js +149 -6
  87. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  88. package/dist/commands/runtime-uat.d.ts.map +1 -1
  89. package/dist/commands/runtime-uat.guard.test.js +13 -15
  90. package/dist/commands/runtime-uat.guard.test.js.map +1 -1
  91. package/dist/commands/runtime-uat.js +4 -3
  92. package/dist/commands/runtime-uat.js.map +1 -1
  93. package/dist/config-reader.js +1 -1
  94. package/dist/config-reader.js.map +1 -1
  95. package/dist/index.js +54 -78
  96. package/dist/index.js.map +1 -1
  97. package/dist/services/__tests__/evaluator-runner-deps.test.d.ts +2 -0
  98. package/dist/services/__tests__/evaluator-runner-deps.test.d.ts.map +1 -0
  99. package/dist/services/__tests__/evaluator-runner-deps.test.js +304 -0
  100. package/dist/services/__tests__/evaluator-runner-deps.test.js.map +1 -0
  101. package/dist/services/__tests__/rulehost-readiness.test.js.map +1 -1
  102. package/dist/services/__tests__/runtime-adapter-resolver.test.js.map +1 -1
  103. package/dist/services/config-doctor.d.ts.map +1 -1
  104. package/dist/services/config-doctor.js +9 -2
  105. package/dist/services/config-doctor.js.map +1 -1
  106. package/dist/services/console-launcher.d.ts +6 -0
  107. package/dist/services/console-launcher.d.ts.map +1 -1
  108. package/dist/services/console-launcher.js.map +1 -1
  109. package/dist/services/pain-flood-simulation-runner.d.ts.map +1 -1
  110. package/dist/services/pain-flood-simulation-runner.js +9 -0
  111. package/dist/services/pain-flood-simulation-runner.js.map +1 -1
  112. package/dist/services/pd-config-loader.js +2 -2
  113. package/dist/services/pd-config-loader.js.map +1 -1
  114. package/dist/services/proven-channel-baseline-runner.d.ts.map +1 -1
  115. package/dist/services/proven-channel-baseline-runner.js +10 -2
  116. package/dist/services/proven-channel-baseline-runner.js.map +1 -1
  117. package/dist/services/quality-scorecard/data-extractor.js.map +1 -1
  118. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -1
  119. package/dist/services/rulehost-pipeline-runner.d.ts +77 -1
  120. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  121. package/dist/services/rulehost-pipeline-runner.js +111 -7
  122. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  123. package/dist/services/rulehost-readiness.js.map +1 -1
  124. package/dist/services/runtime-adapter-resolver.d.ts +1 -0
  125. package/dist/services/runtime-adapter-resolver.d.ts.map +1 -1
  126. package/dist/services/runtime-adapter-resolver.js +3 -0
  127. package/dist/services/runtime-adapter-resolver.js.map +1 -1
  128. package/dist/services/synthetic-baseline-runner.d.ts.map +1 -1
  129. package/dist/services/synthetic-baseline-runner.js +11 -0
  130. package/dist/services/synthetic-baseline-runner.js.map +1 -1
  131. package/dist/utils/production-workspace-guard.d.ts +4 -4
  132. package/dist/utils/production-workspace-guard.d.ts.map +1 -1
  133. package/dist/utils/production-workspace-guard.js +19 -16
  134. package/dist/utils/production-workspace-guard.js.map +1 -1
  135. package/dist/utils/production-workspace-guard.test.js +54 -19
  136. package/dist/utils/production-workspace-guard.test.js.map +1 -1
  137. package/package.json +8 -5
  138. package/src/commands/__tests__/intent-flag-wiring.test.ts +88 -3
  139. package/src/commands/__tests__/pain-retry-maxTokens-flag.test.ts +73 -0
  140. package/src/commands/__tests__/rulecode-flag-wiring.test.ts +31 -24
  141. package/src/commands/__tests__/run-rulehost-flag-wiring.test.ts +12 -0
  142. package/src/commands/admission-gate.ts +30 -0
  143. package/src/commands/candidate.ts +74 -6
  144. package/src/commands/console.ts +41 -0
  145. package/src/commands/demo-story-a.ts +4 -2
  146. package/src/commands/diagnose.ts +158 -10
  147. package/src/commands/errors-list.ts +440 -0
  148. package/src/commands/health-codex.ts +249 -0
  149. package/src/commands/health.ts +60 -0
  150. package/src/commands/intent.ts +98 -29
  151. package/src/commands/pain-evidence.ts +6 -0
  152. package/src/commands/pain-record.ts +21 -0
  153. package/src/commands/pain-retry.ts +353 -35
  154. package/src/commands/proven-channel-baseline.ts +4 -2
  155. package/src/commands/runtime-activation.ts +657 -12
  156. package/src/commands/runtime-canary.ts +31 -5
  157. package/src/commands/runtime-health-snapshot.ts +4 -2
  158. package/src/commands/runtime-init.ts +305 -0
  159. package/src/commands/runtime-internalization-context-trace.ts +445 -0
  160. package/src/commands/runtime-internalization-enqueue-successors.ts +3 -1
  161. package/src/commands/runtime-internalization-integrity.ts +4 -2
  162. package/src/commands/runtime-internalization-run-once.ts +20 -5
  163. package/src/commands/runtime-internalization-run-rulehost.ts +159 -6
  164. package/src/commands/runtime-uat.guard.test.ts +14 -16
  165. package/src/commands/runtime-uat.ts +4 -3
  166. package/src/config-reader.ts +1 -1
  167. package/src/index.ts +69 -78
  168. package/src/services/__tests__/evaluator-runner-deps.test.ts +341 -0
  169. package/src/services/config-doctor.ts +9 -2
  170. package/src/services/console-launcher.ts +6 -0
  171. package/src/services/pain-flood-simulation-runner.ts +9 -0
  172. package/src/services/pd-config-loader.ts +2 -2
  173. package/src/services/proven-channel-baseline-runner.ts +10 -3
  174. package/src/services/rulehost-pipeline-runner.ts +157 -6
  175. package/src/services/runtime-adapter-resolver.ts +4 -0
  176. package/src/services/synthetic-baseline-runner.ts +11 -0
  177. package/src/utils/production-workspace-guard.test.ts +62 -20
  178. package/src/utils/production-workspace-guard.ts +19 -16
  179. package/tests/bdd/cli-contract.steps.ts +343 -0
  180. package/tests/bdd/context-trace.steps.ts +229 -0
  181. package/tests/bdd/support/gherkin-loader.ts +147 -0
  182. package/tests/bdd/support/repo-root.ts +62 -0
  183. package/tests/bdd/support/vitest-bdd.ts +179 -0
  184. package/tests/commands/admission-gate-coverage.test.ts +79 -0
  185. package/tests/commands/candidate-audit-repair.test.ts +77 -0
  186. package/tests/commands/candidate-intake.test.ts +155 -10
  187. package/tests/commands/candidate-internalization-backfill.test.ts +30 -0
  188. package/tests/commands/cli-help-snapshot.test.ts +15 -1
  189. package/tests/commands/console-open.test.ts +229 -32
  190. package/tests/commands/diagnose.test.ts +619 -14
  191. package/tests/commands/errors-list.test.ts +678 -0
  192. package/tests/commands/health-codex.test.ts +243 -0
  193. package/tests/commands/health-host-wiring.test.ts +76 -0
  194. package/tests/commands/intent.test.ts +4 -4
  195. package/tests/commands/pain-record.test.ts +80 -0
  196. package/tests/commands/pain-retry.test.ts +94 -3
  197. package/tests/commands/run-rulehost-handler.test.ts +133 -0
  198. package/tests/commands/runtime-activation-approve-flag-wiring.test.ts +227 -0
  199. package/tests/commands/runtime-activation-deactivate-flag-wiring.test.ts +167 -0
  200. package/tests/commands/runtime-activation-dispatch-flag-wiring.test.ts +262 -0
  201. package/tests/commands/runtime-activation-list-flag-wiring.test.ts +193 -0
  202. package/tests/commands/runtime-activation-promote-flag-wiring.test.ts +222 -0
  203. package/tests/commands/runtime-activation.test.ts +1090 -5
  204. package/tests/commands/runtime-canary.test.ts +50 -0
  205. package/tests/commands/runtime-features.test.ts +2 -0
  206. package/tests/commands/runtime-init-empty-workspace.test.ts +234 -0
  207. package/tests/commands/runtime-init.test.ts +346 -0
  208. package/tests/commands/runtime-internalization-context-trace.test.ts +110 -0
  209. package/tests/commands/runtime-internalization.test.ts +2 -2
  210. package/tests/config-reader.test.ts +6 -5
  211. package/tests/e2e/cross-package-acceptance.test.ts +55 -13
  212. package/tests/services/mainline-snapshot-assembler.test.ts +6 -3
  213. package/tests/services/resolve-runtime-from-pd-config.test.ts +2 -0
  214. package/tests/services/rulehost-pipeline-runner.test.ts +1 -1
  215. package/vitest.config.ts +1 -1
@@ -0,0 +1,147 @@
1
+ import { AstBuilder, GherkinClassicTokenMatcher, Parser } from '@cucumber/gherkin';
2
+ import * as messages from '@cucumber/messages';
3
+
4
+ export interface ParsedStep {
5
+ keyword: 'Given' | 'When' | 'Then' | 'And' | 'But';
6
+ text: string;
7
+ }
8
+
9
+ export interface ParsedScenario {
10
+ featureName: string;
11
+ featureTags: string[];
12
+ scenarioName: string;
13
+ scenarioTags: string[];
14
+ background?: ParsedStep[];
15
+ steps: ParsedStep[];
16
+ }
17
+
18
+ // 步骤关键词归一化表。英文关键词由 @cucumber/gherkin 以带尾空格的形式返回
19
+ // (如 "Given "),中文关键词无尾空格 (如 "假如")。trim 后用此表映射到规范形式。
20
+ // 中文条目用于带 # language: zh-CN 指令的文件 (parser 原生返回中文关键词)。
21
+ const KEYWORD_MAP: Record<string, ParsedStep['keyword']> = {
22
+ Given: 'Given',
23
+ When: 'When',
24
+ Then: 'Then',
25
+ And: 'And',
26
+ But: 'But',
27
+ // 中文关键词 (@cucumber/gherkin zh-CN 方言)
28
+ 假如: 'Given',
29
+ 假设: 'Given',
30
+ 假定: 'Given',
31
+ 当: 'When',
32
+ 那么: 'Then',
33
+ 而且: 'And',
34
+ 并且: 'And',
35
+ 同时: 'And',
36
+ 但是: 'But',
37
+ };
38
+
39
+ function normalizeKeyword(keyword: string): ParsedStep['keyword'] {
40
+ const trimmed = keyword.trim();
41
+ const mapped = KEYWORD_MAP[trimmed];
42
+ if (!mapped) {
43
+ // rc-9: 不静默回退到 'Given',未知关键词 fail loud。
44
+ // @cucumber/gherkin parser 理论上只产生已知关键词,这里防御 parser 升级或异常输入。
45
+ throw new Error(`normalizeKeyword: unknown step keyword "${trimmed}"`);
46
+ }
47
+ return mapped;
48
+ }
49
+
50
+ // 行首中文步骤关键词 → 英文。用于无 # language 指令的混合写法
51
+ // (英文 Feature/Scenario + 中文步骤),这是 PD .feature 文件的常见写法。
52
+ // 要求关键词后跟空白,避免误伤描述文本里的中文字符。
53
+ const CHINESE_STEP_KEYWORD_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
54
+ [/^(\s*)(假如|假设|假定)(\s+)/gm, '$1Given$3'],
55
+ [/^(\s*)(当)(\s+)/gm, '$1When$3'],
56
+ [/^(\s*)(那么)(\s+)/gm, '$1Then$3'],
57
+ [/^(\s*)(而且|并且|同时)(\s+)/gm, '$1And$3'],
58
+ [/^(\s*)(但是)(\s+)/gm, '$1But$3'],
59
+ ];
60
+
61
+ function hasLanguageDirective(featureText: string): boolean {
62
+ return /^\s*#\s*language\s*:/m.test(featureText);
63
+ }
64
+
65
+ function normalizeChineseStepKeywords(featureText: string): string {
66
+ let result = featureText;
67
+ for (const [pattern, replacement] of CHINESE_STEP_KEYWORD_PATTERNS) {
68
+ result = result.replace(pattern, replacement);
69
+ }
70
+ return result;
71
+ }
72
+
73
+ /**
74
+ * 解析 .feature 文本为 ParsedScenario[]。
75
+ *
76
+ * 纯逻辑,无 I/O。失败时 fail loud (rc-3),抛错带原因。
77
+ *
78
+ * 中文支持策略:
79
+ * - 有 `# language:` 指令 → 交给 @cucumber/gherkin 原生处理方言,KEYWORD_MAP 兜底归一化。
80
+ * - 无指令但含中文步骤关键词 → 行首归一化为英文后用默认英文方言解析
81
+ * (支持英文 Feature/Scenario + 中文步骤的混合写法)。
82
+ */
83
+ export function parseFeature(featureText: string): ParsedScenario[] {
84
+ if (!featureText || featureText.trim().length === 0) {
85
+ throw new Error('parseFeature: empty feature text');
86
+ }
87
+
88
+ const source = hasLanguageDirective(featureText)
89
+ ? featureText
90
+ : normalizeChineseStepKeywords(featureText);
91
+
92
+ let gherkinDocument: messages.GherkinDocument;
93
+ try {
94
+ const newId = messages.IdGenerator.uuid();
95
+ const parser = new Parser(new AstBuilder(newId), new GherkinClassicTokenMatcher());
96
+ gherkinDocument = parser.parse(source);
97
+ } catch (e) {
98
+ throw new Error(
99
+ `parseFeature: malformed feature file: ${e instanceof Error ? e.message : String(e)}`
100
+ );
101
+ }
102
+
103
+ if (!gherkinDocument.feature) {
104
+ throw new Error('parseFeature: feature file has no Feature section');
105
+ }
106
+
107
+ const feature = gherkinDocument.feature;
108
+ const featureTags = (feature.tags || []).map((t) => t.name);
109
+
110
+ // 提取 Background (一个 Feature 至多一个 Background,适用于全部 scenario)
111
+ let background: ParsedStep[] | undefined;
112
+ for (const child of feature.children || []) {
113
+ if (child.background) {
114
+ background = (child.background.steps || []).map((step) => ({
115
+ keyword: normalizeKeyword(step.keyword),
116
+ text: step.text,
117
+ }));
118
+ break;
119
+ }
120
+ }
121
+
122
+ // 提取 Scenario 列表
123
+ const scenarios: ParsedScenario[] = [];
124
+ for (const child of feature.children || []) {
125
+ if (!child.scenario) continue;
126
+ const scenario = child.scenario;
127
+ const scenarioTags = (scenario.tags || []).map((t) => t.name);
128
+ const steps: ParsedStep[] = (scenario.steps || []).map((step) => ({
129
+ keyword: normalizeKeyword(step.keyword),
130
+ text: step.text,
131
+ }));
132
+ scenarios.push({
133
+ featureName: feature.name || '(unnamed feature)',
134
+ featureTags,
135
+ scenarioName: scenario.name || '(unnamed scenario)',
136
+ scenarioTags,
137
+ background,
138
+ steps,
139
+ });
140
+ }
141
+
142
+ if (scenarios.length === 0) {
143
+ throw new Error('parseFeature: feature file has no Scenario');
144
+ }
145
+
146
+ return scenarios;
147
+ }
@@ -0,0 +1,62 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { dirname, join, resolve, isAbsolute } from 'node:path';
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+
5
+ /**
6
+ * 解析仓库根路径。优先用 PD_REPO_ROOT 环境变量,否则从 import.meta.url 向上查找
7
+ * 含 "principles-disciple-monorepo" 的 package.json。
8
+ *
9
+ * 不依赖 process.cwd(),避免在 package 目录下运行测试时路径错误。
10
+ */
11
+ export function resolveRepoRoot(): string {
12
+ // 优先使用环境变量 (CI 显式注入)
13
+ const envRoot = process.env.PD_REPO_ROOT;
14
+ if (envRoot && existsSync(join(envRoot, 'package.json'))) {
15
+ try {
16
+ const pkg = JSON.parse(readFileSync(join(envRoot, 'package.json'), 'utf8'));
17
+ if (pkg.name === 'principles-disciple-monorepo') {
18
+ return envRoot;
19
+ }
20
+ } catch {
21
+ // fallthrough to import.meta.url strategy
22
+ }
23
+ }
24
+
25
+ // 从 import.meta.url 向上查找
26
+ const thisFileDir = dirname(fileURLToPath(import.meta.url));
27
+ let current: string = thisFileDir;
28
+ for (let i = 0; i < 20; i++) {
29
+ const pkgPath = join(current, 'package.json');
30
+ if (existsSync(pkgPath)) {
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
33
+ if (pkg.name === 'principles-disciple-monorepo') {
34
+ return current;
35
+ }
36
+ } catch {
37
+ // continue upward
38
+ }
39
+ }
40
+ const parent = dirname(current);
41
+ if (parent === current) {
42
+ break;
43
+ }
44
+ current = parent;
45
+ }
46
+
47
+ throw new Error(
48
+ `resolveRepoRoot: cannot find principles-disciple-monorepo from ${thisFileDir}. ` +
49
+ `Set PD_REPO_ROOT env var or run from within the repo. Searched: ${thisFileDir} and 20 ancestors.`
50
+ );
51
+ }
52
+
53
+ /**
54
+ * 把相对仓库根的路径解析为绝对路径。
55
+ * 如果传入的已经是绝对路径,直接返回(若存在)。
56
+ */
57
+ export function resolveFeaturePath(relativePath: string): string {
58
+ if (isAbsolute(relativePath)) {
59
+ return relativePath;
60
+ }
61
+ return resolve(resolveRepoRoot(), relativePath);
62
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, it } from 'vitest';
2
+ import { parseFeature, type ParsedStep } from './gherkin-loader.js';
3
+
4
+ export interface StepContext {
5
+ state: Record<string, unknown>;
6
+ redactable: Record<string, unknown>;
7
+ attachments: Array<{ name: string; body: string }>;
8
+ }
9
+
10
+ export type StepFn = (ctx: StepContext, ...args: unknown[]) => void | Promise<void>;
11
+
12
+ export interface StepMatch {
13
+ fn: StepFn;
14
+ args: unknown[];
15
+ }
16
+
17
+ export interface StepRegistry {
18
+ given(pattern: string | RegExp, fn: StepFn): void;
19
+ when(pattern: string | RegExp, fn: StepFn): void;
20
+ then(pattern: string | RegExp, fn: StepFn): void;
21
+ match(step: ParsedStep): StepMatch | null;
22
+ /** 返回已注册步骤的描述列表,用于 fail-loud 诊断 (rc-3)。 */
23
+ list(): ReadonlyArray<{ keyword: 'Given' | 'When' | 'Then'; pattern: string }>;
24
+ }
25
+
26
+ interface RegisteredStep {
27
+ keyword: 'Given' | 'When' | 'Then';
28
+ pattern: string | RegExp;
29
+ fn: StepFn;
30
+ }
31
+
32
+ /**
33
+ * defineFeature 使用的测试注册钩子。可选注入,便于单元测试验证
34
+ * @disabled 与正常注册路径调用了正确的钩子 (vitest 4.x 不允许在 it 内调 describe)。
35
+ */
36
+ export interface DefineFeatureHooks {
37
+ describe: (name: string, fn: () => void) => void;
38
+ it: (name: string, fn: () => void | Promise<void>) => void;
39
+ itSkip: (name: string, fn: () => void) => void;
40
+ }
41
+
42
+ export function createStepRegistry(): StepRegistry {
43
+ const steps: RegisteredStep[] = [];
44
+
45
+ function register(keyword: RegisteredStep['keyword']) {
46
+ return (pattern: string | RegExp, fn: StepFn) => {
47
+ steps.push({ keyword, pattern, fn });
48
+ };
49
+ }
50
+
51
+ function match(step: ParsedStep): StepMatch | null {
52
+ for (const registered of steps) {
53
+ // keyword 匹配:And/But 匹配任何 registered keyword (gherkin 语义)
54
+ const keywordMatch =
55
+ step.keyword === registered.keyword ||
56
+ step.keyword === 'And' ||
57
+ step.keyword === 'But';
58
+ if (!keywordMatch) continue;
59
+
60
+ if (typeof registered.pattern === 'string') {
61
+ if (registered.pattern === step.text) {
62
+ return { fn: registered.fn, args: [] };
63
+ }
64
+ } else {
65
+ const m = registered.pattern.exec(step.text);
66
+ if (m) {
67
+ return { fn: registered.fn, args: m.slice(1) };
68
+ }
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ return {
75
+ given: register('Given'),
76
+ when: register('When'),
77
+ then: register('Then'),
78
+ match,
79
+ list: () => steps.map((s) => ({ keyword: s.keyword, pattern: String(s.pattern) })),
80
+ };
81
+ }
82
+
83
+ function parseDisabledTag(tags: string[]): { reason: string; owner: string; date: string } | null {
84
+ for (const tag of tags) {
85
+ if (tag.startsWith('@disabled')) {
86
+ // 格式: @disabled(reason="...",owner="...",date="...")
87
+ // rc-3/rc-9: 三字段必填,缺失即 fail loud,不允许静默 skip。
88
+ const reasonMatch = tag.match(/reason="([^"]+)"/);
89
+ const ownerMatch = tag.match(/owner="([^"]+)"/);
90
+ const dateMatch = tag.match(/date="([^"]+)"/);
91
+ if (!reasonMatch || !ownerMatch || !dateMatch) {
92
+ throw new Error(
93
+ `Malformed @disabled tag: reason, owner, date are all required. ` +
94
+ `Expected: @disabled(reason="...",owner="...",date="..."). Got: ${tag}`
95
+ );
96
+ }
97
+ return {
98
+ reason: reasonMatch[1],
99
+ owner: ownerMatch[1],
100
+ date: dateMatch[1],
101
+ };
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function createStepContext(): StepContext {
108
+ return {
109
+ state: {},
110
+ redactable: {},
111
+ attachments: [],
112
+ };
113
+ }
114
+
115
+ /**
116
+ * 把 .feature 文本注册为 vitest describe/it。
117
+ *
118
+ * 关键行为:
119
+ * - @disabled 标签的 scenario 走 it.skip,并打印显式 skip 报告 (rc-9)
120
+ * - step 未匹配时 fail loud (rc-3)
121
+ * - 每个场景独立 StepContext (rc-7)
122
+ *
123
+ * hooks 可选注入,默认使用 vitest 的 describe/it/it.skip。测试时传 spy
124
+ * 可避免在 it 内调 describe 触发 vitest "suite inside test" 错误。
125
+ */
126
+ export function defineFeature(
127
+ featureText: string,
128
+ registry: StepRegistry,
129
+ hooks?: Partial<DefineFeatureHooks>
130
+ ): void {
131
+ const h: DefineFeatureHooks = {
132
+ describe,
133
+ it,
134
+ itSkip: (name, fn) => it.skip(name, fn),
135
+ ...hooks,
136
+ };
137
+ const scenarios = parseFeature(featureText);
138
+
139
+ h.describe(scenarios[0]?.featureName ?? '(feature)', () => {
140
+ for (const scenario of scenarios) {
141
+ const disabledInfo = parseDisabledTag(scenario.scenarioTags);
142
+
143
+ if (disabledInfo) {
144
+ // 显式 skip 报告 (rc-9: no silent fallback)
145
+ h.itSkip(`${scenario.scenarioName} [SKIP: ${disabledInfo.reason}; owner=${disabledInfo.owner}; date=${disabledInfo.date}]`, () => {});
146
+ continue;
147
+ }
148
+
149
+ h.it(scenario.scenarioName, async () => {
150
+ const ctx = createStepContext();
151
+ const allSteps = [...(scenario.background ?? []), ...scenario.steps];
152
+
153
+ for (const step of allSteps) {
154
+ const match = registry.match(step);
155
+ if (!match) {
156
+ // fail loud (rc-3): 列出已注册 steps 帮助诊断
157
+ const registeredList = registry.list().length > 0
158
+ ? registry.list().map((s) => ` ${s.keyword} ${s.pattern}`).join('\n')
159
+ : ' (no steps registered)';
160
+ throw new Error(
161
+ `Step not matched: ${step.keyword} ${step.text}\n` +
162
+ `Scenario: ${scenario.scenarioName}\n` +
163
+ `Registered steps:\n${registeredList}`
164
+ );
165
+ }
166
+
167
+ try {
168
+ await match.fn(ctx, ...match.args);
169
+ } catch (e) {
170
+ // 增强 error 信息:附加 step 文本和场景名
171
+ const enhanced = e instanceof Error ? e : new Error(String(e));
172
+ enhanced.message = `Step failed: ${step.keyword} ${step.text}\nScenario: ${scenario.scenarioName}\n${enhanced.message}`;
173
+ throw enhanced;
174
+ }
175
+ }
176
+ });
177
+ }
178
+ });
179
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PRI-503 regression: admission gate must be checked before intakeService.intake()
3
+ * in every CLI command that calls it. This static source-text scan prevents future
4
+ * commands (or refactors) from re-introducing the bypass that PR #1134 missed in
5
+ * pain-retry.ts and diagnose.ts (ERR-089 sibling-branch defect).
6
+ *
7
+ * Why static scan: runtime mocking of the full CLI command path is heavy and
8
+ * brittle. A source-text check is deterministic, fast, and directly asserts the
9
+ * invariant ("gate call appears before intake call in the same file").
10
+ */
11
+ import { describe, it, expect } from 'vitest';
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+
15
+ const PD_CLI_COMMANDS_DIR = path.resolve(__dirname, '..', '..', 'src', 'commands');
16
+
17
+ function readSource(fileName: string): string {
18
+ return fs.readFileSync(path.join(PD_CLI_COMMANDS_DIR, fileName), 'utf-8');
19
+ }
20
+
21
+ describe('PRI-503: admission gate coverage (static source scan)', () => {
22
+ describe('shared helper exists', () => {
23
+ it('admission-gate.ts exists and exports checkAdmissionGate', () => {
24
+ const content = readSource('admission-gate.ts');
25
+ expect(content).toContain('export function checkAdmissionGate');
26
+ expect(content).toContain('evaluateCandidateAdmissionFromRecord');
27
+ });
28
+ });
29
+
30
+ describe('candidate.ts uses shared helper (no local definition)', () => {
31
+ it('candidate.ts imports checkAdmissionGate from ./admission-gate.js', () => {
32
+ const content = readSource('candidate.ts');
33
+ expect(content).toContain("from './admission-gate.js'");
34
+ expect(content).toContain('checkAdmissionGate');
35
+ });
36
+
37
+ it('candidate.ts no longer defines a local function checkAdmissionGate', () => {
38
+ const content = readSource('candidate.ts');
39
+ // Local definition would be `function checkAdmissionGate(` at top level
40
+ expect(content).not.toMatch(/function\s+checkAdmissionGate\s*\(/);
41
+ });
42
+ });
43
+
44
+ describe('pain-retry.ts checks admission gate before intake', () => {
45
+ it('pain-retry.ts imports checkAdmissionGate from ./admission-gate.js', () => {
46
+ const content = readSource('pain-retry.ts');
47
+ expect(content).toContain("from './admission-gate.js'");
48
+ expect(content).toContain('checkAdmissionGate');
49
+ });
50
+
51
+ it('pain-retry.ts calls checkAdmissionGate before intakeService.intake', () => {
52
+ const content = readSource('pain-retry.ts');
53
+ const gateIdx = content.indexOf('checkAdmissionGate(');
54
+ const intakeIdx = content.indexOf('intakeService.intake(');
55
+ expect(gateIdx).toBeGreaterThan(-1);
56
+ expect(intakeIdx).toBeGreaterThan(-1);
57
+ // Gate MUST appear before intake in source order
58
+ expect(gateIdx).toBeLessThan(intakeIdx);
59
+ });
60
+ });
61
+
62
+ describe('diagnose.ts checks admission gate before intake', () => {
63
+ it('diagnose.ts imports checkAdmissionGate from ./admission-gate.js', () => {
64
+ const content = readSource('diagnose.ts');
65
+ expect(content).toContain("from './admission-gate.js'");
66
+ expect(content).toContain('checkAdmissionGate');
67
+ });
68
+
69
+ it('diagnose.ts calls checkAdmissionGate before intakeService.intake', () => {
70
+ const content = readSource('diagnose.ts');
71
+ const gateIdx = content.indexOf('checkAdmissionGate(');
72
+ const intakeIdx = content.indexOf('intakeService.intake(');
73
+ expect(gateIdx).toBeGreaterThan(-1);
74
+ expect(intakeIdx).toBeGreaterThan(-1);
75
+ // Gate MUST appear before intake in source order
76
+ expect(gateIdx).toBeLessThan(intakeIdx);
77
+ });
78
+ });
79
+ });
@@ -96,6 +96,18 @@ vi.mock('@principles/core/runtime-v2', () => ({
96
96
  }),
97
97
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
98
98
  PrincipleTreeLedgerAdapter: MockPrincipleTreeLedgerAdapter,
99
+ evaluateCandidateAdmissionFromRecord: vi.fn((candidate: { recommendationKind: string; confidence: number | null }) => {
100
+ if (candidate.recommendationKind === 'defer') {
101
+ return { decision: 'deferred', reason: 'recommendation_kind_defer_not_actionable', nextAction: 'review_defer_disposition_manually', evidenceStatus: 'unknown' };
102
+ }
103
+ if (candidate.confidence === null) {
104
+ return { decision: 'needs_evidence', reason: 'confidence_missing_on_candidate_record', nextAction: 're_run_diagnosis_to_populate_confidence_or_manual_review', evidenceStatus: 'unknown' };
105
+ }
106
+ if (candidate.confidence !== undefined && candidate.confidence < 0.5) {
107
+ return { decision: 'needs_evidence', reason: `confidence_below_threshold:${candidate.confidence.toFixed(2)}<0.5`, nextAction: 'provide_additional_evidence_or_manual_review', evidenceStatus: 'unknown' };
108
+ }
109
+ return { decision: 'admitted', reason: 'cli_partial_check_passed_confidence_and_kind', nextAction: 'none', evidenceStatus: 'unknown' };
110
+ }),
99
111
  }));
100
112
 
101
113
  // PRI-443 Phase 5: loadLedger and getLedgerFilePathPublic now imported from
@@ -341,4 +353,69 @@ describe('pd candidate repair', () => {
341
353
  );
342
354
  expect(exitSpy).toHaveBeenCalledWith(1);
343
355
  });
356
+
357
+ // PRI-442 Stage 4: admission gate bypass prevention for repair
358
+ describe('admission gate bypass prevention (PRI-442 Stage 4)', () => {
359
+ it('refuses repair when recommendationKind is defer', async () => {
360
+ mockStateManager.getCandidate.mockResolvedValue({
361
+ candidateId: 'c-defer',
362
+ status: 'consumed',
363
+ artifactId: 'a1',
364
+ title: 'Deferred',
365
+ description: 'desc',
366
+ recommendationKind: 'defer',
367
+ confidence: 0.9,
368
+ });
369
+
370
+ await handleCandidateRepair({ candidateId: 'c-defer', workspace: '/tmp/test-workspace', json: true });
371
+
372
+ expect(consoleLogSpy).toHaveBeenCalledWith(
373
+ expect.stringContaining('"status": "refused"'),
374
+ );
375
+ expect(consoleLogSpy).toHaveBeenCalledWith(
376
+ expect.stringContaining('"admissionDecision": "deferred"'),
377
+ );
378
+ expect(exitSpy).toHaveBeenCalledWith(1);
379
+ });
380
+
381
+ it('refuses repair when confidence is null (rc-3 fail loud)', async () => {
382
+ mockStateManager.getCandidate.mockResolvedValue({
383
+ candidateId: 'c-null',
384
+ status: 'consumed',
385
+ artifactId: 'a1',
386
+ title: 'Null conf',
387
+ description: 'desc',
388
+ recommendationKind: 'principle',
389
+ confidence: null,
390
+ });
391
+
392
+ await handleCandidateRepair({ candidateId: 'c-null', workspace: '/tmp/test-workspace', json: false });
393
+
394
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
395
+ expect.stringContaining('Admission gate refused'),
396
+ );
397
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
398
+ expect.stringContaining('needs_evidence'),
399
+ );
400
+ expect(exitSpy).toHaveBeenCalledWith(1);
401
+ });
402
+
403
+ it('allows repair when candidate passes admission gate', async () => {
404
+ mockStateManager.getCandidate.mockResolvedValue({
405
+ candidateId: 'c-ok',
406
+ status: 'consumed',
407
+ artifactId: 'a1',
408
+ title: 'OK',
409
+ description: 'desc',
410
+ recommendationKind: 'principle',
411
+ confidence: 0.8,
412
+ });
413
+ mockAdapter.existsForCandidate.mockReturnValue(null);
414
+ mockService.intake.mockResolvedValue({ id: 'entry-1', title: 'OK', text: 'text', status: 'probation' });
415
+
416
+ await handleCandidateRepair({ candidateId: 'c-ok', workspace: '/tmp/test-workspace', json: true });
417
+
418
+ expect(mockService.intake).toHaveBeenCalledWith('c-ok');
419
+ });
420
+ });
344
421
  });