@dzhechkov/harness-core 0.8.46 → 0.8.47
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/.dz-manifest.json +175 -163
- package/README.md +84 -2
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +32 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-snapshot.d.ts +14 -1
- package/dist/agentdb-snapshot.d.ts.map +1 -1
- package/dist/agentdb-snapshot.js +100 -4
- package/dist/agentdb-snapshot.js.map +1 -1
- package/dist/apply-leg.d.ts +9 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +26 -4
- package/dist/apply-leg.js.map +1 -1
- package/dist/architecture.d.ts +0 -5
- package/dist/architecture.d.ts.map +1 -1
- package/dist/architecture.js +25 -2
- package/dist/architecture.js.map +1 -1
- package/dist/codex-rollouts.d.ts +35 -7
- package/dist/codex-rollouts.d.ts.map +1 -1
- package/dist/codex-rollouts.js +201 -113
- package/dist/codex-rollouts.js.map +1 -1
- package/dist/cost-ledger.d.ts +42 -3
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +478 -15
- package/dist/cost-ledger.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +16 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +21 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +7 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +48 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/loop-plan.d.ts +4 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +4 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/mutation-gate.d.ts +36 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +109 -19
- package/dist/mutation-gate.js.map +1 -1
- package/dist/npm-homepage.d.ts +64 -0
- package/dist/npm-homepage.d.ts.map +1 -0
- package/dist/npm-homepage.js +109 -0
- package/dist/npm-homepage.js.map +1 -0
- package/dist/operations.d.ts +6 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +150 -24
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts.map +1 -1
- package/dist/pack-inventory.js +6 -1
- package/dist/pack-inventory.js.map +1 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +4 -1
- package/dist/parity.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +43 -1
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +269 -13
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/publish.d.ts +5 -1
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +18 -1
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +63 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +82 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release-package-audit.d.ts +86 -0
- package/dist/release-package-audit.d.ts.map +1 -0
- package/dist/release-package-audit.js +272 -0
- package/dist/release-package-audit.js.map +1 -0
- package/dist/release.d.ts +8 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +58 -32
- package/dist/release.js.map +1 -1
- package/dist/round.d.ts +10 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +5 -1
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +11 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +124 -20
- package/dist/run-records.js.map +1 -1
- package/dist/stage-usage.d.ts +148 -0
- package/dist/stage-usage.d.ts.map +1 -0
- package/dist/stage-usage.js +261 -0
- package/dist/stage-usage.js.map +1 -0
- package/dist/statusline.d.ts +23 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +167 -1
- package/dist/statusline.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +21 -19
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +206 -95
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/dist/workflow-run.d.ts +12 -3
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +29 -2
- package/dist/workflow-run.js.map +1 -1
- package/package.json +3 -3
- package/sbom.json +264 -234
- package/src/agentdb-index.ts +31 -2
- package/src/agentdb-snapshot.ts +105 -4
- package/src/apply-leg.ts +26 -4
- package/src/architecture.ts +15 -2
- package/src/codex-rollouts.ts +155 -147
- package/src/cost-ledger.ts +308 -19
- package/src/feature-adr-routing.ts +22 -0
- package/src/guard.ts +52 -0
- package/src/index.ts +15 -3
- package/src/loop-plan.ts +8 -0
- package/src/mutation-gate.ts +153 -19
- package/src/npm-homepage.ts +142 -0
- package/src/operations.ts +177 -22
- package/src/pack-inventory.ts +6 -1
- package/src/parity.ts +4 -1
- package/src/publish-sibling-drift.ts +214 -11
- package/src/publish.ts +20 -1
- package/src/qe-bridge.ts +86 -1
- package/src/release-package-audit.ts +264 -0
- package/src/release.ts +55 -22
- package/src/round.ts +8 -0
- package/src/run-records.ts +101 -22
- package/src/stage-usage.ts +191 -0
- package/src/statusline.ts +149 -1
- package/src/workflow-run-dispatch.ts +148 -89
- package/src/workflow-run.ts +40 -4
- package/dist/ledger-cost-fill.d.ts +0 -58
- package/dist/ledger-cost-fill.d.ts.map +0 -1
- package/dist/ledger-cost-fill.js +0 -78
- package/dist/ledger-cost-fill.js.map +0 -1
- package/dist/retro.d.ts +0 -131
- package/dist/retro.d.ts.map +0 -1
- package/dist/retro.js +0 -207
- package/dist/retro.js.map +0 -1
- package/dist/sbom.d.ts +0 -42
- package/dist/sbom.d.ts.map +0 -1
- package/dist/sbom.js +0 -120
- package/dist/sbom.js.map +0 -1
package/src/qe-bridge.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { extractReportGrade } from './reqe.js';
|
|
35
|
+
import { readOpenRoundTaskId, type RoundState } from './round.js';
|
|
35
36
|
|
|
36
37
|
export const QE_BRIDGE_SCHEMA = 'qe-bridge-signoff-1';
|
|
37
38
|
export const QE_BRIDGE_FAILURE_SCHEMA = 'qe-bridge-failure-1';
|
|
@@ -155,13 +156,97 @@ export interface BridgeSignoff {
|
|
|
155
156
|
elapsedMs: number;
|
|
156
157
|
emittedAt: string;
|
|
157
158
|
/** experiment-instrument FR-1/FR-3/T5 (ADR-001): the task identity of the slug's single open
|
|
158
|
-
* round
|
|
159
|
+
* round snapshot captured before the reviewer child — filled by the cli (it owns `.dz/rounds/`), null
|
|
159
160
|
* when there was no open round to fill from, never guessed. Optional so a signoff written before
|
|
160
161
|
* this feature landed parses unchanged (NFR-1). */
|
|
161
162
|
taskId?: string | null;
|
|
162
163
|
/** experiment-instrument FR-1/FR-3/T5: present whenever `taskId` above was filled from a lookup —
|
|
163
164
|
* names where it came from (`readOpenRoundTaskId`'s own source, r1-1/r1-2 extended to five values). */
|
|
164
165
|
taskIdSource?: 'open-round' | 'derived-legacy' | 'no-open-round' | 'ambiguous' | 'unavailable';
|
|
166
|
+
/** Pipeline round identity; runId in BridgeAudit remains the bridge invocation. */
|
|
167
|
+
round?: number | null;
|
|
168
|
+
roundRun?: string | null;
|
|
169
|
+
roundIdentitySource?: 'explicit' | 'open-round' | 'derived-legacy' | 'no-open-round' | 'ambiguous' | 'unavailable';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type BridgeRoundIdentity = {
|
|
173
|
+
round: number | null; roundRun: string | null; taskId: string | null;
|
|
174
|
+
taskIdSource: NonNullable<BridgeSignoff['taskIdSource']>;
|
|
175
|
+
roundIdentitySource: NonNullable<BridgeSignoff['roundIdentitySource']>;
|
|
176
|
+
};
|
|
177
|
+
const identityTextValid = (value: unknown, max: number): value is string =>
|
|
178
|
+
typeof value === 'string' && value.trim() !== '' && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
179
|
+
const identityRoundValid = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
180
|
+
|
|
181
|
+
/** Resolve copied values from ONE already-read authority snapshot, without IO or new IDs. */
|
|
182
|
+
export function resolveBridgeRoundIdentity(input: {
|
|
183
|
+
slug: string; states: readonly RoundState[]; unreadableCount?: number; readError?: string;
|
|
184
|
+
explicit?: { round?: unknown; roundRun?: unknown; taskId?: unknown };
|
|
185
|
+
}): { ok: true; identity: BridgeRoundIdentity } | { ok: false; reason: string } {
|
|
186
|
+
const explicit = input.explicit ?? {};
|
|
187
|
+
const hasExplicit = Object.keys(explicit).length > 0;
|
|
188
|
+
const suppliedRound = typeof explicit.round === 'string' && /^\d+$/.test(explicit.round) ? Number(explicit.round) : explicit.round;
|
|
189
|
+
if ('round' in explicit && !identityRoundValid(suppliedRound)) return { ok: false, reason: '--round must be a safe positive integer' };
|
|
190
|
+
if ('roundRun' in explicit && !identityTextValid(explicit.roundRun, 512)) return { ok: false, reason: '--round-run must be nonempty, at most 512 characters, without controls' };
|
|
191
|
+
if ('taskId' in explicit && !identityTextValid(explicit.taskId, 120)) return { ok: false, reason: '--task must be nonempty, at most 120 characters, without controls' };
|
|
192
|
+
const lookup = readOpenRoundTaskId(input.states, input.slug, input.unreadableCount ?? 0, input.readError !== undefined);
|
|
193
|
+
const empty: BridgeRoundIdentity = { round: null, roundRun: null, taskId: null, taskIdSource: lookup.source, roundIdentitySource: lookup.source };
|
|
194
|
+
if (lookup.source === 'no-open-round' || lookup.source === 'ambiguous' || lookup.source === 'unavailable') {
|
|
195
|
+
return hasExplicit ? { ok: false, reason: `round identity authority ${lookup.source}${input.readError ? ': ' + input.readError : ''}` } : { ok: true, identity: empty };
|
|
196
|
+
}
|
|
197
|
+
const state = input.states.find((s) => s.slug === input.slug)!;
|
|
198
|
+
if (!identityRoundValid(state.round) || !identityTextValid(lookup.taskId, 120) || (state.taskId != null && !identityTextValid(state.taskId, 120)) || (state.run != null && !identityTextValid(state.run, 512))) {
|
|
199
|
+
return hasExplicit ? { ok: false, reason: 'round identity authority unavailable: malformed state identity' }
|
|
200
|
+
: { ok: true, identity: { ...empty, taskIdSource: 'unavailable', roundIdentitySource: 'unavailable' } };
|
|
201
|
+
}
|
|
202
|
+
const roundRun = state.run?.trim() ?? null;
|
|
203
|
+
for (const [field, claim, current] of [['round', suppliedRound, state.round], ['roundRun', explicit.roundRun, roundRun], ['taskId', explicit.taskId, lookup.taskId]] as const) {
|
|
204
|
+
if (field in explicit && current === null) return { ok: false, reason: `round identity unverified: ${field} absent in authority` };
|
|
205
|
+
if (field in explicit && (typeof claim === 'string' ? claim.trim() : claim) !== current) return { ok: false, reason: `round identity conflict: ${field} does not match open round` };
|
|
206
|
+
}
|
|
207
|
+
return { ok: true, identity: { round: state.round, roundRun, taskId: lookup.taskId, taskIdSource: lookup.source,
|
|
208
|
+
roundIdentitySource: hasExplicit ? 'explicit' : lookup.source } };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Compare all present claims before cardinality. Raw invalid fields can never become legacy. */
|
|
212
|
+
export function selectQeBridgeRoundSignoff<T extends { round?: unknown; roundRun?: unknown; taskId?: unknown; roundIdentitySource?: unknown; bridgeRunId?: string | null }>(
|
|
213
|
+
candidates: readonly T[], closing: { round: number; run?: string | null; taskId?: string | null },
|
|
214
|
+
): { status: 'none' } | { status: 'ambiguous'; candidates: readonly T[] }
|
|
215
|
+
| { status: 'identity-mismatch' | 'identity-unverified'; reason: string; candidates: readonly T[] }
|
|
216
|
+
| { status: 'one'; candidate: T; reviewIdentitySource: 'round-run-task' | 'partial-identity' | 'legacy-window'; reviewIdentity: { round: number | null; roundRun: string | null; taskId: string | null; bridgeRunId: string | null } } {
|
|
217
|
+
const compatible: T[] = []; const legacy: T[] = []; const foreign: T[] = [];
|
|
218
|
+
for (const candidate of candidates) {
|
|
219
|
+
let present = 0; let mismatch = false;
|
|
220
|
+
if (candidate.roundIdentitySource != null && (typeof candidate.roundIdentitySource !== 'string' || !['explicit', 'open-round', 'derived-legacy', 'no-open-round', 'ambiguous', 'unavailable'].includes(candidate.roundIdentitySource))) {
|
|
221
|
+
return { status: 'identity-unverified', reason: 'malformed roundIdentitySource', candidates };
|
|
222
|
+
}
|
|
223
|
+
for (const [field, current, valid] of [['round', closing.round, identityRoundValid], ['roundRun', closing.run, (v: unknown) => identityTextValid(v, 512)], ['taskId', closing.taskId, (v: unknown) => identityTextValid(v, 120)]] as const) {
|
|
224
|
+
const claim = candidate[field];
|
|
225
|
+
if (claim === undefined || claim === null) continue;
|
|
226
|
+
if (!valid(claim)) return { status: 'identity-unverified', reason: `malformed ${field}`, candidates };
|
|
227
|
+
present++;
|
|
228
|
+
if (current === undefined || current === null || !valid(current)) return { status: 'identity-unverified', reason: `closing authority missing ${field}`, candidates };
|
|
229
|
+
if (claim !== current) mismatch = true;
|
|
230
|
+
}
|
|
231
|
+
if (present === 0 && ['explicit', 'open-round', 'derived-legacy'].includes(String(candidate.roundIdentitySource))) {
|
|
232
|
+
return { status: 'identity-unverified', reason: 'claimed round identity has no fields', candidates };
|
|
233
|
+
}
|
|
234
|
+
if (mismatch) foreign.push(candidate);
|
|
235
|
+
else if (present > 0) compatible.push(candidate);
|
|
236
|
+
else legacy.push(candidate);
|
|
237
|
+
}
|
|
238
|
+
if (compatible.length > 1) return { status: 'ambiguous', candidates: compatible };
|
|
239
|
+
const chosen = compatible[0] ?? (foreign.length === 0 && legacy.length === 1 ? legacy[0] : undefined);
|
|
240
|
+
if (!chosen) {
|
|
241
|
+
if (foreign.length) return { status: 'identity-mismatch', reason: 'present round/run/task differs from closing round', candidates };
|
|
242
|
+
return legacy.length > 1 ? { status: 'ambiguous', candidates: legacy } : { status: 'none' };
|
|
243
|
+
}
|
|
244
|
+
const round = chosen.round == null ? null : chosen.round as number;
|
|
245
|
+
const roundRun = chosen.roundRun == null ? null : chosen.roundRun as string;
|
|
246
|
+
const taskId = chosen.taskId == null ? null : chosen.taskId as string;
|
|
247
|
+
return { status: 'one', candidate: chosen, reviewIdentitySource: round !== null && roundRun !== null && taskId !== null ? 'round-run-task'
|
|
248
|
+
: round !== null || roundRun !== null || taskId !== null ? 'partial-identity' : 'legacy-window',
|
|
249
|
+
reviewIdentity: { round, roundRun, taskId, bridgeRunId: chosen.bridgeRunId ?? null } };
|
|
165
250
|
}
|
|
166
251
|
|
|
167
252
|
/** Where each channel was found, for the audit bundle: an auditor can re-derive the verdict from
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/** Pure evidence admission for a freshly installed release tarball (ADR-001). */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { dirname, isAbsolute, posix, relative, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface ReleasePackageAuditPlan {
|
|
6
|
+
readonly package: string;
|
|
7
|
+
readonly version: string;
|
|
8
|
+
readonly dir: string;
|
|
9
|
+
readonly phases: readonly ['pack', 'install', 'validate', 'audit'];
|
|
10
|
+
readonly timeoutMs: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ReleasePackageAuditResult {
|
|
13
|
+
readonly scope: 'package-consumer';
|
|
14
|
+
readonly package: string;
|
|
15
|
+
readonly version: string;
|
|
16
|
+
readonly status: 'clean' | 'findings' | 'error';
|
|
17
|
+
readonly failureClass?: 'AUDIT_ERROR' | 'UNEXECUTED_STEP' | 'VULNS_HIGH';
|
|
18
|
+
readonly reason?: string;
|
|
19
|
+
readonly candidateSha256?: string;
|
|
20
|
+
readonly lockSha256?: string;
|
|
21
|
+
readonly npmVersion?: string;
|
|
22
|
+
readonly platform?: { readonly os: string; readonly cpu: string };
|
|
23
|
+
readonly resolvedCount?: number;
|
|
24
|
+
readonly installedCount?: number;
|
|
25
|
+
readonly closure?: readonly { readonly path: string; readonly name: string; readonly version: string; readonly installed: boolean }[];
|
|
26
|
+
readonly optionalAbsences?: readonly { readonly name: string; readonly kind: 'optional-peer' | 'platform-excluded' | 'locked-not-installed'; readonly parent: string }[];
|
|
27
|
+
readonly counts?: Readonly<Record<string, number>>;
|
|
28
|
+
readonly findings?: readonly { readonly name: string; readonly severity: string }[];
|
|
29
|
+
readonly phases: readonly { readonly phase: string; readonly exitCode: number; readonly timedOut?: boolean }[];
|
|
30
|
+
}
|
|
31
|
+
export interface ReleaseWorkspaceAuditReport {
|
|
32
|
+
readonly scope: 'workspace';
|
|
33
|
+
readonly nonBlocking: true;
|
|
34
|
+
readonly includeDev: boolean;
|
|
35
|
+
readonly status: 'clean' | 'findings' | 'error' | 'not-run';
|
|
36
|
+
readonly counts?: Readonly<Record<string, number>>;
|
|
37
|
+
readonly findings?: readonly { readonly name: string; readonly severity: string }[];
|
|
38
|
+
readonly reason?: string;
|
|
39
|
+
}
|
|
40
|
+
type Obj = Record<string, any>;
|
|
41
|
+
const obj = (value: unknown): value is Obj => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
const integer = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
43
|
+
const version = (v: unknown): v is string => typeof v === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/.test(v);
|
|
44
|
+
const hash = (v: unknown): v is string => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
45
|
+
function requireEvidence(condition: unknown, reason: string): asserts condition { if (!condition) throw new Error(reason); }
|
|
46
|
+
const severities = ['info', 'low', 'moderate', 'high', 'critical'] as const;
|
|
47
|
+
function auditReport(input: unknown): { counts: Record<string, number>; findings: Array<{ name: string; severity: string }>; high: boolean; totalDependencies: number } {
|
|
48
|
+
requireEvidence(obj(input) && integer(input.exitCode) && input.timedOut !== true, 'audit execution is missing, malformed or timed out');
|
|
49
|
+
requireEvidence(typeof input.stdout === 'string' && input.stdout.trim() !== '', 'audit JSON is empty');
|
|
50
|
+
let report: unknown; try { report = JSON.parse(input.stdout); } catch { throw new Error('audit JSON cannot be parsed'); }
|
|
51
|
+
requireEvidence(obj(report) && report.auditReportVersion === 2 && !('error' in report), 'unsupported audit schema or error envelope');
|
|
52
|
+
requireEvidence(obj(report.metadata) && obj(report.metadata.vulnerabilities) && obj(report.vulnerabilities), 'audit vulnerability evidence is missing');
|
|
53
|
+
const counts = report.metadata.vulnerabilities;
|
|
54
|
+
requireEvidence([...severities, 'total'].every(k => integer(counts[k])), 'audit counts must be complete nonnegative integers');
|
|
55
|
+
requireEvidence(severities.reduce((sum, k) => sum + counts[k], 0) === counts.total, 'audit severity total is inconsistent');
|
|
56
|
+
const findings = Object.entries(report.vulnerabilities).map(([name, item]) => {
|
|
57
|
+
requireEvidence(obj(item) && severities.includes(item.severity), 'unknown structured advisory severity');
|
|
58
|
+
if (item.via !== undefined) {
|
|
59
|
+
requireEvidence(Array.isArray(item.via), 'via advisory evidence must be an array');
|
|
60
|
+
for (const advisory of item.via) {
|
|
61
|
+
// npm10 emits strings for metavulnerability dependencies. Those are references,
|
|
62
|
+
// not advisory objects; retain them without inventing a raw-advisory count.
|
|
63
|
+
if (typeof advisory === 'string') {
|
|
64
|
+
requireEvidence(advisory.trim().length > 0, 'via dependency reference is empty');
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
requireEvidence(obj(advisory) && severities.includes(advisory.severity), 'via advisory severity is unknown or malformed');
|
|
68
|
+
requireEvidence(integer(advisory.source) && ['name', 'dependency', 'title', 'url', 'range'].every(key => typeof advisory[key] === 'string'), 'via advisory object has unsupported field types');
|
|
69
|
+
// Metadata counts aggregate vulnerability entries, not individual via advisories.
|
|
70
|
+
// A lower advisory under a higher aggregate is valid; the reverse contradicts it.
|
|
71
|
+
requireEvidence(severities.indexOf(advisory.severity) <= severities.indexOf(item.severity), 'via advisory severity exceeds the vulnerability entry severity');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { name, severity: item.severity as string };
|
|
75
|
+
});
|
|
76
|
+
requireEvidence(severities.every(s => findings.filter(f => f.severity === s).length === counts[s]), 'structured advisory severities contradict counts');
|
|
77
|
+
const deps = report.metadata.dependencies;
|
|
78
|
+
requireEvidence(obj(deps) && ['prod', 'dev', 'optional', 'peer', 'peerOptional', 'total'].every(k => integer(deps[k])), 'dependency count evidence is missing');
|
|
79
|
+
// These categories overlap (npm includes the synthetic consumer in prod); never sum them.
|
|
80
|
+
const high = counts.high + counts.critical > 0;
|
|
81
|
+
requireEvidence(high || input.exitCode === 0, 'nonzero audit exit without valid high/critical findings');
|
|
82
|
+
return { counts, findings, high, totalDependencies: deps.total };
|
|
83
|
+
}
|
|
84
|
+
/** pnpm's retained workspace JSON uses advisory IDs/counts per finding, not npm-v2 nodes. */
|
|
85
|
+
function workspaceReport(input: unknown) {
|
|
86
|
+
requireEvidence(obj(input) && integer(input.exitCode) && input.timedOut !== true && typeof input.stdout === 'string', 'workspace execution unavailable');
|
|
87
|
+
let data: unknown; try { data = JSON.parse(input.stdout); } catch { throw new Error('workspace JSON cannot be parsed'); }
|
|
88
|
+
if (obj(data) && data.auditReportVersion !== undefined) return auditReport(input);
|
|
89
|
+
requireEvidence(obj(data) && !('error' in data) && Array.isArray(data.actions) && obj(data.advisories) && obj(data.metadata), 'unsupported workspace audit schema or error envelope');
|
|
90
|
+
const values = data.metadata.vulnerabilities;
|
|
91
|
+
requireEvidence(obj(values) && severities.every(k => integer(values[k])), 'workspace severity counts unavailable');
|
|
92
|
+
const counts: Record<string, number> = Object.fromEntries(severities.map(k => [k, values[k]]));
|
|
93
|
+
counts.total = severities.reduce((n, k) => n + counts[k]!, 0);
|
|
94
|
+
const findings = Object.values(data.advisories).map(item => { requireEvidence(obj(item) && typeof item.module_name === 'string' && severities.includes(item.severity), 'unsupported workspace advisory'); return { name: item.module_name as string, severity: item.severity as string }; });
|
|
95
|
+
// Legacy pnpm counters may count multiple findings for one advisory ID. Any structured
|
|
96
|
+
// severity still needs a corresponding nonzero metadata count; never synthesize clean.
|
|
97
|
+
requireEvidence(severities.every(k => counts[k]! >= findings.filter(f => f.severity === k).length), 'workspace advisory severity contradicts metadata');
|
|
98
|
+
requireEvidence(['dependencies', 'devDependencies', 'optionalDependencies', 'totalDependencies'].every(k => integer(data.metadata[k])), 'workspace dependency counters unavailable');
|
|
99
|
+
const high = counts.high! + counts.critical! > 0;
|
|
100
|
+
requireEvidence(high || input.exitCode === 0, 'workspace nonzero exit without threshold findings');
|
|
101
|
+
return { counts, findings, high, totalDependencies: data.metadata.totalDependencies as number };
|
|
102
|
+
}
|
|
103
|
+
export function planReleasePackageAudit(pkg: { readonly name: string; readonly version: string; readonly dir: string }, timeoutMs = 120_000): ReleasePackageAuditPlan {
|
|
104
|
+
return { package: pkg.name, version: pkg.version, dir: pkg.dir, phases: ['pack', 'install', 'validate', 'audit'], timeoutMs };
|
|
105
|
+
}
|
|
106
|
+
function inside(path: string, parent: string): boolean { const rel = relative(parent, path); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); }
|
|
107
|
+
function dependencyTables(manifest: Obj): Array<[string, Obj]> {
|
|
108
|
+
return ['dependencies', 'optionalDependencies', 'peerDependencies'].flatMap(key => {
|
|
109
|
+
if (manifest[key] === undefined) return [];
|
|
110
|
+
requireEvidence(obj(manifest[key]), `malformed ${key}`);
|
|
111
|
+
requireEvidence(Object.values(manifest[key]).every(v => typeof v === 'string'), `malformed ${key} specs`);
|
|
112
|
+
return [[key, manifest[key]]];
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function dependencyPath(parent: string, name: string, nodes: Obj): string | undefined {
|
|
116
|
+
requireEvidence(/^(@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(name), 'unsupported dependency name');
|
|
117
|
+
let cursor = parent;
|
|
118
|
+
while (cursor !== '.' && cursor !== '') {
|
|
119
|
+
const candidate = `${cursor}/node_modules/${name}`;
|
|
120
|
+
if (nodes[candidate] !== undefined) return candidate;
|
|
121
|
+
cursor = posix.dirname(cursor);
|
|
122
|
+
}
|
|
123
|
+
const top = `node_modules/${name}`;
|
|
124
|
+
return nodes[top] === undefined ? undefined : top;
|
|
125
|
+
}
|
|
126
|
+
function excludesHost(metadata: unknown, host: Obj): boolean {
|
|
127
|
+
if (!obj(metadata)) return false;
|
|
128
|
+
const excludes = (values: unknown, current: string): boolean => {
|
|
129
|
+
if (values === undefined) return false;
|
|
130
|
+
requireEvidence(Array.isArray(values) && values.every(v => typeof v === 'string'), 'unsupported OS/CPU metadata');
|
|
131
|
+
const positive = values.filter(v => !v.startsWith('!'));
|
|
132
|
+
return values.includes(`!${current}`) || (positive.length > 0 && !positive.includes('any') && !positive.includes(current));
|
|
133
|
+
};
|
|
134
|
+
return excludes(metadata.os, host.os) || excludes(metadata.cpu, host.cpu);
|
|
135
|
+
}
|
|
136
|
+
function closure(roots: readonly { package: string; version: string; dir: string; tarball: string; integrity?: string }[], e: Obj, strictGraph = false) {
|
|
137
|
+
requireEvidence(typeof e.consumerDir === 'string' && isAbsolute(e.consumerDir) && roots.every(root => !inside(e.consumerDir, root.dir) && !inside(e.consumerDir, dirname(dirname(dirname(root.dir))))), 'consumer must be outside the workspace');
|
|
138
|
+
requireEvidence(roots.every(root => typeof root.tarball === 'string' && isAbsolute(root.tarball)), 'candidate tarball identity is missing');
|
|
139
|
+
requireEvidence(obj(e.platform) && typeof e.platform.os === 'string' && typeof e.platform.cpu === 'string' && (!strictGraph || e.platform.os.length > 0 && e.platform.cpu.length > 0), 'host platform missing');
|
|
140
|
+
requireEvidence(obj(e.lock) && [2, 3].includes(e.lock.lockfileVersion) && obj(e.lock.packages), 'unsupported consumer lock schema');
|
|
141
|
+
const nodes = e.lock.packages;
|
|
142
|
+
requireEvidence(obj(nodes['']) && version(nodes[''].version) && obj(nodes[''].dependencies), 'private versioned consumer root missing');
|
|
143
|
+
const rootPaths = new Map(roots.map(root => ['node_modules/' + root.package, root]));
|
|
144
|
+
requireEvidence(rootPaths.size === roots.length && Object.keys(nodes[''].dependencies).length === roots.length && roots.every(root => nodes[''].dependencies[root.package] === `file:${root.tarball}`), 'consumer root must contain only the selected tarball cohort');
|
|
145
|
+
for (const [path, root] of rootPaths) {
|
|
146
|
+
requireEvidence(obj(nodes[path]) && nodes[path].version === root.version && typeof nodes[path].resolved === 'string' && nodes[path].resolved.startsWith('file:') && resolve(e.consumerDir, nodes[path].resolved.slice(5)) === root.tarball, 'selected root lock identity missing');
|
|
147
|
+
if (strictGraph) requireEvidence(typeof root.integrity === 'string' && /^sha512-[A-Za-z0-9+/]+={0,2}$/.test(root.integrity) && nodes[path].integrity === root.integrity, 'selected root SRI contradicts supplied artifact');
|
|
148
|
+
}
|
|
149
|
+
requireEvidence(Array.isArray(e.installed), 'installed dependency inventory missing');
|
|
150
|
+
const installed = new Map<string, Obj>();
|
|
151
|
+
for (const entry of e.installed) {
|
|
152
|
+
requireEvidence(obj(entry) && typeof entry.path === 'string' && obj(entry.manifest) && entry.contained === true, 'installed node escapes consumer or has malformed identity');
|
|
153
|
+
requireEvidence(!installed.has(entry.path), 'duplicate installed node');
|
|
154
|
+
installed.set(entry.path, entry.manifest);
|
|
155
|
+
}
|
|
156
|
+
requireEvidence(roots.every(root => installed.get('node_modules/' + root.package)?.name === root.package && installed.get('node_modules/' + root.package)?.version === root.version), 'selected root not physically installed');
|
|
157
|
+
const absences: Array<{ name: string; kind: 'optional-peer' | 'platform-excluded' | 'locked-not-installed'; parent: string }> = [];
|
|
158
|
+
const inventory: Array<{ path: string; name: string; version: string; installed: boolean }> = [];
|
|
159
|
+
const graph: Array<Record<string, any>> = [];
|
|
160
|
+
for (const [path, node] of Object.entries(nodes)) {
|
|
161
|
+
if (path === '') continue;
|
|
162
|
+
requireEvidence(obj(node) && path.startsWith('node_modules/') && !path.split('/').some(p => p === '..' || p === '.') && !path.includes('\\') && node.link !== true && version(node.version), 'unsupported linked/versionless lock node');
|
|
163
|
+
if (!rootPaths.has(path) && node.resolved !== undefined) requireEvidence(typeof node.resolved === 'string' && /^https?:\/\//.test(node.resolved), 'dependency resolves outside registry policy');
|
|
164
|
+
if (!rootPaths.has(path) && node.resolved === undefined) requireEvidence(node.inBundle === true, 'unresolved non-bundled dependency node');
|
|
165
|
+
const manifest = installed.get(path);
|
|
166
|
+
const name = path.split('node_modules/').pop()!;
|
|
167
|
+
if (manifest !== undefined) requireEvidence(manifest.name === name && manifest.version === node.version, 'installed identity contradicts resolved lock');
|
|
168
|
+
else requireEvidence(node.optional === true && excludesHost(node, e.platform), 'resolved dependency absent without supported platform exclusion');
|
|
169
|
+
inventory.push({ path, name, version: node.version, installed: manifest !== undefined });
|
|
170
|
+
const data = manifest ?? node;
|
|
171
|
+
if (strictGraph) {
|
|
172
|
+
requireEvidence(node.dev !== true && node.extraneous !== true, 'unselected development or extraneous node is outside production cohort');
|
|
173
|
+
requireEvidence(/^(?:node_modules\/(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+)(?:\/node_modules\/(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+)*$/i.test(path), 'unsupported noncanonical lock path');
|
|
174
|
+
requireEvidence(data.peerDependenciesMeta === undefined || obj(data.peerDependenciesMeta) && Object.values(data.peerDependenciesMeta).every(value => obj(value) && (value.optional === undefined || typeof value.optional === 'boolean')), 'malformed optional peer metadata');
|
|
175
|
+
requireEvidence(!path.split('/').some(segment => segment === '') && (nodes[path].inBundle === true || rootPaths.has(path) || typeof node.integrity === 'string' && /^sha512-[A-Za-z0-9+/]+={0,2}$/.test(node.integrity)), 'registry node SRI or canonical path missing');
|
|
176
|
+
const equal = (a: unknown, b: unknown): boolean => JSON.stringify(Object.entries((a ?? {}) as Obj).sort()) === JSON.stringify(Object.entries((b ?? {}) as Obj).sort());
|
|
177
|
+
for (const table of ['dependencies', 'peerDependencies', 'optionalDependencies', 'peerDependenciesMeta']) requireEvidence(manifest === undefined || equal(manifest[table], node[table]), 'installed declarations contradict resolved lock');
|
|
178
|
+
for (const field of ['os', 'cpu']) requireEvidence(manifest === undefined || JSON.stringify(manifest[field]) === JSON.stringify(node[field]), 'installed platform declaration contradicts lock');
|
|
179
|
+
}
|
|
180
|
+
const edges: Array<{ table: string; name: string; spec: string; target: string | null }> = [];
|
|
181
|
+
for (const [table, declarations] of dependencyTables(data)) {
|
|
182
|
+
for (const [dep, spec] of Object.entries(declarations)) {
|
|
183
|
+
requireEvidence(!/^(?:workspace:|file:|link:|npm:)/.test(spec as string), 'unsupported local or alias dependency spec');
|
|
184
|
+
const child = dependencyPath(path, dep, nodes);
|
|
185
|
+
edges.push({ table, name: dep, spec: spec as string, target: child ?? null });
|
|
186
|
+
if (child !== undefined) {
|
|
187
|
+
if (!installed.has(child) && table === 'optionalDependencies') absences.push({ name: dep, kind: 'locked-not-installed', parent: path });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (table === 'dependencies' && data.optionalDependencies?.[dep] !== undefined) continue;
|
|
191
|
+
if (table === 'peerDependencies' && data.peerDependenciesMeta?.[dep]?.optional === true) { absences.push({ name: dep, kind: 'optional-peer', parent: path }); continue; }
|
|
192
|
+
if (table === 'optionalDependencies' && excludesHost(e.optionalMetadata?.[`${path}:${dep}`], e.platform)) { absences.push({ name: dep, kind: 'platform-excluded', parent: path }); continue; }
|
|
193
|
+
throw new Error(`required dependency coverage missing: ${dep}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
graph.push({ path, name, version: node.version, selectedRoot: rootPaths.has(path), installed: manifest !== undefined, resolved: node.resolved ?? null, integrity: node.integrity ?? null, bundled: node.inBundle === true, optional: node.optional === true, ...(strictGraph ? { optionalWitnesses: Object.fromEntries(Object.keys(data.optionalDependencies ?? {}).filter(name => dependencyPath(path, name, nodes) === undefined).map(name => [name, e.optionalMetadata?.[path + ':' + name] ?? null])) } : {}), declarations: Object.fromEntries(dependencyTables(data)), peerDependenciesMeta: data.peerDependenciesMeta ?? {}, os: data.os ?? null, cpu: data.cpu ?? null, edges: edges.sort((a, b) => (a.table + ':' + a.name).localeCompare(b.table + ':' + b.name)) });
|
|
197
|
+
const bundles = data.bundleDependencies ?? data.bundledDependencies;
|
|
198
|
+
if (bundles !== undefined && bundles !== false) {
|
|
199
|
+
requireEvidence(bundles === true || (Array.isArray(bundles) && bundles.every(n => typeof n === 'string')), 'unsupported bundle declaration');
|
|
200
|
+
const names = bundles === true ? Object.keys(data.dependencies ?? {}) : bundles;
|
|
201
|
+
for (const name of names) {
|
|
202
|
+
const child = dependencyPath(path, name, nodes);
|
|
203
|
+
requireEvidence(child !== undefined && nodes[child].inBundle === true && installed.has(child), 'bundled bytes absent from installed audited inventory');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
requireEvidence([...installed.keys()].every(path => nodes[path] !== undefined), 'installed node absent from lock coverage');
|
|
208
|
+
return { inventory, absences, graph: graph.sort((a, b) => a.path.localeCompare(b.path)) };
|
|
209
|
+
}
|
|
210
|
+
export function judgeReleasePackageAudit(plan: ReleasePackageAuditPlan, evidence: unknown): ReleasePackageAuditResult;
|
|
211
|
+
export function judgeReleasePackageAudit(plan: undefined, evidence: unknown, includeDev?: boolean): ReleaseWorkspaceAuditReport;
|
|
212
|
+
export function judgeReleasePackageAudit(plan: ReleasePackageAuditPlan | undefined, evidence: unknown, includeDev = false): ReleasePackageAuditResult | ReleaseWorkspaceAuditReport {
|
|
213
|
+
if (plan === undefined) {
|
|
214
|
+
const base = { scope: 'workspace' as const, nonBlocking: true as const, includeDev: includeDev || (obj(evidence) && evidence.includeDev === true) };
|
|
215
|
+
if (evidence === undefined) return { ...base, status: 'not-run', reason: 'workspace audit was not executed' };
|
|
216
|
+
try { const r = workspaceReport(evidence); return { ...base, status: r.counts.total! > 0 ? 'findings' : 'clean', counts: r.counts, findings: r.findings }; }
|
|
217
|
+
catch (error) { return { ...base, status: 'error', reason: error instanceof Error ? error.message : 'workspace audit unavailable' }; }
|
|
218
|
+
}
|
|
219
|
+
const base = { scope: 'package-consumer' as const, package: plan.package, version: plan.version };
|
|
220
|
+
let phases: ReleasePackageAuditResult['phases'] = [];
|
|
221
|
+
try {
|
|
222
|
+
requireEvidence(obj(evidence), 'package audit evidence missing'); const e = evidence;
|
|
223
|
+
requireEvidence(e.package === plan.package && e.version === plan.version, 'package audit identity mismatch');
|
|
224
|
+
requireEvidence(Array.isArray(e.phases), 'package phase records missing');
|
|
225
|
+
phases = e.phases.map((p: unknown) => { requireEvidence(obj(p) && typeof p.phase === 'string' && integer(p.exitCode), 'malformed phase record'); return { phase: p.phase, exitCode: p.exitCode, ...(p.timedOut === true ? { timedOut: true } : {}) }; });
|
|
226
|
+
requireEvidence(plan.phases.join(',') === 'pack,install,validate,audit' && phases.map(p => p.phase).join(',') === plan.phases.join(','), 'required package phase missing, duplicated or reordered');
|
|
227
|
+
requireEvidence(phases.every(p => p.timedOut !== true && (p.phase === 'audit' || p.exitCode === 0)), 'package phase failed or timed out');
|
|
228
|
+
requireEvidence(e.sourceRestored === true && e.cleanup === true, 'source restoration or owned scratch cleanup not established');
|
|
229
|
+
requireEvidence(hash(e.candidateSha256) && hash(e.lockSha256), 'candidate/lock digest missing');
|
|
230
|
+
requireEvidence(typeof e.npmVersion === 'string' && /^10\.\d+\.\d+$/.test(e.npmVersion), 'unsupported or missing npm version');
|
|
231
|
+
const c = closure([{ ...plan, tarball: e.tarball }], e); const r = auditReport(e.audit);
|
|
232
|
+
requireEvidence(r.totalDependencies === c.inventory.length, 'audit dependency total does not cover the validated lock inventory');
|
|
233
|
+
requireEvidence(phases[3]!.exitCode === e.audit.exitCode, 'audit phase and execution exits disagree');
|
|
234
|
+
return { ...base, status: r.high ? 'findings' : 'clean', ...(r.high ? { failureClass: 'VULNS_HIGH' as const, reason: `${r.counts.total} vulnerabilities found (high ${r.counts.high}, critical ${r.counts.critical})` } : {}), phases,
|
|
235
|
+
candidateSha256: e.candidateSha256, lockSha256: e.lockSha256, npmVersion: e.npmVersion, platform: e.platform,
|
|
236
|
+
resolvedCount: c.inventory.length, installedCount: e.installed.length, closure: c.inventory, optionalAbsences: c.absences, counts: r.counts, findings: r.findings };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const reason = error instanceof Error ? error.message : 'package audit unavailable';
|
|
239
|
+
const e = obj(evidence) ? evidence : {};
|
|
240
|
+
return { ...base, status: 'error', failureClass: reason.includes('phase missing') || reason.includes('evidence missing') ? 'UNEXECUTED_STEP' : 'AUDIT_ERROR', reason, phases,
|
|
241
|
+
...(hash(e.candidateSha256) ? { candidateSha256: e.candidateSha256 } : {}), ...(hash(e.lockSha256) ? { lockSha256: e.lockSha256 } : {}),
|
|
242
|
+
...(version(e.npmVersion) ? { npmVersion: e.npmVersion } : {}),
|
|
243
|
+
...(obj(e.platform) && typeof e.platform.os === 'string' && typeof e.platform.cpu === 'string' ? { platform: { os: e.platform.os, cpu: e.platform.cpu } } : {}) };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
/** Additional five-root admission; the singleton judge uses the same closure/edge/audit validators. */
|
|
249
|
+
export function judgeReleaseCohortAudit(roots: readonly { readonly package: string; readonly version: string; readonly dir: string; readonly tarball: string; readonly sha256: string; readonly integrity: string }[], evidence: unknown): { scope: 'package-cohort'; status: 'clean' | 'findings' | 'error'; reason?: string; graph?: readonly Record<string, any>[]; optionalAbsences?: readonly { name: string; kind: string; parent: string }[]; resolvedCount?: number; counts?: Readonly<Record<string, number>> } {
|
|
250
|
+
const base = { scope: 'package-cohort' as const };
|
|
251
|
+
try {
|
|
252
|
+
const expected = ['harness-core', 'harness-cli', 'skills-meta', 'keysarium', 'skills-feature-adr'].map(name => '@dzhechkov/' + name).sort();
|
|
253
|
+
requireEvidence(Array.isArray(roots) && roots.length === 5 && JSON.stringify(roots.map(root => root.package).sort()) === JSON.stringify(expected), 'cohort must be the exact authorized five');
|
|
254
|
+
requireEvidence(roots.every(root => version(root.version) && hash(root.sha256) && typeof root.dir === 'string' && isAbsolute(root.dir) && typeof root.tarball === 'string' && isAbsolute(root.tarball) && root.tarball === resolve(root.tarball)) && new Set(roots.map(root => root.tarball)).size === roots.length, 'cohort root artifact identity incomplete');
|
|
255
|
+
requireEvidence(obj(evidence) && evidence.cleanup === true && evidence.sourceRestored === true && hash(evidence.lockSha256) && /^10\.\d+\.\d+$/.test(evidence.npmVersion), 'cohort execution, restoration or cleanup evidence incomplete');
|
|
256
|
+
const e = evidence;
|
|
257
|
+
requireEvidence(typeof e.lockBytes === 'string' && Buffer.byteLength(e.lockBytes) <= 16 * 1024 * 1024 && createHash('sha256').update(e.lockBytes).digest('hex') === e.lockSha256 && JSON.stringify(JSON.parse(e.lockBytes)) === JSON.stringify(e.lock), 'cohort raw lock/digest/object evidence disagree');
|
|
258
|
+
requireEvidence(Array.isArray(e.phases) && e.phases.map((p: Obj) => p.phase).join(',') === 'pack,install,validate,audit' && e.phases.every((p: Obj) => integer(p.exitCode) && p.timedOut !== true && (p.phase === 'audit' || p.exitCode === 0)), 'cohort execution phases missing or failed');
|
|
259
|
+
const c = closure(roots, e, true); const audit = auditReport(e.audit);
|
|
260
|
+
requireEvidence(c.inventory.length <= 10000 && audit.totalDependencies === c.inventory.length && e.phases[3].exitCode === e.audit.exitCode, 'cohort audit does not cover the complete lock');
|
|
261
|
+
if (audit.high) return { ...base, status: 'findings', reason: 'cohort has high/critical findings', counts: audit.counts };
|
|
262
|
+
return { ...base, status: 'clean', graph: c.graph, optionalAbsences: c.absences, resolvedCount: c.inventory.length, counts: audit.counts };
|
|
263
|
+
} catch (error) { return { ...base, status: 'error', reason: error instanceof Error ? error.message : 'cohort admission unavailable' }; }
|
|
264
|
+
}
|
package/src/release.ts
CHANGED
|
@@ -25,7 +25,10 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
25
25
|
import type { Dirent } from 'node:fs';
|
|
26
26
|
import { join, relative } from 'node:path';
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { planReleasePackageAudit, judgeReleasePackageAudit } from './release-package-audit.js';
|
|
29
|
+
import type { ReleasePackageAuditPlan, ReleasePackageAuditResult, ReleaseWorkspaceAuditReport } from './release-package-audit.js';
|
|
30
|
+
|
|
31
|
+
import { discoverPackages, matchesPublishFilter, validatePublishFilters, orderByDependencies } from './publish.js';
|
|
29
32
|
import { planPackedInstallSmoke, judgePackedInstallSmoke } from './packed-install-smoke.js';
|
|
30
33
|
import type { PackedInstallPlan, PackedInstallExecution } from './packed-install-smoke.js';
|
|
31
34
|
|
|
@@ -90,7 +93,7 @@ export interface GateStep {
|
|
|
90
93
|
* `exec` (default): the CLI runs `cmd`. `synthetic-fail`: the PLAN already knows this step
|
|
91
94
|
* fails (missing bin, stale dist) — classification sees it without any execution.
|
|
92
95
|
*/
|
|
93
|
-
readonly kind?: 'exec' | 'synthetic-fail' | undefined;
|
|
96
|
+
readonly kind?: 'exec' | 'synthetic-fail' | 'package-audit' | undefined;
|
|
94
97
|
/** For `synthetic-fail` steps: the failure class the verdict must carry. */
|
|
95
98
|
readonly failClass?: ReleaseFailureClass | undefined;
|
|
96
99
|
/**
|
|
@@ -125,6 +128,9 @@ export interface GatePlan {
|
|
|
125
128
|
* refuses it — the two doors would not be equal, contradicting FR-6's own claim.
|
|
126
129
|
*/
|
|
127
130
|
readonly packedInstallPlan?: PackedInstallPlan | undefined;
|
|
131
|
+
readonly packageAuditPlans?: readonly ReleasePackageAuditPlan[];
|
|
132
|
+
readonly workspaceAuditStep?: GateStep;
|
|
133
|
+
readonly workspaceAuditIncludeDev?: boolean;
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
/** The CLI's record of running one exec step. */
|
|
@@ -134,6 +140,7 @@ export interface GateExecution {
|
|
|
134
140
|
readonly stdout: string;
|
|
135
141
|
readonly stderr: string;
|
|
136
142
|
readonly durationMs: number;
|
|
143
|
+
readonly packageAuditEvidence?: unknown;
|
|
137
144
|
readonly timedOut?: boolean | undefined;
|
|
138
145
|
}
|
|
139
146
|
|
|
@@ -175,6 +182,8 @@ export interface ReleaseVerdict {
|
|
|
175
182
|
/** Fail-closed decision point: `'proceed'` iff every gate is clean AND something ran. */
|
|
176
183
|
readonly publishAction: 'proceed' | 'blocked';
|
|
177
184
|
readonly timestamp: string;
|
|
185
|
+
readonly packageAudits: readonly ReleasePackageAuditResult[];
|
|
186
|
+
readonly workspaceAudit: ReleaseWorkspaceAuditReport;
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
/** Default per-step timeouts (NFR-4: a hung child is a classified failure, not a hung release). */
|
|
@@ -249,6 +258,7 @@ export function collectPackageFacts(monorepoRoot: string, filter?: readonly stri
|
|
|
249
258
|
throw new Error('release: --filter requires non-empty package-name substrings (empty would match ALL packages)');
|
|
250
259
|
}
|
|
251
260
|
const discovered = discoverPackages(monorepoRoot);
|
|
261
|
+
validatePublishFilters(filter, discovered);
|
|
252
262
|
const selected =
|
|
253
263
|
filter === undefined ? discovered : discovered.filter((p) => filter.some((f) => matchesPublishFilter(p, f, monorepoRoot)));
|
|
254
264
|
const ordered = orderByDependencies(selected);
|
|
@@ -379,21 +389,22 @@ export function planReleaseGates(facts: readonly ReleasePackageFacts[], opts: Pl
|
|
|
379
389
|
}
|
|
380
390
|
}
|
|
381
391
|
|
|
382
|
-
//
|
|
392
|
+
// HARD audit: every selected root, including binless/dependency-free packages.
|
|
393
|
+
const packageAuditPlans = facts.map(f => planReleasePackageAudit(f, t.audit));
|
|
394
|
+
for (const audit of packageAuditPlans) steps.push({
|
|
395
|
+
id: `audit:package:${audit.package}`, gate: 'audit', pkg: audit.package,
|
|
396
|
+
cmd: 'pack → install (--ignore-scripts, prod/optional/peer) → validate closure → npm audit --json --audit-level=high',
|
|
397
|
+
cwd: audit.dir, timeoutMs: t.audit, reason: 'isolated selected tarball consumer must have no high/critical advisories', kind: 'package-audit',
|
|
398
|
+
});
|
|
383
399
|
const dev = opts.includeDevDeps === true;
|
|
384
|
-
|
|
385
|
-
id: 'audit:workspace',
|
|
386
|
-
gate: 'audit',
|
|
400
|
+
const workspaceAuditStep: GateStep = {
|
|
401
|
+
id: 'audit:workspace', gate: 'audit',
|
|
387
402
|
cmd: opts.pnpmLockPresent
|
|
388
|
-
? `pnpm audit${dev ? '' : ' --prod'} --audit-level high`
|
|
389
|
-
: `npm audit${dev ? '' : ' --omit=dev'} --audit-level=high`,
|
|
390
|
-
cwd: opts.monorepoRoot,
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
? 'no >=high advisories across ALL workspace dependencies (dev included via --audit-dev)'
|
|
394
|
-
: 'no >=high advisories in production dependencies (dev-only chains excluded — widen with --audit-dev)',
|
|
395
|
-
kind: 'exec',
|
|
396
|
-
});
|
|
403
|
+
? `pnpm audit${dev ? '' : ' --prod'} --audit-level high --json`
|
|
404
|
+
: `npm audit${dev ? '' : ' --omit=dev'} --audit-level=high --json --offline=false`,
|
|
405
|
+
cwd: opts.monorepoRoot, timeoutMs: t.audit,
|
|
406
|
+
reason: 'separate nonblocking workspace advisory observation', kind: 'exec',
|
|
407
|
+
};
|
|
397
408
|
|
|
398
409
|
// Gates 3+4 — per package. AM-3: a stale dist is NEVER checked/booted as-is.
|
|
399
410
|
for (const f of facts) {
|
|
@@ -559,7 +570,7 @@ export function planReleaseGates(facts: readonly ReleasePackageFacts[], opts: Pl
|
|
|
559
570
|
}
|
|
560
571
|
}
|
|
561
572
|
|
|
562
|
-
return { steps, skips, packages: facts.map((f) => f.name), ...(packedInstallPlan !== undefined ? { packedInstallPlan } : {}) };
|
|
573
|
+
return { packageAuditPlans, workspaceAuditStep, workspaceAuditIncludeDev: dev, steps, skips, packages: facts.map((f) => f.name), ...(packedInstallPlan !== undefined ? { packedInstallPlan } : {}) };
|
|
563
574
|
}
|
|
564
575
|
|
|
565
576
|
/* ------------------------------------------------------------------ */
|
|
@@ -802,6 +813,23 @@ export function classifyGateExecutions(
|
|
|
802
813
|
packedInstallVerdict = judgePackedInstallSmoke(packedInstallPlan, translated);
|
|
803
814
|
}
|
|
804
815
|
|
|
816
|
+
const packageAudits: ReleasePackageAuditResult[] = [];
|
|
817
|
+
for (const name of plan?.packages ?? []) {
|
|
818
|
+
const auditPlans = (plan.packageAuditPlans ?? []).filter(p => p.package === name);
|
|
819
|
+
const steps = (plan.steps ?? []).filter(s => s.gate === 'audit' && s.pkg === name && s.kind === 'package-audit');
|
|
820
|
+
const records = (executions ?? []).filter(e => e?.stepId === `audit:package:${name}`);
|
|
821
|
+
if (auditPlans.length !== 1 || steps.length !== 1 || records.length !== 1) {
|
|
822
|
+
packageAudits.push({ scope: 'package-consumer', package: name, version: auditPlans[0]?.version ?? '', status: 'error', failureClass: 'UNEXECUTED_STEP', reason: 'selected package audit plan/step/execution missing or duplicated', phases: [] });
|
|
823
|
+
} else {
|
|
824
|
+
const result = judgeReleasePackageAudit(auditPlans[0]!, records[0]!.packageAuditEvidence);
|
|
825
|
+
packageAudits.push(records[0]!.timedOut === true || (result.status === 'clean' && records[0]!.exitCode !== 0)
|
|
826
|
+
? { ...result, status: 'error', failureClass: 'AUDIT_ERROR', reason: 'package audit wrapper timeout or exit/evidence inconsistency' }
|
|
827
|
+
: result);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
const workspaceRecords = (executions ?? []).filter(e => e?.stepId === 'audit:workspace');
|
|
831
|
+
const workspaceAudit = judgeReleasePackageAudit(undefined, workspaceRecords.length === 1 ? { ...workspaceRecords[0], includeDev: plan.workspaceAuditIncludeDev === true } : undefined, plan.workspaceAuditIncludeDev === true);
|
|
832
|
+
|
|
805
833
|
const gates: GateResult[] = RELEASE_GATE_ORDER.map((gate) => {
|
|
806
834
|
const gateSteps = (plan?.steps ?? []).filter((s) => s?.gate === gate);
|
|
807
835
|
const gateSkips = (plan?.skips ?? []).filter((s) => s?.gate === gate);
|
|
@@ -810,6 +838,8 @@ export function classifyGateExecutions(
|
|
|
810
838
|
|
|
811
839
|
for (const step of gateSteps) {
|
|
812
840
|
try {
|
|
841
|
+
if (gate === 'audit') continue; // typed per-root judge below owns this gate
|
|
842
|
+
|
|
813
843
|
if (packedInstallBinStepIds.has(step.id)) continue; // judged separately below (AM-7)
|
|
814
844
|
if (step.kind === 'synthetic-fail') {
|
|
815
845
|
failures.push({ pkg: step.pkg, reason: step.reason, class: step.failClass ?? 'EXIT_NONZERO' });
|
|
@@ -836,11 +866,6 @@ export function classifyGateExecutions(
|
|
|
836
866
|
continue;
|
|
837
867
|
}
|
|
838
868
|
if (typeof exec.exitCode !== 'number' || exec.exitCode !== 0) {
|
|
839
|
-
if (gate === 'audit') {
|
|
840
|
-
const { cls, reason } = classifyAuditFailure(`${exec.stdout ?? ''}\n${exec.stderr ?? ''}`);
|
|
841
|
-
const detail = auditDetailLine(exec.stdout, exec.stderr);
|
|
842
|
-
failures.push({ pkg: step.pkg, reason: `${reason}${detail ? ` — ${detail}` : ''}`, class: cls });
|
|
843
|
-
} else {
|
|
844
869
|
// FR-1: for tests/syntax/smoke, name the ACTUAL failure (summary + failing tests),
|
|
845
870
|
// not just the first output line — see testsFailureDetail's doc comment for why.
|
|
846
871
|
const detail = testsFailureDetail(exec.stderr, exec.stdout);
|
|
@@ -851,7 +876,6 @@ export function classifyGateExecutions(
|
|
|
851
876
|
// AM-4: per-stream tails, never merged — see GateFailure.tails doc comment.
|
|
852
877
|
tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
|
|
853
878
|
});
|
|
854
|
-
}
|
|
855
879
|
continue;
|
|
856
880
|
}
|
|
857
881
|
passed += 1;
|
|
@@ -861,6 +885,13 @@ export function classifyGateExecutions(
|
|
|
861
885
|
}
|
|
862
886
|
}
|
|
863
887
|
|
|
888
|
+
if (gate === 'audit') {
|
|
889
|
+
for (const result of packageAudits) {
|
|
890
|
+
if (result.status === 'clean') passed += 1;
|
|
891
|
+
else failures.push({ pkg: result.package, class: result.failureClass ?? 'AUDIT_ERROR', reason: result.reason ?? 'package consumer audit unavailable' });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
864
895
|
// AM-7: fold the packed-install bin verdicts (judged via judgePackedInstallSmoke, above) into
|
|
865
896
|
// the 'smoke' gate — the ONLY gate that ever plans packed-install steps.
|
|
866
897
|
if (gate === 'smoke' && packedInstallVerdict !== undefined) {
|
|
@@ -893,6 +924,8 @@ export function classifyGateExecutions(
|
|
|
893
924
|
|
|
894
925
|
return {
|
|
895
926
|
gates,
|
|
927
|
+
packageAudits,
|
|
928
|
+
workspaceAudit,
|
|
896
929
|
ok,
|
|
897
930
|
blockedBy,
|
|
898
931
|
skipped: plan?.skips ?? [],
|
package/src/round.ts
CHANGED
|
@@ -102,6 +102,8 @@ export interface RoundLedgerRow {
|
|
|
102
102
|
* fix-round-1 #1/#2 (ADR-001 п.2 amended): also present when an EXPLICIT `--reviewer` AGREES with
|
|
103
103
|
* the sidecar's own `gradedBy` (`reviewSource:'flag+qe-bridge'` below names that case). */
|
|
104
104
|
readonly reviewMinutes?: number;
|
|
105
|
+
readonly reviewIdentitySource?: 'round-run-task' | 'partial-identity' | 'legacy-window';
|
|
106
|
+
readonly reviewIdentity?: { readonly round: number | null; readonly roundRun: string | null; readonly taskId: string | null; readonly bridgeRunId: string | null };
|
|
105
107
|
/** measurement-integrity FR-7: present ONLY alongside `reviewMinutes` — names where `reviewer` and
|
|
106
108
|
* `reviewMinutes` came from, so a reader never confuses a sidecar-sourced figure for a flag.
|
|
107
109
|
* review-cost-ledger fix-round-1 #1/#2 (ADR-001 п.2 amended): `'qe-bridge'` when `reviewer` was
|
|
@@ -188,6 +190,8 @@ export interface RoundLedgerRow {
|
|
|
188
190
|
* `closeRound` never opens a file.
|
|
189
191
|
*/
|
|
190
192
|
export interface RoundReviewSidecar {
|
|
193
|
+
readonly reviewIdentitySource?: RoundLedgerRow['reviewIdentitySource'];
|
|
194
|
+
readonly reviewIdentity?: RoundLedgerRow['reviewIdentity'];
|
|
191
195
|
/** Who graded it — family + model, e.g. `codex:gpt-5.6-sol`. */
|
|
192
196
|
readonly gradedBy: string;
|
|
193
197
|
readonly elapsedMs: number;
|
|
@@ -609,6 +613,8 @@ export function closeRound(input: {
|
|
|
609
613
|
...(nonEmpty(input.stateId) ? { stateId: input.stateId } : {}),
|
|
610
614
|
...(input.state.envelope !== undefined ? { envelope: input.state.envelope } : {}),
|
|
611
615
|
...(reviewSource !== null ? { reviewSource } : {}),
|
|
616
|
+
...(reviewerTiedToSidecar && input.reviewSidecar?.reviewIdentitySource !== undefined ? { reviewIdentitySource: input.reviewSidecar.reviewIdentitySource } : {}),
|
|
617
|
+
...(reviewerTiedToSidecar && input.reviewSidecar?.reviewIdentity !== undefined ? { reviewIdentity: input.reviewSidecar.reviewIdentity } : {}),
|
|
612
618
|
...(reviewMinutes !== null ? { reviewMinutes } : {}),
|
|
613
619
|
taskId,
|
|
614
620
|
...(taskIdSource !== null ? { taskIdSource } : {}),
|
|
@@ -700,11 +706,13 @@ export function readOpenRoundTaskId(
|
|
|
700
706
|
states: readonly RoundState[],
|
|
701
707
|
slug: string,
|
|
702
708
|
unreadableStateCount = 0,
|
|
709
|
+
authorityReadError = false,
|
|
703
710
|
): {
|
|
704
711
|
readonly taskId: string | null;
|
|
705
712
|
readonly source: 'open-round' | 'derived-legacy' | 'no-open-round' | 'ambiguous' | 'unavailable';
|
|
706
713
|
} {
|
|
707
714
|
const matches = states.filter((s) => s.slug === slug);
|
|
715
|
+
if (authorityReadError) return { taskId: null, source: 'unavailable' };
|
|
708
716
|
const unreadable = Number.isFinite(unreadableStateCount) && unreadableStateCount > 0 ? Math.floor(unreadableStateCount) : 0;
|
|
709
717
|
if (unreadable > 0 && matches.length === 0) return { taskId: null, source: 'unavailable' };
|
|
710
718
|
if (unreadable > 0) return { taskId: null, source: 'ambiguous' };
|