iterate-plugin 3.14.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * src/client/run-compare.ts — cross-session run comparison (the observatory F2 tab).
3
+ *
4
+ * The F2 convergence tab compares ROUNDS *within* one session, but comparing
5
+ * two ITERATIONS of the same project (before / after a refactor) used to be a
6
+ * manual chore: export JSON for each run, open both files, line up the columns.
7
+ * This module turns that into a first-class interaction. The current run is
8
+ * reduced to a compact, serializable summary and saved to localStorage as the
9
+ * comparison baseline; any later run can then be compared against it side by
10
+ * side, with a per-metric delta and a directional "better / worse" tint.
11
+ *
12
+ * Design constraints (mirrors `i18n.ts` / `session-cache.ts`):
13
+ * - **Pure / dependency-free**: no React, no DOM, no Chinese literals (the
14
+ * bilingual labels live next to `t()` in index.ts, which is the layer the
15
+ * i18n dictionary-contract test scans). The summary builder and the
16
+ * compare reducer are unit-testable on their own.
17
+ * - **Versioned**: the stored payload carries a `version` so a future schema
18
+ * change can migrate or reject stale baselines instead of mis-reading them.
19
+ * - **Never fabricated**: every field is derived from real manifest fields
20
+ * with defensive optional chaining; a run with no findings simply reads 0.
21
+ */
22
+
23
+ /** Current schema version of the persisted baseline. */
24
+ export const RUN_BASELINE_VERSION = 1
25
+
26
+ /** localStorage key holding the single comparison baseline (per browser). */
27
+ export const RUN_BASELINE_STORAGE_KEY = 'iterate.run-baseline'
28
+
29
+ /**
30
+ * A compact, serializable summary of one run — everything the side-by-side
31
+ * comparison needs, and nothing the client cannot already see in the manifest.
32
+ */
33
+ export interface RunBaseline {
34
+ version: 1
35
+ /** ISO timestamp of when this baseline was saved (NOT the run end time). */
36
+ savedAt: string
37
+ /** Project root the run was anchored to (informational). */
38
+ project?: string
39
+ /** 'normal' | 'dry-run' — the mode that produced the run. */
40
+ mode?: string
41
+ /** Number of review rounds captured. */
42
+ rounds: number
43
+ /** Total findings raised across the run. */
44
+ findings: number
45
+ /** critical + high findings (the "must fix" surface). */
46
+ criticalHigh: number
47
+ /** Fixes that succeeded. */
48
+ fixed: number
49
+ /** Fixes that failed (rolled back / validation-rejected). */
50
+ failedFixes: number
51
+ /** Fraction of validation command runs that passed, 0-100, or null if none ran. */
52
+ validationPassRate: number | null
53
+ /** Number of validation command runs. */
54
+ validationTotal: number
55
+ /** 'pass' | 'fail' | null — the quality gate verdict, when it was computed. */
56
+ qualityGateStatus: string | null
57
+ /** Quality gate overall score (0-100) when computed. */
58
+ qualityGateScore: number | null
59
+ /** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
60
+ stoppedReason: string | null
61
+ /** Findings per round (the convergence series), for a side-by-side trend. */
62
+ convergence: number[]
63
+ }
64
+
65
+ /** The manifest-shaped inputs the summary builder reads (defensively). */
66
+ export interface RunBaselineInput {
67
+ project?: string
68
+ mode?: string
69
+ convergence?: number[]
70
+ findings?: Array<{ severity?: string }>
71
+ fixes?: Array<{ success?: boolean }>
72
+ validations?: Array<{ allowed?: boolean }>
73
+ qualityGateStatus?: string | null
74
+ qualityGateScore?: number | null
75
+ stoppedReason?: string | null
76
+ }
77
+
78
+ /**
79
+ * Reduce a run manifest (plus the session-scanned quality gate) to a compact,
80
+ * comparison-ready summary. Pure: `savedAt` is injected by the caller so this
81
+ * stays deterministic and testable.
82
+ */
83
+ export function buildRunBaseline(input: RunBaselineInput, savedAt: string): RunBaseline {
84
+ const convergence = Array.isArray(input.convergence) ? input.convergence : []
85
+ // Only object entries count — a null / primitive in a malformed manifest
86
+ // must never be read as a finding / fix / validation (and skew the rate).
87
+ const findings = (Array.isArray(input.findings) ? input.findings : []).filter(
88
+ (f): f is { severity?: string } => typeof f === 'object' && f !== null,
89
+ )
90
+ const fixes = (Array.isArray(input.fixes) ? input.fixes : []).filter(
91
+ (f): f is { success?: boolean } => typeof f === 'object' && f !== null,
92
+ )
93
+ const validations = (Array.isArray(input.validations) ? input.validations : []).filter(
94
+ (v): v is { allowed?: boolean } => typeof v === 'object' && v !== null,
95
+ )
96
+
97
+ let criticalHigh = 0
98
+ for (const f of findings) {
99
+ const sev = String((f && f.severity) || '').toLowerCase()
100
+ if (sev === 'critical' || sev === 'high') criticalHigh += 1
101
+ }
102
+
103
+ let fixed = 0
104
+ let failedFixes = 0
105
+ for (const x of fixes) {
106
+ if (x && x.success === true) fixed += 1
107
+ else if (x && x.success === false) failedFixes += 1
108
+ }
109
+
110
+ let passed = 0
111
+ for (const v of validations) {
112
+ if (v && v.allowed === true) passed += 1
113
+ }
114
+ const validationTotal = validations.length
115
+ const validationPassRate = validationTotal > 0 ? Math.round((passed / validationTotal) * 100) : null
116
+
117
+ return {
118
+ version: RUN_BASELINE_VERSION,
119
+ savedAt,
120
+ ...(input.project ? { project: input.project } : {}),
121
+ ...(input.mode ? { mode: input.mode } : {}),
122
+ rounds: convergence.length,
123
+ findings: findings.length,
124
+ criticalHigh,
125
+ fixed,
126
+ failedFixes,
127
+ validationPassRate,
128
+ validationTotal,
129
+ qualityGateStatus: input.qualityGateStatus || null,
130
+ qualityGateScore: typeof input.qualityGateScore === 'number' ? input.qualityGateScore : null,
131
+ stoppedReason: input.stoppedReason || null,
132
+ convergence,
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Parse a persisted baseline JSON string. Returns `null` for anything that is
138
+ * not a well-formed, version-matched baseline — a stale/corrupt payload must
139
+ * never be mis-read as a real run, and never throws.
140
+ */
141
+ export function parseRunBaseline(raw: string | null): RunBaseline | null {
142
+ if (!raw) return null
143
+ let parsed: unknown
144
+ try {
145
+ parsed = JSON.parse(raw)
146
+ } catch {
147
+ return null
148
+ }
149
+ if (typeof parsed !== 'object' || parsed === null) return null
150
+ const b = parsed as Record<string, unknown>
151
+ if (b.version !== RUN_BASELINE_VERSION) return null
152
+ if (typeof b.savedAt !== 'string' || b.savedAt.length === 0) return null
153
+ if (typeof b.findings !== 'number' || !Number.isFinite(b.findings)) return null
154
+ // convergence must be a number array (defensive; a non-array becomes []).
155
+ const convergence = Array.isArray(b.convergence)
156
+ ? b.convergence.filter((n): n is number => typeof n === 'number' && Number.isFinite(n))
157
+ : []
158
+ return {
159
+ version: RUN_BASELINE_VERSION,
160
+ savedAt: b.savedAt,
161
+ ...(typeof b.project === 'string' && b.project ? { project: b.project } : {}),
162
+ ...(typeof b.mode === 'string' && b.mode ? { mode: b.mode } : {}),
163
+ rounds: typeof b.rounds === 'number' && Number.isFinite(b.rounds) ? b.rounds : convergence.length,
164
+ findings: b.findings,
165
+ criticalHigh: typeof b.criticalHigh === 'number' && Number.isFinite(b.criticalHigh) ? b.criticalHigh : 0,
166
+ fixed: typeof b.fixed === 'number' && Number.isFinite(b.fixed) ? b.fixed : 0,
167
+ failedFixes: typeof b.failedFixes === 'number' && Number.isFinite(b.failedFixes) ? b.failedFixes : 0,
168
+ validationPassRate:
169
+ typeof b.validationPassRate === 'number' && Number.isFinite(b.validationPassRate) ? b.validationPassRate : null,
170
+ validationTotal:
171
+ typeof b.validationTotal === 'number' && Number.isFinite(b.validationTotal) ? b.validationTotal : 0,
172
+ qualityGateStatus: typeof b.qualityGateStatus === 'string' && b.qualityGateStatus ? b.qualityGateStatus : null,
173
+ qualityGateScore:
174
+ typeof b.qualityGateScore === 'number' && Number.isFinite(b.qualityGateScore) ? b.qualityGateScore : null,
175
+ stoppedReason: typeof b.stoppedReason === 'string' && b.stoppedReason ? b.stoppedReason : null,
176
+ convergence,
177
+ }
178
+ }
179
+
180
+ /**
181
+ * One row of the side-by-side comparison.
182
+ *
183
+ * `higherIsBetter` tells the renderer how to tint the delta:
184
+ * - `true` → a larger current value is an improvement (fixed, pass rate, score)
185
+ * - `false` → a smaller current value is an improvement (findings, failed fixes)
186
+ * - `null` → neutral (rounds — fewer is usually faster but not always better)
187
+ */
188
+ export interface RunCompareMetric {
189
+ key: 'rounds' | 'findings' | 'criticalHigh' | 'fixed' | 'failedFixes' | 'validationPassRate' | 'qualityGateScore'
190
+ baseline: number | null
191
+ current: number | null
192
+ higherIsBetter: boolean | null
193
+ }
194
+
195
+ /**
196
+ * Compare a saved baseline against the current run. Both sides come from
197
+ * `buildRunBaseline`, so `null` means "that side had no such measurement"
198
+ * (e.g. a dry-run has no fixes; a run that never validated has no pass rate).
199
+ */
200
+ export function compareRuns(baseline: RunBaseline, current: RunBaseline): RunCompareMetric[] {
201
+ const num = (v: number | null | undefined): number | null =>
202
+ typeof v === 'number' && Number.isFinite(v) ? v : null
203
+ const rows: RunCompareMetric[] = [
204
+ { key: 'rounds', baseline: num(baseline.rounds), current: num(current.rounds), higherIsBetter: null },
205
+ { key: 'findings', baseline: num(baseline.findings), current: num(current.findings), higherIsBetter: false },
206
+ { key: 'criticalHigh', baseline: num(baseline.criticalHigh), current: num(current.criticalHigh), higherIsBetter: false },
207
+ { key: 'fixed', baseline: num(baseline.fixed), current: num(current.fixed), higherIsBetter: true },
208
+ { key: 'failedFixes', baseline: num(baseline.failedFixes), current: num(current.failedFixes), higherIsBetter: false },
209
+ { key: 'validationPassRate', baseline: num(baseline.validationPassRate), current: num(current.validationPassRate), higherIsBetter: true },
210
+ { key: 'qualityGateScore', baseline: num(baseline.qualityGateScore), current: num(current.qualityGateScore), higherIsBetter: true },
211
+ ]
212
+ return rows
213
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * src/client/shortcuts.ts — iterate-plugin keyboard-command contributions.
3
+ *
4
+ * dsh 0.2.2-rc.1 added the *unified keyboard-command* capability
5
+ * (`@deepseek-ai/dsh-client-shortcuts`): any client plugin may register a
6
+ * command with a default key binding and a target resolver, and dsh's own
7
+ * keyboard adapter dispatches the gesture to it. The same capability also lets
8
+ * a context-menu action carry a `commandId` so the menu shows the live
9
+ * keycap next to the action (selection still invokes the action's `run`).
10
+ *
11
+ * This module turns that new upstream capability into a *user-perceivable*
12
+ * iterate surface: instead of hunting for the docked observatory (or
13
+ * right-clicking a session), the user can now press **Cmd/Ctrl+Shift+O** to
14
+ * open the iterate quality command center from anywhere in the session. The
15
+ * matching context-menu action carries the same `commandId`, so the shortcut
16
+ * is discoverable in the right-click menu ("open command center" shows the
17
+ * keycap), and dsh's own shortcut settings page lists it as an editable
18
+ * binding.
19
+ *
20
+ * Design (mirrors `context-menu.ts` and the rest of the client half):
21
+ * - **Defensive**: the `shortcuts` service is optional and probed at
22
+ * apply-time (see `readShortcuts` in `index.ts`); a host that predates
23
+ * 0.2.2-rc.1 simply has no keyboard command (the docked panel stays the
24
+ * entry point). Nothing throws.
25
+ * - **Pure builder**: `buildObservatoryShortcutCommand` returns the command
26
+ * shape with no `ctx`/DOM side effects, so it is unit-testable on its own
27
+ * (label resolution, binding defaults, resolve→run). The imperative
28
+ * wiring (`openObservatory` / `lang`) is injected by
29
+ * `registerIterateShortcuts`.
30
+ * - **Bilingual**: the label resolves against the language passed in at
31
+ * registration (a language switch is picked up by dsh's own command
32
+ * catalog on the next render).
33
+ * - **No external import**: like the rest of the client half, the dsh
34
+ * `@deepseek-ai/dsh-client-shortcuts` types are NOT imported (no
35
+ * compile-time coupling to a package a pre-0.2.2-rc.1 host may not have);
36
+ * the surface below is the structural subset this plugin consumes.
37
+ */
38
+
39
+ import type { Lang } from './i18n.ts'
40
+
41
+ // ─── Structural service types (defensive; mirrors ContextMenusService) ───────
42
+
43
+ /** Stable command identity owned by this plugin (a branded string upstream). */
44
+ export const OBSERVATORY_SHORTCUT_ID = 'iterate.open-observatory'
45
+
46
+ /** One physical key + modifiers; `primary` expands to Meta on macOS. */
47
+ export interface ShortcutBinding {
48
+ code: string
49
+ secondCode?: string
50
+ modifiers: readonly string[]
51
+ }
52
+
53
+ /** The dsh command catalog shape this plugin registers. */
54
+ export interface ShortcutCommand {
55
+ id: string
56
+ label: () => string
57
+ aliases: readonly string[]
58
+ defaults: Readonly<Record<string, ShortcutBinding>>
59
+ regions: readonly string[]
60
+ modals: readonly string[]
61
+ resolve(context: { region?: string; modal?: string | null; target?: Element | null }): ShortcutResolution
62
+ }
63
+
64
+ /** A resolved command either runs, is blocked with a reason, or passes. */
65
+ export type ShortcutResolution =
66
+ | { status: 'handled'; run: () => void }
67
+ | { status: 'blocked'; reason: string }
68
+ | { status: 'pass' }
69
+
70
+ /** Structural view of the dsh `shortcuts` service this plugin consumes. */
71
+ export interface ShortcutsService {
72
+ register(command: ShortcutCommand): () => void
73
+ }
74
+
75
+ /** Injection points the shortcut wiring needs (kept dependency-light for tests). */
76
+ export interface ShortcutDeps {
77
+ /** Open the observatory panel (optionally for a session, on a tab). */
78
+ openObservatory: (detail?: { sessionId?: string; tab?: string }) => void
79
+ /** Resolve the current UI language at label-build time. */
80
+ lang: () => Lang
81
+ }
82
+
83
+ /**
84
+ * Default key binding for the observatory command: Cmd/Ctrl+Shift+O
85
+ * ("O" = Open), applied uniformly across every runtime/platform profile so the
86
+ * feature is on by default on desktop and web. If an existing dsh command
87
+ * already owns this combination, `register` throws and the plugin degrades
88
+ * gracefully (no keyboard command; the observatory stays mouse-reachable).
89
+ */
90
+ const DEFAULT_BINDING: ShortcutBinding = { code: 'KeyO', modifiers: ['primary', 'shift'] }
91
+
92
+ const DEFAULT_PROFILES = [
93
+ 'desktop:macos',
94
+ 'desktop:windows',
95
+ 'desktop:linux',
96
+ 'web:macos',
97
+ 'web:windows',
98
+ 'web:linux',
99
+ ] as const
100
+
101
+ /** Every runtime/platform profile maps to the same default binding. */
102
+ export const OBSERVATORY_DEFAULT_BINDINGS: Readonly<Record<string, ShortcutBinding>> = Object.fromEntries(
103
+ DEFAULT_PROFILES.map((profile) => [profile, DEFAULT_BINDING]),
104
+ ) as Readonly<Record<string, ShortcutBinding>>
105
+
106
+ /**
107
+ * Build the pure command descriptor for "open iterate observatory". The
108
+ * resolver always handles the gesture and runs the injected opener; it never
109
+ * blocks (opening the observatory is safe from any region and no modal).
110
+ */
111
+ export function buildObservatoryShortcutCommand(deps: ShortcutDeps): ShortcutCommand {
112
+ const label = (): string =>
113
+ deps.lang() === 'zh' ? '打开 iterate 观测台' : 'Open iterate observatory'
114
+ return {
115
+ id: OBSERVATORY_SHORTCUT_ID,
116
+ label,
117
+ aliases: [],
118
+ defaults: OBSERVATORY_DEFAULT_BINDINGS,
119
+ regions: ['page', 'editable', 'terminal'],
120
+ modals: [],
121
+ resolve: () => ({ status: 'handled', run: () => deps.openObservatory({}) }),
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Register the observatory keyboard command on the (already-validated)
127
+ * `shortcuts` service. Returns an unregister function; a host without the
128
+ * service is a no-op that returns `() => {}` (callers guard).
129
+ */
130
+ export function registerIterateShortcuts(service: ShortcutsService, deps: ShortcutDeps): () => void {
131
+ try {
132
+ const dispose = service.register(buildObservatoryShortcutCommand(deps))
133
+ let done = false
134
+ return () => {
135
+ if (done) return
136
+ done = true
137
+ try {
138
+ dispose()
139
+ } catch {
140
+ /* noop */
141
+ }
142
+ }
143
+ } catch (err) {
144
+ // A duplicate id or an overlapping default binding must not take the
145
+ // plugin down — log and continue with no keyboard command (the docked
146
+ // panel and the context-menu "open" action remain the entry points).
147
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
148
+ console.error('[iterate-ui] keyboard-command registration failed; continuing without it.', err)
149
+ }
150
+ return () => {}
151
+ }
152
+ }