@cyning/harness 2.9.0 → 2.10.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.
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { resolveHarnessRoot } from './paths.js';
5
+
6
+ const STATUSES = new Set(['mechanical', 'partial', 'prompt-only']);
7
+ const GAP_STATUSES = new Set(['open', 'closed', 'deferred']);
8
+
9
+ /**
10
+ * 轻量结构校验(对齐 schema/discipline-coverage.v1.schema.json 必填约束)。
11
+ * @returns {{ ok: boolean, errors: string[] }}
12
+ */
13
+ export function validateDisciplineCoverage(data) {
14
+ const errors = [];
15
+ if (!data || typeof data !== 'object') {
16
+ return { ok: false, errors: ['根须为 object'] };
17
+ }
18
+ if (typeof data.version !== 'string' || !data.version.trim()) {
19
+ errors.push('缺 version');
20
+ }
21
+ if (typeof data.as_of_package_version !== 'string' || !data.as_of_package_version.trim()) {
22
+ errors.push('缺 as_of_package_version');
23
+ }
24
+ if (typeof data.scope !== 'string' || !data.scope.trim()) {
25
+ errors.push('缺 scope');
26
+ }
27
+ if (!Array.isArray(data.statements) || data.statements.length === 0) {
28
+ errors.push('缺 statements[] 或为空');
29
+ } else {
30
+ data.statements.forEach((s, i) => {
31
+ if (!s || typeof s.id !== 'string' || !s.id.trim()) {
32
+ errors.push(`statements[${i}] 缺 id`);
33
+ }
34
+ if (!s || typeof s.source !== 'string') {
35
+ errors.push(`statements[${i}] 缺 source`);
36
+ }
37
+ if (!s || typeof s.summary !== 'string') {
38
+ errors.push(`statements[${i}] 缺 summary`);
39
+ }
40
+ if (!s || !STATUSES.has(s.status)) {
41
+ errors.push(`statements[${i}] status 非法: ${s?.status}`);
42
+ }
43
+ });
44
+ }
45
+ if (data.gaps != null) {
46
+ if (!Array.isArray(data.gaps)) {
47
+ errors.push('gaps 须为 array');
48
+ } else {
49
+ data.gaps.forEach((g, i) => {
50
+ if (!g || typeof g.id !== 'string') errors.push(`gaps[${i}] 缺 id`);
51
+ if (!g || typeof g.title !== 'string') errors.push(`gaps[${i}] 缺 title`);
52
+ if (!g || !GAP_STATUSES.has(g.status)) {
53
+ errors.push(`gaps[${i}] status 非法: ${g?.status}`);
54
+ }
55
+ });
56
+ }
57
+ }
58
+ return { ok: errors.length === 0, errors };
59
+ }
60
+
61
+ /**
62
+ * 加载并校验 harness/discipline-coverage.yaml。
63
+ */
64
+ export function loadDisciplineCoverage(options = {}) {
65
+ const harnessRoot = options.harnessRoot ?? resolveHarnessRoot();
66
+ const filePath =
67
+ options.filePath ??
68
+ path.join(harnessRoot, 'harness', 'discipline-coverage.yaml');
69
+
70
+ if (!fs.existsSync(filePath)) {
71
+ const e = new Error(`discipline-coverage.yaml 不存在: ${filePath}`);
72
+ e.exitCode = 1;
73
+ throw e;
74
+ }
75
+
76
+ let data;
77
+ try {
78
+ data = yaml.load(fs.readFileSync(filePath, 'utf8'));
79
+ } catch (err) {
80
+ const e = new Error(`discipline-coverage.yaml 解析失败: ${err.message}`);
81
+ e.exitCode = 1;
82
+ throw e;
83
+ }
84
+
85
+ const v = validateDisciplineCoverage(data);
86
+ if (!v.ok) {
87
+ const e = new Error(
88
+ `discipline-coverage.yaml 校验失败:\n - ${v.errors.join('\n - ')}`,
89
+ );
90
+ e.exitCode = 1;
91
+ e.validationErrors = v.errors;
92
+ throw e;
93
+ }
94
+
95
+ return { data, filePath, harnessRoot };
96
+ }
package/lib/lifecycle.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import yaml from 'js-yaml';
4
4
  import { resolveHarnessRoot } from './paths.js';
5
+ import { findReview, parseHumanGates } from './task-meta.js';
5
6
 
6
7
  const SEVERITIES = new Set(['block', 'warn']);
7
8
 
@@ -119,3 +120,224 @@ export function formatLifecycleShow(data) {
119
120
  }
120
121
  return lines.join('\n').trimEnd();
121
122
  }
123
+
124
+ /** 本波已接线的守卫(其余 unevaluated) */
125
+ const GUARD_ADAPTERS = {
126
+ 'HG-AUDIT-R1': evaluateHgAuditR1,
127
+ reviews_retention: evaluateReviewsRetention,
128
+ };
129
+
130
+ function findRepoRootFromTask(absTask) {
131
+ let cur = path.dirname(absTask);
132
+ while (true) {
133
+ if (path.basename(cur) === 'docs') return path.dirname(cur);
134
+ const parent = path.dirname(cur);
135
+ if (parent === cur) return null;
136
+ cur = parent;
137
+ }
138
+ }
139
+
140
+ function flagsAllow(flags, allowFlag) {
141
+ if (allowFlag === '--allow-no-review') return Boolean(flags.allowNoReview);
142
+ if (allowFlag === '--allow-lint-fail') return Boolean(flags.allowLintFail);
143
+ if (allowFlag === '--allow-no-spec-review') return Boolean(flags.allowNoSpecReview);
144
+ return false;
145
+ }
146
+
147
+ function evaluateHgAuditR1({ taskPath }) {
148
+ const content = fs.readFileSync(taskPath, 'utf8');
149
+ const gates = parseHumanGates(content);
150
+ const audit = gates.find((g) => g.id === 'HG-AUDIT-R1');
151
+ if (!audit) {
152
+ return { status: 'fail', detail: '闸表无 HG-AUDIT-R1 行' };
153
+ }
154
+ if (audit.status === 'approved') {
155
+ return { status: 'pass', detail: 'HG-AUDIT-R1=approved' };
156
+ }
157
+ return {
158
+ status: 'fail',
159
+ detail: `HG-AUDIT-R1=${audit.status}(须 approved)`,
160
+ };
161
+ }
162
+
163
+ function evaluateReviewsRetention({ taskPath, target, flags }) {
164
+ const review = findReview(target, taskPath);
165
+ if (review.found) {
166
+ return { status: 'pass', detail: review.latest };
167
+ }
168
+ if (flags.allowNoReview) {
169
+ return {
170
+ status: 'warn',
171
+ detail: 'missing R<n> review(--allow-no-review)',
172
+ };
173
+ }
174
+ return { status: 'fail', detail: 'missing R<n> review' };
175
+ }
176
+
177
+ /**
178
+ * 转移 dry-run(引擎最小骨架 · v2.10+)。
179
+ * 默认不写盘;无 --apply。
180
+ *
181
+ * @param {{
182
+ * transitionId: string,
183
+ * fromState: string,
184
+ * taskPath?: string,
185
+ * harnessRoot?: string,
186
+ * flags?: { allowNoReview?: boolean, allowLintFail?: boolean, allowNoSpecReview?: boolean },
187
+ * cwd?: string,
188
+ * }} options
189
+ */
190
+ export function dryRunTransition(options = {}) {
191
+ const {
192
+ transitionId,
193
+ fromState,
194
+ taskPath,
195
+ harnessRoot,
196
+ flags = {},
197
+ cwd = process.cwd(),
198
+ } = options;
199
+
200
+ const { data } = loadLifecycle({ harnessRoot });
201
+ const knownIds = data.transitions.map((t) => t.id);
202
+ const transition = data.transitions.find((t) => t.id === transitionId);
203
+
204
+ if (!transition) {
205
+ return {
206
+ engine: 'lifecycle-dry-run',
207
+ lifecycle_doc_version: data.version,
208
+ transition_id: transitionId ?? null,
209
+ from: fromState ?? null,
210
+ to: null,
211
+ hat: null,
212
+ structure_ok: false,
213
+ guards: [],
214
+ blocked: true,
215
+ unevaluated_count: 0,
216
+ detail: `未知 transition: ${transitionId}(已知: ${knownIds.join(', ')})`,
217
+ exitCode: 2,
218
+ };
219
+ }
220
+
221
+ if (!fromState || !transition.from.includes(fromState)) {
222
+ return {
223
+ engine: 'lifecycle-dry-run',
224
+ lifecycle_doc_version: data.version,
225
+ transition_id: transition.id,
226
+ from: fromState ?? null,
227
+ to: transition.to,
228
+ hat: transition.hat ?? null,
229
+ structure_ok: false,
230
+ guards: [],
231
+ blocked: true,
232
+ unevaluated_count: 0,
233
+ detail: `from "${fromState}" ∉ ${JSON.stringify(transition.from)}`,
234
+ exitCode: 2,
235
+ };
236
+ }
237
+
238
+ const absTask = taskPath
239
+ ? path.isAbsolute(taskPath)
240
+ ? taskPath
241
+ : path.resolve(cwd, taskPath)
242
+ : null;
243
+
244
+ if (taskPath && absTask && !fs.existsSync(absTask)) {
245
+ const e = new Error(`--task 不可读: ${taskPath}`);
246
+ e.exitCode = 1;
247
+ throw e;
248
+ }
249
+
250
+ const canEval = Boolean(absTask);
251
+ const target = absTask ? findRepoRootFromTask(absTask) ?? cwd : cwd;
252
+ const guards = [];
253
+
254
+ for (const g of transition.guards) {
255
+ const adapter = GUARD_ADAPTERS[g.id];
256
+ if (!canEval || !adapter) {
257
+ guards.push({
258
+ id: g.id,
259
+ severity: g.severity,
260
+ status: 'unevaluated',
261
+ detail: !canEval ? '无 --task · 未求值' : '本波未接线 adapter',
262
+ allow_flag: g.allow_flag ?? null,
263
+ });
264
+ continue;
265
+ }
266
+
267
+ const evaluated = adapter({
268
+ taskPath: absTask,
269
+ target,
270
+ flags,
271
+ guard: g,
272
+ cwd,
273
+ });
274
+
275
+ let status = evaluated.status;
276
+ let detail = evaluated.detail ?? null;
277
+
278
+ if (
279
+ status === 'fail' &&
280
+ g.allow_flag &&
281
+ flagsAllow(flags, g.allow_flag)
282
+ ) {
283
+ status = 'warn';
284
+ detail = `${detail ?? 'fail'}(${g.allow_flag} 豁免)`;
285
+ }
286
+
287
+ guards.push({
288
+ id: g.id,
289
+ severity: g.severity,
290
+ status,
291
+ detail,
292
+ allow_flag: g.allow_flag ?? null,
293
+ });
294
+ }
295
+
296
+ const unevaluated_count = guards.filter((g) => g.status === 'unevaluated').length;
297
+ const blocked = guards.some(
298
+ (g) => g.severity === 'block' && g.status === 'fail',
299
+ );
300
+
301
+ return {
302
+ engine: 'lifecycle-dry-run',
303
+ lifecycle_doc_version: data.version,
304
+ transition_id: transition.id,
305
+ from: fromState,
306
+ to: transition.to,
307
+ hat: transition.hat ?? null,
308
+ structure_ok: true,
309
+ guards,
310
+ blocked,
311
+ unevaluated_count,
312
+ exitCode: blocked ? 2 : 0,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * 人读 dry-run 报告。
318
+ */
319
+ export function formatLifecycleDryRun(report) {
320
+ const lines = [
321
+ '=== Harness lifecycle dry-run ===',
322
+ `engine: ${report.engine}`,
323
+ `lifecycle: v${report.lifecycle_doc_version}`,
324
+ `transition: ${report.transition_id} · ${report.from} → ${report.to}${report.hat ? ` · hat=${report.hat}` : ''}`,
325
+ `structure_ok: ${report.structure_ok}`,
326
+ `blocked: ${report.blocked}`,
327
+ `unevaluated_count: ${report.unevaluated_count}`,
328
+ ];
329
+ if (report.detail) lines.push(`detail: ${report.detail}`);
330
+ if (report.unevaluated_count > 0) {
331
+ lines.push('WARN: 存在未求值守卫 · unevaluated ≠ pass(勿当作已通过)');
332
+ }
333
+ lines.push('', '## guards');
334
+ for (const g of report.guards ?? []) {
335
+ const d = g.detail ? ` · ${g.detail}` : '';
336
+ lines.push(`- [${g.severity}] ${g.id}: ${g.status}${d}`);
337
+ }
338
+ lines.push('');
339
+ lines.push(
340
+ '注: dry-run 为旁路资格报告 · 不替代 verify / gate-check 作为 30 硬闸 · 非 G7 runner',
341
+ );
342
+ return lines.join('\n');
343
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyning/harness",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "cyning-harness discipline package · init / upgrade / check CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://cyning.dev/schemas/cyning-harness/discipline-coverage.v1.json",
4
+ "title": "cyning-harness Discipline Coverage (mechanization matrix)",
5
+ "description": "Starter 子集机械化率资产 · 随包版本维护 · 非 audit UI(v2.10+)",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["version", "as_of_package_version", "scope", "statements"],
9
+ "properties": {
10
+ "version": { "type": "string", "minLength": 1 },
11
+ "as_of_package_version": { "type": "string", "minLength": 1 },
12
+ "scope": { "type": "string", "minLength": 1 },
13
+ "source_audit": { "type": "string" },
14
+ "statements": {
15
+ "type": "array",
16
+ "minItems": 1,
17
+ "items": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "required": ["id", "source", "summary", "status"],
21
+ "properties": {
22
+ "id": { "type": "string", "minLength": 1 },
23
+ "source": { "type": "string", "minLength": 1 },
24
+ "summary": { "type": "string", "minLength": 1 },
25
+ "status": {
26
+ "type": "string",
27
+ "enum": ["mechanical", "partial", "prompt-only"]
28
+ },
29
+ "mechanism": { "type": ["string", "null"] },
30
+ "gap": { "type": ["string", "null"] },
31
+ "notes": { "type": ["string", "null"] },
32
+ "mechanism_quality": { "type": ["string", "null"] }
33
+ }
34
+ }
35
+ },
36
+ "gaps": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "required": ["id", "title", "status"],
42
+ "properties": {
43
+ "id": { "type": "string", "minLength": 1 },
44
+ "title": { "type": "string", "minLength": 1 },
45
+ "status": {
46
+ "type": "string",
47
+ "enum": ["open", "closed", "deferred"]
48
+ },
49
+ "closed_in": { "type": ["string", "null"] },
50
+ "note": { "type": ["string", "null"] }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://cyning.dev/schemas/cyning-harness/lifecycle.v1.json",
4
4
  "title": "cyning-harness Task Lifecycle (minimal)",
5
- "description": "状态/转移/守卫登记 · 只读真值 · 非引擎(v2.7+)",
5
+ "description": "状态/转移/守卫登记真值 · lifecycle dry-run 引擎消费(yaml ≠ 引擎 · v2.7+ 登记 · v2.10+ dry-run)",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["version", "states", "transitions"],