OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / scripts / generate-release-evidence-handoff.mjs

347 lines · javascript ref:
#!/usr/bin/env node

import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'

import {
  analyzeReleaseEvidence,
  parseMarkdownTables
} from './check-release-evidence.mjs'

const DEFAULT_LOG = new URL('../docs/RELEASE_SMOKE_EVIDENCE_LOG_2026-06-23.md', import.meta.url)
const PASS_STATUSES = new Set(['PASS', 'DEFER'])
const NEGATIVE_DECISION_RE = /^(NO|FAIL|FAILED|BLOCKED)\b/
const READY_ANSWER_RE = /^(YES|PASS|DEFER)\b|OUT OF SCOPE/

export function buildEvidenceHandoff (markdown, options = {}) {
  const file = options.file || DEFAULT_LOG.pathname
  const analysis = analyzeReleaseEvidence(markdown)
  const rows = indexEvidenceRows(markdown)
  const automatedRows = indexAutomatedEvidenceRows(options.storySmoke)
  const blockers = mergeDuplicateBlockers([
    ...analysis.incomplete.map((item) => ({ ...item, status: 'incomplete' })),
    ...analysis.failures.map((item) => ({ ...item, status: 'failure' }))
  ])
  const groupsBySection = new Map()

  for (const blocker of blockers) {
    const row = findIndexedRow(rows, blocker)
    const automatedEvidence = findAutomatedEvidenceRow(automatedRows, blocker)
    const item = {
      section: blocker.section,
      item: blocker.item,
      status: blocker.status,
      reason: blocker.reason,
      kind: row?.kind || kindForSection(blocker.section),
      expected: row?.expected || '',
      result: row?.result || '',
      evidence: row?.evidence || '',
      answer: row?.answer || '',
      automatedEvidence,
      template: row ? templateForRow(row, blocker, automatedEvidence) : ''
    }
    if (!groupsBySection.has(item.section)) {
      groupsBySection.set(item.section, { section: item.section, items: [] })
    }
    groupsBySection.get(item.section).items.push(item)
  }

  return {
    ok: analysis.ok,
    file,
    counts: analysis.counts,
    groups: [...groupsBySection.values()],
    passed: analysis.passed,
    deferred: analysis.deferred
  }
}

export function formatEvidenceHandoffMarkdown (handoff) {
  const out = []
  out.push('# Release Evidence Handoff')
  out.push('')
  out.push(`Evidence log: \`${handoff.file}\``)
  out.push(`Status: \`${handoff.ok ? 'READY' : 'BLOCKED'}\``)
  out.push('')
  out.push('## Summary')
  out.push('')
  out.push('| Result | Count |')
  out.push('| --- | --- |')
  out.push(`| Passed | ${handoff.counts.passed} |`)
  out.push(`| Deferred | ${handoff.counts.deferred} |`)
  out.push(`| Incomplete | ${handoff.counts.incomplete} |`)
  out.push(`| Failures | ${handoff.counts.failures} |`)
  out.push('')

  out.push('## Blocking Rows')
  out.push('')
  if (!handoff.groups.length) {
    out.push('No blocking evidence rows remain. Re-run the release evidence gate before announcement.')
    out.push('')
  } else {
    for (const group of handoff.groups) {
      out.push(`### ${group.section}`)
      out.push('')
      for (const item of group.items) {
        out.push(`- [ ] ${item.item}`)
        out.push(`  - Blocker: ${item.reason}`)
        if (item.expected) out.push(`  - Expected: ${item.expected}`)
        if (item.result || item.evidence || item.answer) {
          out.push(`  - Current: ${currentStateFor(item)}`)
        }
        if (item.automatedEvidence) {
          out.push(`  - Automated evidence: ${item.automatedEvidence.result} - ${item.automatedEvidence.evidence}`)
        }
        if (item.template) {
          out.push('  - Fill:')
          out.push('')
          out.push('```md')
          out.push(item.template)
          out.push('```')
          out.push('')
        }
      }
    }
  }

  out.push('## Evidence Rules')
  out.push('')
  out.push('- Use `PASS` only when the operator has the evidence in hand.')
  out.push('- Use `DEFER` only with an explicit scope decision and evidence note.')
  out.push('- Evidence can be a screenshot/log path, terminal excerpt, CI URL, release asset URL, or store validation URL.')
  out.push('- Re-run `npm run check:release-evidence` after updating the log.')
  out.push('')

  return out.join('\n')
}

function indexEvidenceRows (markdown) {
  const tables = parseMarkdownTables(markdown)
  const rows = []

  for (const table of tables) {
    const headers = table.header.map((h) => normalize(h).toLowerCase())
    if (table.section === 'Run Metadata' && headers.includes('field') && headers.includes('value')) {
      for (const row of table.rows) {
        const obj = rowObject(table, row)
        rows.push({
          kind: 'metadata',
          section: table.section,
          item: normalize(obj.field),
          value: normalize(obj.value)
        })
      }
      continue
    }

    if (headers.includes('gate') && headers.includes('result') && headers.includes('evidence')) {
      for (const row of table.rows) {
        const obj = rowObject(table, row)
        rows.push({
          kind: 'gate',
          section: table.section,
          item: normalize(obj.gate),
          expected: normalize(obj.expected),
          result: normalizeUpper(obj.result),
          evidence: normalize(obj.evidence)
        })
      }
      continue
    }

    if (table.section === 'Announcement Decision' && headers.includes('question') && headers.includes('answer')) {
      for (const row of table.rows) {
        const obj = rowObject(table, row)
        const question = normalize(obj.question)
        rows.push({
          kind: 'decision',
          section: table.section,
          item: question,
          answer: normalize(obj.answer)
        })
        if (/^Final decision/i.test(question)) {
          rows.push({
            kind: 'decision',
            section: table.section,
            item: 'Final decision',
            answer: normalize(obj.answer),
            originalQuestion: question
          })
        }
      }
    }
  }

  return rows
}

function rowObject (table, row) {
  const out = {}
  for (let i = 0; i < table.header.length; i++) {
    out[normalize(table.header[i]).toLowerCase()] = row[i] || ''
  }
  return out
}

function findIndexedRow (rows, blocker) {
  return rows.find((row) => row.section === blocker.section && row.item === blocker.item) ||
    (blocker.item === 'Final decision'
      ? rows.find((row) => row.section === blocker.section && row.kind === 'decision' && /^Final decision/i.test(row.item))
      : null)
}

function mergeDuplicateBlockers (blockers) {
  const merged = new Map()
  for (const blocker of blockers) {
    const key = `${blocker.section}\0${canonicalBlockerItem(blocker.item)}`
    if (!merged.has(key)) {
      merged.set(key, { ...blocker })
      continue
    }
    const existing = merged.get(key)
    existing.reason = mergeReasons(existing.reason, blocker.reason)
    if (existing.status !== 'failure' && blocker.status === 'failure') existing.status = 'failure'
  }
  return [...merged.values()]
}

function canonicalBlockerItem (item) {
  return /^Final decision/i.test(String(item || '')) ? 'Final decision' : item
}

function mergeReasons (...reasons) {
  return [...new Set(reasons.flatMap((reason) => String(reason || '').split(/\s*;\s*/)).filter(Boolean))].join('; ')
}

function templateForRow (row, blocker, automatedEvidence = null) {
  if (row.kind === 'metadata') {
    return `| ${row.item} | <record value> |`
  }
  if (row.kind === 'decision') {
    return `| ${row.originalQuestion || row.item} | ${decisionTemplateValue(row, blocker)} |`
  }
  if (row.kind === 'gate') {
    const status = automatedEvidence?.result || (PASS_STATUSES.has(row.result) ? row.result : '<PASS|DEFER>')
    const evidence = automatedEvidence?.evidence || row.evidence || '<evidence path, URL, or terminal excerpt>'
    return `| ${row.item} | ${row.expected || '<expected result>'} | ${status} | ${evidence} |`
  }
  return ''
}

function indexAutomatedEvidenceRows (storySmoke) {
  const report = normalizeStorySmokeReport(storySmoke)
  const rawRows = Array.isArray(report?.releaseEvidence?.rows)
    ? report.releaseEvidence.rows
    : Array.isArray(report?.evidenceRows)
        ? report.evidenceRows
        : []
  const rows = []

  for (const row of rawRows) {
    const section = normalize(row.section || '')
    const item = normalize(row.gate || row.item || '')
    const result = normalizeUpper(row.result || 'PASS')
    const evidence = normalize(row.evidence || '')
    if (!section || !item || !PASS_STATUSES.has(result) || !evidence) continue
    rows.push({ section, item, result, evidence })
  }

  return rows
}

function normalizeStorySmokeReport (storySmoke) {
  if (!storySmoke) return null
  if (typeof storySmoke === 'string') {
    try { return JSON.parse(storySmoke) } catch { return null }
  }
  if (typeof storySmoke === 'object') return storySmoke
  return null
}

function findAutomatedEvidenceRow (rows, blocker) {
  return rows.find((row) => row.section === blocker.section && row.item === blocker.item) || null
}

function decisionTemplateValue (row, blocker) {
  const question = row.originalQuestion || row.item
  if (/^Final decision/i.test(question) || blocker.item === 'Final decision') return 'GO desktop only'
  if (blocker.status === 'failure' || NEGATIVE_DECISION_RE.test(normalizeUpper(row.answer))) return 'yes'
  if (row.answer && READY_ANSWER_RE.test(normalizeUpper(row.answer))) return row.answer
  return '<yes|defer: documented scope|out of scope: documented scope>'
}

function currentStateFor (item) {
  if (item.kind === 'decision') return `answer=${item.answer || '<blank>'}`
  if (item.kind === 'metadata') return 'value=<blank>'
  return `result=${item.result || '<blank>'}; evidence=${item.evidence || '<blank>'}`
}

function kindForSection (section) {
  if (section === 'Run Metadata') return 'metadata'
  if (section === 'Announcement Decision') return 'decision'
  return 'gate'
}

function normalize (value) {
  return String(value || '').replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
}

function normalizeUpper (value) {
  return normalize(value).toUpperCase()
}

function normalizeFormat (value) {
  const format = String(value || '').toLowerCase()
  if (format === 'markdown' || format === 'json') return format
  usage(2, `unsupported format: ${value}`)
}

function parseArgs (argv) {
  const parsed = {
    file: DEFAULT_LOG,
    format: 'markdown',
    storySmokeFile: null
  }
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--file') parsed.file = new URL(requireValue(argv, ++i, arg), pathToFileURL(process.cwd() + '/'))
    else if (arg === '--format') parsed.format = normalizeFormat(requireValue(argv, ++i, arg))
    else if (arg === '--story-smoke-json') parsed.storySmokeFile = new URL(requireValue(argv, ++i, arg), pathToFileURL(process.cwd() + '/'))
    else if (arg === '--json') parsed.format = 'json'
    else if (arg === '-h' || arg === '--help') usage(0)
    else usage(2, `unknown option: ${arg}`)
  }
  return parsed
}

function requireValue (argv, index, flag) {
  const value = argv[index] || ''
  if (!value || value.startsWith('--')) usage(2, `${flag} requires a value`)
  return value
}

function usage (code, message = '') {
  if (message) console.error('error:', message)
  console.error('usage: node scripts/generate-release-evidence-handoff.mjs [--file docs/RELEASE_SMOKE_EVIDENCE_LOG_2026-06-23.md] [--story-smoke-json story-smoke.json] [--format markdown|json] [--json]')
  process.exit(code)
}

function main () {
  const args = parseArgs(process.argv.slice(2))
  const markdown = readFileSync(args.file, 'utf8')
  const storySmoke = args.storySmokeFile ? JSON.parse(readFileSync(args.storySmokeFile, 'utf8')) : null
  const handoff = buildEvidenceHandoff(markdown, { file: args.file.pathname, storySmoke })
  if (args.format === 'json') console.log(JSON.stringify(handoff, null, 2))
  else console.log(formatEvidenceHandoffMarkdown(handoff))
  process.exit(handoff.ok ? 0 : 1)
}

if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
  try {
    main()
  } catch (err) {
    console.error(err.stack || err.message)
    process.exit(1)
  }
}