release-skill 0.9.33 → 0.9.35

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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codebuddy-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.qoder-plugin/plugin.json +1 -1
  7. package/CHANGELOG.md +48 -0
  8. package/INSTALL.md +2 -2
  9. package/INSTALL.zh-CN.md +2 -2
  10. package/README.md +25 -15
  11. package/README.zh-CN.md +11 -15
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +5878 -4056
  14. package/adapters/claude/schemas/.render-manifest.json +8 -4
  15. package/adapters/claude/schemas/release-plan.schema.json +85 -0
  16. package/adapters/claude/schemas/release-project.schema.json +18 -0
  17. package/adapters/claude/schemas/test-intake.schema.json +217 -0
  18. package/adapters/claude/skills/release-finish/SKILL.md +5 -1
  19. package/adapters/claude/skills/release-help/SKILL.md +4 -1
  20. package/adapters/claude/skills/release-prepare/SKILL.md +9 -2
  21. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  22. package/adapters/codex/bin/release-skill.bundle.mjs +5878 -4056
  23. package/adapters/codex/schemas/.render-manifest.json +8 -4
  24. package/adapters/codex/schemas/release-plan.schema.json +85 -0
  25. package/adapters/codex/schemas/release-project.schema.json +18 -0
  26. package/adapters/codex/schemas/test-intake.schema.json +217 -0
  27. package/adapters/codex/skills/release-finish/SKILL.md +5 -1
  28. package/adapters/codex/skills/release-help/SKILL.md +4 -1
  29. package/adapters/codex/skills/release-prepare/SKILL.md +9 -2
  30. package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
  31. package/adapters/cursor/bin/release-skill.bundle.mjs +5878 -4056
  32. package/adapters/cursor/schemas/.render-manifest.json +8 -4
  33. package/adapters/cursor/schemas/release-plan.schema.json +85 -0
  34. package/adapters/cursor/schemas/release-project.schema.json +18 -0
  35. package/adapters/cursor/schemas/test-intake.schema.json +217 -0
  36. package/adapters/cursor/skills/release-finish/SKILL.md +5 -1
  37. package/adapters/cursor/skills/release-help/SKILL.md +4 -1
  38. package/adapters/cursor/skills/release-prepare/SKILL.md +9 -2
  39. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  40. package/adapters/kimi/bin/release-skill.bundle.mjs +5878 -4056
  41. package/adapters/kimi/schemas/.render-manifest.json +8 -4
  42. package/adapters/kimi/schemas/release-plan.schema.json +85 -0
  43. package/adapters/kimi/schemas/release-project.schema.json +18 -0
  44. package/adapters/kimi/schemas/test-intake.schema.json +217 -0
  45. package/adapters/kimi/skills/release-finish/SKILL.md +5 -1
  46. package/adapters/kimi/skills/release-help/SKILL.md +4 -1
  47. package/adapters/kimi/skills/release-prepare/SKILL.md +9 -2
  48. package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
  49. package/adapters/qoder/bin/release-skill.bundle.mjs +5878 -4056
  50. package/adapters/qoder/schemas/.render-manifest.json +8 -4
  51. package/adapters/qoder/schemas/release-plan.schema.json +85 -0
  52. package/adapters/qoder/schemas/release-project.schema.json +18 -0
  53. package/adapters/qoder/schemas/test-intake.schema.json +217 -0
  54. package/adapters/qoder/skills/release-finish/SKILL.md +5 -1
  55. package/adapters/qoder/skills/release-help/SKILL.md +4 -1
  56. package/adapters/qoder/skills/release-prepare/SKILL.md +9 -2
  57. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  58. package/adapters/workbuddy/bin/release-skill.bundle.mjs +5878 -4056
  59. package/adapters/workbuddy/schemas/.render-manifest.json +8 -4
  60. package/adapters/workbuddy/schemas/release-plan.schema.json +85 -0
  61. package/adapters/workbuddy/schemas/release-project.schema.json +18 -0
  62. package/adapters/workbuddy/schemas/test-intake.schema.json +217 -0
  63. package/adapters/workbuddy/skills/release-finish/SKILL.md +5 -1
  64. package/adapters/workbuddy/skills/release-help/SKILL.md +4 -1
  65. package/adapters/workbuddy/skills/release-prepare/SKILL.md +9 -2
  66. package/bin/release-skill-cli.mjs +37 -2
  67. package/bin/release-skill.bundle.mjs +5878 -4056
  68. package/package.json +1 -1
  69. package/platform-manifest.json +4 -4
  70. package/references/.render-manifest.json +6 -6
  71. package/references/01-state-machine.md +12 -1
  72. package/references/02-project-config.md +23 -1
  73. package/references/05-evidence-and-errors.md +30 -0
  74. package/schemas/.render-manifest.json +8 -4
  75. package/schemas/release-plan.schema.json +85 -0
  76. package/schemas/release-project.schema.json +18 -0
  77. package/schemas/test-intake.schema.json +217 -0
  78. package/skills/release-finish/SKILL.md +5 -1
  79. package/skills/release-help/SKILL.md +4 -1
  80. package/skills/release-prepare/SKILL.md +9 -2
  81. package/skills-src/release-finish/SKILL.md +5 -1
  82. package/skills-src/release-help/SKILL.md +4 -1
  83. package/skills-src/release-prepare/SKILL.md +9 -2
  84. package/src/commands/approve.mjs +13 -0
  85. package/src/commands/post-release-local.mjs +33 -15
  86. package/src/commands/prepare.mjs +501 -5
  87. package/src/commands/publish.mjs +2 -0
  88. package/src/commands/reconcile.mjs +2 -0
  89. package/src/commands/route.mjs +32 -7
  90. package/src/commands/ship.mjs +168 -5
  91. package/src/core/config.mjs +5 -0
  92. package/src/core/plan.mjs +3 -1
  93. package/src/core/test-candidate.mjs +649 -0
  94. package/src/core/test-intake.mjs +645 -0
@@ -0,0 +1,645 @@
1
+ /**
2
+ * Explicit test-intake reads, coverage acceptance, and binding re-check.
3
+ *
4
+ * The module does not start a model, a skill, or an arbitrary command.
5
+ * It checks declared files, digests, and the acceptance contract only.
6
+ *
7
+ * @module core/test-intake
8
+ */
9
+
10
+ import { resolve, sep } from 'node:path';
11
+ import Ajv from 'ajv';
12
+ import addFormats from 'ajv-formats';
13
+ import { readFileStrict, HARNESS_ERROR_KINDS } from 'skill-family-harness-node';
14
+ import { readTrustedPackageResourceSync } from './trusted-resource.mjs';
15
+ import { ReleaseError, CONFIG_INVALID, GATE_FAILED } from './errors.mjs';
16
+ import { computePlanDigest } from './plan.mjs';
17
+
18
+ const RELATIVE_FILE = /^(?!\/)(?!.*\\)(?!.*\0)(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\/\/)(?!.*\/$).+$/;
19
+
20
+ const INTAKE_SCHEMA = JSON.parse(readTrustedPackageResourceSync(
21
+ 'schemas/test-intake.schema.json',
22
+ ).toString('utf8'));
23
+
24
+ const ajv = new Ajv({ allErrors: true, strict: false });
25
+ addFormats(ajv);
26
+ ajv.addSchema(INTAKE_SCHEMA);
27
+
28
+ function compileDef(name) {
29
+ return ajv.compile({ $ref: `${INTAKE_SCHEMA.$id}#/definitions/${name}` });
30
+ }
31
+
32
+ const validateRequirements = compileDef('requirementsDocument');
33
+ const validateResult = compileDef('resultDocument');
34
+ const validateAcceptance = compileDef('testAcceptance');
35
+
36
+ const DONE_DISPOSITIONS = new Set(['executed', 'reused', 'not-applicable']);
37
+ const OPEN_DISPOSITIONS = new Set(['failed', 'incomplete', 'uncovered']);
38
+
39
+ /**
40
+ * Drop testAcceptance before the existing plan digest. Record-layer fields
41
+ * stay excluded by computePlanDigest itself.
42
+ *
43
+ * @param {object} plan
44
+ * @returns {string}
45
+ */
46
+ export function subjectDigestOf(plan) {
47
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
48
+ throw new ReleaseError(GATE_FAILED, 'test intake subject is missing', {
49
+ reason: 'TEST_ACCEPTANCE_SUBJECT',
50
+ });
51
+ }
52
+ const { testAcceptance: _testAcceptance, ...rest } = plan;
53
+ return computePlanDigest(rest);
54
+ }
55
+
56
+ /**
57
+ * Lexical project-relative ordinary file path. `foo..bar` stays allowed.
58
+ *
59
+ * @param {string} value
60
+ * @param {string} field
61
+ */
62
+ export function assertIntakeRelativePath(value, field) {
63
+ if (typeof value !== 'string' || !RELATIVE_FILE.test(value)) {
64
+ throw new ReleaseError(
65
+ CONFIG_INVALID,
66
+ `testIntake ${field} is not a project-relative ordinary file path`,
67
+ { reason: 'TEST_INTAKE_PATH', field },
68
+ );
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Collapse `.` segments so `a/./b` and `a/b` compare as the same path.
74
+ * `..` is rejected by assertIntakeRelativePath before this runs.
75
+ *
76
+ * @param {string} value
77
+ * @returns {string}
78
+ */
79
+ export function normalizeIntakePath(value) {
80
+ const parts = [];
81
+ for (const segment of value.split('/')) {
82
+ if (segment === '' || segment === '.') continue;
83
+ parts.push(segment);
84
+ }
85
+ return parts.join('/');
86
+ }
87
+
88
+ /**
89
+ * @param {{ requirementsFile: string, resultFile: string }} testIntake
90
+ */
91
+ export function assertTestIntakePaths(testIntake) {
92
+ assertIntakeRelativePath(testIntake?.requirementsFile, 'requirementsFile');
93
+ assertIntakeRelativePath(testIntake?.resultFile, 'resultFile');
94
+ if (normalizeIntakePath(testIntake.requirementsFile) === normalizeIntakePath(testIntake.resultFile)) {
95
+ throw new ReleaseError(
96
+ CONFIG_INVALID,
97
+ 'testIntake requirementsFile and resultFile must be different paths',
98
+ { reason: 'TEST_INTAKE_PATH', field: 'testIntake' },
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Undeclared intake keeps the old path. A declared intake is production-only.
105
+ *
106
+ * @param {{ config: object, production: boolean }} options
107
+ * @returns {{ declared: boolean }}
108
+ */
109
+ export function assertTestIntakeDeclaration({ config, production }) {
110
+ if (!config?.testIntake) return { declared: false };
111
+ if (production !== true) {
112
+ throw new ReleaseError(
113
+ CONFIG_INVALID,
114
+ 'testIntake is unsupported outside production prepare',
115
+ { reason: 'TEST_INTAKE_NON_PRODUCTION', field: 'testIntake' },
116
+ );
117
+ }
118
+ assertTestIntakePaths(config.testIntake);
119
+ return { declared: true };
120
+ }
121
+
122
+ function schemaFailure(validator, field) {
123
+ const errors = validator.errors ?? [];
124
+ const summary = errors.map((entry) => {
125
+ const extra = entry.params?.additionalProperty ? ` (${entry.params.additionalProperty})` : '';
126
+ return `${entry.instancePath || '/'} ${entry.message}${extra}`;
127
+ }).join('; ');
128
+ throw new ReleaseError(
129
+ CONFIG_INVALID,
130
+ `testIntake ${field} schema validation failed: ${summary}`,
131
+ { field, validationErrors: errors.map((entry) => ({
132
+ instancePath: entry.instancePath,
133
+ message: entry.message,
134
+ params: entry.params,
135
+ })) },
136
+ );
137
+ }
138
+
139
+ function harnessKind(err) {
140
+ return err?.details?.kind ?? err?.cause?.details?.kind ?? null;
141
+ }
142
+
143
+ function throwRead(err, label, { missingCode, missingReason, tamperCode, tamperReason }) {
144
+ const kind = harnessKind(err);
145
+ if (kind === HARNESS_ERROR_KINDS.MISSING_RESOURCE) {
146
+ throw new ReleaseError(missingCode, `${label} is missing`, {
147
+ reason: missingReason,
148
+ field: label,
149
+ cause: kind,
150
+ });
151
+ }
152
+ if (
153
+ kind === HARNESS_ERROR_KINDS.CONTENT_GUARD_REJECTED
154
+ || kind === HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY
155
+ ) {
156
+ throw new ReleaseError(tamperCode, `${label} does not match the declared digest`, {
157
+ reason: tamperReason,
158
+ field: label,
159
+ cause: kind,
160
+ });
161
+ }
162
+ const cause = kind ?? 'unknown';
163
+ throw new ReleaseError(tamperCode, `${label} cannot be read`, {
164
+ reason: cause === 'unknown' ? 'unknown' : cause,
165
+ field: label,
166
+ cause,
167
+ });
168
+ }
169
+
170
+ async function readJsonStrict(root, relPath, label, { missingCode, missingReason }) {
171
+ let receipt;
172
+ try {
173
+ receipt = await readFileStrict(root, relPath, { encoding: 'utf8' });
174
+ } catch (err) {
175
+ throwRead(err, label, {
176
+ missingCode,
177
+ missingReason,
178
+ tamperCode: missingCode,
179
+ tamperReason: missingReason,
180
+ });
181
+ }
182
+ try {
183
+ return { document: JSON.parse(receipt.content), sha256: receipt.sha256, path: relPath };
184
+ } catch {
185
+ throw new ReleaseError(CONFIG_INVALID, `testIntake ${label} is not valid JSON`, {
186
+ field: label,
187
+ });
188
+ }
189
+ }
190
+
191
+ async function verifyFileRef(root, ref, label) {
192
+ try {
193
+ await readFileStrict(root, ref.path, { expectedSha256: ref.sha256 });
194
+ } catch (err) {
195
+ throwRead(err, label, {
196
+ missingCode: GATE_FAILED,
197
+ missingReason: 'TEST_ACCEPTANCE_MISSING',
198
+ tamperCode: GATE_FAILED,
199
+ tamperReason: 'TEST_ACCEPTANCE_TAMPERED',
200
+ });
201
+ }
202
+ }
203
+
204
+ function scopeKey(scope) {
205
+ return scope.kind === 'project' ? 'project' : `unit:${scope.unitId}`;
206
+ }
207
+
208
+ function assertHandoff(item, config, selectedSet) {
209
+ const handoff = item.handoff;
210
+ if (!handoff || typeof handoff.id !== 'string' || typeof handoff.kind !== 'string') {
211
+ throw new ReleaseError(
212
+ CONFIG_INVALID,
213
+ `testIntake requirement "${item.id}" is missing a later-phase handoff`,
214
+ { field: 'handoff', requirementId: item.id },
215
+ );
216
+ }
217
+ const applicable = item.scope?.kind === 'project' || selectedSet.has(item.scope?.unitId);
218
+ let targetUnit = null;
219
+
220
+ if (item.phase === 'consumer-verify') {
221
+ if (handoff.kind !== 'gate') {
222
+ throw new ReleaseError(
223
+ CONFIG_INVALID,
224
+ `testIntake requirement "${item.id}" consumer-verify handoff "${handoff.id}" must be a consumer-verify gate, not kind "${handoff.kind}"`,
225
+ { field: 'handoff', requirementId: item.id, handoffId: handoff.id },
226
+ );
227
+ }
228
+ const gate = (config.verificationGates ?? []).find((entry) => (
229
+ entry?.id === handoff.id && entry.phase === 'consumer-verify'
230
+ ));
231
+ if (!gate) {
232
+ throw new ReleaseError(
233
+ CONFIG_INVALID,
234
+ `testIntake requirement "${item.id}" handoff gate "${handoff.id}" is not a consumer-verify gate`,
235
+ { field: 'handoff', requirementId: item.id, handoffId: handoff.id },
236
+ );
237
+ }
238
+ targetUnit = typeof gate.scope?.unit === 'string' ? gate.scope.unit : null;
239
+ } else if (item.phase === 'postverify') {
240
+ if (handoff.kind !== 'hook') {
241
+ throw new ReleaseError(
242
+ CONFIG_INVALID,
243
+ `testIntake requirement "${item.id}" postverify handoff "${handoff.id}" must be a postVerify hook, not kind "${handoff.kind}"`,
244
+ { field: 'handoff', requirementId: item.id, handoffId: handoff.id },
245
+ );
246
+ }
247
+ let foundUnit = null;
248
+ for (const unit of config.releaseUnits ?? []) {
249
+ for (const hook of unit.postPublish?.hooks ?? []) {
250
+ if (hook?.id !== handoff.id) continue;
251
+ if ((hook.phase ?? 'distribute') !== 'postVerify') continue;
252
+ foundUnit = unit.id;
253
+ }
254
+ }
255
+ if (!foundUnit) {
256
+ throw new ReleaseError(
257
+ CONFIG_INVALID,
258
+ `testIntake requirement "${item.id}" handoff hook "${handoff.id}" is not a postVerify hook`,
259
+ { field: 'handoff', requirementId: item.id, handoffId: handoff.id },
260
+ );
261
+ }
262
+ targetUnit = foundUnit;
263
+ } else if (item.phase === 'finish') {
264
+ if (handoff.kind !== 'finish') {
265
+ throw new ReleaseError(
266
+ CONFIG_INVALID,
267
+ `testIntake requirement "${item.id}" finish handoff "${handoff.id}" must be the finish entry, not kind "${handoff.kind}"`,
268
+ { field: 'handoff', requirementId: item.id, handoffId: handoff.id },
269
+ );
270
+ }
271
+ const finishIds = new Set(['release-finish']);
272
+ if (typeof config.releaseFinish?.setupSkill === 'string' && config.releaseFinish.setupSkill.length > 0) {
273
+ finishIds.add(config.releaseFinish.setupSkill);
274
+ }
275
+ if (!finishIds.has(handoff.id)) {
276
+ throw new ReleaseError(
277
+ CONFIG_INVALID,
278
+ `testIntake requirement "${item.id}" handoff finish "${handoff.id}" is not declared`,
279
+ { field: 'handoff', requirementId: item.id, handoffId: handoff.id },
280
+ );
281
+ }
282
+ } else {
283
+ throw new ReleaseError(
284
+ CONFIG_INVALID,
285
+ `testIntake requirement "${item.id}" has an unknown later phase "${item.phase}"`,
286
+ { field: 'phase', requirementId: item.id },
287
+ );
288
+ }
289
+
290
+ if (item.scope?.kind === 'unit' && targetUnit && targetUnit !== item.scope.unitId) {
291
+ throw new ReleaseError(
292
+ CONFIG_INVALID,
293
+ `testIntake requirement "${item.id}" handoff "${handoff.id}" is outside unit "${item.scope.unitId}"`,
294
+ { field: 'handoff', requirementId: item.id, handoffId: handoff.id, unitId: targetUnit },
295
+ );
296
+ }
297
+ if (applicable && targetUnit && !selectedSet.has(targetUnit)) {
298
+ throw new ReleaseError(
299
+ CONFIG_INVALID,
300
+ `testIntake requirement "${item.id}" handoff "${handoff.id}" is outside the selected unit scope`,
301
+ { field: 'handoff', requirementId: item.id, handoffId: handoff.id, unitId: targetUnit },
302
+ );
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Read and check the requirements attachment. Does not read resultFile.
308
+ *
309
+ * @param {{ root: string, config: object, selectedUnitIds?: string[] }} options
310
+ */
311
+ export async function readTestRequirements({ root, config, selectedUnitIds }) {
312
+ if (!config?.testIntake) {
313
+ throw new ReleaseError(CONFIG_INVALID, 'testIntake is not declared', {
314
+ reason: 'TEST_INTAKE_PATH',
315
+ field: 'testIntake',
316
+ });
317
+ }
318
+ assertTestIntakePaths(config.testIntake);
319
+ const knownUnits = new Set((config.releaseUnits ?? []).map((unit) => unit.id));
320
+ const selected = selectedUnitIds === undefined
321
+ ? [...knownUnits]
322
+ : [...selectedUnitIds];
323
+ for (const unitId of selected) {
324
+ if (!knownUnits.has(unitId)) {
325
+ throw new ReleaseError(
326
+ CONFIG_INVALID,
327
+ `testIntake selected unit "${unitId}" is not configured`,
328
+ { field: 'selectedUnitIds', unitId },
329
+ );
330
+ }
331
+ }
332
+ const selectedSet = new Set(selected);
333
+ const loaded = await readJsonStrict(
334
+ root,
335
+ config.testIntake.requirementsFile,
336
+ 'requirementsFile',
337
+ { missingCode: CONFIG_INVALID, missingReason: 'TEST_INTAKE_PATH' },
338
+ );
339
+ if (!validateRequirements(loaded.document)) schemaFailure(validateRequirements, 'requirementsFile');
340
+
341
+ const seenIds = new Set();
342
+ const seenDeclarations = new Set();
343
+ for (const item of loaded.document.requirements) {
344
+ if (seenIds.has(item.id)) {
345
+ throw new ReleaseError(
346
+ CONFIG_INVALID,
347
+ `testIntake requirementsFile contains duplicate id "${item.id}"`,
348
+ { field: 'requirements', requirementId: item.id },
349
+ );
350
+ }
351
+ seenIds.add(item.id);
352
+ if (item.scope.kind === 'unit' && !knownUnits.has(item.scope.unitId)) {
353
+ throw new ReleaseError(
354
+ CONFIG_INVALID,
355
+ `testIntake requirement "${item.id}" references unknown unit "${item.scope.unitId}"`,
356
+ { field: 'scope', requirementId: item.id, unitId: item.scope.unitId },
357
+ );
358
+ }
359
+ const declarationKey = JSON.stringify({
360
+ scope: scopeKey(item.scope),
361
+ phase: item.phase,
362
+ path: item.criterion.path,
363
+ anchor: item.criterion.anchor ?? null,
364
+ });
365
+ if (seenDeclarations.has(declarationKey)) {
366
+ throw new ReleaseError(
367
+ CONFIG_INVALID,
368
+ `testIntake requirement "${item.id}" conflicts with another declaration`,
369
+ { field: 'requirements', requirementId: item.id },
370
+ );
371
+ }
372
+ seenDeclarations.add(declarationKey);
373
+ if (item.phase === 'pre-freeze') {
374
+ if (item.handoff !== undefined) {
375
+ throw new ReleaseError(
376
+ CONFIG_INVALID,
377
+ `testIntake requirement "${item.id}" must not declare a pre-freeze handoff`,
378
+ { field: 'handoff', requirementId: item.id },
379
+ );
380
+ }
381
+ } else {
382
+ assertHandoff(item, config, selectedSet);
383
+ }
384
+ await verifyFileRef(root, item.source, `requirement ${item.id} source`);
385
+ await verifyFileRef(root, item.criterion, `requirement ${item.id} criterion`);
386
+ }
387
+
388
+ const applicable = loaded.document.requirements.filter((item) => (
389
+ item.scope.kind === 'project' || selectedSet.has(item.scope.unitId)
390
+ ));
391
+ const preFreeze = applicable.filter((item) => item.phase === 'pre-freeze');
392
+ const deferred = applicable.filter((item) => item.phase !== 'pre-freeze');
393
+ return {
394
+ document: loaded.document,
395
+ file: { path: config.testIntake.requirementsFile, sha256: loaded.sha256 },
396
+ resultFile: config.testIntake.resultFile,
397
+ applicable,
398
+ preFreeze,
399
+ deferred,
400
+ selectedUnitIds: selected,
401
+ allRequirements: loaded.document.requirements,
402
+ };
403
+ }
404
+
405
+ function gap(requirementId, reason, extra = {}) {
406
+ return { requirementId, reason, ...extra };
407
+ }
408
+
409
+ async function verifyResultRefs(root, item) {
410
+ await verifyFileRef(root, item.decision, `result ${item.requirementId} decision`);
411
+ await verifyFileRef(root, item.applicability, `result ${item.requirementId} applicability`);
412
+ for (const ref of item.evidence ?? []) {
413
+ await verifyFileRef(root, ref, `result ${item.requirementId} evidence`);
414
+ }
415
+ for (const ref of item.limitations ?? []) {
416
+ await verifyFileRef(root, ref, `result ${item.requirementId} limitation`);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Accept pre-freeze results bound to this prepared object.
422
+ * Structural failures throw. Coverage gaps return accepted:false.
423
+ *
424
+ * @param {{ root: string, requirements: object, prepared: object }} options
425
+ */
426
+ export async function acceptTestResults({ root, requirements, prepared }) {
427
+ const subjectDigest = subjectDigestOf(prepared);
428
+ let loaded;
429
+ try {
430
+ loaded = await readJsonStrict(root, requirements.resultFile, 'resultFile', {
431
+ missingCode: GATE_FAILED,
432
+ missingReason: 'TEST_REQUIREMENT_MISSING',
433
+ });
434
+ } catch (err) {
435
+ if (err instanceof ReleaseError && err.details?.reason === 'TEST_REQUIREMENT_MISSING') {
436
+ return {
437
+ accepted: false,
438
+ gaps: [gap(null, 'TEST_REQUIREMENT_MISSING', { field: 'resultFile' })],
439
+ subjectDigest,
440
+ };
441
+ }
442
+ throw err;
443
+ }
444
+ if (!validateResult(loaded.document)) schemaFailure(validateResult, 'resultFile');
445
+
446
+ const byId = new Map();
447
+ for (const item of loaded.document.results) {
448
+ if (byId.has(item.requirementId)) {
449
+ throw new ReleaseError(
450
+ CONFIG_INVALID,
451
+ `testIntake resultFile contains duplicate requirementId "${item.requirementId}"`,
452
+ { field: 'results', requirementId: item.requirementId },
453
+ );
454
+ }
455
+ byId.set(item.requirementId, item);
456
+ }
457
+ const known = new Set(requirements.allRequirements.map((item) => item.id));
458
+ for (const item of loaded.document.results) {
459
+ if (!known.has(item.requirementId)) {
460
+ throw new ReleaseError(
461
+ CONFIG_INVALID,
462
+ `testIntake result requirementId "${item.requirementId}" is not in the requirements file`,
463
+ { field: 'results', requirementId: item.requirementId },
464
+ );
465
+ }
466
+ }
467
+ for (const item of loaded.document.results) {
468
+ await verifyResultRefs(root, item);
469
+ }
470
+
471
+ const gaps = [];
472
+ if (loaded.document.subjectDigest !== subjectDigest) {
473
+ gaps.push(gap(null, 'TEST_SUBJECT_MISMATCH'));
474
+ }
475
+ if (loaded.document.requirementsSha256 !== requirements.file.sha256) {
476
+ gaps.push(gap(null, 'TEST_REQUIREMENTS_DIGEST_MISMATCH'));
477
+ }
478
+
479
+ const requirementById = new Map(requirements.allRequirements.map((item) => [item.id, item]));
480
+ for (const item of loaded.document.results) {
481
+ const requirement = requirementById.get(item.requirementId);
482
+ if (requirement.phase !== 'pre-freeze' && DONE_DISPOSITIONS.has(item.disposition) && item.verdict === 'passed') {
483
+ gaps.push(gap(item.requirementId, 'TEST_RESULT_EARLY_PHASE', { phase: requirement.phase }));
484
+ }
485
+ }
486
+
487
+ for (const requirement of requirements.preFreeze) {
488
+ const item = byId.get(requirement.id);
489
+ if (!item) {
490
+ gaps.push(gap(requirement.id, 'TEST_REQUIREMENT_MISSING'));
491
+ continue;
492
+ }
493
+ if (OPEN_DISPOSITIONS.has(item.disposition)) {
494
+ gaps.push(gap(requirement.id, 'unknown', { disposition: item.disposition }));
495
+ continue;
496
+ }
497
+ if (!DONE_DISPOSITIONS.has(item.disposition) || item.verdict !== 'passed') {
498
+ gaps.push(gap(requirement.id, 'unknown', {
499
+ disposition: item.disposition,
500
+ verdict: item.verdict,
501
+ }));
502
+ continue;
503
+ }
504
+ const expectedAcceptor = item.disposition === 'not-applicable' ? requirement.owner : requirement.acceptor;
505
+ if (item.acceptedBy !== expectedAcceptor) {
506
+ gaps.push(gap(requirement.id, 'TEST_ACCEPTOR_MISMATCH'));
507
+ continue;
508
+ }
509
+ if (item.disposition !== 'not-applicable' && item.evidence.length === 0) {
510
+ gaps.push(gap(requirement.id, 'TEST_EVIDENCE_EMPTY'));
511
+ }
512
+ }
513
+
514
+ if (gaps.length > 0) {
515
+ return { accepted: false, gaps, subjectDigest };
516
+ }
517
+
518
+ const acceptedRequirementIds = requirements.preFreeze.map((item) => item.id).sort();
519
+ const deferredRequirementIds = requirements.deferred.map((item) => item.id).sort();
520
+ const testAcceptance = {
521
+ formatVersion: 1,
522
+ subjectDigest,
523
+ requirements: { path: requirements.file.path, sha256: requirements.file.sha256 },
524
+ result: { path: requirements.resultFile, sha256: loaded.sha256 },
525
+ acceptedRequirementIds,
526
+ deferredRequirementIds,
527
+ };
528
+ if (!validateAcceptance(testAcceptance)) schemaFailure(validateAcceptance, 'testAcceptance');
529
+ return { accepted: true, gaps: [], subjectDigest, testAcceptance };
530
+ }
531
+
532
+ /**
533
+ * @param {string} planPath
534
+ * @returns {string}
535
+ */
536
+ export function projectRootFromPlanPath(planPath) {
537
+ const abs = resolve(planPath);
538
+ const marker = `${sep}.release-skill${sep}`;
539
+ const idx = abs.lastIndexOf(marker);
540
+ if (idx < 0) {
541
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance plan path is outside a .release-skill authority', {
542
+ reason: 'TEST_ACCEPTANCE_ROOT',
543
+ });
544
+ }
545
+ return abs.slice(0, idx);
546
+ }
547
+
548
+ /**
549
+ * Re-check a formal plan's testAcceptance originals. Absent field is the old path.
550
+ *
551
+ * @param {{ root: string, plan: object }} options
552
+ */
553
+ export async function verifyTestAcceptance({ root, plan }) {
554
+ if (!plan?.testAcceptance) return { applicable: false };
555
+ if (typeof root !== 'string' || root.length === 0) {
556
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance root is missing', {
557
+ reason: 'TEST_ACCEPTANCE_ROOT',
558
+ });
559
+ }
560
+ if (!validateAcceptance(plan.testAcceptance)) schemaFailure(validateAcceptance, 'testAcceptance');
561
+ const subjectDigest = subjectDigestOf(plan);
562
+ if (plan.testAcceptance.subjectDigest !== subjectDigest) {
563
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance subjectDigest does not match the plan object', {
564
+ reason: 'TEST_ACCEPTANCE_SUBJECT',
565
+ });
566
+ }
567
+ const requirementsRel = plan.testAcceptance.requirements.path;
568
+ const resultRel = plan.testAcceptance.result.path;
569
+ let requirementsReceipt;
570
+ let resultReceipt;
571
+ try {
572
+ requirementsReceipt = await readFileStrict(root, requirementsRel, {
573
+ encoding: 'utf8',
574
+ expectedSha256: plan.testAcceptance.requirements.sha256,
575
+ });
576
+ } catch (err) {
577
+ throwRead(err, 'testAcceptance requirements', {
578
+ missingCode: GATE_FAILED,
579
+ missingReason: 'TEST_ACCEPTANCE_MISSING',
580
+ tamperCode: GATE_FAILED,
581
+ tamperReason: 'TEST_ACCEPTANCE_TAMPERED',
582
+ });
583
+ }
584
+ try {
585
+ resultReceipt = await readFileStrict(root, resultRel, {
586
+ encoding: 'utf8',
587
+ expectedSha256: plan.testAcceptance.result.sha256,
588
+ });
589
+ } catch (err) {
590
+ throwRead(err, 'testAcceptance result', {
591
+ missingCode: GATE_FAILED,
592
+ missingReason: 'TEST_ACCEPTANCE_MISSING',
593
+ tamperCode: GATE_FAILED,
594
+ tamperReason: 'TEST_ACCEPTANCE_TAMPERED',
595
+ });
596
+ }
597
+ let requirementsDoc;
598
+ let resultDoc;
599
+ try {
600
+ requirementsDoc = JSON.parse(requirementsReceipt.content);
601
+ resultDoc = JSON.parse(resultReceipt.content);
602
+ } catch {
603
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance original is not valid JSON', {
604
+ reason: 'TEST_ACCEPTANCE_TAMPERED',
605
+ });
606
+ }
607
+ if (!validateRequirements(requirementsDoc)) {
608
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance requirements original no longer matches the intake schema', {
609
+ reason: 'TEST_ACCEPTANCE_TAMPERED',
610
+ });
611
+ }
612
+ if (!validateResult(resultDoc)) {
613
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance result original no longer matches the intake schema', {
614
+ reason: 'TEST_ACCEPTANCE_TAMPERED',
615
+ });
616
+ }
617
+ if (resultDoc.subjectDigest !== subjectDigest) {
618
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance result subjectDigest does not match the plan object', {
619
+ reason: 'TEST_SUBJECT_MISMATCH',
620
+ });
621
+ }
622
+ if (resultDoc.requirementsSha256 !== requirementsReceipt.sha256) {
623
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance result is not bound to the requirements original', {
624
+ reason: 'TEST_REQUIREMENTS_DIGEST_MISMATCH',
625
+ });
626
+ }
627
+ const accepted = [...plan.testAcceptance.acceptedRequirementIds].sort();
628
+ const deferred = [...plan.testAcceptance.deferredRequirementIds].sort();
629
+ if (
630
+ accepted.join('\0') !== plan.testAcceptance.acceptedRequirementIds.join('\0')
631
+ || deferred.join('\0') !== plan.testAcceptance.deferredRequirementIds.join('\0')
632
+ ) {
633
+ throw new ReleaseError(GATE_FAILED, 'testAcceptance requirement ids are not sorted', {
634
+ reason: 'TEST_ACCEPTANCE_SUBJECT',
635
+ });
636
+ }
637
+ for (const item of requirementsDoc.requirements) {
638
+ await verifyFileRef(root, item.source, `requirement ${item.id} source`);
639
+ await verifyFileRef(root, item.criterion, `requirement ${item.id} criterion`);
640
+ }
641
+ for (const item of resultDoc.results) {
642
+ await verifyResultRefs(root, item);
643
+ }
644
+ return { applicable: true, testAcceptance: plan.testAcceptance };
645
+ }