Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 27 additions & 0 deletions .github/workflows/features.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
.intent/cache/
.intent/write.lock
coverage/
reports/
14 changes: 14 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,3 +90,15 @@ 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.
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
step Gherkin ou código de aplicação é executado por essa verificação.
3 changes: 3 additions & 0 deletions examples/transfer/product/behaviors/transferencia.feature
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@
@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
E possui saldo suficiente
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
E possui saldo suficiente
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
Expand Down
8 changes: 8 additions & 0 deletions examples/transfer/product/decisions/DEC-001.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
196 changes: 196 additions & 0 deletions scripts/opa.report.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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 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(
repositoryRoot,
'opa/policies',
`${packageName.split('.').at(-1)}.rego`,
);
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', '<br>');
}

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 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,
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,
context: {
decision: decisionNotes.get(decisionId) ?? null,
rules,
cases: enrichedCases,
scenarios,
},
stderr: (conformance.stderr || '').trim() || null,
};

mkdirSync(reportDirectory, { recursive: true });
writeFileSync(
resolve(reportDirectory, 'conformance.json'),
`${JSON.stringify(report, null, 2)}\n`,
);

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',
'',
`- 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}`,
'',
'## 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 |',
'| --- | --- | --- | --- |',
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.']),
'',
'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);

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);
Loading
Loading