wendkeep 0.78.0 → 0.80.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.
- package/CHANGELOG.md +67 -0
- package/README.en.md +58 -3
- package/README.md +58 -3
- package/docs/en/commands/changes-and-verification.md +116 -1
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/sessions-and-import.md +6 -0
- package/docs/en/commands/verify.md +54 -0
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +115 -1
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/sessions-and-import.md +7 -0
- package/docs/pt-BR/commands/verify.md +53 -0
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +220 -123
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/session-stop.mjs +40 -1
- package/hooks/spec-core.mjs +93 -29
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +7 -0
- package/packages/vault/src/memory-handoff.mjs +15 -0
- package/schema/artifact-manifest-v1.schema.json +35 -0
- package/schema/handoff-contract-v1.schema.json +37 -0
- package/schema/task-contract-v1.schema.json +57 -0
- package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
- package/src/archive-operation-lock.mjs +235 -0
- package/src/change.mjs +1780 -79
- package/src/delivery.mjs +724 -67
- package/src/memory.mjs +2 -1
- package/src/provenance-gate.mjs +575 -0
- package/src/provenance-sources.mjs +547 -0
- package/src/receipt-ledger.mjs +841 -0
- package/src/release-provenance.mjs +48 -0
- package/src/task-contracts.mjs +510 -0
- package/src/task-leases.mjs +105 -0
- package/src/task.mjs +115 -0
- package/src/verify.mjs +32 -0
- package/src/worktree-cleanup.mjs +1733 -118
- package/src/worktree.mjs +94 -5
package/src/memory.mjs
CHANGED
|
@@ -1889,7 +1889,7 @@ function legacyCheckpointMigration(vault, sessionId, entry, authority, fullRepla
|
|
|
1889
1889
|
}
|
|
1890
1890
|
|
|
1891
1891
|
export function migrateLegacyMemoryCheckpoints(vault, {
|
|
1892
|
-
now = new Date().toISOString(), memoryLock = {},
|
|
1892
|
+
now = new Date().toISOString(), memoryLock = {}, beforeRegistryMutation,
|
|
1893
1893
|
} = {}) {
|
|
1894
1894
|
const expectedAuthority = readMemoryAuthority(vault);
|
|
1895
1895
|
const outcome = withMemoryLock(vault, () => {
|
|
@@ -1910,6 +1910,7 @@ export function migrateLegacyMemoryCheckpoints(vault, {
|
|
|
1910
1910
|
status: 'unchanged', migrated: 0, sessions: [], backupPath: null,
|
|
1911
1911
|
};
|
|
1912
1912
|
|
|
1913
|
+
if (beforeRegistryMutation) beforeRegistryMutation();
|
|
1913
1914
|
let backupPath = null;
|
|
1914
1915
|
let backupCreated = false;
|
|
1915
1916
|
try {
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { evaluateEvidenceBinding } from '../packages/vault/src/evidence-envelope.mjs';
|
|
2
|
+
|
|
3
|
+
export const PROVENANCE_STATES = Object.freeze([
|
|
4
|
+
'verified',
|
|
5
|
+
'reported',
|
|
6
|
+
'legacy-unbound',
|
|
7
|
+
'stale',
|
|
8
|
+
'conflict',
|
|
9
|
+
'unproven',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const STATE_PRECEDENCE = Object.freeze({
|
|
13
|
+
verified: 0,
|
|
14
|
+
reported: 1,
|
|
15
|
+
'unproven': 2,
|
|
16
|
+
'legacy-unbound': 3,
|
|
17
|
+
stale: 4,
|
|
18
|
+
conflict: 5,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const BINDING_KEYS = [
|
|
22
|
+
'project_id', 'repository_id', 'worktree_id', 'work_session_id', 'change_slug',
|
|
23
|
+
'branch', 'head_sha', 'base_sha', 'index_tree_sha', 'worktree_digest',
|
|
24
|
+
'dirty', 'tasks_sha256', 'effective_spec_sha256', 'sensor_config_sha256',
|
|
25
|
+
'package_name', 'package_version', 'target_commit', 'target_ref', 'tag',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const REASON_CODES = Object.freeze({
|
|
29
|
+
missingEvidence: 'PROV_EVIDENCE_MISSING',
|
|
30
|
+
legacyEvidence: 'PROV_EVIDENCE_LEGACY',
|
|
31
|
+
invalidEvidence: 'PROV_EVIDENCE_INVALID',
|
|
32
|
+
stale: 'WENDKEEP_PROVENANCE_STALE',
|
|
33
|
+
context: 'WENDKEEP_PROVENANCE_CONTEXT_MISMATCH',
|
|
34
|
+
binding: 'WENDKEEP_PROVENANCE_BINDING_CONFLICT',
|
|
35
|
+
missingObservation: 'PROV_RECEIPT_OBSERVATION_MISSING',
|
|
36
|
+
invalidReceipt: 'PROV_RECEIPT_INVALID',
|
|
37
|
+
legacyReceipt: 'PROV_RECEIPT_LEGACY',
|
|
38
|
+
receiptConflict: 'PROV_RECEIPT_CONFLICT',
|
|
39
|
+
receiptUnbound: 'PROV_RECEIPT_UNBOUND',
|
|
40
|
+
releaseConflict: 'PROV_RELEASE_CHAIN_CONFLICT',
|
|
41
|
+
releaseMissing: 'PROV_RELEASE_CHAIN_UNPROVEN',
|
|
42
|
+
releaseReported: 'PROV_RELEASE_SOURCE_REPORTED',
|
|
43
|
+
requiredMissing: 'PROV_REQUIRED_ASSESSMENT_MISSING',
|
|
44
|
+
invalidAssessmentState: 'PROV_ASSESSMENT_STATE_INVALID',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function asArray(value) {
|
|
48
|
+
return Array.isArray(value) ? value : value == null ? [] : [value];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function unique(values) {
|
|
52
|
+
return [...new Set(asArray(values).filter((value) => value != null && String(value) !== ''))];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function firstDefined(...values) {
|
|
56
|
+
return values.find((value) => value !== undefined && value !== null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function plain(value) {
|
|
60
|
+
if (value == null || typeof value !== 'object') return value;
|
|
61
|
+
if (Array.isArray(value)) return value.map(plain);
|
|
62
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)]));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeContext(value = {}) {
|
|
66
|
+
const context = value && typeof value === 'object' ? value : {};
|
|
67
|
+
return {
|
|
68
|
+
...(context.context && typeof context.context === 'object' ? context.context : {}),
|
|
69
|
+
...context,
|
|
70
|
+
...(context.identity && typeof context.identity === 'object' ? context.identity : {}),
|
|
71
|
+
...(context.snapshot && typeof context.snapshot === 'object' ? context.snapshot : {}),
|
|
72
|
+
...(context.subject && typeof context.subject === 'object' ? context.subject : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractBinding(value = {}) {
|
|
77
|
+
const source = normalizeContext(value);
|
|
78
|
+
const nested = normalizeContext(source.context);
|
|
79
|
+
const result = {};
|
|
80
|
+
for (const key of BINDING_KEYS) {
|
|
81
|
+
const candidate = firstDefined(source[key], nested[key]);
|
|
82
|
+
if (candidate !== undefined && candidate !== null) result[key] = candidate;
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function bindingMismatches(actual, expected) {
|
|
88
|
+
const left = extractBinding(actual);
|
|
89
|
+
const right = extractBinding(expected);
|
|
90
|
+
const mismatches = [];
|
|
91
|
+
for (const key of BINDING_KEYS) {
|
|
92
|
+
if (right[key] !== undefined && left[key] !== undefined && left[key] !== right[key]) {
|
|
93
|
+
mismatches.push(`${key} mismatch`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return mismatches;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assessment({
|
|
100
|
+
kind,
|
|
101
|
+
state,
|
|
102
|
+
reasonCodes = [],
|
|
103
|
+
diagnostics = [],
|
|
104
|
+
repair,
|
|
105
|
+
receipts = [],
|
|
106
|
+
...extra
|
|
107
|
+
} = {}) {
|
|
108
|
+
const result = {
|
|
109
|
+
...plain(extra),
|
|
110
|
+
...(kind ? { kind } : {}),
|
|
111
|
+
ok: state === 'verified',
|
|
112
|
+
state: PROVENANCE_STATES.includes(state) ? state : 'unproven',
|
|
113
|
+
reasonCodes: unique(reasonCodes),
|
|
114
|
+
diagnostics: plain(diagnostics),
|
|
115
|
+
repair: repair || null,
|
|
116
|
+
receipts: plain(receipts),
|
|
117
|
+
};
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function diagnosticsFor(kind, state, expected, observed, reasons) {
|
|
122
|
+
return [{
|
|
123
|
+
kind,
|
|
124
|
+
state,
|
|
125
|
+
blocker: reasons[0] || null,
|
|
126
|
+
expected: sanitize(expected),
|
|
127
|
+
observed: sanitize(observed),
|
|
128
|
+
}];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sanitize(value) {
|
|
132
|
+
if (value == null) return value;
|
|
133
|
+
if (typeof value === 'string') {
|
|
134
|
+
const redacted = value
|
|
135
|
+
.replace(/\b(authorization\s*:\s*bearer)\s+[^\s,;]+/gi, '$1 [redacted]')
|
|
136
|
+
.replace(/\b(token|secret|password|api[_-]?key)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
137
|
+
.replace(/[A-Za-z]:[\\/][^\s"'`,;)}\]]+/g, '[redacted-path]')
|
|
138
|
+
.replace(/(^|[\s("'=])\/(?:[^/\s]+\/)+[^\s"'`,;)}\]]*/g, '$1[redacted-path]');
|
|
139
|
+
return redacted.length > 200 ? `${redacted.slice(0, 197)}...` : redacted;
|
|
140
|
+
}
|
|
141
|
+
if (typeof value !== 'object') return value;
|
|
142
|
+
if (Array.isArray(value)) return value.map(sanitize);
|
|
143
|
+
const output = {};
|
|
144
|
+
for (const [key, item] of Object.entries(value)) {
|
|
145
|
+
if (/token|secret|password|authorization|private|content|output|path/i.test(key)) continue;
|
|
146
|
+
output[key] = sanitize(item);
|
|
147
|
+
}
|
|
148
|
+
return output;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fromBindingResult(kind, binding, expected, evidence) {
|
|
152
|
+
const reasons = binding?.reasons || [];
|
|
153
|
+
if (binding?.state === 'bound') {
|
|
154
|
+
return assessment({ kind, state: 'verified', diagnostics: diagnosticsFor(kind, 'verified', expected, evidence, []) });
|
|
155
|
+
}
|
|
156
|
+
if (binding?.state === 'context-mismatch') {
|
|
157
|
+
return assessment({
|
|
158
|
+
kind, state: 'conflict', reasonCodes: [REASON_CODES.context],
|
|
159
|
+
diagnostics: diagnosticsFor(kind, 'conflict', expected, evidence, reasons),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (binding?.state === 'stale') {
|
|
163
|
+
return assessment({
|
|
164
|
+
kind, state: 'stale', reasonCodes: [REASON_CODES.stale],
|
|
165
|
+
diagnostics: diagnosticsFor(kind, 'stale', expected, evidence, reasons),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (binding?.state === 'legacy-unbound') {
|
|
169
|
+
return assessment({
|
|
170
|
+
kind, state: 'legacy-unbound', reasonCodes: [REASON_CODES.legacyEvidence],
|
|
171
|
+
diagnostics: diagnosticsFor(kind, 'legacy-unbound', expected, evidence, reasons),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return assessment({
|
|
175
|
+
kind, state: 'unproven', reasonCodes: [REASON_CODES.missingEvidence],
|
|
176
|
+
diagnostics: diagnosticsFor(kind, 'unproven', expected, evidence, reasons),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function compareProof(kind, proof, expected, code = REASON_CODES.binding) {
|
|
181
|
+
if (!proof) return [];
|
|
182
|
+
const mismatches = bindingMismatches(proof, expected);
|
|
183
|
+
return mismatches.length ? [code, ...mismatches] : [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Classify a v2 evidence envelope against the subject resolved for this operation.
|
|
188
|
+
* This function is deliberately pure: `verification` and `verdict` are already captured
|
|
189
|
+
* observations, never callbacks or paths to read.
|
|
190
|
+
*/
|
|
191
|
+
export function classifyEvidenceEnvelope({ evidence, expected = {}, verification, verdict } = {}) {
|
|
192
|
+
const kind = 'envelope';
|
|
193
|
+
if (!evidence) {
|
|
194
|
+
return assessment({
|
|
195
|
+
kind, state: 'unproven', reasonCodes: [REASON_CODES.missingEvidence],
|
|
196
|
+
diagnostics: diagnosticsFor(kind, 'unproven', expected, null, ['evidence missing']),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const normalizedExpected = normalizeContext(expected);
|
|
200
|
+
const binding = evaluateEvidenceBinding(evidence, {
|
|
201
|
+
...normalizedExpected,
|
|
202
|
+
identity: expected.identity || normalizedExpected,
|
|
203
|
+
snapshot: expected.snapshot || normalizedExpected,
|
|
204
|
+
});
|
|
205
|
+
const result = fromBindingResult(kind, binding, expected, evidence);
|
|
206
|
+
const proofMismatches = [
|
|
207
|
+
...compareProof(kind, verification, { ...normalizedExpected, ...extractBinding(evidence) }),
|
|
208
|
+
...compareProof(kind, verdict, { ...normalizedExpected, ...extractBinding(evidence) }),
|
|
209
|
+
];
|
|
210
|
+
if (proofMismatches.length) {
|
|
211
|
+
return assessment({
|
|
212
|
+
...result,
|
|
213
|
+
state: 'conflict',
|
|
214
|
+
reasonCodes: unique([...result.reasonCodes, REASON_CODES.binding]),
|
|
215
|
+
diagnostics: [
|
|
216
|
+
...(result.diagnostics || []),
|
|
217
|
+
...diagnosticsFor(kind, 'conflict', expected, { verification, verdict }, proofMismatches),
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function observationStatus(observation) {
|
|
225
|
+
if (!observation) return 'missing';
|
|
226
|
+
if (observation.status === 'offline' || observation.state === 'offline' || observation.available === false) return 'reported';
|
|
227
|
+
if (observation.status === 'conflict' || observation.state === 'conflict') return 'conflict';
|
|
228
|
+
if (observation.status === 'stale' || observation.state === 'stale') return 'stale';
|
|
229
|
+
if (observation.status === 'verified' || observation.state === 'verified') return 'verified';
|
|
230
|
+
return 'reported';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Classify an operational receipt against an observed subject. */
|
|
234
|
+
export function classifyReceipt({ receipt, observation, subject = {} } = {}) {
|
|
235
|
+
const kind = receipt?.kind || receipt?.operation || 'receipt';
|
|
236
|
+
if (!receipt) {
|
|
237
|
+
return assessment({
|
|
238
|
+
kind, state: 'unproven', reasonCodes: [REASON_CODES.invalidReceipt],
|
|
239
|
+
diagnostics: diagnosticsFor(kind, 'unproven', subject, null, ['receipt missing']),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (receipt.schema_version !== 2) {
|
|
243
|
+
return assessment({
|
|
244
|
+
kind, state: 'legacy-unbound', reasonCodes: [REASON_CODES.legacyReceipt],
|
|
245
|
+
diagnostics: diagnosticsFor(kind, 'legacy-unbound', subject, receipt, ['receipt schema is not v2']),
|
|
246
|
+
receipts: [receipt],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (!receipt.receipt_id || !receipt.kind) {
|
|
250
|
+
return assessment({
|
|
251
|
+
kind, state: 'unproven', reasonCodes: [REASON_CODES.invalidReceipt],
|
|
252
|
+
diagnostics: diagnosticsFor(kind, 'unproven', subject, receipt, ['receipt_id or kind missing']),
|
|
253
|
+
receipts: [receipt],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const receiptMismatches = bindingMismatches(receipt, subject);
|
|
257
|
+
if (receiptMismatches.length) {
|
|
258
|
+
return assessment({
|
|
259
|
+
kind, state: 'conflict', reasonCodes: [REASON_CODES.receiptConflict],
|
|
260
|
+
diagnostics: diagnosticsFor(kind, 'conflict', subject, receipt, receiptMismatches),
|
|
261
|
+
receipts: [receipt],
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const subjectBinding = extractBinding(subject);
|
|
265
|
+
const receiptBinding = extractBinding(receipt);
|
|
266
|
+
const unbound = Object.keys(subjectBinding).filter((key) => receiptBinding[key] === undefined);
|
|
267
|
+
if (unbound.length) {
|
|
268
|
+
return assessment({
|
|
269
|
+
kind, state: 'reported', reasonCodes: [REASON_CODES.receiptUnbound],
|
|
270
|
+
diagnostics: diagnosticsFor(kind, 'reported', subject, receipt, [`receipt binding missing: ${unbound.join(', ')}`]),
|
|
271
|
+
receipts: [receipt],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const status = observationStatus(observation);
|
|
275
|
+
if (status === 'conflict') {
|
|
276
|
+
return assessment({
|
|
277
|
+
kind, state: 'conflict', reasonCodes: [REASON_CODES.receiptConflict],
|
|
278
|
+
diagnostics: diagnosticsFor(kind, 'conflict', subject, observation, ['receipt observation conflicts']),
|
|
279
|
+
receipts: [receipt],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (status === 'stale') {
|
|
283
|
+
return assessment({
|
|
284
|
+
kind, state: 'stale', reasonCodes: [REASON_CODES.stale],
|
|
285
|
+
diagnostics: diagnosticsFor(kind, 'stale', subject, observation, ['receipt observation is stale']),
|
|
286
|
+
receipts: [receipt],
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (status === 'missing') {
|
|
290
|
+
return assessment({
|
|
291
|
+
kind, state: 'reported', reasonCodes: [REASON_CODES.missingObservation],
|
|
292
|
+
diagnostics: diagnosticsFor(kind, 'reported', subject, receipt, ['receipt claim has no observation']),
|
|
293
|
+
receipts: [receipt],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (status === 'reported') {
|
|
297
|
+
return assessment({
|
|
298
|
+
kind, state: 'reported', reasonCodes: ['PROV_RECEIPT_REPORTED'],
|
|
299
|
+
diagnostics: diagnosticsFor(kind, 'reported', subject, observation, ['observation did not verify binding']),
|
|
300
|
+
receipts: [receipt],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const observationBinding = extractBinding(observation);
|
|
304
|
+
const observationMismatches = bindingMismatches(observation, subject);
|
|
305
|
+
if (observation.receipt_id && observation.receipt_id !== receipt.receipt_id) observationMismatches.push('receipt_id mismatch');
|
|
306
|
+
if (observationMismatches.length) {
|
|
307
|
+
return assessment({
|
|
308
|
+
kind, state: 'conflict', reasonCodes: [REASON_CODES.receiptConflict],
|
|
309
|
+
diagnostics: diagnosticsFor(kind, 'conflict', subject, observation, observationMismatches),
|
|
310
|
+
receipts: [receipt],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const missingObservationBinding = Object.keys(subjectBinding)
|
|
314
|
+
.filter((key) => observationBinding[key] === undefined);
|
|
315
|
+
if (!observation.receipt_id || missingObservationBinding.length) {
|
|
316
|
+
return assessment({
|
|
317
|
+
kind, state: 'reported', reasonCodes: [REASON_CODES.missingObservation],
|
|
318
|
+
diagnostics: diagnosticsFor(kind, 'reported', subject, observation, [
|
|
319
|
+
!observation.receipt_id
|
|
320
|
+
? 'receipt observation missing receipt_id'
|
|
321
|
+
: `receipt observation binding missing: ${missingObservationBinding.join(', ')}`,
|
|
322
|
+
]),
|
|
323
|
+
receipts: [receipt],
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return assessment({
|
|
327
|
+
kind, state: 'verified', diagnostics: diagnosticsFor(kind, 'verified', subject, observation, []),
|
|
328
|
+
receipts: [receipt],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function chainPart(chain, ...keys) {
|
|
333
|
+
for (const key of keys) {
|
|
334
|
+
if (chain?.[key] !== undefined && chain?.[key] !== null) return chain[key];
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function chainValue(part, ...keys) {
|
|
340
|
+
if (part == null) return undefined;
|
|
341
|
+
if (typeof part !== 'object') return part;
|
|
342
|
+
return firstDefined(...keys.map((key) => part[key]));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Evaluate the complete commit/tag/package/artifact/CI/NPM/Release chain. */
|
|
346
|
+
export function evaluateReleaseChain(input = {}) {
|
|
347
|
+
const chain = input.chain || input;
|
|
348
|
+
const expected = normalizeContext(input.context || input.expected || {});
|
|
349
|
+
const commit = chainPart(chain, 'commit', 'target', 'target_commit', 'commit_sha', 'head_sha');
|
|
350
|
+
const tag = chainPart(chain, 'tag') || (chain.tag_name ? {
|
|
351
|
+
name: chain.tag_name,
|
|
352
|
+
commit: firstDefined(chain.tag_commit, chain.commit_sha, chain.head_sha),
|
|
353
|
+
} : undefined);
|
|
354
|
+
const pkg = chainPart(chain, 'package', 'pkg') || (chain.package_name || chain.package_version || chain.version ? {
|
|
355
|
+
name: chain.package_name,
|
|
356
|
+
version: firstDefined(chain.package_version, chain.version),
|
|
357
|
+
commit: firstDefined(chain.package_commit, chain.commit_sha, chain.head_sha),
|
|
358
|
+
} : undefined);
|
|
359
|
+
const artifact = chainPart(chain, 'artifact', 'tarball') || (chain.artifact_integrity ? {
|
|
360
|
+
integrity: chain.artifact_integrity,
|
|
361
|
+
commit: firstDefined(chain.artifact_commit, chain.commit_sha, chain.head_sha),
|
|
362
|
+
} : undefined);
|
|
363
|
+
const npm = chainPart(chain, 'npm', 'registry') || (chain.npm_integrity ? {
|
|
364
|
+
name: chain.npm_name || chain.package_name,
|
|
365
|
+
version: chain.npm_version || firstDefined(chain.package_version, chain.version),
|
|
366
|
+
integrity: chain.npm_integrity,
|
|
367
|
+
repository: chain.npm_repository,
|
|
368
|
+
} : undefined);
|
|
369
|
+
const ci = chainPart(chain, 'ci', 'workflow') || (chain.ci_commit || chain.ci_status ? {
|
|
370
|
+
commit: firstDefined(chain.ci_commit, chain.commit_sha, chain.head_sha),
|
|
371
|
+
status: chain.ci_status,
|
|
372
|
+
repository: chain.ci_repository,
|
|
373
|
+
} : undefined);
|
|
374
|
+
const release = chainPart(chain, 'release', 'github_release') || (chain.release_tag || chain.release_version ? {
|
|
375
|
+
tag: chain.release_tag || chain.tag_name,
|
|
376
|
+
version: chain.release_version || firstDefined(chain.package_version, chain.version),
|
|
377
|
+
repository: chain.release_repository,
|
|
378
|
+
} : undefined);
|
|
379
|
+
const targetCommit = firstDefined(expected.target_commit, expected.head_sha);
|
|
380
|
+
const packageName = expected.package_name;
|
|
381
|
+
const packageVersion = firstDefined(expected.package_version, expected.version);
|
|
382
|
+
const tagName = expected.tag;
|
|
383
|
+
const expectedRepository = firstDefined(expected.repository, expected.repository_full_name);
|
|
384
|
+
const expectedMissing = [
|
|
385
|
+
['expected target commit', targetCommit],
|
|
386
|
+
['expected package name', packageName],
|
|
387
|
+
['expected package version', packageVersion],
|
|
388
|
+
['expected tag', tagName],
|
|
389
|
+
['expected repository', expectedRepository],
|
|
390
|
+
].filter(([, value]) => value == null || value === '').map(([label]) => label);
|
|
391
|
+
if (expectedMissing.length) {
|
|
392
|
+
return assessment({
|
|
393
|
+
kind: 'release-chain', state: 'unproven', reasonCodes: [REASON_CODES.releaseMissing],
|
|
394
|
+
diagnostics: diagnosticsFor('release-chain', 'unproven', expected, chain, [`missing ${expectedMissing.join(', ')}`]),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
const sourceReported = [npm, ci, release, artifact].some((part) => {
|
|
398
|
+
const status = String(chainValue(part, 'status', 'state', 'availability') || '').toLowerCase();
|
|
399
|
+
return chainValue(part, 'available') === false
|
|
400
|
+
|| ['offline', 'unavailable', 'timeout', 'unknown'].includes(status);
|
|
401
|
+
});
|
|
402
|
+
if (sourceReported) {
|
|
403
|
+
return assessment({
|
|
404
|
+
kind: 'release-chain', state: 'reported', reasonCodes: [REASON_CODES.releaseReported],
|
|
405
|
+
diagnostics: diagnosticsFor('release-chain', 'reported', expected, chain, ['external source unavailable']),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
const missing = [];
|
|
409
|
+
for (const [label, value] of [
|
|
410
|
+
['commit', commit], ['tag', tag], ['package', pkg], ['artifact', artifact], ['npm', npm], ['ci', ci], ['release', release],
|
|
411
|
+
]) if (value == null) missing.push(label);
|
|
412
|
+
for (const [label, value] of [
|
|
413
|
+
['commit sha', chainValue(commit, 'sha', 'commit', 'target_commit', 'head_sha')],
|
|
414
|
+
['tag name', chainValue(tag, 'name', 'tag')],
|
|
415
|
+
['tag commit', chainValue(tag, 'commit', 'target_commit', 'sha')],
|
|
416
|
+
['package name', chainValue(pkg, 'name')],
|
|
417
|
+
['package version', chainValue(pkg, 'version')],
|
|
418
|
+
['package commit', chainValue(pkg, 'commit', 'target_commit', 'head_sha')],
|
|
419
|
+
['artifact integrity', chainValue(artifact, 'integrity', 'sha512', 'hash')],
|
|
420
|
+
['artifact commit', chainValue(artifact, 'commit', 'target_commit', 'head_sha')],
|
|
421
|
+
['NPM integrity', chainValue(npm, 'integrity', 'dist_integrity')],
|
|
422
|
+
['NPM package name', chainValue(npm, 'name')],
|
|
423
|
+
['NPM package version', chainValue(npm, 'version')],
|
|
424
|
+
['NPM commit', chainValue(npm, 'commit', 'target_commit', 'head_sha')],
|
|
425
|
+
['NPM repository', chainValue(npm, 'repository', 'repo', 'full_name')],
|
|
426
|
+
['CI status', chainValue(ci, 'status', 'conclusion')],
|
|
427
|
+
['CI commit', chainValue(ci, 'commit', 'target_commit', 'head_sha', 'sha')],
|
|
428
|
+
['CI repository', chainValue(ci, 'repository', 'repo', 'full_name')],
|
|
429
|
+
['Release tag', chainValue(release, 'tag', 'tag_name')],
|
|
430
|
+
['Release version', chainValue(release, 'version')],
|
|
431
|
+
['Release commit', chainValue(release, 'commit', 'target_commit', 'head_sha', 'sha')],
|
|
432
|
+
['Release repository', chainValue(release, 'repository', 'repo', 'full_name')],
|
|
433
|
+
['Release status', chainValue(release, 'status', 'state')],
|
|
434
|
+
]) if (value == null || value === '') missing.push(label);
|
|
435
|
+
if (missing.length) {
|
|
436
|
+
return assessment({
|
|
437
|
+
kind: 'release-chain', state: 'unproven', reasonCodes: [REASON_CODES.releaseMissing],
|
|
438
|
+
diagnostics: diagnosticsFor('release-chain', 'unproven', expected, chain, [`missing ${missing.join(', ')}`]),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
const mismatches = [];
|
|
442
|
+
const commitSha = chainValue(commit, 'sha', 'commit', 'target_commit', 'head_sha') || commit;
|
|
443
|
+
if (targetCommit && commitSha !== targetCommit) mismatches.push('target commit mismatch');
|
|
444
|
+
for (const [label, part, keys] of [
|
|
445
|
+
['tag', tag, ['commit', 'target_commit', 'sha']],
|
|
446
|
+
['package', pkg, ['commit', 'target_commit', 'head_sha']],
|
|
447
|
+
['artifact', artifact, ['commit', 'target_commit', 'head_sha']],
|
|
448
|
+
['npm', npm, ['commit', 'target_commit', 'head_sha']],
|
|
449
|
+
['ci', ci, ['commit', 'target_commit', 'head_sha', 'sha']],
|
|
450
|
+
['release', release, ['commit', 'target_commit', 'head_sha', 'sha']],
|
|
451
|
+
]) {
|
|
452
|
+
const observedCommit = chainValue(part, ...keys);
|
|
453
|
+
if (observedCommit && targetCommit && observedCommit !== targetCommit) mismatches.push(`${label} commit mismatch`);
|
|
454
|
+
}
|
|
455
|
+
const observedTag = chainValue(tag, 'name', 'tag');
|
|
456
|
+
if (observedTag && tagName && observedTag !== tagName) mismatches.push('tag mismatch');
|
|
457
|
+
const observedVersion = chainValue(pkg, 'version');
|
|
458
|
+
if (packageVersion && observedVersion && observedVersion !== packageVersion) mismatches.push('package version mismatch');
|
|
459
|
+
if (packageName && chainValue(pkg, 'name') && chainValue(pkg, 'name') !== packageName) mismatches.push('package name mismatch');
|
|
460
|
+
const integrity = firstDefined(chainValue(artifact, 'integrity'), chainValue(artifact, 'sha512'), chainValue(artifact, 'hash'));
|
|
461
|
+
const npmIntegrity = chainValue(npm, 'integrity', 'dist_integrity');
|
|
462
|
+
if (!integrity || !npmIntegrity) mismatches.push('artifact integrity missing');
|
|
463
|
+
else if (integrity !== npmIntegrity) mismatches.push('artifact integrity mismatch');
|
|
464
|
+
if (chainValue(npm, 'name') && packageName && chainValue(npm, 'name') !== packageName) mismatches.push('NPM package mismatch');
|
|
465
|
+
if (chainValue(npm, 'version') && packageVersion && chainValue(npm, 'version') !== packageVersion) mismatches.push('NPM version mismatch');
|
|
466
|
+
if (expectedRepository) {
|
|
467
|
+
for (const [label, part] of [['NPM', npm], ['CI', ci], ['Release', release]]) {
|
|
468
|
+
const repository = chainValue(part, 'repository', 'repo', 'full_name');
|
|
469
|
+
if (repository && repository !== expectedRepository) mismatches.push(`${label} repository mismatch`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const ciStatus = chainValue(ci, 'status', 'conclusion');
|
|
473
|
+
if (ciStatus && String(ciStatus).toLowerCase() !== 'success') mismatches.push('CI not successful');
|
|
474
|
+
const releaseStatus = chainValue(release, 'status', 'state');
|
|
475
|
+
if (releaseStatus && !['published', 'verified'].includes(String(releaseStatus).toLowerCase())) mismatches.push('Release not published');
|
|
476
|
+
if (chainValue(release, 'tag', 'tag_name') && tagName && chainValue(release, 'tag', 'tag_name') !== tagName) mismatches.push('Release tag mismatch');
|
|
477
|
+
if (chainValue(release, 'version') && packageVersion && chainValue(release, 'version') !== packageVersion) mismatches.push('Release version mismatch');
|
|
478
|
+
if (mismatches.length) {
|
|
479
|
+
return assessment({
|
|
480
|
+
kind: 'release-chain', state: 'conflict', reasonCodes: [REASON_CODES.releaseConflict],
|
|
481
|
+
diagnostics: diagnosticsFor('release-chain', 'conflict', expected, chain, mismatches),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return assessment({
|
|
485
|
+
kind: 'release-chain', state: 'verified',
|
|
486
|
+
diagnostics: diagnosticsFor('release-chain', 'verified', expected, chain, []),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function normalizeAssessments(assessments) {
|
|
491
|
+
const entries = Array.isArray(assessments)
|
|
492
|
+
? assessments.map((item, index) => [String(index), item])
|
|
493
|
+
: Object.entries(assessments || {});
|
|
494
|
+
return entries.map(([fallbackKind, value]) => {
|
|
495
|
+
const item = value && typeof value === 'object' ? value : {};
|
|
496
|
+
if (PROVENANCE_STATES.includes(item.state)) return { ...item, kind: item.kind || fallbackKind };
|
|
497
|
+
return {
|
|
498
|
+
...item,
|
|
499
|
+
kind: item.kind || fallbackKind,
|
|
500
|
+
state: 'unproven',
|
|
501
|
+
reasonCodes: unique([...asArray(item.reasonCodes), REASON_CODES.invalidAssessmentState]),
|
|
502
|
+
diagnostics: [
|
|
503
|
+
...asArray(item.diagnostics),
|
|
504
|
+
{ kind: item.kind || fallbackKind, state: 'unproven', blocker: REASON_CODES.invalidAssessmentState },
|
|
505
|
+
],
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function aggregateState(items) {
|
|
511
|
+
if (!items.length) return 'unproven';
|
|
512
|
+
return items.reduce((selected, item) => (
|
|
513
|
+
(STATE_PRECEDENCE[item.state] ?? STATE_PRECEDENCE.unproven) > (STATE_PRECEDENCE[selected] ?? STATE_PRECEDENCE.unproven)
|
|
514
|
+
? item.state : selected
|
|
515
|
+
), 'verified');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Compose assessments into a fail-closed operation gate. */
|
|
519
|
+
export function evaluateProvenanceGate({ purpose = 'operation', assessments = [], requiredKinds = [] } = {}) {
|
|
520
|
+
const items = normalizeAssessments(assessments);
|
|
521
|
+
const byKind = new Map(items.map((item) => [item.kind, item]));
|
|
522
|
+
const required = unique(requiredKinds);
|
|
523
|
+
const missing = required
|
|
524
|
+
.filter((kind) => !byKind.has(kind))
|
|
525
|
+
.map((kind) => assessment({
|
|
526
|
+
kind, state: 'unproven', reasonCodes: [REASON_CODES.requiredMissing],
|
|
527
|
+
diagnostics: diagnosticsFor(kind, 'unproven', { purpose, kind }, null, ['required assessment missing']),
|
|
528
|
+
}));
|
|
529
|
+
const considered = required.length
|
|
530
|
+
? [...required.map((kind) => byKind.get(kind)).filter(Boolean), ...missing]
|
|
531
|
+
: [...items];
|
|
532
|
+
const state = aggregateState(considered);
|
|
533
|
+
const blockers = considered
|
|
534
|
+
.filter((item) => item.state !== 'verified')
|
|
535
|
+
.sort((left, right) => (STATE_PRECEDENCE[right.state] ?? 2) - (STATE_PRECEDENCE[left.state] ?? 2));
|
|
536
|
+
const reasonCodes = unique(blockers.flatMap((item) => item.reasonCodes || []));
|
|
537
|
+
const diagnostics = blockers.flatMap((item) => {
|
|
538
|
+
const current = asArray(item.diagnostics);
|
|
539
|
+
return current.length ? current : [{
|
|
540
|
+
kind: item.kind,
|
|
541
|
+
state: item.state,
|
|
542
|
+
blocker: item.reasonCodes?.[0] || null,
|
|
543
|
+
}];
|
|
544
|
+
});
|
|
545
|
+
const receipts = considered.flatMap((item) => asArray(item.receipts));
|
|
546
|
+
const first = blockers[0];
|
|
547
|
+
return {
|
|
548
|
+
ok: considered.length > 0 && considered.every((item) => item.state === 'verified'),
|
|
549
|
+
state,
|
|
550
|
+
reasonCodes,
|
|
551
|
+
diagnostics: sanitize(diagnostics),
|
|
552
|
+
repair: first ? repairForAssessment({ assessment: first, context: { purpose } }) : null,
|
|
553
|
+
receipts: sanitize(receipts),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Return stable operator-facing recovery guidance for an assessment. */
|
|
558
|
+
export function repairForAssessment({ assessment: current = {}, context = {} } = {}) {
|
|
559
|
+
const state = current.state || 'unproven';
|
|
560
|
+
const purpose = String(context.purpose || context.operation || 'operação')
|
|
561
|
+
.replace(/[^A-Za-z0-9 _-]/g, '')
|
|
562
|
+
.replace(/(?:token|secret|password|authorization|private)/gi, '[redacted]')
|
|
563
|
+
.slice(0, 80) || 'operação';
|
|
564
|
+
if (state === 'verified') return { command: null, explanation: 'Prova fresca já verificada; nenhuma recuperação necessária.' };
|
|
565
|
+
if (state === 'conflict') {
|
|
566
|
+
return {
|
|
567
|
+
command: 'wendkeep context status && wendkeep verify --deep',
|
|
568
|
+
explanation: `Resolva o contexto conflitante de ${purpose}, recapture a prova no checkout atual e execute verify --deep.`,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
command: 'wendkeep verify --deep',
|
|
573
|
+
explanation: `Produza uma prova fresca e vinculada para ${purpose}; a recuperação não promove o artefato atual automaticamente.`,
|
|
574
|
+
};
|
|
575
|
+
}
|