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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ npm exec --no -- prd validate --strict
- `init` com agente, instruções, cinco templates de skills e workflow de CI para Copilot.
- Migração aditiva do nosso protótipo anterior e CLI legado isolado.

O runtime `reference` usa a semântica nativa de `evaluateDecision`; `--runtime opa` gera Rego temporário e requer o executável `opa` disponível no PATH (ou em `PRD_OPA_BINARY`). OPA é um runtime candidato de conformance, não a autoridade semântica do modelo.

A [matriz de capacidades](framework/capabilities.json) é o contrato de escopo. **Não há conformidade OMG completa, motor BPMN, FEEL, prova modal, execução de step definitions, servidor MCP ou autenticação de revisores nesta versão.**

### Como os dados se relacionam
Expand Down Expand Up @@ -116,6 +118,7 @@ Cenários Gherkin usam `SCN-...` e não duplicam um arquivo YAML de identidade.
| `test --require-tests --require-bound-scenarios` | Executa casos de decisões e exige vínculos de cenários |
| `decision evaluate ID --input '{...}'` | Avalia uma tabela suportada |
| `decision analyze ID` | Analisa domínios finitos |
| `conformance decision ID --runtime reference|opa` | Compara um runtime executável com a semântica de referência |
| `cite ID --into notes/design.md` | Preserva snapshot e cita conteúdo |
| `citations check`, `citations refresh arquivo.md` | Verifica ou atualiza citações intactas |
| `change new SLUG --title MOTIVO` | Captura baseline |
Expand Down
4 changes: 2 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ CLI, compiler, schemas, perfis limitados, init com Copilot, modelo de exemplo, g

## 0.3 — Verificação do candidato

Adicionar testes de propriedade; análise incremental; inventário de citações por documento; suporte revisado a Outline. Saída: ampliar a cobertura semântica e medir regressões incrementais. O alpha já bloqueia approve/apply quando os casos de decisão do candidato falham.
Formalizar runtimes de decisão e conformance; adicionar testes de propriedade; análise incremental; inventário de citações por documento; suporte revisado a Outline. Saída: ampliar a cobertura semântica e medir regressões incrementais. O alpha já bloqueia approve/apply quando os casos de decisão do candidato falham.

## 0.4 — Execução e evidências

Adaptadores reais para step definitions e resultados de CI; manifest de evidência com commit/digest; comparação diferencial de decisões. Saída: distinguir formalmente testes do modelo e testes da aplicação nas APIs e relatórios.
Adaptadores reais para step definitions e resultados de CI; manifest de evidência com commit/digest; comparação diferencial com dados/fatos produzidos por aplicações. Saída: distinguir formalmente testes do modelo e testes da aplicação nas APIs e relatórios.

## 0.5 — Integrações

Expand Down
17 changes: 17 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
| `npm test` | regressão automatizada do CLI, compilador e domínio | execução de uma aplicação externa |
| `prd validate --strict` | schemas, referências e invariantes dos perfis | conformidade OMG completa |
| `prd test` | resultados dos casos de decisões tipadas | execução dos passos Gherkin |
| `prd conformance decision ID --runtime opa` | equivalência dos casos entre a semântica de referência e o adapter OPA | equivalência para entradas fora dos casos declarados |
| `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 All @@ -36,3 +37,19 @@ O teste de distribuição deve usar um diretório fora do repositório de origem

Isso detecta referências acidentais ao workspace de desenvolvimento que uma
execução direta de `dist/` não detecta.


## Conformidade de runtimes de decisão

`evaluateDecision()` continua sendo a semântica de referência do perfil
`dmn-table/v1`. A porta `DecisionRuntime` permite executar os mesmos casos em um
runtime candidato sem mover a autoridade semântica para esse runtime.

```sh
prd conformance decision DEC-001 --runtime reference
prd conformance decision DEC-001 --runtime opa
```

O adapter OPA compila somente o subconjunto já suportado: tabela `UNIQUE`,
condições de igualdade, wildcard por condição omitida e saída escalar. Ele não
expande o perfil DMN. O executável `opa` é opcional e não é baixado pelo pacote.
7 changes: 5 additions & 2 deletions framework/capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
"omitted conditions as wildcard",
"typed outputs",
"finite domain exhaustiveness up to 4096 combinations",
"scenario-linked cases"
"scenario-linked cases",
"reference runtime conformance contract",
"optional OPA/Rego adapter for declared decision cases"
],
"unsupported": [
"FEEL",
Expand Down Expand Up @@ -112,6 +114,7 @@
"authenticated PR approval provider",
"scenario step runner",
"RDF/OWL exports",
"decision differential runner"
"external fact resolver conformance runner",
"historical decision differential runner"
]
}
75 changes: 75 additions & 0 deletions src/application/decision-conformance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { evaluateDecision } from '../domain/decisions.js';
import type { DecisionSpec, Scalar } from '../domain/decisions.js';
import type { DecisionRuntime, DecisionRuntimeResult } from './ports/decision-runtime.js';

export interface DecisionConformanceCase {
id: string;
scenario?: string;
input: Record<string, Scalar>;
expected: Scalar;
reference?: DecisionRuntimeResult;
runtime?: DecisionRuntimeResult;
passed: boolean;
error?: string;
}

export interface DecisionConformanceResult {
decision: string;
runtime: string;
passed: boolean;
cases: DecisionConformanceCase[];
matched: number;
divergent: number;
}

export async function conformDecision(
decisionId: string,
spec: DecisionSpec,
runtime: DecisionRuntime,
): Promise<DecisionConformanceResult> {
const cases: DecisionConformanceCase[] = [];

for (const testCase of spec.cases) {
try {
const reference = evaluateDecision(spec, testCase.input);
const candidate = await runtime.evaluate({
decisionId,
spec,
input: testCase.input,
});
const passed =
Object.is(reference.value, testCase.expected) &&
Object.is(candidate.value, reference.value) &&
candidate.ruleId === reference.ruleId;
Comment on lines +40 to +43

cases.push({
id: testCase.id,
scenario: testCase.scenario,
input: testCase.input,
expected: testCase.expected,
reference,
runtime: candidate,
passed,
});
} catch (error) {
cases.push({
Comment on lines +54 to +55
id: testCase.id,
scenario: testCase.scenario,
input: testCase.input,
expected: testCase.expected,
passed: false,
error: (error as Error).message,
});
}
}

const matched = cases.filter(c => c.passed).length;
return {
decision: decisionId,
runtime: runtime.name,
passed: cases.length > 0 && matched === cases.length,
cases,
matched,
divergent: cases.length - matched,
};
}
17 changes: 17 additions & 0 deletions src/application/ports/decision-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { DecisionSpec, Scalar } from '../../domain/decisions.js';

export interface DecisionRuntimeRequest {
decisionId: string;
spec: DecisionSpec;
input: Record<string, Scalar>;
}

export interface DecisionRuntimeResult {
value: Scalar;
ruleId: string;
}

export interface DecisionRuntime {
readonly name: string;
evaluate(request: DecisionRuntimeRequest): Promise<DecisionRuntimeResult>;
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ export * from './application/product-service.js';
export { createCompiler } from './compiler/compile.js';
export { createWorkspace } from './infrastructure/filesystem/workspace.js';
export { services } from './bootstrap.js';
export * from './application/ports/decision-runtime.js';
export * from './application/decision-conformance.js';
export * from './infrastructure/reference/reference-decision-runtime.js';
export * from './infrastructure/opa/rego-generator.js';
export * from './infrastructure/opa/opa-decision-runtime.js';
97 changes: 97 additions & 0 deletions src/infrastructure/opa/opa-decision-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { IntentError, isRecord } from '../../domain/model.js';
import type { Scalar } from '../../domain/decisions.js';
import type {
DecisionRuntime,
DecisionRuntimeRequest,
DecisionRuntimeResult,
} from '../../application/ports/decision-runtime.js';
import { generateDecisionRego, opaPackageName } from './rego-generator.js';

function isScalar(value: unknown): value is Scalar {
return typeof value === 'string' || typeof value === 'boolean' ||
(typeof value === 'number' && Number.isFinite(value));
}

export class OpaDecisionRuntime implements DecisionRuntime {
readonly name = 'opa';

constructor(readonly binary = process.env.PRD_OPA_BINARY || 'opa') {}

async evaluate(request: DecisionRuntimeRequest): Promise<DecisionRuntimeResult> {
const dir = mkdtempSync(join(tmpdir(), 'prd-opa-'));
const policy = join(dir, 'decision.rego');

try {
writeFileSync(policy, generateDecisionRego(request.decisionId, request.spec), 'utf8');
const query = `data.${opaPackageName(request.decisionId)}.result`;
const result = spawnSync(
this.binary,
[
'eval',
'--format=json',
'--strict',
'--fail',
'--stdin-input',
'--data',
policy,
query,
],
{
input: JSON.stringify(request.input),
encoding: 'utf8',
windowsHide: true,
},
);

if (result.error) {
const code = (result.error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
throw new IntentError(
'OPA_RUNTIME_UNAVAILABLE',
`OPA executable not found: ${this.binary}. Install OPA or set PRD_OPA_BINARY.`,
);
}
throw result.error;
}

if (result.status !== 0) {
throw new IntentError(
'OPA_EVAL_FAILED',
(result.stderr || result.stdout || 'OPA evaluation failed.').trim(),
);
}

let document: unknown;
try {
document = JSON.parse(result.stdout);
} catch {
throw new IntentError('OPA_INVALID_OUTPUT', 'OPA returned invalid JSON.');
}

if (!isRecord(document) || !Array.isArray(document.result)) {
throw new IntentError('OPA_INVALID_OUTPUT', 'OPA result array is missing.');
}
const first = document.result[0];
if (!isRecord(first) || !Array.isArray(first.expressions)) {
throw new IntentError('OPA_INVALID_OUTPUT', 'OPA expressions are missing.');
}
const expression = first.expressions[0];
if (!isRecord(expression) || !isRecord(expression.value)) {
throw new IntentError('OPA_INVALID_OUTPUT', 'OPA decision result is missing.');
}

const value = expression.value.value;
const ruleId = expression.value.ruleId;
if (!isScalar(value) || typeof ruleId !== 'string') {
throw new IntentError('OPA_INVALID_OUTPUT', 'OPA decision result has invalid value or ruleId.');
}
return { value, ruleId };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
}
32 changes: 32 additions & 0 deletions src/infrastructure/opa/rego-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { DecisionSpec, Scalar } from '../../domain/decisions.js';

function scalar(value: Scalar): string {
return JSON.stringify(value);
}

export function opaPackageName(decisionId: string): string {
const safe = decisionId.toLowerCase().replace(/[^a-z0-9_]/g, '_');
return `prd.decision.${safe}`;
}

export function generateDecisionRego(decisionId: string, spec: DecisionSpec): string {
const packageName = opaPackageName(decisionId);
const rules = spec.rules.map(rule => {
const conditions = Object.entries(rule.when)
.map(([name, value]) => ` input[${JSON.stringify(name)}] == ${scalar(value)}`)
.join('\n');
const body = conditions || ' true';
return `matches contains {"value": ${scalar(rule.then)}, "ruleId": ${JSON.stringify(rule.id)}} if {\n${body}\n}`;
});

return [
`package ${packageName}`,
'',
...rules.flatMap(rule => [rule, '']),
'result := item if {',
' count(matches) == 1',
' item := matches[_]',
'}',
'',
].join('\n');
}
10 changes: 10 additions & 0 deletions src/infrastructure/reference/reference-decision-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { evaluateDecision } from '../../domain/decisions.js';
import type { DecisionRuntime, DecisionRuntimeRequest } from '../../application/ports/decision-runtime.js';

export class ReferenceDecisionRuntime implements DecisionRuntime {
readonly name = 'reference';

async evaluate(request: DecisionRuntimeRequest) {
return evaluateDecision(request.spec, request.input);
}
}
13 changes: 13 additions & 0 deletions src/interfaces/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { graph, mermaid } from '../../domain/graph.js';
import { IntentError } from '../../domain/model.js';
import { evaluateDecision, analyzeDecision } from '../../domain/decisions.js';
import type { DecisionSpec } from '../../domain/decisions.js';
import { conformDecision } from '../../application/decision-conformance.js';
import { ReferenceDecisionRuntime } from '../../infrastructure/reference/reference-decision-runtime.js';
import { OpaDecisionRuntime } from '../../infrastructure/opa/opa-decision-runtime.js';
const cli = new Command().name('prd').description('PRD as a Code').version('0.2.0-alpha.1')
.option('-C, --root <directory>', 'product directory or a child directory', '.')
.option('--json', 'machine-readable output').showHelpAfterError().showSuggestionAfterError().exitOverride();
Expand Down Expand Up @@ -43,6 +46,16 @@ cli.command('test').description('Execute typed decision cases; report unbound Gh
const decision = cli.command('decision').description('Evaluate supported decision tables');
decision.command('evaluate').argument('<id>').requiredOption('--input <json>', 'typed input object').action((id, opts) => { const a = service().load().artifacts.find(s => s.artifact.metadata.id === id && s.artifact.kind === 'Decision'); if (!a) throw new IntentError('UNKNOWN_DECISION', id); out(evaluateDecision(a.artifact.spec as unknown as DecisionSpec, JSON.parse(opts.input))); });
decision.command('analyze').argument('<id>').action(id => { const a = service().load().artifacts.find(s => s.artifact.metadata.id === id && s.artifact.kind === 'Decision'); if (!a) throw new IntentError('UNKNOWN_DECISION', id); const result = analyzeDecision(a.artifact.spec as unknown as DecisionSpec); out(result); if (result.status !== 'valid') process.exitCode = 1; });
const conformance = cli.command('conformance').description('Compare executable decision runtimes with PRD as a Code reference semantics');
conformance.command('decision').argument('<id>').addOption(new Option('--runtime <runtime>').choices(['reference', 'opa']).default('reference'))
.action(async (id, opts) => {
const a = service().load().artifacts.find(s => s.artifact.metadata.id === id && s.artifact.kind === 'Decision');
if (!a) throw new IntentError('UNKNOWN_DECISION', id);
const runtime = opts.runtime === 'opa' ? new OpaDecisionRuntime() : new ReferenceDecisionRuntime();
const result = await conformDecision(id, a.artifact.spec as unknown as DecisionSpec, runtime);
out(result, `${result.passed ? '✓' : '✗'} ${id} on ${result.runtime}: ${result.matched}/${result.cases.length} cases conform`);
if (!result.passed) process.exitCode = 1;
});
cli.command('cite').argument('<id>').option('--into <file.md>', 'append to supporting document').action((id, opts) => { const s = service(); if (opts.into) out(s.citeInto(id, opts.into)); else { const block = s.cite(id); out({ block }, block.trimEnd()); } });
const citations = cli.command('citations').description('Verify current/stale/tampered/unresolved native citations');
citations.command('check').action(() => { const r = service().citations(); out(r); if (!r.valid) process.exitCode = 1; });
Expand Down
Loading
Loading