From f638e3f9e63c97c5cdf59d433b1dd930ab9cd639 Mon Sep 17 00:00:00 2001 From: Cleilson Date: Sun, 4 Oct 2026 20:44:34 -0300 Subject: [PATCH 1/2] ci: publish OPA and feature reports --- .github/workflows/ci.yml | 9 ++ .github/workflows/features.yaml | 27 ++++++ .gitignore | 1 + docs/TESTING.md | 9 ++ package.json | 4 +- scripts/opa.report.mjs | 104 +++++++++++++++++++++++ scripts/test-feature.mjs | 144 ++++++++++++++++++++++++++++++++ 7 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/features.yaml create mode 100644 scripts/opa.report.mjs create mode 100644 scripts/test-feature.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98eba6f..b04309e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,12 @@ jobs: - run: npm run opa:test - run: npm run opa:server:test - run: npm run opa:conformance + - run: npm run opa:report + if: always() + - uses: actions/upload-artifact@v4 + if: always() + with: + name: opa-conformance-report + path: reports/opa + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/features.yaml b/.github/workflows/features.yaml new file mode 100644 index 0000000..78d069e --- /dev/null +++ b/.github/workflows/features.yaml @@ -0,0 +1,27 @@ +name: Features +on: + push: + branches: [main] + pull_request: + branches: [main] +permissions: + contents: read +jobs: + gherkin: + name: Feature binding + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run features:test + - uses: actions/upload-artifact@v4 + if: always() + with: + name: feature-binding-report + path: reports/features + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 8536d77..a5dfa46 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .intent/cache/ .intent/write.lock coverage/ +reports/ diff --git a/docs/TESTING.md b/docs/TESTING.md index 112df6e..457ef58 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,6 +14,8 @@ | `prd test` | resultados dos casos de decisões tipadas | execução dos passos Gherkin | | `prd conformance decision ID --runtime opa` | equivalência dos casos declarados entre a referência e o adapter OPA | semântica fora do subconjunto `dmn-table/v1` | | `prd conformance decision ID --runtime opa --parity` | paridade para chave extra/ausente, tipo, domínio, gap e overlap | conformidade DMN geral ou entradas arbitrárias | +| `npm run opa:report` | relatório JSON/Markdown da paridade entre a referência e o candidato OPA | execução do produto ou dos passos Gherkin | +| `npm run features:test` | parsing Gherkin, identidade única `@scenario_SCN-...` e vínculo com casos de decisão | execução de step definitions ou da aplicação | | `prd citations check` | estado dos blocos e snapshots conhecidos | autenticidade do autor | Ao adicionar um tipo ou alterar um contrato, atualize em conjunto o schema, o @@ -88,3 +90,10 @@ resultados esperados do mesmo YAML. `opa:conformance` usa a policy commitada par os casos principais, ativa `--parity` e ainda cobre números `0`, `-1`, decimal, inteiro acima de `2^53` e strings com aspas. GitHub Actions e Azure Pipelines instalam OPA 1.4.2, regeneram `opa/` e falham se qualquer artefato divergir. + +O job `OPA / Rego` também executa `npm run opa:report` e publica os arquivos JSON +e Markdown como o artefato `opa-conformance-report`, inclusive quando o job falha. +Uma action separada, `Features`, executa somente o parser oficial, exige exatamente +uma tag `@scenario_SCN-...` por cenário e confirma os vínculos por meio de +`prd test --require-bound-scenarios`. Ela publica `feature-binding-report`; nenhum +step Gherkin ou código de aplicação é executado por essa verificação. diff --git a/package.json b/package.json index 633b7f9..4be7e29 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "opa:check": "opa fmt --list --fail opa/policies opa/tests && opa check --strict opa/policies opa/tests", "opa:test": "opa test opa/policies opa/tests --verbose", "opa:server:test": "node scripts/test-opa-server.mjs", - "opa:conformance": "node scripts/test-opa-conformance.mjs" + "opa:conformance": "node scripts/test-opa-conformance.mjs", + "opa:report": "node scripts/opa.report.mjs", + "features:test": "npm run build && node scripts/test-feature.mjs" }, "dependencies": { "commander": "14.0.2", "yaml": "2.9.1", "ajv": "8.17.1", diff --git a/scripts/opa.report.mjs b/scripts/opa.report.mjs new file mode 100644 index 0000000..2bf284c --- /dev/null +++ b/scripts/opa.report.mjs @@ -0,0 +1,104 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { parse } from 'yaml'; +import { opaPackageName } from '../dist/infrastructure/opa/rego-generator.js'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const reportDirectory = resolve(repositoryRoot, 'reports/opa'); +const productRoot = resolve(repositoryRoot, 'examples/transfer'); +const decisionPath = resolve(productRoot, 'product/decisions/DEC-001.yaml'); +const artifact = parse(readFileSync(decisionPath, 'utf8')); +const decisionId = artifact.metadata.id; +const packageName = opaPackageName(decisionId); +const policyPath = resolve( + repositoryRoot, + 'opa/policies', + `${packageName.split('.').at(-1)}.rego`, +); +const cli = resolve(repositoryRoot, 'dist/interfaces/cli/main.js'); + +function run(args) { + return spawnSync(process.execPath, [cli, ...args], { + encoding: 'utf8', + windowsHide: true, + }); +} + +const version = run(['--version']); +const conformance = run([ + '-C', + productRoot, + '--json', + 'conformance', + 'decision', + decisionId, + '--runtime', + 'opa', + '--parity', + '--policy', + policyPath, +]); + +let document = null; +let parseError = null; +try { + document = JSON.parse(conformance.stdout); +} catch (error) { + parseError = error instanceof Error ? error.message : String(error); +} + +const report = { + generatedAt: new Date().toISOString(), + commit: process.env.GITHUB_SHA ?? null, + runUrl: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` + : null, + tool: (version.stdout || '').trim(), + decision: decisionId, + policy: policyPath.slice(repositoryRoot.length + 1), + package: packageName, + exitCode: conformance.status, + parseError, + conformance: document, + stderr: (conformance.stderr || '').trim() || null, +}; + +mkdirSync(reportDirectory, { recursive: true }); +writeFileSync( + resolve(reportDirectory, 'conformance.json'), + `${JSON.stringify(report, null, 2)}\n`, +); + +const cases = document?.cases ?? []; +const rows = cases + .map(item => { + const error = item.runtimeError ?? item.referenceError ?? item.error ?? ''; + return `| ${item.id} | ${item.kind} | ${item.passed ? 'pass' : 'fail'} | ${error} |`; + }) + .join('\n'); +const markdown = [ + '# OPA conformance report', + '', + `- Decision: \`${decisionId}\``, + `- Package: \`${packageName}\``, + `- Policy: \`${report.policy}\``, + `- Tool: ${report.tool || 'unknown'}`, + `- Runtime: ${document?.runtime ?? 'opa'}${document?.runtimeVersion ? ` ${document.runtimeVersion}` : ''}`, + `- Result: ${document?.passed ? 'passed' : 'failed'} (${document?.matched ?? 0}/${cases.length})`, + `- Parity: ${document?.parity ? 'yes' : 'no'}`, + `- Exit: ${conformance.status}`, + '', + '| Case | Kind | Result | Error |', + '| --- | --- | --- | --- |', + rows || '| — | — | — | report was not JSON |', + '', + 'This report compares the reference decision evaluator with the OPA candidate. It does not execute Gherkin steps.', + '', +].join('\n'); +writeFileSync(resolve(reportDirectory, 'conformance.md'), markdown); + +process.stdout.write(`OPA report: ${reportDirectory}\n`); +if (conformance.error && conformance.status === null) throw conformance.error; +if (conformance.status !== 0 || parseError) process.exit(conformance.status || 1); diff --git a/scripts/test-feature.mjs b/scripts/test-feature.mjs new file mode 100644 index 0000000..8e3f279 --- /dev/null +++ b/scripts/test-feature.mjs @@ -0,0 +1,144 @@ +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { Parser, AstBuilder, GherkinClassicTokenMatcher } from '@cucumber/gherkin'; +import { IdGenerator } from '@cucumber/messages'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const reportDirectory = resolve(repositoryRoot, 'reports/features'); +const ignored = new Set(['.git', 'node_modules', 'dist', 'reports']); + +function walk(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + if (ignored.has(entry.name)) return []; + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return walk(path); + return entry.isFile() && entry.name.endsWith('.feature') ? [path] : []; + }); +} + +function parseFeature(path) { + const text = readFileSync(path, 'utf8'); + const parser = new Parser( + new AstBuilder(IdGenerator.incrementing()), + new GherkinClassicTokenMatcher(), + ); + const document = parser.parse(text); + if (!document.feature) throw new Error('Expected a Gherkin Feature/Funcionalidade.'); + const scenarios = []; + const visit = (children, inherited) => { + for (const child of children) { + if (child.rule) visit(child.rule.children, [...inherited, ...child.rule.tags]); + if (!child.scenario) continue; + const tags = [...inherited, ...child.scenario.tags].map(tag => tag.name); + const ids = tags.filter(tag => tag.startsWith('@scenario_')); + const id = ids[0]?.slice('@scenario_'.length); + const valid = ids.length === 1 && /^@scenario_SCN-[A-Za-z0-9][A-Za-z0-9-]*$/.test(ids[0]); + scenarios.push({ + id: valid ? id : null, + name: child.scenario.name, + line: child.scenario.location.line, + tags, + steps: child.scenario.steps.map(step => step.text), + error: valid + ? null + : `Scenario at line ${child.scenario.location.line} requires exactly one @scenario_SCN-... tag.`, + }); + } + }; + visit(document.feature.children, document.feature.tags); + return { + file: relative(repositoryRoot, path), + name: document.feature.name, + language: document.feature.language, + tags: document.feature.tags.map(tag => tag.name), + scenarios, + }; +} + +const files = walk(repositoryRoot).map(path => { + try { + return { ok: true, ...parseFeature(path) }; + } catch (error) { + return { + ok: false, + file: relative(repositoryRoot, path), + error: error instanceof Error ? error.message : String(error), + scenarios: [], + }; + } +}); + +const productRoot = resolve(repositoryRoot, 'examples/transfer'); +const cli = resolve(repositoryRoot, 'dist/interfaces/cli/main.js'); +const bound = spawnSync(process.execPath, [ + cli, + '-C', + productRoot, + '--json', + 'test', + '--require-tests', + '--require-bound-scenarios', +], { encoding: 'utf8', windowsHide: true }); +let binding = null; +try { + binding = JSON.parse(bound.stdout); +} catch { + binding = { + passed: false, + error: ( + bound.error?.message || + bound.stderr || + bound.stdout || + 'prd test did not return JSON' + ).trim(), + }; +} + +const scenarios = files.flatMap(file => + file.scenarios.map(scenario => ({ file: file.file, ...scenario })), +); +const tagErrors = scenarios.filter(scenario => scenario.error); +const report = { + generatedAt: new Date().toISOString(), + commit: process.env.GITHUB_SHA ?? null, + scope: 'Gherkin parse, scenario identity and decision-case binding. Steps are not executed.', + files: files.length, + scenarios: scenarios.length, + tagErrors: tagErrors.length, + binding, + features: files, + passed: files.every(file => file.ok) && tagErrors.length === 0 && + binding?.passed === true && bound.status === 0, +}; + +mkdirSync(reportDirectory, { recursive: true }); +writeFileSync( + resolve(reportDirectory, 'features.json'), + `${JSON.stringify(report, null, 2)}\n`, +); +const markdown = [ + '# Feature binding report', + '', + `- Files: ${report.files}`, + `- Scenarios: ${report.scenarios}`, + `- Tag errors: ${report.tagErrors}`, + `- Bound test: ${binding?.passed ? 'passed' : 'failed'}`, + `- Unbound: ${(binding?.unboundScenarios ?? []).join(', ') || 'none'}`, + '', + '| File | Scenario | Line | Error |', + '| --- | --- | --- | --- |', + ...scenarios.map(scenario => + `| ${scenario.file} | ${scenario.id ?? scenario.name} | ${scenario.line} | ${scenario.error ?? ''} |`, + ), + ...files.filter(file => !file.ok).map(file => + `| ${file.file} | — | — | ${file.error} |`, + ), + '', + 'Step definitions are out of scope. A green report means the feature parsed and every scenario id is bound to a decision case.', + '', +].join('\n'); +writeFileSync(resolve(reportDirectory, 'features.md'), markdown); +process.stdout.write(`Feature report: ${reportDirectory} (${report.passed ? 'passed' : 'failed'})\n`); +if (!report.passed) process.exit(1); From edbfa39dbc75064cc2f79c9d992df4af10ee450b Mon Sep 17 00:00:00 2001 From: Cleilson Date: Sun, 4 Oct 2026 21:57:12 -0300 Subject: [PATCH 2/2] docs: enrich OPA conformance report --- docs/TESTING.md | 5 + .../product/behaviors/transferencia.feature | 3 + .../transfer/product/decisions/DEC-001.yaml | 8 ++ scripts/opa.report.mjs | 112 ++++++++++++++++-- 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 457ef58..f01e464 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -93,6 +93,11 @@ instalam OPA 1.4.2, regeneram `opa/` e falham se qualquer artefato divergir. O job `OPA / Rego` também executa `npm run opa:report` e publica os arquivos JSON e Markdown como o artefato `opa-conformance-report`, inclusive quando o job falha. +O relatório enriquece regras, casos e cenários somente com comentários adjacentes +nas formas `# decision ID:`, `# rule ID:`, `# case ID:` e `# scenario ID:`; +comentários livres não são interpretados como metadados. Para probes de paridade, +o próprio script descreve o contrato provado e mostra o código devolvido em +“Observado”, mesmo quando o caso passa. Uma action separada, `Features`, executa somente o parser oficial, exige exatamente uma tag `@scenario_SCN-...` por cenário e confirma os vínculos por meio de `prd test --require-bound-scenarios`. Ela publica `feature-binding-report`; nenhum diff --git a/examples/transfer/product/behaviors/transferencia.feature b/examples/transfer/product/behaviors/transferencia.feature index a18ec8e..ebd485f 100644 --- a/examples/transfer/product/behaviors/transferencia.feature +++ b/examples/transfer/product/behaviors/transferencia.feature @@ -2,6 +2,7 @@ @process_PROC-001 @requirement_REQ-001 @rule_BR-001 Funcionalidade: Avaliar elegibilidade da transferência + # scenario SCN-001: conta ativa e saldo suficiente. O caso CASE-001 espera ELEGIVEL. @scenario_SCN-001 Cenário: Conta ativa e saldo suficiente Dado que a conta de origem está ativa @@ -9,6 +10,7 @@ Funcionalidade: Avaliar elegibilidade da transferência Quando a elegibilidade é avaliada Então o resultado deve ser "ELEGIVEL" + # scenario SCN-002: conta inativa. O saldo suficiente não autoriza a transferência. Caso CASE-002. @scenario_SCN-002 Cenário: Conta inativa Dado que a conta de origem está inativa @@ -16,6 +18,7 @@ Funcionalidade: Avaliar elegibilidade da transferência Quando a elegibilidade é avaliada Então o resultado deve ser "CONTA_INATIVA" + # scenario SCN-003: conta ativa sem saldo. Caso CASE-003 espera SALDO_INSUFICIENTE. @scenario_SCN-003 Cenário: Saldo insuficiente Dado que a conta de origem está ativa diff --git a/examples/transfer/product/decisions/DEC-001.yaml b/examples/transfer/product/decisions/DEC-001.yaml index f03ece8..264d00f 100644 --- a/examples/transfer/product/decisions/DEC-001.yaml +++ b/examples/transfer/product/decisions/DEC-001.yaml @@ -1,3 +1,4 @@ +# decision DEC-001: elegibilidade didática. Conta inativa basta para recusar; saldo só importa com conta ativa. apiVersion: prd.devcomputaria/v1alpha1 kind: Decision metadata: @@ -20,39 +21,46 @@ spec: - CONTA_INATIVA - SALDO_INSUFICIENTE rules: + # rule DROW-001: coringa de saldo. contaAtiva false cobre os dois valores de saldoSuficiente. - id: DROW-001 when: contaAtiva: false then: CONTA_INATIVA + # rule DROW-002: conta ativa e saldo insuficiente. Não cobre conta inativa. - id: DROW-002 when: contaAtiva: true saldoSuficiente: false then: SALDO_INSUFICIENTE + # rule DROW-003: única linha de aceite. As duas condições são exigidas. - id: DROW-003 when: contaAtiva: true saldoSuficiente: true then: ELEGIVEL cases: + # case CASE-001: caminho feliz. Vincula SCN-001 e deve disparar só DROW-003. - id: CASE-001 scenario: SCN-001 input: contaAtiva: true saldoSuficiente: true expected: ELEGIVEL + # case CASE-002: conta inativa com saldo. O saldo não muda o resultado; dispara DROW-001. - id: CASE-002 scenario: SCN-002 input: contaAtiva: false saldoSuficiente: true expected: CONTA_INATIVA + # case CASE-003: conta ativa sem saldo. Dispara DROW-002, não a linha coringa. - id: CASE-003 scenario: SCN-003 input: contaAtiva: true saldoSuficiente: false expected: SALDO_INSUFICIENTE + # case CASE-004: segundo ponto do coringa. Sem cenário Gherkin; prova que saldo false também cai em DROW-001. - id: CASE-004 input: contaAtiva: false diff --git a/scripts/opa.report.mjs b/scripts/opa.report.mjs index 2bf284c..03366d2 100644 --- a/scripts/opa.report.mjs +++ b/scripts/opa.report.mjs @@ -9,7 +9,10 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const reportDirectory = resolve(repositoryRoot, 'reports/opa'); const productRoot = resolve(repositoryRoot, 'examples/transfer'); const decisionPath = resolve(productRoot, 'product/decisions/DEC-001.yaml'); -const artifact = parse(readFileSync(decisionPath, 'utf8')); +const featurePath = resolve(productRoot, 'product/behaviors/transferencia.feature'); +const decisionSource = readFileSync(decisionPath, 'utf8'); +const featureSource = readFileSync(featurePath, 'utf8'); +const artifact = parse(decisionSource); const decisionId = artifact.metadata.id; const packageName = opaPackageName(decisionId); const policyPath = resolve( @@ -19,6 +22,47 @@ const policyPath = resolve( ); const cli = resolve(repositoryRoot, 'dist/interfaces/cli/main.js'); +const parityNotes = { + 'PARITY-EXTRA-INPUT': 'Chave extra no input. A referência rejeita o conjunto de chaves; o runtime deve devolver INVALID_INPUT antes de tratar a linha.', + 'PARITY-MISSING-INPUT': 'Chave declarada ausente. Não é coringa: falta de campo é contrato inválido, não regra faltante.', + 'PARITY-WRONG-TYPE': 'Tipo diferente do declarado. Igualdade do Rego não pode aceitar o que a referência recusa.', + 'PARITY-OUTSIDE-DOMAIN': 'Valor fora de values. O domínio do probe é reduzido ao valor do caso semente.', + 'PARITY-GAP': 'Tabela sem linhas. Prova DECISION_GAP no código de erro. Não prova buraco parcial numa tabela preenchida.', + 'PARITY-OVERLAP': 'A linha que casa com o caso semente é duplicada. UNIQUE não escolhe pela ordem.', + 'PARITY-INVALID-OUTPUT': 'then fora do domínio de saída. Os dois lados devem falhar com INVALID_DECISION_OUTPUT.', +}; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function markedNotes(source, kind) { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const notes = new Map(); + const marker = new RegExp(`^\\s*# ${kind} ([A-Za-z0-9-]+):\\s*(.+?)\\s*$`); + for (let index = 0; index < lines.length - 1; index += 1) { + const match = lines[index].match(marker); + if (!match) continue; + const [, id, note] = match; + const next = lines[index + 1]; + const adjacent = kind === 'decision' + ? /^apiVersion:\s*\S+/.test(next) + : kind === 'scenario' + ? new RegExp(`^\\s*(?:@\\S+\\s+)*@scenario_${escapeRegExp(id)}(?:\\s+@\\S+)*\\s*$`).test(next) + : new RegExp(`^\\s*- id:\\s*${escapeRegExp(id)}\\s*$`).test(next); + if (adjacent) notes.set(id, note); + } + return notes; +} + +function scalar(value) { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function markdownCell(value) { + return String(value ?? '').replaceAll('|', '\\|').replaceAll('\n', '
'); +} + function run(args) { return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', @@ -49,6 +93,28 @@ try { parseError = error instanceof Error ? error.message : String(error); } +const decisionNotes = markedNotes(decisionSource, 'decision'); +const ruleNotes = markedNotes(decisionSource, 'rule'); +const caseNotes = markedNotes(decisionSource, 'case'); +const scenarioNotes = markedNotes(featureSource, 'scenario'); +const rules = artifact.spec.rules.map(rule => ({ + id: rule.id, + when: Object.entries(rule.when).map(([name, value]) => `${name}=${scalar(value)}`).join(', ') || 'true', + then: rule.then, + note: ruleNotes.get(rule.id) ?? null, +})); +const scenarios = [...scenarioNotes].map(([id, note]) => ({ id, note })); +const cases = document?.cases ?? []; +const enrichedCases = cases.map(item => ({ + id: item.id, + kind: item.kind, + passed: item.passed, + input: item.input, + expected: item.expectedError ?? item.expected ?? null, + observed: item.runtimeError ?? item.runtime?.value ?? null, + note: item.kind === 'parity' ? parityNotes[item.id] ?? null : caseNotes.get(item.id) ?? null, +})); + const report = { generatedAt: new Date().toISOString(), commit: process.env.GITHUB_SHA ?? null, @@ -62,6 +128,12 @@ const report = { exitCode: conformance.status, parseError, conformance: document, + context: { + decision: decisionNotes.get(decisionId) ?? null, + rules, + cases: enrichedCases, + scenarios, + }, stderr: (conformance.stderr || '').trim() || null, }; @@ -71,13 +143,13 @@ writeFileSync( `${JSON.stringify(report, null, 2)}\n`, ); -const cases = document?.cases ?? []; -const rows = cases - .map(item => { - const error = item.runtimeError ?? item.referenceError ?? item.error ?? ''; - return `| ${item.id} | ${item.kind} | ${item.passed ? 'pass' : 'fail'} | ${error} |`; - }) +const ruleRows = rules + .map(rule => `| ${rule.id} | ${markdownCell(rule.when)} | ${markdownCell(rule.then)} | ${markdownCell(rule.note)} |`) + .join('\n'); +const caseRows = enrichedCases + .map(item => `| ${item.id} | ${item.kind} | ${item.passed ? 'pass' : 'fail'} | ${markdownCell(JSON.stringify(item.input))} | ${markdownCell(item.expected)} | ${markdownCell(item.observed)} | ${markdownCell(item.note)} |`) .join('\n'); +const scenarioRows = scenarios.map(item => `- ${item.id}: ${item.note}`); const markdown = [ '# OPA conformance report', '', @@ -90,11 +162,31 @@ const markdown = [ `- Parity: ${document?.parity ? 'yes' : 'no'}`, `- Exit: ${conformance.status}`, '', - '| Case | Kind | Result | Error |', + '## Decisão', + '', + `- ${decisionNotes.get(decisionId) ?? 'Sem comentário estruturado.'}`, + '', + '## Regras', + '', + 'Comentários `# rule ID:` do YAML. Célula omitida é coringa, não ausência de input.', + '', + '| Rule | When | Then | Nota |', '| --- | --- | --- | --- |', - rows || '| — | — | — | report was not JSON |', + ruleRows || '| — | — | — | sem regras |', + '', + '## Casos', + '', + 'Comentários `# case ID:` e `# scenario ID:`. Na paridade, a coluna observado é o código esperado, não uma falha.', + '', + '| Case | Kind | Result | Input | Esperado | Observado | Nota |', + '| --- | --- | --- | --- | --- | --- | --- |', + caseRows || '| — | — | — | — | — | — | report was not JSON |', + '', + '## Cenários', + '', + ...(scenarioRows.length ? scenarioRows : ['- Sem comentários estruturados.']), '', - 'This report compares the reference decision evaluator with the OPA candidate. It does not execute Gherkin steps.', + 'A comparação é entre evaluateDecision e o candidato OPA. O relatório não executa passos Gherkin.', '', ].join('\n'); writeFileSync(resolve(reportDirectory, 'conformance.md'), markdown);