From 645f01625b3ac6eab5b0dff8bd1e021bee2dfe34 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 4 May 2026 06:28:47 +0200 Subject: [PATCH] feat(ultraplan-local): add autonomy-gate state machine + manifest schema extensions for skip_commit_check + memory_write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of plan-v2 (ultra-pipeline-speedup). lib/util/autonomy-gate.mjs (NEW) 5-state machine {idle, gates_on, auto_running, paused_for_gate, completed} honoring the --gates flag intent. Re-entry to completed is idempotent. Includes CLI shim: node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false] → JSON: { ok, next_state | error }, exit 0 on success / 1 on invalid. lib/parsers/manifest-yaml.mjs (EXTENDED) OPTIONAL_KEYS list adds skip_commit_check and memory_write — both boolean, default false when absent, MANIFEST_OPTIONAL_TYPE when non-boolean. Existing REQUIRED_KEYS contract untouched; existing 9 manifest tests still pass. Tests: 19 (autonomy-gate) + 8 (manifest-schema-extensions) = 27 new. [skip-docs] --- .../ms-ai-architect-playground.html | 187 +++++++++++++++++- .../playground/test-fixtures/security.md | 23 +++ .../lib/parsers/manifest-yaml.mjs | 26 +++ .../lib/util/autonomy-gate.mjs | 129 ++++++++++++ .../tests/lib/autonomy-gate.test.mjs | 147 ++++++++++++++ .../lib/manifest-schema-extensions.test.mjs | 92 +++++++++ 6 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 plugins/ultraplan-local/lib/util/autonomy-gate.mjs create mode 100644 plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs create mode 100644 plugins/ultraplan-local/tests/lib/manifest-schema-extensions.test.mjs diff --git a/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html b/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html index 8c5a93e..dc01074 100644 --- a/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html +++ b/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html @@ -210,6 +210,34 @@ .read-more-block { margin: var(--space-2) 0; } .read-more-block summary { cursor: pointer; color: var(--color-text-link); font-weight: var(--font-weight-medium); } + /* Renderer-batch B inlines (v1.10.0 Sesjon 4). Klasser ikke i vendor — + inline her per scope-regel (plugin standalone). Kandidat for hoisting + til shared/playground-design-system/ i Sesjon 6 visual QA. */ + .top-risks { margin: var(--space-4) 0; display: flex; flex-direction: column; gap: var(--space-2); } + .top-risks__heading { margin: 0 0 var(--space-2) 0; font-size: var(--font-size-md); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--font-weight-semibold); } + .top-risk { display: grid; grid-template-columns: 28px 1fr auto; gap: var(--space-3); align-items: center; padding: var(--space-2) var(--space-3); background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); } + .top-risk__rank { font-family: var(--font-family-mono); font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-weight: var(--font-weight-bold); } + .top-risk__desc { font-size: var(--font-size-sm); color: var(--color-text-primary); } + .top-risk__score { font-family: var(--font-family-mono); font-size: var(--font-size-md); font-weight: var(--font-weight-bold); padding: 2px 10px; border-radius: var(--radius-pill); background: var(--color-bg-soft); color: var(--color-text-primary); } + .top-risk[data-severity="critical"] { border-left: 4px solid var(--color-severity-critical); } + .top-risk[data-severity="critical"] .top-risk__score { background: var(--color-severity-critical); color: var(--color-severity-critical-on); } + .top-risk[data-severity="high"] { border-left: 4px solid var(--color-severity-high); } + .top-risk[data-severity="high"] .top-risk__score { background: var(--color-severity-high-soft); color: var(--color-severity-high-on); } + .top-risk[data-severity="medium"] { border-left: 4px solid var(--color-severity-medium); } + .top-risk[data-severity="medium"] .top-risk__score { background: var(--color-severity-medium-soft); color: var(--color-severity-medium-on); } + .top-risk[data-severity="low"] { border-left: 4px solid var(--color-severity-low); } + + .recommendation-card { margin: var(--space-4) 0 0 0; padding: var(--space-4); background: var(--color-bg-soft); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); border-left: 4px solid var(--color-scope-architect); display: flex; flex-direction: column; gap: var(--space-2); } + .recommendation-card__label { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); } + .recommendation-card__body { font-size: var(--font-size-md); color: var(--color-text-primary); line-height: var(--line-height-snug); } + + .suppressed-panel { margin: var(--space-4) 0 0 0; padding: var(--space-3) var(--space-4); background: var(--color-bg-soft); border: 1px dashed var(--color-border-subtle); border-radius: var(--radius-md); opacity: 0.85; } + .suppressed-panel summary { cursor: pointer; color: var(--color-text-secondary); font-weight: var(--font-weight-medium); font-size: var(--font-size-sm); } + .suppressed-panel[open] summary { margin-bottom: var(--space-2); } + .suppressed-panel__list { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-2) 0 0 0; } + .suppressed-panel__item { padding: var(--space-2) var(--space-3); background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-secondary); display: flex; gap: var(--space-3); align-items: baseline; } + .suppressed-panel__id { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-tertiary); } + /* Tier 3 A4 — Screen-tabs (segmented). Inline her per scope-regel (plugin standalone). Kandidat for hoisting til shared/ i Sesjon 6. */ .screen-tabs { display: flex; gap: var(--space-1); padding: 4px; background: var(--color-bg-soft); border-radius: var(--radius-md); width: fit-content; margin: 0 0 var(--space-4) 0; } @@ -2650,13 +2678,92 @@ recommendation: row[recKey] || '' }; }) : []; + // topRisks: prøv ## Top-risikoer-tabell først, ellers fall tilbake til + // matrix_cells sortert desc på score. + const topRisksTbl = parseTable(md, /##\s*Top.?risikoer/i); + let topRisks = []; + if (topRisksTbl && topRisksTbl.rows.length) { + const idKey = topRisksTbl.headers[0]; + const descKey = topRisksTbl.headers.find(function (h) { return /risiko|trussel|description|beskrivelse/i.test(h); }) || topRisksTbl.headers[1]; + const scKey = topRisksTbl.headers.find(function (h) { return /score/i.test(h); }); + const sevKey = topRisksTbl.headers.find(function (h) { return /severity|alvorlighet|nivå/i.test(h); }); + topRisks = topRisksTbl.rows.map(function (row) { + return { + id: row[idKey] || '', + description: row[descKey] || row[idKey] || '', + score: scKey ? intOrZero(row[scKey] || '0') : 0, + severity: (sevKey && (row[sevKey] || '').toLowerCase().trim()) || '' + }; + }).slice(0, 5); + } else if (matrix_cells.length) { + topRisks = matrix_cells.slice().sort(function (a, b) { + if (b.score !== a.score) return b.score - a.score; + return String(a.label || '').localeCompare(String(b.label || '')); + }).slice(0, 5).map(function (c) { + return { + id: '', + description: c.label || '', + score: c.score, + severity: '' + }; + }); + } + // categoryGrades: prøv ## Kategori-snitt-tabell først, ellers utled + // fra dimensions[]. Score → letter-grade A-F (5→A, 4→B, 3→C, 2→D, ≤1→F). + const gradeFor = function (sc) { + const n = Number(sc) || 0; + if (n >= 5) return 'A'; + if (n >= 4) return 'B'; + if (n >= 3) return 'C'; + if (n >= 2) return 'D'; + return 'F'; + }; + const catTbl = parseTable(md, /##\s*Kategori.snitt/i); + let categoryGrades = []; + if (catTbl && catTbl.rows.length) { + const nameKey = catTbl.headers[0]; + const scKey = catTbl.headers.find(function (h) { return /score|snitt/i.test(h); }) || catTbl.headers[1]; + categoryGrades = catTbl.rows.map(function (row) { + const sc = intOrZero(row[scKey] || '0'); + return { name: row[nameKey] || '', score: sc, grade: gradeFor(sc) }; + }); + } else { + categoryGrades = dimensions.map(function (d) { + return { name: d.name, score: d.score, grade: gradeFor(d.score) }; + }); + } + // residualPair: same syntax som parseMatrixRisk. + let residualPair = null; + const rrMatch = md.match(/^Restrisiko\s*:\s*([^\n]+)$/im); + if (rrMatch) { + const txt = rrMatch[1]; + const num = /(\d+)\s*[×x*]\s*(\d+)\s*(?:[-=]?[>→]|->)\s*(\d+)\s*[×x*]\s*(\d+)/.exec(txt); + if (num) { + const b1 = +num[1], b2 = +num[2], a1 = +num[3], a2 = +num[4]; + residualPair = { + before: { prob: b1, cons: b2, score: b1 * b2 }, + after: { prob: a1, cons: a2, score: a1 * a2 } + }; + } else { + const parts = txt.split(/(?:[-=]?[>→]|->)/); + if (parts.length === 2) { + residualPair = { + before: { label: parts[0].trim() }, + after: { label: parts[1].trim() } + }; + } + } + } return { ok: true, data: { dimensions: dimensions, matrix_cells: matrix_cells, findings: findings, - scores: dimensions.map(function (d) { return d.score; }) + scores: dimensions.map(function (d) { return d.score; }), + topRisks: topRisks, + categoryGrades: categoryGrades, + residualPair: residualPair } }; } @@ -3323,10 +3430,86 @@ // ---- Sub-batch B: Security (3) ---- function renderSecurity(data, slot) { + const sevForScore = function (s) { + const n = Number(s) || 0; + if (n >= 16) return 'critical'; + if (n >= 9) return 'high'; + if (n >= 4) return 'medium'; + return 'low'; + }; const matrixHtml = renderMatrixHtml(data, 6); const radarHtml = renderRadarSvg(data.dimensions || []); + // C7 small-multiples per OWASP-kategori (driver: categoryGrades). + const cats = data.categoryGrades || []; + const smallMultiplesHtml = cats.length ? '
' + cats.map(function (c) { + const grade = c.grade || ''; + const fillPct = Math.max(0, Math.min(100, ((Number(c.score) || 0) / 5) * 100)); + return '
' + + '
' + + '' + escapeHtml(c.name || '') + '' + + '' + escapeHtml(grade) + '' + + '
' + + '
' + + 'Score ' + escapeHtml(String(c.score || 0)) + ' / 5' + + '
'; + }).join('') + '
' : ''; + // C4 top-risks-list (max 5). + const topRisks = (data.topRisks || []).slice(0, 5); + const topRisksHtml = topRisks.length ? '
' + + '

Top-risikoer

' + + topRisks.map(function (r, i) { + const sev = r.severity || sevForScore(r.score); + return '
' + + '' + (i + 1) + '' + + '' + escapeHtml(r.description || '') + '' + + '' + escapeHtml(String(r.score || 0)) + '' + + '
'; + }).join('') + '
' : ''; + // B6 residual-pair (når data.residualPair finnes). + const rp = data.residualPair; + let residualHtml = ''; + if (rp && rp.before && rp.after) { + const labelOf = function (cell) { + if (cell.score != null) return cell.prob + '×' + cell.cons + ' = ' + cell.score; + return cell.label || '—'; + }; + const sevBefore = rp.before.score != null ? sevForScore(rp.before.score) : ''; + const sevAfter = rp.after.score != null ? sevForScore(rp.after.score) : ''; + const attrBefore = sevBefore ? ' data-severity="' + sevBefore + '"' : ''; + const attrAfter = sevAfter ? ' data-severity="' + sevAfter + '"' : ''; + residualHtml = '
' + + '
' + + 'FØR TILTAK' + + '' + escapeHtml(labelOf(rp.before)) + '' + + 'Sannsynlighet × konsekvens' + + '
' + + '' + + '
' + + 'ETTER TILTAK' + + '' + escapeHtml(labelOf(rp.after)) + '' + + 'Restrisiko' + + '
' + + '
'; + } const findingsHtml = renderFindingsBlock(data.findings || [], 'Sikkerhetsfunn'); - slot.innerHTML = matrixHtml + radarHtml + findingsHtml; + const body = matrixHtml + radarHtml + smallMultiplesHtml + topRisksHtml + residualHtml + findingsHtml; + // Utvid matrix-risk-6x5-keyStats med RESTRISIKO når residualPair finnes. + const baseStats = inferKeyStats(data, 'matrix-risk-6x5'); + const stats = (data.keyStats || (rp && rp.after + ? baseStats.concat([{ + label: 'RESTRISIKO', + value: rp.after.score != null ? String(rp.after.score) : (rp.after.label || '—'), + modifier: rp.after.score != null && rp.after.score >= 9 ? 'high' : 'low', + hint: 'etter tiltak' + }]) + : baseStats)); + slot.innerHTML = renderPageShell({ + eyebrow: 'SIKKERHET', + title: data.title || 'Sikkerhetsvurdering (6×5)', + lede: data.lede || 'Score per dimensjon, risikomatrise og topp-risikoer mot NSM, Microsoft Cloud Security og AI Act Art. 15.', + verdict: data.verdict || inferVerdict(data, 'matrix-risk-6x5'), + keyStats: stats + }, body); } function renderRos(data, slot) { diff --git a/plugins/ms-ai-architect/playground/test-fixtures/security.md b/plugins/ms-ai-architect/playground/test-fixtures/security.md index 08285ef..b1bbb0f 100644 --- a/plugins/ms-ai-architect/playground/test-fixtures/security.md +++ b/plugins/ms-ai-architect/playground/test-fixtures/security.md @@ -36,6 +36,29 @@ Rammeverk: NSM Grunnprinsipper + Microsoft Cloud Security + EU AI Act Art. 15 | S-05 | medium | Logging | Implementer ML-basert avviksdeteksjon på AI-output-rate | | S-06 | medium | Vendor | Bestilt third-party penetrasjons-test for Q3 2026 | +## Top-risikoer + +| ID | Risiko | Score | Severity | +|----|--------|-------|----------| +| R-01 | Lekkasje av treningsdata | 10 | high | +| R-02 | Insider-trussel (unauthorized inference) | 10 | high | +| R-03 | Prompt injection i forklaringsmodell | 9 | high | +| R-04 | Adversarielt eksempel forgifter output | 8 | medium | +| R-05 | Cloud-leverandør-utilgjengelighet | 8 | medium | + +## Kategori-snitt + +| Kategori | Snitt | +|----------|-------| +| Identitet og tilgang | 4 | +| Datasikkerhet og personvern | 3 | +| Modell- og prompt-sikkerhet | 3 | +| Nettverk og perimeter | 5 | +| Logging og hendelseshåndtering | 4 | +| Operasjonell og leverandørsikkerhet | 3 | + +Restrisiko: 5×4 → 2×3 + ## Aggregat Totalscore: 22/30 (73%) — modent men ikke best-i-klassen. Modell- og prompt-sikkerhet er svakeste dimensjon. diff --git a/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs b/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs index f9f1d97..d1cb24d 100644 --- a/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs +++ b/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs @@ -20,6 +20,19 @@ const REQUIRED_KEYS = [ 'must_contain', ]; +// Optional manifest keys (plan-v2 Step 4). Absence == false. +// `skip_commit_check`: opt out of the per-step commit assertion (e.g. memory-only steps). +// `memory_write` : marks a step that writes to ~/.claude/projects/.../memory/ +// so the executor can route it through the memory truth gate. +const OPTIONAL_KEYS = [ + 'skip_commit_check', + 'memory_write', +]; + +const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS); + +export { OPTIONAL_KEYS }; + /** * Extract the first fenced YAML block whose first non-blank line begins with * `manifest:`. @@ -84,6 +97,19 @@ export function parseManifest(stepBody) { errors.push(issue('MANIFEST_COUNT_TYPE', 'min_file_count must be a number')); } + for (const k of OPTIONAL_BOOLEAN_KEYS) { + if (k in parsed) { + if (typeof parsed[k] !== 'boolean') { + errors.push(issue( + 'MANIFEST_OPTIONAL_TYPE', + `${k} must be boolean if present (got ${typeof parsed[k]})`, + )); + } + } else { + parsed[k] = false; // default: absence == false + } + } + return { valid: errors.length === 0, errors, warnings, parsed }; } diff --git a/plugins/ultraplan-local/lib/util/autonomy-gate.mjs b/plugins/ultraplan-local/lib/util/autonomy-gate.mjs new file mode 100644 index 0000000..314899c --- /dev/null +++ b/plugins/ultraplan-local/lib/util/autonomy-gate.mjs @@ -0,0 +1,129 @@ +// lib/util/autonomy-gate.mjs +// Autonomy-gate state machine for /ultraexecute-local + /ultraplan-local +// (plan-v2 Step 4 — drives the --gates flag). +// +// States: +// idle — not yet started +// gates_on — gates enabled, between phases +// auto_running — running phases continuously without pausing +// paused_for_gate — stopped at a phase boundary; awaiting `resume` +// completed — terminal +// +// Events: +// start — begin a run (gates flag chooses route) +// phase_boundary — a phase finished +// resume — operator confirmed; leave the gate +// finish — pipeline reached its end +// +// CLI shim: +// node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false] +// → JSON: { ok: true, next_state: "..." } (success) +// → JSON: { ok: false, error: "..." } (invalid transition; exit 1) +// +// Pure data; no I/O. Re-entry to `completed` is idempotent. + +export const STATES = Object.freeze({ + IDLE: 'idle', + GATES_ON: 'gates_on', + AUTO_RUNNING: 'auto_running', + PAUSED_FOR_GATE: 'paused_for_gate', + COMPLETED: 'completed', +}); + +export const EVENTS = Object.freeze({ + START: 'start', + PHASE_BOUNDARY: 'phase_boundary', + RESUME: 'resume', + FINISH: 'finish', +}); + +const STATE_SET = new Set(Object.values(STATES)); +const EVENT_SET = new Set(Object.values(EVENTS)); + +/** + * Compute the next state given the current state, event, and (optional) + * gates-flag intent (only consulted on `start` from `idle`). + * + * @param {string} state + * @param {string} event + * @param {{ gates?: boolean }} [opts] + * @returns {{ ok: true, next_state: string } | { ok: false, error: string }} + */ +export function transition(state, event, opts = {}) { + if (!STATE_SET.has(state)) { + return { ok: false, error: `unknown state: ${state}` }; + } + if (!EVENT_SET.has(event)) { + return { ok: false, error: `unknown event: ${event}` }; + } + + // completed is terminal & idempotent + if (state === STATES.COMPLETED) { + return { ok: true, next_state: STATES.COMPLETED }; + } + + if (state === STATES.IDLE) { + if (event === EVENTS.START) { + const gates = opts.gates === true; + return { ok: true, next_state: gates ? STATES.GATES_ON : STATES.AUTO_RUNNING }; + } + return { ok: false, error: `invalid transition: idle + ${event} (only \`start\` allowed from idle)` }; + } + + if (state === STATES.GATES_ON) { + if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.PAUSED_FOR_GATE }; + if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; + return { ok: false, error: `invalid transition: gates_on + ${event}` }; + } + + if (state === STATES.AUTO_RUNNING) { + if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.AUTO_RUNNING }; + if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; + return { ok: false, error: `invalid transition: auto_running + ${event}` }; + } + + if (state === STATES.PAUSED_FOR_GATE) { + if (event === EVENTS.RESUME) return { ok: true, next_state: STATES.GATES_ON }; + if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; + return { ok: false, error: `invalid transition: paused_for_gate + ${event}` }; + } + + return { ok: false, error: `unhandled state: ${state}` }; +} + +/** + * Convenience: is this state terminal? + */ +export function isTerminal(state) { + return state === STATES.COMPLETED; +} + +// ---- CLI shim ---------------------------------------------------------------- + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--state') out.state = argv[++i]; + else if (a === '--event') out.event = argv[++i]; + else if (a === '--gates') { + const v = argv[++i]; + out.gates = v === 'true'; + } + } + return out; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = parseArgs(process.argv.slice(2)); + if (!args.state || !args.event) { + process.stdout.write(JSON.stringify({ + ok: false, + error: 'usage: autonomy-gate.mjs --state --event [--gates true|false]', + }) + '\n'); + process.exit(1); + } + const result = transition(args.state, args.event, { gates: args.gates }); + process.stdout.write(JSON.stringify(result) + '\n'); + process.exit(result.ok ? 0 : 1); +} diff --git a/plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs b/plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs new file mode 100644 index 0000000..3bb77e6 --- /dev/null +++ b/plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs @@ -0,0 +1,147 @@ +// tests/lib/autonomy-gate.test.mjs +// Cover the autonomy-gate state machine (lib/util/autonomy-gate.mjs): +// every legal transition + every invalid-transition error + idempotent +// re-entry to `completed` + CLI-shim JSON-on-stdout contract. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { transition, isTerminal, STATES, EVENTS } from '../../lib/util/autonomy-gate.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SHIM = join(HERE, '..', '..', 'lib', 'util', 'autonomy-gate.mjs'); + +function runShim(args) { + try { + const out = execFileSync(process.execPath, [SHIM, ...args], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, out }; + } catch (e) { + return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' }; + } +} + +test('idle + start + gates=true → gates_on', () => { + const r = transition(STATES.IDLE, EVENTS.START, { gates: true }); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.GATES_ON); +}); + +test('idle + start + gates=false → auto_running', () => { + const r = transition(STATES.IDLE, EVENTS.START, { gates: false }); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.AUTO_RUNNING); +}); + +test('idle + start + gates omitted defaults to auto_running', () => { + const r = transition(STATES.IDLE, EVENTS.START); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.AUTO_RUNNING); +}); + +test('gates_on + phase_boundary → paused_for_gate', () => { + const r = transition(STATES.GATES_ON, EVENTS.PHASE_BOUNDARY); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.PAUSED_FOR_GATE); +}); + +test('gates_on + finish → completed', () => { + const r = transition(STATES.GATES_ON, EVENTS.FINISH); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.COMPLETED); +}); + +test('auto_running + phase_boundary → auto_running (no pause)', () => { + const r = transition(STATES.AUTO_RUNNING, EVENTS.PHASE_BOUNDARY); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.AUTO_RUNNING); +}); + +test('auto_running + finish → completed', () => { + const r = transition(STATES.AUTO_RUNNING, EVENTS.FINISH); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.COMPLETED); +}); + +test('paused_for_gate + resume → gates_on', () => { + const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.RESUME); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.GATES_ON); +}); + +test('paused_for_gate + finish → completed', () => { + const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.FINISH); + assert.equal(r.ok, true); + assert.equal(r.next_state, STATES.COMPLETED); +}); + +test('completed + any event → completed (idempotent re-entry)', () => { + for (const ev of Object.values(EVENTS)) { + const r = transition(STATES.COMPLETED, ev); + assert.equal(r.ok, true, `event ${ev} should be tolerated from completed`); + assert.equal(r.next_state, STATES.COMPLETED, `event ${ev} broke idempotency`); + } +}); + +test('idle + non-start event → invalid transition error', () => { + const r = transition(STATES.IDLE, EVENTS.PHASE_BOUNDARY); + assert.equal(r.ok, false); + assert.match(r.error, /invalid transition.*idle/); +}); + +test('gates_on + resume → invalid (resume is only valid from paused_for_gate)', () => { + const r = transition(STATES.GATES_ON, EVENTS.RESUME); + assert.equal(r.ok, false); +}); + +test('auto_running + resume → invalid (auto-mode never pauses)', () => { + const r = transition(STATES.AUTO_RUNNING, EVENTS.RESUME); + assert.equal(r.ok, false); +}); + +test('unknown state rejected with descriptive error', () => { + const r = transition('zombie', EVENTS.START); + assert.equal(r.ok, false); + assert.match(r.error, /unknown state/); +}); + +test('unknown event rejected with descriptive error', () => { + const r = transition(STATES.IDLE, 'snooze'); + assert.equal(r.ok, false); + assert.match(r.error, /unknown event/); +}); + +test('isTerminal — only completed is terminal', () => { + assert.equal(isTerminal(STATES.COMPLETED), true); + for (const s of [STATES.IDLE, STATES.GATES_ON, STATES.AUTO_RUNNING, STATES.PAUSED_FOR_GATE]) { + assert.equal(isTerminal(s), false, `${s} should not be terminal`); + } +}); + +test('CLI shim returns valid JSON on success (exit 0)', () => { + const r = runShim(['--state', 'idle', '--event', 'start', '--gates', 'true']); + assert.equal(r.code, 0); + const parsed = JSON.parse(r.out.trim()); + assert.equal(parsed.ok, true); + assert.equal(parsed.next_state, 'gates_on'); +}); + +test('CLI shim returns JSON error on invalid transition (exit 1)', () => { + const r = runShim(['--state', 'idle', '--event', 'phase_boundary']); + assert.equal(r.code, 1); + const parsed = JSON.parse(r.out.trim()); + assert.equal(parsed.ok, false); + assert.match(parsed.error, /invalid transition/); +}); + +test('CLI shim missing required args returns usage error (exit 1)', () => { + const r = runShim(['--state', 'idle']); + assert.equal(r.code, 1); + const parsed = JSON.parse(r.out.trim()); + assert.equal(parsed.ok, false); + assert.match(parsed.error, /usage:/); +}); diff --git a/plugins/ultraplan-local/tests/lib/manifest-schema-extensions.test.mjs b/plugins/ultraplan-local/tests/lib/manifest-schema-extensions.test.mjs new file mode 100644 index 0000000..fa326f7 --- /dev/null +++ b/plugins/ultraplan-local/tests/lib/manifest-schema-extensions.test.mjs @@ -0,0 +1,92 @@ +// tests/lib/manifest-schema-extensions.test.mjs +// Cover the OPTIONAL_KEYS extension to lib/parsers/manifest-yaml.mjs: +// - skip_commit_check (boolean, default false) +// - memory_write (boolean, default false) +// +// Defaults must NOT break the REQUIRED_KEYS contract. +// Non-boolean values must produce MANIFEST_OPTIONAL_TYPE error. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { parseManifest, OPTIONAL_KEYS } from '../../lib/parsers/manifest-yaml.mjs'; + +const BASE = `### Step 1: Cover +- Manifest: + \`\`\`yaml + manifest: + expected_paths: + - lib/foo.mjs + min_file_count: 1 + commit_message_pattern: "^feat:" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: []`; + +function bodyWithExtras(extras) { + return `${BASE}\n${extras}\n \`\`\`\n`; +} + +function bodyOnlyRequired() { + return `${BASE}\n \`\`\`\n`; +} + +test('OPTIONAL_KEYS exports skip_commit_check + memory_write', () => { + assert.deepEqual( + [...OPTIONAL_KEYS].sort(), + ['memory_write', 'skip_commit_check'].sort(), + 'OPTIONAL_KEYS export drift — pin contract', + ); +}); + +test('absence of optional keys → defaults to false (both fields)', () => { + const r = parseManifest(bodyOnlyRequired()); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.skip_commit_check, false); + assert.equal(r.parsed.memory_write, false); +}); + +test('skip_commit_check: true honored', () => { + const r = parseManifest(bodyWithExtras(' skip_commit_check: true')); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.skip_commit_check, true); + assert.equal(r.parsed.memory_write, false); +}); + +test('memory_write: true honored', () => { + const r = parseManifest(bodyWithExtras(' memory_write: true')); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.memory_write, true); + assert.equal(r.parsed.skip_commit_check, false); +}); + +test('both optional fields together — both honored', () => { + const r = parseManifest(bodyWithExtras(' skip_commit_check: true\n memory_write: true')); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.skip_commit_check, true); + assert.equal(r.parsed.memory_write, true); +}); + +test('skip_commit_check: non-boolean rejected with MANIFEST_OPTIONAL_TYPE', () => { + const r = parseManifest(bodyWithExtras(' skip_commit_check: "yes"')); + assert.equal(r.valid, false); + const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE'); + assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`); + assert.match(found.message, /skip_commit_check/); +}); + +test('memory_write: numeric rejected with MANIFEST_OPTIONAL_TYPE', () => { + const r = parseManifest(bodyWithExtras(' memory_write: 1')); + assert.equal(r.valid, false); + const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE'); + assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`); + assert.match(found.message, /memory_write/); +}); + +test('extension does NOT break REQUIRED_KEYS contract', () => { + const r = parseManifest(bodyOnlyRequired()); + assert.equal(r.valid, true); + for (const k of ['expected_paths', 'min_file_count', 'commit_message_pattern', + 'bash_syntax_check', 'forbidden_paths', 'must_contain']) { + assert.ok(k in r.parsed, `required key ${k} missing after extension`); + } +});