diff --git a/docs/IMPROVEMENTS_AND_TESTS.md b/docs/IMPROVEMENTS_AND_TESTS.md new file mode 100644 index 0000000..a502cb0 --- /dev/null +++ b/docs/IMPROVEMENTS_AND_TESTS.md @@ -0,0 +1,86 @@ +# Análise do Repositório: Melhorias e Novos Testes + +Este documento apresenta uma análise detalhada da arquitetura, compilador, domínio, CLI e governança do **PRD as Code** (`@devcomputaria/prd-as-code`), identificando oportunidades de melhorias técnicas e novos testes automatizados para fortalecer o framework. + +--- + +## 1. Visão Geral do Projeto + +O **PRD as Code** é um framework TypeScript monolítico e modular (Node.js 22+) que provê vocabulário, regras, requisitos, decisões, processos e evidências versionadas através do CLI `prd`. O sistema utiliza um compilador nativo que lê arquivos YAML e Gherkin (`.feature`), valida schemas AJV, constrói um grafo de dependências e avalia tabelas de decisão DMN (`dmn-table/v1`). + +--- + +## 2. Melhorias Recomendadas + +### 2.1 Compilador e Parsing +1. **Suporte a Execução de Rows em Scenario Outlines (Gherkin)**: + - *Situação Atual*: O compilador detecta `Scenario Outline` e emite um aviso `GHERKIN_OUTLINE_NOT_BOUND` sem associar as linhas da tabela de `Examples` a casos de teste. + - *Melhoria*: Expandir o parser para que cada linha de um `Examples` gere uma instância de cenário associada com tags derivadas ou parâmetros específicos. +2. **Localização Precisa de Erros em Arquivos YAML/Front Matter**: + - *Situação Atual*: Quando ocorre erro de schema AJV ou sintaxe em YAML, os diagnósticos nem sempre informam a linha e coluna exatas do campo inválido. + - *Melhoria*: Integrar os metadados de AST do pacote `yaml` aos erros de validação AJV para indicar linha e coluna exatas do erro na CLI e IDEs. +3. **Expansão de Expressões SBVR em BusinessRules**: + - *Situação Atual*: Regras narrativas sem `formulation` são marcadas como `unsupported`. + - *Melhoria*: Expandir o schema `sbvr-core/v1` para suportar quantificadores múltiplos (ex.: `forAll` combinado com `thereExists`) e expressões lógicas compostas (`and`, `or`, `implies`). + +### 2.2 Domínio e Avaliação de Decisões +1. **Suporte a Tipos Complexos e Intervalos DMN**: + - *Situação Atual*: Suporta apenas escalar boolean, number, string com igualdade exata. + - *Melhoria*: Adicionar suporte a tipos de data/hora (`date-time`) e condições de intervalo (ex.: `10..50`, `< 100`, `>= 0`) na avaliação de decisões. +2. **Novas Politicas de Decisão (Hit Policies)**: + - *Situação Atual*: Suporta apenas Hit Policy `UNIQUE`. + - *Melhoria*: Adicionar suporte às hit policies `FIRST`, `PRIORITY` e `COLLECT` no runtime de referência e nos adapters. + +### 2.3 CLI e Experiência do Desenvolvedor (DX) +1. **Comando Watch (`prd watch`)**: + - *Melhoria*: Implementar `--watch` no `validate` e `test` para revalidar/testar automaticamente alterações nos arquivos do diretório `product/`. +2. **Relatórios Visuais no Terminal e Saídas Coloridas**: + - *Melhoria*: Adicionar formatadores de tabela e suporte a cores (ANSI styles) na CLI para saídas humanas mais legíveis. +3. **Autocomplete Shell Completion**: + - *Melhoria*: Adicionar gerador de scripts de autocompletar para Bash/Zsh (`prd completion`). + +### 2.4 Governança, Mudanças e Citações +1. **Aprovações Múltiplas (M-of-N Reviewers)**: + - *Situação Atual*: A proposta armazena apenas um aprovador em `change.approval`. + - *Melhoria*: Permitir política configurable de aprovação (ex.: exigir 2 ou mais aprovadores antes de aplicar o `apply`). +2. **Exportação/Importação de Bundles de Mudança**: + - *Melhoria*: Permitir exportar um `change` como um arquivo zip/tarball assinado contendo os rascunhos para ser revisado em CI ou PRs do GitHub. + +### 2.5 Performance e Escalabilidade +1. **Cache de Compilação baseado em Hashes de Arquivos**: + - *Melhoria*: Evitar recalcular compilação inteira quando apenas um arquivo do produto foi alterado, utilizando cache parcial baseado em hash/mtime. + +--- + +## 3. Novos Testes Recomendados + +### 3.1 Testes Baseados em Propriedades (Property-Based Testing) +1. **Gerador de Tabelas de Decisão Aleatórias**: + - Gerar matrizes de decisão sintéticas válidas e verificar se a análise finita (`analyzeDecision`) identifica corretamente lacunas (`DECISION_GAP`) e sobreposições (`DECISION_OVERLAP`). +2. **Validação de Invariantes em Grafos Apressados**: + - Gerar grafos de artefatos aleatórios para testar a detecção de ciclos de dependência (`DEPENDENCY_CYCLE`). + +### 3.2 Testes de Casos de Borda do Compilador e Gherkin +1. **Tags Múltiplas ou Inválidas de Cenário**: + - Testar arquivos `.feature` com mais de uma tag `@scenario_` ou tags malformadas (ex.: `@scenario_123` em vez de `@scenario_SCN-001`). +2. **Tags de Relação Inválidas no Gherkin**: + - Testar cenários Gherkin com tags como `@requirement_REQ-INVALID` ou `@rule_` apontando para IDs não existentes ou com tipos trocados. +3. **Arquivos com Encoding Especial e Unicode BOM**: + - Testar se o compilador lida corretamente com arquivos salvos em UTF-8 com BOM (`\uFEFF`) e com diferentes quebras de linha (`\r\n` vs `\n`). + +### 3.3 Testes de Concorrência e Sistema de Arquivos +1. **Acesso Concorrente ao Lock System**: + - Testar comportamento quando múltiplos processos tentam adquirir `.intent/lock` simultaneamente. +2. **Recuperação de Interrupções de Disco durante `apply`**: + - Testar falhas simuladas de escrita na metade da aplicação de um batch de arquivos para verificar o isolamento e rollback via journal `.intent/pending.json`. + +### 3.4 Testes de Segurança e Paridade OPA +1. **Sanitização de Nomes e Strings no Gerador OPA**: + - Testar se caracteres especiais, aspas ou nomes de regras contendo caracteres reservados em Rego não causam injeção de código na geração de policies OPA. +2. **Paridade em Valores Numéricos Limite**: + - Testar paridade entre o runtime de referência e OPA para valores extremos como `Number.MAX_SAFE_INTEGER`, números decimais de alta precisão e valores negativos. + +--- + +## 4. Próximos Passos +- Implementar testes de casos de borda em `tests/native.test.mjs` cobrindo validações Gherkin e verificações estritas do compilador. diff --git a/framework/schemas/Actor.schema.json b/framework/schemas/Actor.schema.json index 42d6d16..a1e9508 100644 --- a/framework/schemas/Actor.schema.json +++ b/framework/schemas/Actor.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/Behavior.schema.json b/framework/schemas/Behavior.schema.json index 424dfb3..a9e07be 100644 --- a/framework/schemas/Behavior.schema.json +++ b/framework/schemas/Behavior.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/BoundedContext.schema.json b/framework/schemas/BoundedContext.schema.json index 637f9b4..d246545 100644 --- a/framework/schemas/BoundedContext.schema.json +++ b/framework/schemas/BoundedContext.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/BusinessRule.schema.json b/framework/schemas/BusinessRule.schema.json index ec77811..5d8970b 100644 --- a/framework/schemas/BusinessRule.schema.json +++ b/framework/schemas/BusinessRule.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -152,22 +184,70 @@ ] }, "forEach": { - "type": "object", - "properties": { - "variable": { - "type": "string", - "minLength": 1 + "oneOf": [ + { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "conceptRef": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9][A-Za-z0-9-]*$" + } + }, + "required": [ + "variable", + "conceptRef" + ], + "additionalProperties": false }, - "conceptRef": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9][A-Za-z0-9-]*$" + { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "conceptRef": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9][A-Za-z0-9-]*$" + } + }, + "required": [ + "variable", + "conceptRef" + ], + "additionalProperties": false + }, + "minItems": 1 } + ] + }, + "quantifiers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "conceptRef": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9][A-Za-z0-9-]*$" + } + }, + "required": [ + "variable", + "conceptRef" + ], + "additionalProperties": false }, - "required": [ - "variable", - "conceptRef" - ], - "additionalProperties": false + "minItems": 1 }, "assertion": { "$ref": "#/$defs/expression" @@ -175,7 +255,6 @@ }, "required": [ "modality", - "forEach", "assertion" ], "additionalProperties": false diff --git a/framework/schemas/Decision.schema.json b/framework/schemas/Decision.schema.json index 0e2b324..1fd0d4a 100644 --- a/framework/schemas/Decision.schema.json +++ b/framework/schemas/Decision.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/Evidence.schema.json b/framework/schemas/Evidence.schema.json index e997f04..e41d5e4 100644 --- a/framework/schemas/Evidence.schema.json +++ b/framework/schemas/Evidence.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/FactType.schema.json b/framework/schemas/FactType.schema.json index 3d92b78..b21855f 100644 --- a/framework/schemas/FactType.schema.json +++ b/framework/schemas/FactType.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/Journey.schema.json b/framework/schemas/Journey.schema.json index 9cba891..91551f4 100644 --- a/framework/schemas/Journey.schema.json +++ b/framework/schemas/Journey.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/OpenQuestion.schema.json b/framework/schemas/OpenQuestion.schema.json index 1af6a99..e5c018c 100644 --- a/framework/schemas/OpenQuestion.schema.json +++ b/framework/schemas/OpenQuestion.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/Process.schema.json b/framework/schemas/Process.schema.json index baeecab..99e25c6 100644 --- a/framework/schemas/Process.schema.json +++ b/framework/schemas/Process.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/Requirement.schema.json b/framework/schemas/Requirement.schema.json index 6561a6c..c2e26a8 100644 --- a/framework/schemas/Requirement.schema.json +++ b/framework/schemas/Requirement.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/Term.schema.json b/framework/schemas/Term.schema.json index e5713a4..2c1772f 100644 --- a/framework/schemas/Term.schema.json +++ b/framework/schemas/Term.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/UseCase.schema.json b/framework/schemas/UseCase.schema.json index ec51a93..1b79176 100644 --- a/framework/schemas/UseCase.schema.json +++ b/framework/schemas/UseCase.schema.json @@ -68,6 +68,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/framework/schemas/artifact.schema.json b/framework/schemas/artifact.schema.json index eeee2c1..e6a8d4e 100644 --- a/framework/schemas/artifact.schema.json +++ b/framework/schemas/artifact.schema.json @@ -69,6 +69,38 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "and" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 1 + } + }, + "required": [ + "or" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -750,22 +782,70 @@ ] }, "forEach": { - "type": "object", - "properties": { - "variable": { - "type": "string", - "minLength": 1 + "oneOf": [ + { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "conceptRef": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9][A-Za-z0-9-]*$" + } + }, + "required": [ + "variable", + "conceptRef" + ], + "additionalProperties": false }, - "conceptRef": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9][A-Za-z0-9-]*$" + { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "conceptRef": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9][A-Za-z0-9-]*$" + } + }, + "required": [ + "variable", + "conceptRef" + ], + "additionalProperties": false + }, + "minItems": 1 } + ] + }, + "quantifiers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "conceptRef": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*-[A-Za-z0-9][A-Za-z0-9-]*$" + } + }, + "required": [ + "variable", + "conceptRef" + ], + "additionalProperties": false }, - "required": [ - "variable", - "conceptRef" - ], - "additionalProperties": false + "minItems": 1 }, "assertion": { "$ref": "#/$defs/expression" @@ -773,7 +853,6 @@ }, "required": [ "modality", - "forEach", "assertion" ], "additionalProperties": false diff --git a/scripts/generate-schemas.py b/scripts/generate-schemas.py index bc48724..deb10be 100644 --- a/scripts/generate-schemas.py +++ b/scripts/generate-schemas.py @@ -8,13 +8,14 @@ def arr(v,minimum=0):return {'type':'array','items':v,'minItems':minimum} scalar={'type':['string','number','boolean']};scalar_map={'type':'object','additionalProperties':scalar} def const(v):return {'const':v} link=obj({'relation':{'enum':['uses','depends-on','governed-by','performed-by','defines','satisfies','verifies','contains','related-to','realized-by','derived-from']},'target':ref}) -expr={'oneOf':[obj({'factRef':ident,'bindings':{'type':'object','additionalProperties':s}}),obj({'not':{'$ref':'#/$defs/expression'}}),obj({'all':arr({'$ref':'#/$defs/expression'},1)}),obj({'any':arr({'$ref':'#/$defs/expression'},1)}),obj({'implies':obj({'if':{'$ref':'#/$defs/expression'},'then':{'$ref':'#/$defs/expression'}})})]} +expr={'oneOf':[obj({'factRef':ident,'bindings':{'type':'object','additionalProperties':s}}),obj({'not':{'$ref':'#/$defs/expression'}}),obj({'all':arr({'$ref':'#/$defs/expression'},1)}),obj({'any':arr({'$ref':'#/$defs/expression'},1)}),obj({'and':arr({'$ref':'#/$defs/expression'},1)}),obj({'or':arr({'$ref':'#/$defs/expression'},1)}),obj({'implies':obj({'if':{'$ref':'#/$defs/expression'},'then':{'$ref':'#/$defs/expression'}})})]} variable=obj({'name':s,'type':{'enum':['boolean','number','string']},'values':dict(arr(scalar,1),uniqueItems=True)},['name','type']) plain=obj({'profile':const('product-core/v1'),'description':s}) specs={k:plain for k in ['Actor','Journey','UseCase','BoundedContext']} specs['Term']=obj({'profile':const('sbvr-core/v1'),'definition':s,'designations':arr(s,1)},['profile','definition']) specs['FactType']=obj({'profile':const('sbvr-core/v1'),'reading':s,'roles':arr(obj({'name':s,'conceptRef':ident}),1)}) -specs['BusinessRule']=obj({'profile':const('sbvr-core/v1'),'statement':s,'formulation':obj({'modality':{'enum':['necessity','obligation','prohibition','permission']},'forEach':obj({'variable':s,'conceptRef':ident}),'assertion':{'$ref':'#/$defs/expression'}})},['profile','statement']) +quantifier=obj({'variable':s,'conceptRef':ident}) +specs['BusinessRule']=obj({'profile':const('sbvr-core/v1'),'statement':s,'formulation':obj({'modality':{'enum':['necessity','obligation','prohibition','permission']},'forEach':{'oneOf':[quantifier,arr(quantifier,1)]},'quantifiers':arr(quantifier,1),'assertion':{'$ref':'#/$defs/expression'}},['modality','assertion'])},['profile','statement']) specs['Requirement']=obj({'profile':const('reqif-core/v1'),'category':{'enum':['functional','quality','constraint']},'statement':s,'acceptance':arr(s,1),'parentRef':ident,'attributes':scalar_map,'externalRefs':arr(obj({'system':s,'identifier':s}))},['profile','category','statement','acceptance']) specs['Decision']=obj({'profile':const('dmn-table/v1'),'hitPolicy':const('UNIQUE'),'inputs':arr(variable,1),'output':variable,'rules':arr(obj({'id':s,'when':scalar_map,'then':scalar}),1),'cases':arr(obj({'id':s,'scenario':ident,'input':scalar_map,'expected':scalar},['id','input','expected']))}) node=obj({'id':s,'type':{'enum':['startEvent','endEvent','task','businessRuleTask','exclusiveGateway']},'title':s,'decisionRef':ident},['id','type']) diff --git a/src/compiler/compile.ts b/src/compiler/compile.ts index 4eb684d..0a00779 100644 --- a/src/compiler/compile.ts +++ b/src/compiler/compile.ts @@ -1,6 +1,6 @@ import { digest } from '../domain/digest.js'; import { Ajv } from 'ajv'; -import { parseDocument } from 'yaml'; +import { parseDocument, LineCounter } from 'yaml'; import { Parser, AstBuilder, GherkinClassicTokenMatcher } from '@cucumber/gherkin'; import { IdGenerator } from '@cucumber/messages'; import type { FeatureChild, RuleChild, Tag } from '@cucumber/messages'; @@ -8,10 +8,22 @@ import { canonical, isRecord, sortedJson } from '../domain/model.js'; import type { Artifact, Diagnostic, Model, Scenario, SourceArtifact, SourceFile } from '../domain/model.js'; import { inspectSemantics } from './semantics.js'; import type { Compiler } from '../application/ports/compiler.js'; + +export function parseYamlDocument(text: string) { + const lineCounter = new LineCounter(); + const doc = parseDocument(text.replace(/\r\n?/g, '\n'), { uniqueKeys: true, strict: true, lineCounter }); + if (doc.errors.length || doc.warnings.length) { + const err = [...doc.errors, ...doc.warnings][0]!; + const line = err.linePos?.[0]?.line; + const error = new Error(err.message) as Error & { line?: number }; + if (line) error.line = line; + throw error; + } + return { value: doc.toJS({ maxAliasCount: 0 }), doc, lineCounter }; +} + export function yaml(text: string): unknown { - const doc = parseDocument(text.replace(/\r\n?/g, '\n'), { uniqueKeys: true, strict: true }); - if (doc.errors.length || doc.warnings.length) throw new Error([...doc.errors, ...doc.warnings].map(e => e.message).join('; ')); - return doc.toJS({ maxAliasCount: 0 }); + return parseYamlDocument(text).value; } export function createCompiler(schemas: Record): Compiler { const ajv = new Ajv({ allErrors: true, strict: false, allowUnionTypes: true }); @@ -31,8 +43,25 @@ export function createCompiler(schemas: Record): Compiler { const s = child.scenario, tags = [...inherited, ...s.tags].map(t => t.name); const ids = tags.filter(t => t.startsWith('@scenario_')); if (ids.length !== 1 || !/^@scenario_SCN-[A-Za-z0-9][A-Za-z0-9-]*$/.test(ids[0]!)) throw new Error(`Scenario at line ${s.location.line} requires exactly one @scenario_SCN-... tag.`); - scenarios.push({ id: ids[0]!.slice(10), name: s.name, file: file.path, line: s.location.line, tags, steps: [...background, ...localBackground, ...s.steps.map(x => x.text)] }); - if (s.examples.length) diagnostics.push({ code: 'GHERKIN_OUTLINE_NOT_BOUND', severity: 'unsupported', file: file.path, line: s.location.line, message: 'Scenario Outline is parsed but row-level execution bindings are not implemented in v0.2.' }); + const baseId = ids[0]!.slice(10); + const baseSteps = [...background, ...localBackground, ...s.steps.map(x => x.text)]; + scenarios.push({ id: baseId, name: s.name, file: file.path, line: s.location.line, tags, steps: baseSteps }); + if (s.examples.length) { + let rowNum = 1; + for (const example of s.examples) { + const headers = example.tableHeader?.cells.map(c => c.value) ?? []; + for (const row of example.tableBody) { + const values = row.cells.map(c => c.value); + const rowSteps = [...background, ...localBackground, ...s.steps.map(x => { + let text = x.text; + headers.forEach((h, i) => { if (values[i] !== undefined) text = text.replace(new RegExp(`<${h}>`, 'g'), values[i]!); }); + return text; + })]; + scenarios.push({ id: `${baseId}#${rowNum}`, name: `${s.name} (Row ${rowNum})`, file: file.path, line: row.location.line, tags, steps: rowSteps }); + rowNum++; + } + } + } } } } @@ -40,16 +69,30 @@ export function createCompiler(schemas: Record): Compiler { continue; } const normalized = file.text.replace(/\r\n?/g, '\n').replace(/^\uFEFF/, ''); - let value: unknown, body: string | undefined; + let value: unknown, body: string | undefined, parsedDoc: ReturnType | undefined, lineOffset = 0; if (file.path.endsWith('.md')) { const m = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/.exec(normalized); if (!m) throw new Error('Product Markdown requires artifact front matter. Keep supporting docs outside product/.'); - value = yaml(m[1]!); body = m[2]!.replace(/^\n+|\n+$/g, ''); - } else value = yaml(normalized); + parsedDoc = parseYamlDocument(m[1]!); + value = parsedDoc.value; body = m[2]!.replace(/^\n+|\n+$/g, ''); + lineOffset = 1; // Front matter begins after the opening '---' at line 1 + } else { + parsedDoc = parseYamlDocument(normalized); + value = parsedDoc.value; + } const validator = isRecord(value) && typeof value.kind === 'string' ? validators.get(value.kind) : undefined; if (!validator) throw new Error('Unknown or missing artifact kind. See framework/schemas/.'); if (!validator(value)) { - for (const e of validator.errors ?? []) diagnostics.push({ code: 'SCHEMA_INVALID', severity: 'error', file: file.path, path: e.instancePath || '/', message: e.message ?? 'Invalid schema' }); + for (const e of validator.errors ?? []) { + let line: number | undefined; + if (parsedDoc) { + const segments = e.instancePath.split('/').filter(Boolean).map(p => /^\d+$/.test(p) ? Number(p) : p); + let node = parsedDoc.doc.getIn(segments, true) as { range?: [number, number] } | null; + if (!node && segments.length) node = parsedDoc.doc.getIn(segments.slice(0, -1), true) as { range?: [number, number] } | null; + if (node?.range) line = parsedDoc.lineCounter.linePos(node.range[0]).line + lineOffset; + } + diagnostics.push({ code: 'SCHEMA_INVALID', severity: 'error', file: file.path, line, path: e.instancePath || '/', message: e.message ?? 'Invalid schema' }); + } continue; } const source = { artifact: value as Artifact, path: file.path, body, digest: '' }; diff --git a/src/compiler/semantics.ts b/src/compiler/semantics.ts index e4e7e6d..c652da5 100644 --- a/src/compiler/semantics.ts +++ b/src/compiler/semantics.ts @@ -37,8 +37,14 @@ export function inspectSemantics(sources: SourceArtifact[], scenarios: Scenario[ if (a.kind === 'BusinessRule') { if (!isRecord(spec.formulation)) issue('NARRATIVE_RULE', 'Narrative-only rule: no structured formulation to type-check.', s, 'unsupported'); else { - const f = spec.formulation, q = f.forEach as { variable: string; conceptRef: string }; - reference(s, q.conceptRef, 'quantifies', ['Term']); + const f = spec.formulation; + const quantifiersList: { variable: string; conceptRef: string }[] = []; + if (Array.isArray(f.quantifiers)) quantifiersList.push(...(f.quantifiers as { variable: string; conceptRef: string }[])); + if (Array.isArray(f.forEach)) quantifiersList.push(...(f.forEach as { variable: string; conceptRef: string }[])); + else if (isRecord(f.forEach)) quantifiersList.push(f.forEach as unknown as { variable: string; conceptRef: string }); + + for (const q of quantifiersList) reference(s, q.conceptRef, 'quantifies', ['Term']); + function expression(e: unknown) { if (!isRecord(e)) return; if (typeof e.factRef === 'string') { @@ -46,7 +52,10 @@ export function inspectSemantics(sources: SourceArtifact[], scenarios: Scenario[ const fact = byId.get(e.factRef), bindings = e.bindings as Record; if (fact?.artifact.kind === 'FactType') { const roles = fact.artifact.spec.roles as { name: string; conceptRef: string }[]; - if (Object.keys(bindings).length !== roles.length || roles.some(r => bindings[r.name] !== q.variable || r.conceptRef !== q.conceptRef)) issue('BINDING_TYPE', 'Bindings must supply every fact role with the declared variable of matching concept type.', s); + if (Object.keys(bindings).length !== roles.length || roles.some(r => { + const boundVar = bindings[r.name]; + return !quantifiersList.some(q => q.variable === boundVar && q.conceptRef === r.conceptRef); + })) issue('BINDING_TYPE', 'Bindings must supply every fact role with a declared variable of matching concept type.', s); } } for (const value of Object.values(e)) if (Array.isArray(value)) value.forEach(expression); else if (isRecord(value)) expression(value); diff --git a/tests/native.test.mjs b/tests/native.test.mjs index d84e659..bd9c3dd 100644 --- a/tests/native.test.mjs +++ b/tests/native.test.mjs @@ -44,3 +44,104 @@ test('CLI usage errors are structured JSON with exit 2',()=>{const r=spawnSync(p test('legacy subcommand passes through root and flags',t=>{const root=mkdtempSync(join(tmpdir(),'intent-legacy-cli-'));t.after(()=>rmSync(root,{recursive:true,force:true}));legacyInit(root,'Legacy');const r=spawnSync(process.execPath,[cli,'-C',root,'legacy','validate','--json'],{encoding:'utf8'});assert.equal(r.status,0,r.stderr);assert.doesNotThrow(()=>JSON.parse(r.stdout));}); test('candidate cases block approval and apply even when the structural model is valid',t=>{const s=fixture(t),a=source(s,'DEC-001'),d=structuredClone(a.artifact);d.spec.cases[0].expected='CONTA_INATIVA';s.createChange('bad-case','Wrong expectation');s.stage('bad-case',a.path,stringify(d));assert.equal(s.candidate('bad-case').model.diagnostics.length,0);assert.throws(()=>s.approve('bad-case','Reviewer'),code('DECISION_TEST_FAILED'));assert.equal(s.readChange('bad-case').state,'draft');}); test('generated Copilot skill metadata and relative documentation links resolve',t=>{const s=fixture(t);for(const name of ['product-discover','product-behavior','product-process','product-specify','product-review']){const path=join(s.workspace.root,'.github/skills',name,'SKILL.md');const text=readFileSync(path,'utf8');const meta=parse(text.split('---')[1]);assert.equal(meta.name,name);assert.ok(meta.description);for(const [,link] of text.matchAll(/\]\((\.\.\/[^)]+)\)/g))assert.ok(existsSync(resolve(path,'..',link)),link);}}); + +test('Gherkin scenario tag validation fails when tags are missing, duplicated, or malformed',t=>{ + const s=fixture(t); + const f=s.workspace.productFiles().find(f=>f.path.endsWith('.feature')); + + // Malformed scenario ID tag + s.workspace.write(f.path, f.text.replace('@scenario_SCN-001', '@scenario_123')); + assert.equal(s.validate().valid, false); + + // Duplicate @scenario_ tags on a single scenario + s.workspace.write(f.path, f.text.replace('@scenario_SCN-001', '@scenario_SCN-001 @scenario_SCN-002')); + assert.equal(s.validate().valid, false); +}); + +test('UTF-8 BOM in YAML product files is stripped and parsed correctly',t=>{ + const s=fixture(t); + const a=source(s, 'BR-001'); + const bomText = '\uFEFF' + s.workspace.read(a.path); + s.workspace.write(a.path, bomText); + assert.equal(s.validate().valid, true); +}); + +test('Decision evaluator handles non-finite numbers appropriately',t=>{ + const d=source(fixture(t),'DEC-001').artifact.spec; + assert.throws(()=>evaluateDecision(d,{contaAtiva:true,saldoSuficiente:NaN}),code('INVALID_INPUT')); + assert.throws(()=>evaluateDecision(d,{contaAtiva:true,saldoSuficiente:Infinity}),code('INVALID_INPUT')); +}); + +test('Scenario Outlines generate row-level scenarios with replaced variable placeholders',t=>{ + const s=fixture(t); + const f=s.workspace.productFiles().find(f=>f.path.endsWith('.feature')); + const outlineContent = `# language: pt +@process_PROC-001 +Funcionalidade: Teste Outline + + @scenario_SCN-099 + Esquema do Cenário: Transferência com valor + Dado que a conta possui saldo + Quando transfiro + Então a operação deve ser + + Exemplos: + | saldo | valor | resultado | + | 100 | 50 | APROVADA | + | 10 | 50 | RECUSADA | +`; + s.workspace.write(f.path, outlineContent); + const m=s.compile(); + assert.ok(m.scenarios.some(sc=>sc.id==='SCN-099#1' && sc.steps.some(st=>st.includes('saldo 100')))); + assert.ok(m.scenarios.some(sc=>sc.id==='SCN-099#2' && sc.steps.some(st=>st.includes('saldo 10')))); +}); + +test('Schema validation diagnostics include exact line numbers in YAML files',t=>{ + const s=fixture(t); + const a=source(s, 'BR-001'); + const invalidContent = `apiVersion: prd.devcomputaria/v1alpha1 +kind: BusinessRule +metadata: + id: BR-001 + title: Regra de Elegibilidade + status: active +spec: + profile: sbvr-core/v1 + statement: Elegibilidade + formulation: + modality: INVALID_MODALITY + forEach: + variable: c + conceptRef: TERM-001 + assertion: + factRef: FACT-001 + bindings: + conta: c +`; + s.workspace.write(a.path, invalidContent); + const res = s.validate(); + assert.equal(res.valid, false); + const diag = res.model.diagnostics.find(d=>d.code==='SCHEMA_INVALID'); + assert.ok(diag && diag.line === 11, `Expected line 11, got ${diag?.line}`); +}); + +test('SBVR formulation accepts multiple quantifiers and logical AND/OR expressions',t=>{ + const s=fixture(t); + const a=source(s, 'BR-001'); + const doc = structuredClone(a.artifact); + doc.spec.formulation = { + modality: 'necessity', + quantifiers: [ + { variable: 'c', conceptRef: 'TERM-001' }, + { variable: 'a', conceptRef: 'TERM-001' } + ], + assertion: { + and: [ + { factRef: 'FACT-001', bindings: { transfer: 'c' } }, + { factRef: 'FACT-001', bindings: { transfer: 'a' } } + ] + } + }; + s.workspace.write(a.path, stringify(doc)); + assert.equal(s.validate().valid, true); +});