peaks-loop 4.0.12 → 4.0.14
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 +29 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/core/doctor-command.js +26 -3
- package/dist/cli/commands/outer-cache-commands.d.ts +11 -0
- package/dist/cli/commands/outer-cache-commands.js +120 -0
- package/dist/services/doctor/doctor-service/checks/multi-binary-drift.d.ts +61 -0
- package/dist/services/doctor/doctor-service/checks/multi-binary-drift.js +268 -0
- package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
- package/dist/services/doctor/doctor-service/report/final-summary.d.ts +13 -5
- package/dist/services/doctor/doctor-service/report/final-summary.js +19 -9
- package/dist/services/doctor/doctor-service/types.d.ts +55 -0
- package/dist/services/doctor/doctor-service.d.ts +2 -1
- package/dist/services/doctor/doctor-service.js +1 -0
- package/dist/services/doctor/index.d.ts +1 -1
- package/dist/services/doctor/index.js +1 -1
- package/dist/services/session/session-binding-bridge.js +58 -5
- package/dist/services/skills/hooks-settings-service.js +22 -2
- package/dist/services/skills/outer-cache-hook-constants.d.ts +24 -0
- package/dist/services/skills/outer-cache-hook-constants.js +24 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +3 -1
- package/dist/services/skills/skill-statusline-renderer.js +37 -1
- package/dist/services/skills/skill-statusline-service.d.ts +14 -0
- package/dist/services/skills/skill-statusline-service.js +40 -16
- package/dist/services/skills/skill-statusline-sid-suffix.d.ts +55 -0
- package/dist/services/skills/skill-statusline-sid-suffix.js +65 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.14 — 2026-08-06 (outer-session cache + ensureSession meta over-coverage)
|
|
4
|
+
|
|
5
|
+
**Fixes the "5 terminals / 5 sessions stuck on 3fe1be" bug** (slice `2026-08-06-session-outer-cache-and-meta-coverage`, commit `f02a9b45`):
|
|
6
|
+
- `src/services/session/session-binding-bridge.ts` `getCurrentOuterSessionId(projectRoot)` now resolves `PEAKS_OUTER_SESSION_ID` → `CLAUDE_CODE_SESSION_ID` → file-cache at `.peaks/_runtime/.outer-session-cache.json` → `undefined`. Any IO error or non-string `outerSessionId` is a cache-miss (no throw). The cache file lives under the existing `.peaks/_runtime/` gitignore parent rule — no `.gitignore` modification.
|
|
7
|
+
- Same file: `ensureSession` early-return path now calls `setSessionMeta(projectRoot, existing.sessionId, { outerSessionId })` BEFORE returning. The on-disk `.peaks/_runtime/<sid>/session.json` always reflects the latest outerSessionId (not a stale value captured at first-bind time). All other meta fields (title / skill / mode / gate / createdAt) preserved via read-modify-write; `lastActivity` bumped to `now`.
|
|
8
|
+
- `src/services/skills/hooks-settings-service.ts` `resolveHookEntries('claude-code')` now appends a SessionStart entry that runs `peaks outer-cache write --project ...` to keep the cache in sync with the active Claude Code session. Uninstall strips the entry alongside the gate-enforce entry. Multi-binary drift guard (slice 2026-08-05) still fires correctly because the SessionStart entry uses the same `peaks` binary.
|
|
9
|
+
- New CLI: `peaks outer-cache write` (no flags) + `peaks outer-cache read` (returns `{ missing: true }` envelope on absent / malformed / IO error). Write exits 1 with `OUTER_CACHE_NO_ENV` when neither `PEAKS_OUTER_SESSION_ID` nor `CLAUDE_CODE_SESSION_ID` is set.
|
|
10
|
+
- Tests: `tests/unit/session/get-current-outer-session-id.test.ts` (10 cases pinning env > cache > undefined ordering, cache-miss / malformed JSON / IO error tolerance) + `tests/unit/session/ensure-session-meta-coverage.test.ts` (7 cases pinning AC8-AC11 over-coverage). Regression sweep: 179/179 PASS / 1 SKIP (pre-existing Win-only conditional).
|
|
11
|
+
- A.3 / A.4 / A.5 (workflowId by callerId + `session.json` migration + cross-outer rotation cases) explicitly reserved for a future session per PRD NG1-NG4.
|
|
12
|
+
|
|
13
|
+
**Lockstep bump.** peaks-loop-shared `0.0.43 → 0.0.44` (CLI_VERSION re-stamped to 4.0.14); peaks-loop-mut `0.1.16 → 0.1.17`; peaks-loop-shared-channel `0.0.20 → 0.0.21`.
|
|
14
|
+
|
|
15
|
+
## 4.0.13 — 2026-08-06 (statusline empty-render fix + sid-only marker + multi-binary drift guard)
|
|
16
|
+
|
|
17
|
+
**Statusline callerId fallback + active `[short-sid]` suffix** (slice `2026-08-05-statusline-empty-render-and-short-sid-suffix`, commit `4be37d08`):
|
|
18
|
+
- `src/services/skills/skill-statusline-service.ts` `readPresenceReadOnly` now retries with `callerId: null` when callerId-filtered resolution returns `source: 'none'`. Multi-tenant isolation invariant preserved (slice 4-B Case A regression test stays green).
|
|
19
|
+
- `src/services/skills/skill-statusline-renderer.ts` introduces `formatShortSid(sessionId) = sessionId.split('-').pop()` helper. Active state now renders `Peaks ● peaks-code → peaks-loop [3fe1be]`.
|
|
20
|
+
- New tests: `tests/unit/skills/skill-statusline-empty-render-and-short-sid-suffix.test.ts` (8 cases). Existing dual-skill test updated to accept the `[aaaa]` suffix.
|
|
21
|
+
- Fixes the "new session statusline empty despite active peaks-code lease" bug (root cause: outerSessionId drift between caller shell and lease's callerId).
|
|
22
|
+
|
|
23
|
+
**Statusline sid-only marker (idle/stale) + multi-binary drift guard** (slice `2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard`, commits `95654d48` + `34de6c22` repair):
|
|
24
|
+
- `skill-statusline-service.ts` adds `sessionId: string | null` to `StatusLineModel`. `skill-statusline-sid-suffix.ts` (new, 78 LOC) holds `computeRootSuffix` + `formatShortSid` (extracted from renderer for LOC cap; renderer 806 → 776).
|
|
25
|
+
- Idle and stale states now append ` [3fe1be]` after `peaks-loop` when session.json is bound. Invalid-presence stays unchanged (G2 — read-error signal must remain loud).
|
|
26
|
+
- New `peaks doctor check` plugin `build:multi-binary-drift` scans `process.env.PATH` for `peaks` binaries (cross-platform: `peaks` / `peaks.cmd` / `peaks.ps1`), resolves each to its `node_modules/peaks-loop/package.json`, dedupes by realpath, and emits `PEAKS_MULTI_BINARY_DRIFT` warning when ≥ 2 distinct versions coexist. Caught the `/c/nvm4w/nodejs 4.0.12 + /c/Users/smallMark/AppData/Roaming/npm 3.1.2` case that produced "Hook JSON output validation failed" on fresh IDE sessions.
|
|
27
|
+
- **Severity-aware `buildReport`** (the canonical reference for warn-only doctor checks): `DoctorCheck` now carries `severity: 'error' | 'warning'`. `buildReport` separates errors from warnings; `summary.ok = errors === 0`. Multi-binary drift emits `severity: 'warning'`. CLI exit code only flips on errors.
|
|
28
|
+
- Tests: `tests/unit/skills/skill-statusline-sid-only-marker.test.ts` (8 cases) + `tests/unit/doctor/multi-binary-drift-check.test.ts` (13 cases) + `tests/unit/doctor/final-summary-severity.test.ts` (7 cases) + `tests/unit/doctor/doctor-exit-code-warn-only.test.ts` (5 cases). 170/170 green on slice surface.
|
|
29
|
+
|
|
30
|
+
**Lockstep bump.** peaks-loop-shared `0.0.42 → 0.0.43` (CLI_VERSION re-stamped to 4.0.13); peaks-loop-mut `0.1.15 → 0.1.16`; peaks-loop-shared-channel `0.0.19 → 0.0.20`.
|
|
31
|
+
|
|
3
32
|
## 4.0.12 — 2026-08-05 (5-slice optimization bundle)
|
|
4
33
|
|
|
5
34
|
**publish.yml strict tag gate** (slice 1, commit `f60f7597`):
|
|
@@ -36,6 +36,7 @@ import { registerWorkflowEvalCommands } from './loop-eval-commands.js';
|
|
|
36
36
|
import { registerMutCommands } from './mut-commands.js';
|
|
37
37
|
import { registerObservabilityCommands } from './observability-commands.js';
|
|
38
38
|
import { registerOpenSpecCommands } from './openspec-commands.js';
|
|
39
|
+
import { registerOuterCacheCommands } from './outer-cache-commands.js';
|
|
39
40
|
import { registerPerfAuditCommands } from './perf-audit-commands.js';
|
|
40
41
|
import { registerPerfCommands } from './perf-commands.js';
|
|
41
42
|
import { registerPlaywrightCommands } from './playwright-commands.js';
|
|
@@ -127,6 +128,7 @@ const REGISTRATIONS = [
|
|
|
127
128
|
['adapter-commands-s2a', registerAdapterS2ACommands], ['polyrepo-commands', registerPolyrepoCommands],
|
|
128
129
|
['worktree-auth-commands', registerWorktreeAuthCommand],
|
|
129
130
|
['session-spill-demo', registerSpillDemoCommand],
|
|
131
|
+
['outer-cache-commands', registerOuterCacheCommands],
|
|
130
132
|
];
|
|
131
133
|
function dispatchRegister(register, program, io) {
|
|
132
134
|
if (register.length <= 1) {
|
|
@@ -212,6 +212,10 @@ export function registerDoctorCommand(program, io) {
|
|
|
212
212
|
const data = logsSection === null
|
|
213
213
|
? { ...report, staleBinding: staleBindingSection }
|
|
214
214
|
: { ...report, logs: logsSection, staleBinding: staleBindingSection };
|
|
215
|
+
// Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
216
|
+
// repair cycle: `report.summary.ok` already factors in the
|
|
217
|
+
// severity-aware aggregation in `buildReport` (warnings do NOT
|
|
218
|
+
// flip `ok`). Stale-binding is independent and still escalates.
|
|
215
219
|
const result = report.summary.ok && staleInstances.length === 0
|
|
216
220
|
? ok('doctor', data)
|
|
217
221
|
: fail('doctor', 'DOCTOR_FAILED', staleInstances.length > 0
|
|
@@ -224,9 +228,20 @@ export function registerDoctorCommand(program, io) {
|
|
|
224
228
|
}
|
|
225
229
|
else {
|
|
226
230
|
// Human-readable: one line per check, green/red indicators, no JSON.
|
|
231
|
+
// Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
232
|
+
// repair cycle: warn-only findings (severity: 'warning') surface
|
|
233
|
+
// as `! check.ok` with the `! ` (warning) glyph so the operator
|
|
234
|
+
// can tell them apart from real errors without reading JSON.
|
|
227
235
|
for (const check of report.checks) {
|
|
228
|
-
|
|
229
|
-
|
|
236
|
+
if (check.ok) {
|
|
237
|
+
io.stdout(` + ${check.message}`);
|
|
238
|
+
}
|
|
239
|
+
else if (check.severity === 'warning') {
|
|
240
|
+
io.stdout(` ! ${check.message}`);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
io.stdout(` × ${check.message}`);
|
|
244
|
+
}
|
|
230
245
|
}
|
|
231
246
|
if (logsSection !== null) {
|
|
232
247
|
io.stdout('\n logs:');
|
|
@@ -252,11 +267,19 @@ export function registerDoctorCommand(program, io) {
|
|
|
252
267
|
io.stdout(' rerun with --cleanup-stale to drop them');
|
|
253
268
|
}
|
|
254
269
|
}
|
|
255
|
-
io.stdout(`\n ${report.summary.passed} passed, ${report.summary.failed} failed`);
|
|
270
|
+
io.stdout(`\n ${report.summary.passed} passed, ${report.summary.failed} failed${report.summary.warnings > 0 ? `, ${report.summary.warnings} warning(s)` : ''}`);
|
|
256
271
|
if (!report.summary.ok || staleInstances.length > 0) {
|
|
257
272
|
io.stderr(`\nDOCTOR_FAILED: ${staleInstances.length > 0 ? 'stale binding present' : `${report.summary.failed} check(s) failed`}.`);
|
|
258
273
|
}
|
|
274
|
+
else if (report.summary.warnings > 0) {
|
|
275
|
+
io.stdout(`\n ${report.summary.warnings} warning(s) present — exit code 0 (warn-only).`);
|
|
276
|
+
}
|
|
259
277
|
}
|
|
278
|
+
// Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
279
|
+
// repair cycle: `report.summary.ok` already factors in the
|
|
280
|
+
// severity-aware aggregation in `buildReport` (warnings do NOT
|
|
281
|
+
// flip `ok`). The exit-code gate remains `summary.ok &&
|
|
282
|
+
// !staleInstances` — no separate warning special-case needed here.
|
|
260
283
|
if (!report.summary.ok || staleInstances.length > 0) {
|
|
261
284
|
process.exitCode = 1;
|
|
262
285
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
3
|
+
export type OuterCacheWriteOptions = {
|
|
4
|
+
readonly project?: string;
|
|
5
|
+
readonly json?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type OuterCacheReadOptions = {
|
|
8
|
+
readonly project?: string;
|
|
9
|
+
readonly json?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function registerOuterCacheCommands(program: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2026-08-06-session-outer-cache (G1 / G2) — per-project outer-session
|
|
3
|
+
* cache CLI surface.
|
|
4
|
+
*
|
|
5
|
+
* Writes and reads `.peaks/_runtime/.outer-session-cache.json` so that
|
|
6
|
+
* peaks CLI sub-processes (which typically do NOT inherit
|
|
7
|
+
* `CLAUDE_CODE_SESSION_ID` from the parent shell) can resolve the
|
|
8
|
+
* current outer session id via `getCurrentOuterSessionId(projectRoot)`
|
|
9
|
+
* in `src/services/session/session-binding-bridge.ts`.
|
|
10
|
+
*
|
|
11
|
+
* The SessionStart hook wired by `peaks hooks install` invokes
|
|
12
|
+
* `peaks outer-cache write` to keep this file in sync with the active
|
|
13
|
+
* Claude Code / Trae / IDE session. The file is under `.peaks/_runtime/`
|
|
14
|
+
* (gitignored), so no `.gitignore` change is required.
|
|
15
|
+
*
|
|
16
|
+
* Subcommand surface:
|
|
17
|
+
* - `peaks outer-cache write` (no flags) — read PEAKS_OUTER_SESSION_ID
|
|
18
|
+
* ?? CLAUDE_CODE_SESSION_ID, write the cache file, return JSON
|
|
19
|
+
* envelope with `{ outerSessionId, capturedAt, cachePath, written }`.
|
|
20
|
+
* - `peaks outer-cache read` — return
|
|
21
|
+
* `{ outerSessionId, capturedAt, cachePath }` when present or
|
|
22
|
+
* `{ missing: true, cachePath }` when not. Never throws on
|
|
23
|
+
* malformed JSON / IO error.
|
|
24
|
+
*
|
|
25
|
+
* Both subcommands accept `--project <path>` (defaults to cwd / git
|
|
26
|
+
* root) and `--json` (default in TTY-less invocations).
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
import { fail, ok } from 'peaks-loop-shared/result';
|
|
31
|
+
import { addJsonOption, printResult } from '../cli-helpers.js';
|
|
32
|
+
import { findProjectRoot } from '../../services/config/config-safety.js';
|
|
33
|
+
const OUTER_SESSION_CACHE_REL = join('.peaks', '_runtime', '.outer-session-cache.json');
|
|
34
|
+
function resolveCachePath(projectRoot) {
|
|
35
|
+
return join(projectRoot, OUTER_SESSION_CACHE_REL);
|
|
36
|
+
}
|
|
37
|
+
function readEnvOuter() {
|
|
38
|
+
const peaks = process.env.PEAKS_OUTER_SESSION_ID;
|
|
39
|
+
if (typeof peaks === 'string' && peaks.length > 0)
|
|
40
|
+
return peaks;
|
|
41
|
+
const claude = process.env.CLAUDE_CODE_SESSION_ID;
|
|
42
|
+
if (typeof claude === 'string' && claude.length > 0)
|
|
43
|
+
return claude;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function readCacheFile(cachePath) {
|
|
47
|
+
if (!existsSync(cachePath))
|
|
48
|
+
return { ok: false, missing: true };
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(cachePath, 'utf8');
|
|
51
|
+
const parsed = JSON.parse(raw);
|
|
52
|
+
if (parsed !== null &&
|
|
53
|
+
typeof parsed === 'object' &&
|
|
54
|
+
typeof parsed.outerSessionId === 'string' &&
|
|
55
|
+
(parsed.outerSessionId).length > 0 &&
|
|
56
|
+
typeof parsed.capturedAt === 'string') {
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
outerSessionId: parsed.outerSessionId,
|
|
60
|
+
capturedAt: parsed.capturedAt
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { ok: false, missing: true };
|
|
64
|
+
}
|
|
65
|
+
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
66
|
+
return { ok: false, missing: true };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function registerOuterCacheCommands(program, io) {
|
|
70
|
+
const outerCache = program
|
|
71
|
+
.command('outer-cache')
|
|
72
|
+
.description('Read / write the per-project outer-session cache file (.peaks/_runtime/.outer-session-cache.json). The SessionStart hook installed by `peaks hooks install` keeps this file in sync with the live Claude Code / Trae / IDE session so peaks CLI sub-processes (which typically do NOT inherit CLAUDE_CODE_SESSION_ID) can resolve the current outer session via getCurrentOuterSessionId(projectRoot).');
|
|
73
|
+
addJsonOption(outerCache
|
|
74
|
+
.command('write')
|
|
75
|
+
.description('Write the current outer-session-id (PEAKS_OUTER_SESSION_ID ?? CLAUDE_CODE_SESSION_ID) into the per-project cache file. Idempotent: re-runs overwrite. Exits 1 with OUTER_CACHE_NO_ENV when neither env var is set.')
|
|
76
|
+
.option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
|
|
77
|
+
const projectRoot = options.project ?? (findProjectRoot(process.cwd()) ?? process.cwd());
|
|
78
|
+
const cachePath = resolveCachePath(projectRoot);
|
|
79
|
+
const outerSessionId = readEnvOuter();
|
|
80
|
+
if (outerSessionId === undefined) {
|
|
81
|
+
printResult(io, fail('outer-cache.write', 'OUTER_CACHE_NO_ENV', 'Neither PEAKS_OUTER_SESSION_ID nor CLAUDE_CODE_SESSION_ID is set; nothing to write', { cachePath, written: false, projectRoot }, [
|
|
82
|
+
'Set PEAKS_OUTER_SESSION_ID=<id> or run from inside Claude Code / Trae / IDE so CLAUDE_CODE_SESSION_ID is exported',
|
|
83
|
+
'Re-run `peaks hooks install` to wire the SessionStart hook that writes this cache automatically'
|
|
84
|
+
]), options.json === true);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const capturedAt = new Date().toISOString();
|
|
89
|
+
const payload = { outerSessionId, capturedAt };
|
|
90
|
+
try {
|
|
91
|
+
const dir = dirname(cachePath);
|
|
92
|
+
if (!existsSync(dir))
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
writeFileSync(cachePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
98
|
+
printResult(io, fail('outer-cache.write', 'OUTER_CACHE_WRITE_FAILED', `Failed to write outer-session cache: ${message}`, { cachePath, written: false, projectRoot, outerSessionId, capturedAt }, [message]), options.json === true);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
printResult(io, ok('outer-cache.write', { cachePath, written: true, projectRoot, outerSessionId, capturedAt }, [], [`Wrote ${cachePath}; getCurrentOuterSessionId() will resolve to "${outerSessionId}" until the next SessionStart fires.`]), options.json === true);
|
|
103
|
+
});
|
|
104
|
+
addJsonOption(outerCache
|
|
105
|
+
.command('read')
|
|
106
|
+
.description('Read the current value of the per-project outer-session cache. Returns { missing: true, cachePath } when the file is absent / malformed / empty; never throws on IO error.')
|
|
107
|
+
.option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
|
|
108
|
+
const projectRoot = options.project ?? (findProjectRoot(process.cwd()) ?? process.cwd());
|
|
109
|
+
const cachePath = resolveCachePath(projectRoot);
|
|
110
|
+
const result = readCacheFile(cachePath);
|
|
111
|
+
if (result.ok) {
|
|
112
|
+
printResult(io, ok('outer-cache.read', { cachePath, missing: false, projectRoot, outerSessionId: result.outerSessionId, capturedAt: result.capturedAt }, [], [`outer-session-id resolved: ${result.outerSessionId} (captured ${result.capturedAt})`]), options.json === true);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
printResult(io, ok('outer-cache.read', { cachePath, missing: true, projectRoot }, ['No outer-session cache present — getCurrentOuterSessionId() will return undefined for this project.'], [
|
|
116
|
+
'Re-run `peaks hooks install` to wire the SessionStart hook that writes this cache automatically',
|
|
117
|
+
'Or set PEAKS_OUTER_SESSION_ID=<id> so env-first resolution wins without touching the cache'
|
|
118
|
+
]), options.json === true);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check: PATH-scoped `peaks-loop` binary drift
|
|
3
|
+
* (`build:multi-binary-drift`).
|
|
4
|
+
*
|
|
5
|
+
* Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
6
|
+
* (G3/G4). When more than one `peaks-loop` binary is discoverable on
|
|
7
|
+
* `process.env.PATH` AND the discovered versions disagree, the doctor
|
|
8
|
+
* emits a `PEAKS_MULTI_BINARY_DRIFT` warning. The user-reported
|
|
9
|
+
* production symptom — `peaks` resolving to an old version (e.g.
|
|
10
|
+
* 3.1.2 on `%AppData%\Roaming\npm`) while the freshly installed
|
|
11
|
+
* binary on another PATH entry (e.g. `C:\nvm4w\nodejs\peaks` 4.0.12)
|
|
12
|
+
* is what the user just bumped — produces "Hook JSON output
|
|
13
|
+
* validation failed" when the IDE statusline hook inherits the older
|
|
14
|
+
* binary via PATH ordering.
|
|
15
|
+
*
|
|
16
|
+
* Behavior contract (PRD AC6 / AC7 / AC8 / AC9):
|
|
17
|
+
* - AC6: ≥ 2 peaks-loop binaries with different versions → warning
|
|
18
|
+
* - AC7: warning severity only, doctor exit 0 (no other check
|
|
19
|
+
* flipped to error)
|
|
20
|
+
* - AC8: drift is scoped to peaks-loop (`package.json#name ===
|
|
21
|
+
* 'peaks-loop'`); sibling npm tools are NOT flagged
|
|
22
|
+
* - AC9: cross-platform — uses `process.env.PATH` + `path.delimiter`
|
|
23
|
+
* + binary naming `peaks` / `peaks.cmd` / `peaks.ps1`
|
|
24
|
+
*
|
|
25
|
+
* Pure `inspectMultiBinaryDrift` helper is exported so tests can drive
|
|
26
|
+
* the filesystem walk without monkey-patching `process.env.PATH` or
|
|
27
|
+
* `realpathSync`. Every probe call is wrapped in try/catch (read-only,
|
|
28
|
+
* must not throw across the doctor boundary).
|
|
29
|
+
*/
|
|
30
|
+
import type { DoctorCheckPlugin, MultiBinaryDriftInspection } from '../types.js';
|
|
31
|
+
/**
|
|
32
|
+
* Local record shape — same as the canonical
|
|
33
|
+
* `MultiBinaryDriftInspection.binaries[number]`. Re-declared so the
|
|
34
|
+
* helper signature carries the concrete shape (the canonical
|
|
35
|
+
* `MultiBinaryDriftInspection` widens `version` + `installDate` to
|
|
36
|
+
* `string | null` so external consumers do not depend on the
|
|
37
|
+
* field being nullable).
|
|
38
|
+
*/
|
|
39
|
+
export type PeaksBinaryRecord = {
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly version: string | null;
|
|
42
|
+
readonly installDate: string | null;
|
|
43
|
+
readonly realpath: string;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Pure helper. Inspects `process.env.PATH` (or the injected
|
|
47
|
+
* `pathEnv`) for `peaks-loop` binaries. Each found binary is walked
|
|
48
|
+
* to its package.json via `realpathSync` so symlinks / npm shims /
|
|
49
|
+
* `peaks.cmd` / `peaks.ps1` all collapse to the same dedupe key.
|
|
50
|
+
*
|
|
51
|
+
* Exported so tests can drive the filesystem walk without monkey-
|
|
52
|
+
* patching `process.env.PATH` or `realpathSync`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function inspectMultiBinaryDrift(opts?: {
|
|
55
|
+
pathEnv?: string;
|
|
56
|
+
envReader?: (key: string) => string | undefined;
|
|
57
|
+
binaryExists?: (candidate: string) => boolean;
|
|
58
|
+
binaryRealpath?: (candidate: string) => string;
|
|
59
|
+
packageJsonReader?: (path: string) => Buffer | string | null;
|
|
60
|
+
}): MultiBinaryDriftInspection;
|
|
61
|
+
export declare const check: DoctorCheckPlugin;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check: PATH-scoped `peaks-loop` binary drift
|
|
3
|
+
* (`build:multi-binary-drift`).
|
|
4
|
+
*
|
|
5
|
+
* Slice 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
6
|
+
* (G3/G4). When more than one `peaks-loop` binary is discoverable on
|
|
7
|
+
* `process.env.PATH` AND the discovered versions disagree, the doctor
|
|
8
|
+
* emits a `PEAKS_MULTI_BINARY_DRIFT` warning. The user-reported
|
|
9
|
+
* production symptom — `peaks` resolving to an old version (e.g.
|
|
10
|
+
* 3.1.2 on `%AppData%\Roaming\npm`) while the freshly installed
|
|
11
|
+
* binary on another PATH entry (e.g. `C:\nvm4w\nodejs\peaks` 4.0.12)
|
|
12
|
+
* is what the user just bumped — produces "Hook JSON output
|
|
13
|
+
* validation failed" when the IDE statusline hook inherits the older
|
|
14
|
+
* binary via PATH ordering.
|
|
15
|
+
*
|
|
16
|
+
* Behavior contract (PRD AC6 / AC7 / AC8 / AC9):
|
|
17
|
+
* - AC6: ≥ 2 peaks-loop binaries with different versions → warning
|
|
18
|
+
* - AC7: warning severity only, doctor exit 0 (no other check
|
|
19
|
+
* flipped to error)
|
|
20
|
+
* - AC8: drift is scoped to peaks-loop (`package.json#name ===
|
|
21
|
+
* 'peaks-loop'`); sibling npm tools are NOT flagged
|
|
22
|
+
* - AC9: cross-platform — uses `process.env.PATH` + `path.delimiter`
|
|
23
|
+
* + binary naming `peaks` / `peaks.cmd` / `peaks.ps1`
|
|
24
|
+
*
|
|
25
|
+
* Pure `inspectMultiBinaryDrift` helper is exported so tests can drive
|
|
26
|
+
* the filesystem walk without monkey-patching `process.env.PATH` or
|
|
27
|
+
* `realpathSync`. Every probe call is wrapped in try/catch (read-only,
|
|
28
|
+
* must not throw across the doctor boundary).
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
31
|
+
import { delimiter as pathDelimiter, join } from 'node:path';
|
|
32
|
+
import { getErrorMessage } from 'peaks-loop-shared/result';
|
|
33
|
+
/**
|
|
34
|
+
* Pure helper. Inspects `process.env.PATH` (or the injected
|
|
35
|
+
* `pathEnv`) for `peaks-loop` binaries. Each found binary is walked
|
|
36
|
+
* to its package.json via `realpathSync` so symlinks / npm shims /
|
|
37
|
+
* `peaks.cmd` / `peaks.ps1` all collapse to the same dedupe key.
|
|
38
|
+
*
|
|
39
|
+
* Exported so tests can drive the filesystem walk without monkey-
|
|
40
|
+
* patching `process.env.PATH` or `realpathSync`.
|
|
41
|
+
*/
|
|
42
|
+
export function inspectMultiBinaryDrift(opts) {
|
|
43
|
+
const envReader = opts?.envReader ?? ((k) => process.env[k]);
|
|
44
|
+
const exists = opts?.binaryExists ?? ((p) => {
|
|
45
|
+
try {
|
|
46
|
+
return existsSync(p);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const realpath = opts?.binaryRealpath ?? ((p) => {
|
|
53
|
+
try {
|
|
54
|
+
return realpathSync(p);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const reader = opts?.packageJsonReader ?? ((p) => {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(p);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const pathEnv = opts?.pathEnv ?? envReader('PATH') ?? '';
|
|
69
|
+
if (pathEnv.length === 0) {
|
|
70
|
+
return { binaries: [], driftDetected: false, uniqueVersions: [] };
|
|
71
|
+
}
|
|
72
|
+
const dirs = pathEnv.split(pathDelimiter).filter((d) => d.length > 0);
|
|
73
|
+
const seen = new Map();
|
|
74
|
+
for (const dir of dirs) {
|
|
75
|
+
const candidates = candidateBinaryNames(dir);
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (!exists(candidate))
|
|
78
|
+
continue;
|
|
79
|
+
const rp = realpath(candidate);
|
|
80
|
+
if (seen.has(rp))
|
|
81
|
+
continue;
|
|
82
|
+
const pkgPath = locatePackageJson(rp, candidate);
|
|
83
|
+
const record = readBinaryRecord(candidate, rp, pkgPath, reader);
|
|
84
|
+
seen.set(rp, record);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const binaries = Array.from(seen.values());
|
|
88
|
+
const uniqueVersions = dedupeVersions(binaries.map((b) => b.version));
|
|
89
|
+
return {
|
|
90
|
+
binaries,
|
|
91
|
+
driftDetected: uniqueVersions.length >= 2,
|
|
92
|
+
uniqueVersions
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Cross-platform candidate names. Windows shims the executable as
|
|
97
|
+
* `peaks.cmd` and `peaks.ps1` (npm writes both); POSIX names the
|
|
98
|
+
* binary `peaks`. We probe all three names on every platform —
|
|
99
|
+
* probing a non-existent file is a no-op, so cross-list probing is
|
|
100
|
+
* safe.
|
|
101
|
+
*/
|
|
102
|
+
function candidateBinaryNames(dir) {
|
|
103
|
+
return [join(dir, 'peaks'), join(dir, 'peaks.cmd'), join(dir, 'peaks.ps1')];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Walk from the binary to its `node_modules/peaks-loop/package.json`.
|
|
107
|
+
*
|
|
108
|
+
* Common layouts handled:
|
|
109
|
+
*
|
|
110
|
+
* A. `<root>/bin/peaks` (POSIX npm global / nvm) → parent is
|
|
111
|
+
* `<root>/bin/`, parent's parent is `<root>/`. package.json at
|
|
112
|
+
* `<root>/package.json`.
|
|
113
|
+
* B. `<root>/peaks` or `<root>/peaks.cmd` (Windows npm global shim)
|
|
114
|
+
* → parent is `<root>/`. package.json at `<root>/package.json`,
|
|
115
|
+
* OR `<root>/../node_modules/peaks-loop/package.json`.
|
|
116
|
+
* C. `<root>/node_modules/.bin/peaks` (local install) → parent is
|
|
117
|
+
* `node_modules/.bin/`, parent's parent is `node_modules/`.
|
|
118
|
+
* package.json at `<root>/node_modules/peaks-loop/package.json`.
|
|
119
|
+
*
|
|
120
|
+
* The locator walks up from the binary path (realpathPath first,
|
|
121
|
+
* then originalCandidate) and checks for the canonical
|
|
122
|
+
* `node_modules/peaks-loop/package.json` and a `package.json` at each
|
|
123
|
+
* ancestor. The probe is read-only and stops at the first hit. We do
|
|
124
|
+
* NOT trust `package.json` without verifying its `name === 'peaks-loop'`
|
|
125
|
+
* later (see {@link readBinaryRecord}) — a sibling package's
|
|
126
|
+
* `package.json` is filtered out at read time.
|
|
127
|
+
*/
|
|
128
|
+
function locatePackageJson(realpathPath, originalCandidate) {
|
|
129
|
+
const ancestors = new Set();
|
|
130
|
+
for (const start of [realpathPath, originalCandidate]) {
|
|
131
|
+
let current = start;
|
|
132
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
133
|
+
const parent = join(current, '..');
|
|
134
|
+
if (parent === current)
|
|
135
|
+
break;
|
|
136
|
+
ancestors.add(parent);
|
|
137
|
+
current = parent;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const ancestor of ancestors) {
|
|
141
|
+
const nmPkg = join(ancestor, 'node_modules', 'peaks-loop', 'package.json');
|
|
142
|
+
if (existsSafe(nmPkg))
|
|
143
|
+
return nmPkg;
|
|
144
|
+
}
|
|
145
|
+
for (const ancestor of ancestors) {
|
|
146
|
+
const pkg = join(ancestor, 'package.json');
|
|
147
|
+
if (existsSafe(pkg))
|
|
148
|
+
return pkg;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function existsSafe(p) {
|
|
153
|
+
try {
|
|
154
|
+
return existsSync(p);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function readBinaryRecord(candidate, realpathPath, pkgPath, reader) {
|
|
161
|
+
if (pkgPath === null) {
|
|
162
|
+
return {
|
|
163
|
+
path: candidate,
|
|
164
|
+
version: null,
|
|
165
|
+
installDate: null,
|
|
166
|
+
realpath: realpathPath
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
let version = null;
|
|
170
|
+
try {
|
|
171
|
+
const raw = reader(pkgPath);
|
|
172
|
+
if (raw !== null) {
|
|
173
|
+
const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
|
|
174
|
+
const parsed = JSON.parse(text);
|
|
175
|
+
if (parsed.name === 'peaks-loop' && typeof parsed.version === 'string') {
|
|
176
|
+
version = parsed.version;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
version = null;
|
|
182
|
+
}
|
|
183
|
+
let installDate = null;
|
|
184
|
+
try {
|
|
185
|
+
const stat = statSync(pkgPath);
|
|
186
|
+
installDate = stat.mtime.toISOString();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
installDate = null;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
path: candidate,
|
|
193
|
+
version,
|
|
194
|
+
installDate,
|
|
195
|
+
realpath: realpathPath
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* `version === null` means we could not read the package.json (or
|
|
200
|
+
* its `name` did not equal `peaks-loop`). Those records stay in
|
|
201
|
+
* `binaries` for the report but do NOT contribute to
|
|
202
|
+
* `uniqueVersions` — including null would falsely trigger drift
|
|
203
|
+
* detection when the only failures are unreadable binaries.
|
|
204
|
+
*/
|
|
205
|
+
function dedupeVersions(versions) {
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const v of versions) {
|
|
209
|
+
if (typeof v !== 'string' || v.length === 0)
|
|
210
|
+
continue;
|
|
211
|
+
if (seen.has(v))
|
|
212
|
+
continue;
|
|
213
|
+
seen.add(v);
|
|
214
|
+
out.push(v);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
function run({ options }) {
|
|
219
|
+
const probe = options.multiBinaryDriftProbe ?? (() => inspectMultiBinaryDrift());
|
|
220
|
+
try {
|
|
221
|
+
const result = probe();
|
|
222
|
+
if (result.binaries.length === 0) {
|
|
223
|
+
return [{
|
|
224
|
+
id: 'build:multi-binary-drift',
|
|
225
|
+
ok: true,
|
|
226
|
+
message: 'no peaks-loop binary on PATH (statusLine may be unavailable)'
|
|
227
|
+
}];
|
|
228
|
+
}
|
|
229
|
+
if (!result.driftDetected) {
|
|
230
|
+
return [{
|
|
231
|
+
id: 'build:multi-binary-drift',
|
|
232
|
+
ok: true,
|
|
233
|
+
message: result.binaries.length === 1
|
|
234
|
+
? `single peaks-loop binary on PATH at ${result.binaries[0].path} (version ${result.uniqueVersions[0] ?? 'unknown'})`
|
|
235
|
+
: `${result.binaries.length} peaks-loop binaries on PATH all at version ${result.uniqueVersions[0] ?? 'unknown'}`
|
|
236
|
+
}];
|
|
237
|
+
}
|
|
238
|
+
// Drift detected — WARN-ONLY. AC7: doctor still exit 0 unless
|
|
239
|
+
// another check escalates to error. `ok: false` so the operator
|
|
240
|
+
// sees the finding in the JSON report AND the check carries
|
|
241
|
+
// `severity: 'warning'` so `buildReport` does NOT count it as a
|
|
242
|
+
// failure when computing `summary.ok`. Slice
|
|
243
|
+
// 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
244
|
+
// repair cycle landed the severity-aware summary so the
|
|
245
|
+
// previously-handwaved "future severity-aware summary can
|
|
246
|
+
// downgrade the doctor exit code" actually fires.
|
|
247
|
+
const binaryTable = result.binaries
|
|
248
|
+
.map((b) => ` ${b.path} version=${b.version ?? 'unknown'} date=${b.installDate ?? 'unknown'}`)
|
|
249
|
+
.join('\n');
|
|
250
|
+
return [{
|
|
251
|
+
id: 'build:multi-binary-drift',
|
|
252
|
+
ok: false,
|
|
253
|
+
severity: 'warning',
|
|
254
|
+
message: `PEAKS_MULTI_BINARY_DRIFT: ${result.uniqueVersions.length} distinct peaks-loop versions on PATH (${result.uniqueVersions.join(', ')}). Run \`npm uninstall -g peaks-loop\` on the stale entries, or reorder PATH so the desired binary resolves first. Binaries:\n${binaryTable}`
|
|
255
|
+
}];
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
return [{
|
|
259
|
+
id: 'build:multi-binary-drift',
|
|
260
|
+
ok: false,
|
|
261
|
+
message: `multi-binary drift check failed: ${getErrorMessage(error)}`
|
|
262
|
+
}];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export const check = {
|
|
266
|
+
name: 'multi-binary-drift',
|
|
267
|
+
run
|
|
268
|
+
};
|
|
@@ -43,6 +43,7 @@ import { check as statuslineInstall } from './checks/statusline-install.js';
|
|
|
43
43
|
import { check as statuslineRuntime } from './checks/statusline-runtime.js';
|
|
44
44
|
import { check as codegraphCapability } from './checks/codegraph-capability.js';
|
|
45
45
|
import { check as distSourceVersion } from './checks/dist-source-version.js';
|
|
46
|
+
import { check as multiBinaryDrift } from './checks/multi-binary-drift.js';
|
|
46
47
|
import { check as workspaceLayout } from './checks/workspace-layout.js';
|
|
47
48
|
import { check as gateguardConflict } from './checks/gateguard-conflict.js';
|
|
48
49
|
import { check as checkIdSchema } from './checks/check-id-schema.js';
|
|
@@ -69,6 +70,7 @@ export const PLUGINS = [
|
|
|
69
70
|
statuslineRuntime, // id "statusline:runtime"
|
|
70
71
|
codegraphCapability, // id "capability:codegraph"
|
|
71
72
|
distSourceVersion, // id "build:dist-version-matches-source"
|
|
73
|
+
multiBinaryDrift, // id "build:multi-binary-drift"
|
|
72
74
|
workspaceLayout, // id "build:workspace-layout-canonical"
|
|
73
75
|
gateguardConflict, // id "integration:gateguard-peaks-conflict"
|
|
74
76
|
checkIdSchema, // id "doctor-self:check-id-pattern"
|
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
* Report aggregator: convert the accumulated `DoctorCheck[]` into the
|
|
3
3
|
* public `DoctorReport` shape (checks + summary).
|
|
4
4
|
*
|
|
5
|
-
* The summary is a pure reduction over the checks — it
|
|
6
|
-
*
|
|
7
|
-
* returns the `{ checks, summary }` tuple.
|
|
8
|
-
* so the dispatcher (`index.ts`) stays a thin
|
|
9
|
-
* and the aggregation rule has a single test surface.
|
|
5
|
+
* The summary is a pure reduction over the checks — it separates
|
|
6
|
+
* `error`-severity findings from `warning`-severity findings, derives
|
|
7
|
+
* `ok = errors === 0`, and returns the `{ checks, summary }` tuple.
|
|
8
|
+
* Kept in its own module so the dispatcher (`index.ts`) stays a thin
|
|
9
|
+
* orchestration loop and the aggregation rule has a single test surface.
|
|
10
|
+
*
|
|
11
|
+
* Severity-aware aggregation (slice
|
|
12
|
+
* 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
13
|
+
* repair cycle): a check with `severity: 'warning'` reports `ok: false`
|
|
14
|
+
* (so operators see the finding in the JSON envelope) but does NOT
|
|
15
|
+
* flip `summary.ok` and therefore does NOT flip the doctor exit code.
|
|
16
|
+
* Genuine failures (severity omitted, or `severity: 'error'`) behave
|
|
17
|
+
* the same as the pre-slice `failed === 0` rule.
|
|
10
18
|
*/
|
|
11
19
|
import type { DoctorCheck, DoctorReport } from '../types.js';
|
|
12
20
|
/**
|
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
* Report aggregator: convert the accumulated `DoctorCheck[]` into the
|
|
3
3
|
* public `DoctorReport` shape (checks + summary).
|
|
4
4
|
*
|
|
5
|
-
* The summary is a pure reduction over the checks — it
|
|
6
|
-
*
|
|
7
|
-
* returns the `{ checks, summary }` tuple.
|
|
8
|
-
* so the dispatcher (`index.ts`) stays a thin
|
|
9
|
-
* and the aggregation rule has a single test surface.
|
|
5
|
+
* The summary is a pure reduction over the checks — it separates
|
|
6
|
+
* `error`-severity findings from `warning`-severity findings, derives
|
|
7
|
+
* `ok = errors === 0`, and returns the `{ checks, summary }` tuple.
|
|
8
|
+
* Kept in its own module so the dispatcher (`index.ts`) stays a thin
|
|
9
|
+
* orchestration loop and the aggregation rule has a single test surface.
|
|
10
|
+
*
|
|
11
|
+
* Severity-aware aggregation (slice
|
|
12
|
+
* 2026-08-05-statusline-sid-only-marker-and-multi-binary-drift-guard
|
|
13
|
+
* repair cycle): a check with `severity: 'warning'` reports `ok: false`
|
|
14
|
+
* (so operators see the finding in the JSON envelope) but does NOT
|
|
15
|
+
* flip `summary.ok` and therefore does NOT flip the doctor exit code.
|
|
16
|
+
* Genuine failures (severity omitted, or `severity: 'error'`) behave
|
|
17
|
+
* the same as the pre-slice `failed === 0` rule.
|
|
10
18
|
*/
|
|
11
19
|
/**
|
|
12
20
|
* Build the final report from the accumulated checks.
|
|
@@ -17,13 +25,15 @@
|
|
|
17
25
|
* report shape the public API exposes.
|
|
18
26
|
*/
|
|
19
27
|
export function buildReport(checks) {
|
|
20
|
-
const
|
|
28
|
+
const errors = checks.filter((check) => !check.ok && check.severity !== 'warning').length;
|
|
29
|
+
const warnings = checks.filter((check) => !check.ok && check.severity === 'warning').length;
|
|
21
30
|
return {
|
|
22
31
|
checks: [...checks],
|
|
23
32
|
summary: {
|
|
24
|
-
ok:
|
|
25
|
-
passed: checks.length -
|
|
26
|
-
failed
|
|
33
|
+
ok: errors === 0,
|
|
34
|
+
passed: checks.length - errors - warnings,
|
|
35
|
+
failed: errors,
|
|
36
|
+
warnings
|
|
27
37
|
}
|
|
28
38
|
};
|
|
29
39
|
}
|