From 750ff4aaa21954e181a632738ff348e2f62aa4b7 Mon Sep 17 00:00:00 2001 From: Rapha Date: Tue, 21 Jul 2026 18:49:59 -0300 Subject: [PATCH 1/7] define Archify visualization adapter architecture --- ...chify-post-export-visualization-adapter.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/adr/ADR-0007-archify-post-export-visualization-adapter.md diff --git a/docs/adr/ADR-0007-archify-post-export-visualization-adapter.md b/docs/adr/ADR-0007-archify-post-export-visualization-adapter.md new file mode 100644 index 0000000..6d056be --- /dev/null +++ b/docs/adr/ADR-0007-archify-post-export-visualization-adapter.md @@ -0,0 +1,95 @@ +# ADR-0007 — Archify como adapter de visualização pós-export + +- **Status:** Proposed +- **Data:** 2026-07-21 +- **Decisores:** Sky-Forge maintainers + +## Contexto + +O Sky-Forge já produz artefatos arquiteturais, C4, jornadas, sequências, fluxos e pacotes verificáveis. Falta, porém, uma camada padronizada para transformar esses artefatos em visualizações técnicas interativas, portáveis e adequadas a handoff, revisão e apresentação. + +O projeto externo `tt-a1i/archify` gera diagramas de arquitetura, workflow, sequência, data flow e lifecycle em HTML autônomo a partir de uma representação JSON tipada, com validação e exportação visual. + +O core do Sky-Forge é agnóstico de marca e seus plugins atuais são consumidores pós-export. Portanto, integrar Archify dentro da geração do core violaria o contrato de extensibilidade existente. + +## Decisão + +Adotar Archify como **adapter opcional de visualização pós-export**, sem alterar a fonte da verdade arquitetural e sem introduzir hooks obrigatórios no pipeline de geração. + +O fluxo será: + +```text +Sky package exportado + -> Sky Architecture IR normalizada + -> adapter Archify + -> Archify JSON IR + -> validate + -> render + -> check + -> HTML/SVG/PNG derivados +``` + +### Fonte da verdade + +A fonte canônica continuará sendo o pacote Sky-Forge e a `Sky Architecture IR`. O JSON e o HTML do Archify serão artefatos derivados e regeneráveis. + +### Posicionamento no fluxo + +O adapter roda após `sky validate`/`sky export` e antes ou durante o handoff/showcase. Ele não participa da descoberta, decisão arquitetural ou aprovação humana. + +### Modos suportados inicialmente + +- `architecture` +- `workflow` +- `sequence` + +`dataflow` e `lifecycle` ficam previstos para a segunda onda. + +### Guardrails + +1. Nenhum node ou relacionamento pode ser inventado pelo adapter. +2. Todo elemento deve carregar referência de origem quando disponível. +3. Inferências devem ser explicitamente marcadas como `inferred` e nunca como `confirmed`. +4. O adapter deve falhar de forma clara quando artefatos obrigatórios estiverem ausentes. +5. O core deve continuar passando CI com o plugin removido. +6. A versão do Archify deve ser fixada e atualizada conscientemente. +7. Saídas HTML não são editadas manualmente; devem ser regeneradas. + +## Consequências positivas + +- visualização técnica interativa sem acoplar o core; +- handoff arquitetural mais claro; +- melhor revisão de topologia, rotas e boundaries; +- suporte a apresentações e showcase; +- possibilidade futura de adapters paralelos para LikeC4, Mermaid e PlantUML; +- redução de lock-in por meio da Sky Architecture IR. + +## Consequências negativas + +- novo contrato intermediário a manter; +- necessidade de testes de mapeamento e fidelidade semântica; +- possível sobreposição visual com Cloud Design e LikeC4; +- dependência operacional de Node.js e de uma versão externa fixada. + +## Alternativas rejeitadas + +### Incorporar Archify ao core + +Rejeitada por quebrar o princípio core agnóstico e tornar a geração dependente de uma ferramenta externa. + +### Usar o JSON do Archify como modelo canônico + +Rejeitada por criar lock-in e misturar semântica arquitetural com decisões de renderização. + +### Substituir C4/LikeC4 por Archify + +Rejeitada. Archify será uma camada de comunicação; C4 e os contratos Sky continuam representando o modelo arquitetural. + +## Critérios de aceite arquiteturais + +- existe schema versionado da Sky Architecture IR; +- existe plugin consumidor removível; +- a conversão é determinística para o mesmo pacote e configuração; +- as saídas registram versão do adapter e versão do Archify; +- existem fixtures e testes de contrato; +- nenhum arquivo do core depende da presença do Archify para validar ou exportar pacotes. \ No newline at end of file From a38c466f2a1098221ab36437ff23d74444df269a Mon Sep 17 00:00:00 2001 From: Rapha Date: Tue, 21 Jul 2026 18:50:10 -0300 Subject: [PATCH 2/7] add Archify plugin contract --- plugins/examples/archify/plugin.yaml | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 plugins/examples/archify/plugin.yaml diff --git a/plugins/examples/archify/plugin.yaml b/plugins/examples/archify/plugin.yaml new file mode 100644 index 0000000..3ddb783 --- /dev/null +++ b/plugins/examples/archify/plugin.yaml @@ -0,0 +1,67 @@ +plugin: + id: archify-visualization + name: Archify Visualization Adapter + version: 0.1.0 + owner: sky-forge-community + direction: consumer + +compatibility: + sky_forge_core: ">=1.1.0 <2.0.0" + profiles: + - consulting-handoff + - default + +requires: + - architecture.yaml + - brief.yaml + +optional: + - sequences.yaml + - integrations.yaml + - ux-spec.yaml + - sky-merits.yaml + - agentic-repo-recommendation.yaml + +mappings: + - from: architecture.yaml + to: intermediate/sky-architecture-ir.yaml + notes: Normalize components, relationships, boundaries, views and provenance. + - from: intermediate/sky-architecture-ir.yaml + to: archify/system.architecture.json + notes: Render the bounded high-level system view. + - from: intermediate/sky-architecture-ir.yaml + to: archify/delivery.workflow.json + notes: Render the solution delivery and approval workflow when evidence exists. + - from: sequences.yaml + to: archify/critical-path.sequence.json + notes: Render critical interaction sequences when the optional artifact exists. + +policies: + - id: no-invented-topology + rule: Every rendered node and relationship must reference a source artifact path or be marked inferred. + on_fail: block + - id: confirmed-requires-evidence + rule: Elements marked confirmed require at least one source_ref. + on_fail: block + - id: bounded-architecture-view + rule: Default architecture view should contain 8 to 12 primary nodes; additional detail belongs in cards or named views. + on_fail: warn + - id: private-by-default + rule: Generated artifacts remain private unless the source package is approved for public showcase. + on_fail: block + +produces: + - intermediate/sky-architecture-ir.yaml + - archify/system.architecture.json + - archify/system.architecture.html + - archify/delivery.workflow.json + - archify/delivery.workflow.html + - archify/critical-path.sequence.json + - archify/critical-path.sequence.html + - archify/manifest.yaml + +does_not_modify: + - sky-forge-core + - source-package + - architecture-decisions + - human-approval-state \ No newline at end of file From 9e80e0574f849d6a7cf841c8a99b97cb7f839a8b Mon Sep 17 00:00:00 2001 From: Rapha Date: Tue, 21 Jul 2026 18:50:21 -0300 Subject: [PATCH 3/7] add Sky Architecture IR schema --- .../sky-forge/sky-architecture-ir.schema.yaml | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 schemas/sky-forge/sky-architecture-ir.schema.yaml diff --git a/schemas/sky-forge/sky-architecture-ir.schema.yaml b/schemas/sky-forge/sky-architecture-ir.schema.yaml new file mode 100644 index 0000000..9cfcdf0 --- /dev/null +++ b/schemas/sky-forge/sky-architecture-ir.schema.yaml @@ -0,0 +1,86 @@ +schema: sky-forge/architecture-ir +version: 0.1.0 + +purpose: >- + Renderer-neutral architecture representation derived from a validated Sky-Forge + package. It is the canonical visualization input; renderer-specific formats are + generated from it and remain disposable. + +fields: + metadata: + type: object + required: true + fields: + schema_version: {type: semver, required: true} + package_slug: {type: string, required: true} + generated_at: {type: datetime, required: true} + source_package_version: {type: string, required: true} + generator_version: {type: string, required: true} + + system: + type: object + required: true + fields: + id: {type: identifier, required: true} + name: {type: string, required: true} + description: {type: string, required: true} + scope: {type: string} + + elements: + type: list + required: true + item: + id: {type: identifier, required: true} + name: {type: string, required: true} + kind: {type: enum[person,system,container,component,agent,database,queue,external,policy,artifact], required: true} + description: {type: string} + technology: {type: string} + boundary_id: {type: identifier} + role: {type: string} + confidence: {type: enum[confirmed,inferred,unknown], required: true} + source_refs: {type: list, required: true} + tags: {type: list} + + relationships: + type: list + required: true + item: + id: {type: identifier, required: true} + source: {type: identifier, required: true} + target: {type: identifier, required: true} + label: {type: string, required: true} + protocol: {type: string} + data_classification: {type: string} + confidence: {type: enum[confirmed,inferred,unknown], required: true} + source_refs: {type: list, required: true} + + boundaries: + type: list + item: + id: {type: identifier, required: true} + name: {type: string, required: true} + kind: {type: enum[system,trust,network,organization,data,domain], required: true} + members: {type: list, required: true} + source_refs: {type: list, required: true} + + views: + type: list + required: true + item: + id: {type: identifier, required: true} + name: {type: string, required: true} + type: {type: enum[architecture,workflow,sequence,dataflow,lifecycle], required: true} + question_answered: {type: string, required: true} + include: {type: list, required: true} + primary_path: {type: list} + quality_profile: {type: enum[standard,showcase], required: true} + visual_preset: {type: enum[classic,signal-flow,blueprint], required: true} + +validations_expected: + - ids are unique inside each collection + - every relationship endpoint exists in elements + - every boundary member exists in elements + - every view include and primary_path reference exists in elements + - confirmed elements and relationships have at least one source_ref + - inferred elements and relationships are visibly identified in renderer mappings + - no renderer-specific coordinates, colors or SVG fields exist in this schema From 49016933ee49e814c0f7c2842b2284e21b44a584 Mon Sep 17 00:00:00 2001 From: Rapha Date: Tue, 21 Jul 2026 18:50:33 -0300 Subject: [PATCH 4/7] document Archify adapter implementation --- plugins/examples/archify/README.md | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 plugins/examples/archify/README.md diff --git a/plugins/examples/archify/README.md b/plugins/examples/archify/README.md new file mode 100644 index 0000000..2cf692e --- /dev/null +++ b/plugins/examples/archify/README.md @@ -0,0 +1,99 @@ +# Archify Visualization Adapter + +Adapter opcional que consome um pacote Sky-Forge exportado e produz diagramas técnicos interativos por meio do `tt-a1i/archify`. + +## Princípio + +```text +Sky package -> Sky Architecture IR -> Archify JSON -> validate -> render -> check +``` + +O adapter não decide arquitetura, não altera o pacote de origem e não adiciona hooks ao core. + +## Dependência + +Fixar uma versão explícita do Archify. Versão inicial recomendada para a implementação: + +```text +tt-a1i/archify@2.11.0 +``` + +A instalação pode ser local ao adapter, via submodule, pacote empacotado ou cache de CI. A escolha deve preservar builds reproduzíveis e permitir execução offline após bootstrap. + +## Interface de linha de comando esperada + +```powershell +./plugins/examples/archify/scripts/visualize.ps1 ` + -PackagePath ` + -OutputPath ` + -Views architecture,workflow,sequence ` + -Quality standard +``` + +Interface Node equivalente: + +```bash +node plugins/examples/archify/scripts/visualize.mjs \ + --package \ + --output \ + --views architecture,workflow,sequence \ + --quality standard +``` + +## Etapas + +1. validar a existência dos artefatos obrigatórios; +2. normalizar o pacote em `intermediate/sky-architecture-ir.yaml`; +3. validar a IR contra `schemas/sky-forge/sky-architecture-ir.schema.yaml`; +4. converter cada view selecionada para o schema correspondente do Archify; +5. executar `archify validate`; +6. executar `archify render`; +7. executar `archify check` no HTML produzido; +8. gerar `archify/manifest.yaml` com hashes, versões, fontes e resultados das validações. + +## Manifesto mínimo + +```yaml +adapter: + id: archify-visualization + version: 0.1.0 +renderer: + name: archify + version: 2.11.0 +source: + package_slug: example + package_hash: sha256:... +outputs: + - view: system + type: architecture + json: archify/system.architecture.json + html: archify/system.architecture.html + validation: passed +``` + +## Regras de fidelidade + +- `confirmed` exige `source_refs` não vazio; +- `inferred` deve ser visualmente distinguível e listado no manifesto; +- relações sem origem e destino existentes bloqueiam a geração; +- o adapter não cria tecnologias, protocolos, trust boundaries ou integrações ausentes; +- uma view pode omitir detalhes para legibilidade, mas não pode alterar o significado; +- o HTML é artefato derivado e nunca deve receber edição manual. + +## Saídas iniciais + +- `system.architecture.html` +- `delivery.workflow.html` +- `critical-path.sequence.html`, somente quando houver evidência suficiente + +## Testes obrigatórios + +- package mínimo válido; +- package completo válido; +- artefato obrigatório ausente; +- endpoint de relacionamento inexistente; +- elemento confirmado sem evidência; +- elemento inferido corretamente sinalizado; +- execução determinística; +- ausência do diretório do plugin não quebra o core; +- falha do Archify retorna código diferente de zero e mensagem acionável. From b756c0f668f3052d218826618c75b873273c892a Mon Sep 17 00:00:00 2001 From: Rapha Date: Tue, 21 Jul 2026 18:51:05 -0300 Subject: [PATCH 5/7] add Cursor implementation demand for Archify adapter --- ...IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/tasks/CURSOR_IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md diff --git a/docs/tasks/CURSOR_IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md b/docs/tasks/CURSOR_IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md new file mode 100644 index 0000000..7a46f6a --- /dev/null +++ b/docs/tasks/CURSOR_IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md @@ -0,0 +1,275 @@ +# Demanda para o Cursor — implementar o Archify Visualization Adapter + +## Missão + +Implemente no Sky-Forge um adapter opcional pós-export para gerar visualizações técnicas usando `tt-a1i/archify`, preservando o core agnóstico e a arquitetura definida em: + +- `docs/adr/ADR-0007-archify-post-export-visualization-adapter.md` +- `plugins/examples/archify/plugin.yaml` +- `plugins/examples/archify/README.md` +- `schemas/sky-forge/sky-architecture-ir.schema.yaml` + +Não reduza a demanda a documentação. Entregue código executável, fixtures, testes, comandos e integração não obrigatória com o fluxo de export. + +## Resultado esperado + +Dado um pacote Sky-Forge já exportado, deve ser possível executar: + +```powershell +./plugins/examples/archify/scripts/visualize.ps1 ` + -PackagePath examples/sky-forge-packages/surya-workspace-mvp ` + -OutputPath .tmp/archify/surya-workspace-mvp ` + -Views architecture,workflow,sequence ` + -Quality standard +``` + +Ou a interface Node equivalente: + +```bash +node plugins/examples/archify/scripts/visualize.mjs \ + --package examples/sky-forge-packages/surya-workspace-mvp \ + --output .tmp/archify/surya-workspace-mvp \ + --views architecture,workflow,sequence \ + --quality standard +``` + +A execução deve produzir uma Sky Architecture IR validada, arquivos JSON compatíveis com Archify, HTMLs renderizados e um manifesto de proveniência. + +## Restrições obrigatórias + +1. Não adicionar Archify como dependência obrigatória do core. +2. Não alterar decisões arquiteturais durante a conversão. +3. Não usar o JSON do Archify como fonte canônica. +4. Não criar hooks de plugin dentro da geração. +5. Não quebrar os comandos existentes de `sky validate`, `sky export` ou `check-core-agnostic`. +6. Não editar HTML gerado manualmente. +7. Fixar a versão do Archify; não usar referência flutuante para `main`. +8. Não publicar pacote ou diagrama sem aprovação pública já existente no pacote. +9. Não inventar nodes, relações, protocolos, tecnologias ou boundaries. +10. Não silenciar falhas de validação do Archify. + +## Escopo funcional + +### 1. Normalizador Sky Package -> Sky Architecture IR + +Crie um módulo que leia, conforme disponibilidade: + +- `brief.yaml` +- `architecture.yaml` +- `sequences.yaml` +- `integrations.yaml` +- `ux-spec.yaml` +- `agentic-repo-recommendation.yaml` + +O módulo deve gerar: + +```text +/intermediate/sky-architecture-ir.yaml +``` + +Cada elemento e relação deve conter: + +- ID estável; +- tipo semântico; +- confidence: `confirmed`, `inferred` ou `unknown`; +- `source_refs` com JSON Pointer, YAML path ou referência equivalente; +- tags necessárias ao renderer, sem inserir campos visuais na IR. + +Não trate a ausência de artefatos opcionais como erro. A ausência de `architecture.yaml` ou `brief.yaml` deve bloquear a execução com mensagem acionável. + +### 2. Validação da IR + +Implemente validação para o contrato em `schemas/sky-forge/sky-architecture-ir.schema.yaml`. + +No mínimo, valide: + +- unicidade de IDs; +- endpoints de relações existentes; +- membros de boundaries existentes; +- referências de views existentes; +- `confirmed` com ao menos um `source_ref`; +- ausência de campos específicos de layout/renderização; +- views vazias ou sem pergunta respondida. + +Retorne código diferente de zero para erro bloqueante. + +### 3. Mapeadores para Archify + +Implemente inicialmente: + +- `architecture`; +- `workflow`; +- `sequence`. + +Estrutura sugerida: + +```text +plugins/examples/archify/ +├── plugin.yaml +├── README.md +├── scripts/ +│ ├── visualize.mjs +│ └── visualize.ps1 +├── src/ +│ ├── load-package.mjs +│ ├── normalize-ir.mjs +│ ├── validate-ir.mjs +│ ├── manifest.mjs +│ └── mappers/ +│ ├── architecture.mjs +│ ├── workflow.mjs +│ └── sequence.mjs +├── tests/ +│ ├── fixtures/ +│ └── *.test.mjs +└── package.json +``` + +A arquitetura default deve ser uma visão limitada e legível, preferencialmente entre 8 e 12 nodes primários. Elementos secundários podem ser omitidos da view, mas devem continuar presentes na IR quando existirem no pacote. + +### 4. Execução do Archify + +Use a CLI oficial do Archify para cada saída: + +```bash +node /bin/archify.mjs validate --quality --json +node /bin/archify.mjs render +node /bin/archify.mjs check +``` + +Implemente resolução configurável da instalação, nesta ordem: + +1. `ARCHIFY_HOME`; +2. caminho local versionado/cacheado documentado pelo adapter; +3. mensagem de bootstrap clara, sem download implícito silencioso. + +Não faça clone ou download de dependência durante uma execução normal sem consentimento explícito. + +### 5. Manifesto + +Gerar: + +```text +/archify/manifest.yaml +``` + +Com: + +- versão do adapter; +- versão do Archify; +- hash do pacote de origem; +- data/hora; +- views solicitadas; +- arquivos produzidos; +- hash de cada saída; +- status de `validate`, `render` e `check`; +- lista de inferências; +- lista de artefatos opcionais ausentes; +- indicador de autorização pública herdado do pacote. + +### 6. Integração com Sky CLI + +Implemente uma integração opcional e fina, sem mover o adapter para o core. + +Forma desejada: + +```powershell +./scripts/sky/sky.ps1 visualize ` + -Slug ` + -Renderer archify ` + -Views architecture,workflow,sequence ` + -Quality standard +``` + +Caso isso exija acoplamento indevido, implemente um wrapper em `scripts/sky/visualize.ps1` que apenas delegue ao plugin e documente o comando. O `sky.ps1` não deve falhar quando o plugin estiver ausente; deve retornar uma mensagem orientando instalação/uso opcional. + +### 7. Fixtures e testes + +Use como fixture inicial uma cópia mínima e estável derivada de: + +```text +examples/sky-forge-packages/surya-workspace-mvp +``` + +Cubra obrigatoriamente: + +1. pacote mínimo válido; +2. pacote completo válido; +3. ausência de `architecture.yaml`; +4. relação com endpoint inexistente; +5. elemento `confirmed` sem `source_refs`; +6. elemento `inferred` preservado e registrado no manifesto; +7. view architecture gerada; +8. workflow gerado quando houver evidência; +9. sequence omitida com warning quando não houver evidência; +10. falha da validação Archify propagada; +11. output determinístico, desconsiderando timestamp controlado; +12. remoção de `plugins/examples/archify` não quebra o core. + +Use o mecanismo de testes já adotado pelo repositório quando existir. Caso não exista para Node, use `node:test` para evitar framework adicional. + +## Segurança e privacidade + +- respeite o estado público/privado do pacote; +- nunca copie segredos ou valores de ambiente para diagramas; +- sanitize URLs com tokens, credentials, connection strings e identificadores sensíveis; +- não inclua conteúdo integral de prompts internos como cards; +- trate HTML como output privado por padrão; +- inclua teste de sanitização. + +## Observabilidade + +A CLI deve exibir etapas de alto nível: + +```text +[sky-archify] load package +[sky-archify] normalize IR +[sky-archify] validate IR +[sky-archify] map architecture +[sky-archify] archify validate +[sky-archify] archify render +[sky-archify] archify check +[sky-archify] write manifest +``` + +Em modo `--json`, retornar resumo estruturado para agentes e CI. + +## Critérios de aceite + +A demanda só está concluída quando: + +- [ ] o comando Node funciona em Windows, Linux e macOS; +- [ ] o wrapper PowerShell funciona no Windows; +- [ ] a IR é gerada e validada; +- [ ] ao menos uma fixture produz architecture HTML válido; +- [ ] workflow é produzido a partir de evidência real; +- [ ] sequence é produzida ou omitida de maneira explicável; +- [ ] `archify validate`, `render` e `check` são executados; +- [ ] erros retornam código não zero; +- [ ] manifesto registra proveniência e hashes; +- [ ] testes automatizados passam; +- [ ] `check-core-agnostic.ps1` continua passando; +- [ ] documentação inclui bootstrap e troubleshooting; +- [ ] nenhuma dependência obrigatória foi introduzida no core; +- [ ] nenhum artefato confirmado foi criado sem evidência. + +## Entrega esperada do Cursor + +Ao finalizar: + +1. apresente a árvore de arquivos criada/alterada; +2. liste decisões técnicas tomadas; +3. mostre comandos executados; +4. mostre resultados dos testes; +5. registre limitações ou itens para Onda 2 (`dataflow`, `lifecycle`, showcase e adapters alternativos); +6. atualize este documento marcando os critérios realmente atendidos, sem declarar sucesso para itens não verificados. + +## Onda 2 — fora do escopo desta implementação + +- mapper `dataflow`; +- mapper `lifecycle`; +- upload/publicação automática em showcase; +- edição WYSIWYG; +- substituição de C4 ou LikeC4; +- geração de arquitetura a partir apenas de texto livre; +- alterações na lógica de decisão dos agentes arquiteturais. From 315326f94c2f679f39270c5f2602d300b0644faa Mon Sep 17 00:00:00 2001 From: Raphael Date: Tue, 21 Jul 2026 21:01:27 -0300 Subject: [PATCH 6/7] Implement executable Archify post-export visualization adapter. Add Sky Architecture IR normalization, Archify mappers, pinned bootstrap, CLI wrappers, contract tests, and optional sky visualize integration without coupling the core. Co-authored-by: Cursor --- .gitignore | 3 + docs/_meta/PLUGINS.md | 14 + ...chify-post-export-visualization-adapter.md | 2 +- ...IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md | 38 +- plugins/README.md | 1 + plugins/examples/archify/.gitignore | 4 + plugins/examples/archify/ARCHIFY.lock.json | 10 + plugins/examples/archify/README.md | 99 +++-- plugins/examples/archify/package-lock.json | 33 ++ plugins/examples/archify/package.json | 18 + .../archify/scripts/bootstrap-archify.mjs | 54 +++ .../examples/archify/scripts/visualize.mjs | 328 ++++++++++++++++ .../examples/archify/scripts/visualize.ps1 | 58 +++ plugins/examples/archify/src/hash.mjs | 41 ++ plugins/examples/archify/src/load-package.mjs | 71 ++++ plugins/examples/archify/src/log.mjs | 9 + plugins/examples/archify/src/manifest.mjs | 52 +++ .../archify/src/mappers/architecture.mjs | 128 +++++++ .../examples/archify/src/mappers/sequence.mjs | 77 ++++ .../examples/archify/src/mappers/workflow.mjs | 136 +++++++ plugins/examples/archify/src/normalize-ir.mjs | 349 ++++++++++++++++++ .../examples/archify/src/resolve-archify.mjs | 90 +++++ plugins/examples/archify/src/sanitize.mjs | 37 ++ plugins/examples/archify/src/validate-ir.mjs | 144 ++++++++ .../examples/archify/tests/contract.test.mjs | 213 +++++++++++ .../archify/tests/core-agnostic.test.mjs | 25 ++ .../bad-relationship/architecture.yaml | 5 + .../fixtures/bad-relationship/brief.yaml | 5 + .../fixtures/complete-valid/architecture.yaml | 20 + .../tests/fixtures/complete-valid/brief.yaml | 5 + .../complete-valid/handoff-solution.yaml | 7 + .../fixtures/complete-valid/package.yaml | 8 + .../fixtures/complete-valid/sequences.yaml | 14 + .../confirmed-no-refs/architecture.yaml | 3 + .../fixtures/confirmed-no-refs/brief.yaml | 5 + .../fixtures/minimal-valid/architecture.yaml | 11 + .../tests/fixtures/minimal-valid/brief.yaml | 5 + .../fixtures/missing-architecture/brief.yaml | 5 + .../fixtures/with-inferred/architecture.yaml | 6 + .../tests/fixtures/with-inferred/brief.yaml | 5 + .../fixtures/with-secrets/architecture.yaml | 6 + .../tests/fixtures/with-secrets/brief.yaml | 5 + .../fixtures/with-sequence/architecture.yaml | 4 + .../tests/fixtures/with-sequence/brief.yaml | 5 + .../fixtures/with-sequence/sequences.yaml | 9 + .../examples/archify/tests/sanitize.test.mjs | 34 ++ scripts/sky/sky.ps1 | 36 +- scripts/sky/visualize.ps1 | 81 ++++ 48 files changed, 2267 insertions(+), 51 deletions(-) create mode 100644 plugins/examples/archify/.gitignore create mode 100644 plugins/examples/archify/ARCHIFY.lock.json create mode 100644 plugins/examples/archify/package-lock.json create mode 100644 plugins/examples/archify/package.json create mode 100644 plugins/examples/archify/scripts/bootstrap-archify.mjs create mode 100644 plugins/examples/archify/scripts/visualize.mjs create mode 100644 plugins/examples/archify/scripts/visualize.ps1 create mode 100644 plugins/examples/archify/src/hash.mjs create mode 100644 plugins/examples/archify/src/load-package.mjs create mode 100644 plugins/examples/archify/src/log.mjs create mode 100644 plugins/examples/archify/src/manifest.mjs create mode 100644 plugins/examples/archify/src/mappers/architecture.mjs create mode 100644 plugins/examples/archify/src/mappers/sequence.mjs create mode 100644 plugins/examples/archify/src/mappers/workflow.mjs create mode 100644 plugins/examples/archify/src/normalize-ir.mjs create mode 100644 plugins/examples/archify/src/resolve-archify.mjs create mode 100644 plugins/examples/archify/src/sanitize.mjs create mode 100644 plugins/examples/archify/src/validate-ir.mjs create mode 100644 plugins/examples/archify/tests/contract.test.mjs create mode 100644 plugins/examples/archify/tests/core-agnostic.test.mjs create mode 100644 plugins/examples/archify/tests/fixtures/bad-relationship/architecture.yaml create mode 100644 plugins/examples/archify/tests/fixtures/bad-relationship/brief.yaml create mode 100644 plugins/examples/archify/tests/fixtures/complete-valid/architecture.yaml create mode 100644 plugins/examples/archify/tests/fixtures/complete-valid/brief.yaml create mode 100644 plugins/examples/archify/tests/fixtures/complete-valid/handoff-solution.yaml create mode 100644 plugins/examples/archify/tests/fixtures/complete-valid/package.yaml create mode 100644 plugins/examples/archify/tests/fixtures/complete-valid/sequences.yaml create mode 100644 plugins/examples/archify/tests/fixtures/confirmed-no-refs/architecture.yaml create mode 100644 plugins/examples/archify/tests/fixtures/confirmed-no-refs/brief.yaml create mode 100644 plugins/examples/archify/tests/fixtures/minimal-valid/architecture.yaml create mode 100644 plugins/examples/archify/tests/fixtures/minimal-valid/brief.yaml create mode 100644 plugins/examples/archify/tests/fixtures/missing-architecture/brief.yaml create mode 100644 plugins/examples/archify/tests/fixtures/with-inferred/architecture.yaml create mode 100644 plugins/examples/archify/tests/fixtures/with-inferred/brief.yaml create mode 100644 plugins/examples/archify/tests/fixtures/with-secrets/architecture.yaml create mode 100644 plugins/examples/archify/tests/fixtures/with-secrets/brief.yaml create mode 100644 plugins/examples/archify/tests/fixtures/with-sequence/architecture.yaml create mode 100644 plugins/examples/archify/tests/fixtures/with-sequence/brief.yaml create mode 100644 plugins/examples/archify/tests/fixtures/with-sequence/sequences.yaml create mode 100644 plugins/examples/archify/tests/sanitize.test.mjs create mode 100644 scripts/sky/visualize.ps1 diff --git a/.gitignore b/.gitignore index 3039a89..fc5d255 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,7 @@ __pycache__/ .venv/ dist/ +# Local scratch / generated previews +.tmp/ + harness-report.json diff --git a/docs/_meta/PLUGINS.md b/docs/_meta/PLUGINS.md index 5b18cf0..eed992d 100644 --- a/docs/_meta/PLUGINS.md +++ b/docs/_meta/PLUGINS.md @@ -25,3 +25,17 @@ Ver [EXTERNAL_WORKSPACE_INTEGRATION.md](./EXTERNAL_WORKSPACE_INTEGRATION.md). ``` Core deve passar com `plugins/examples/` ausente. + +## Exemplo: Archify (visualização pós-export) + +Adapter opcional em `plugins/examples/archify/`. Não é dependência do core. + +```powershell +cd plugins/examples/archify +npm install +node scripts/bootstrap-archify.mjs +cd ../../.. +./scripts/sky/sky.ps1 visualize -PackagePath examples/sky-forge-packages/surya-workspace-mvp -Renderer archify +``` + +Ver `plugins/examples/archify/README.md` e ADR-0007. diff --git a/docs/adr/ADR-0007-archify-post-export-visualization-adapter.md b/docs/adr/ADR-0007-archify-post-export-visualization-adapter.md index 6d056be..9346557 100644 --- a/docs/adr/ADR-0007-archify-post-export-visualization-adapter.md +++ b/docs/adr/ADR-0007-archify-post-export-visualization-adapter.md @@ -1,6 +1,6 @@ # ADR-0007 — Archify como adapter de visualização pós-export -- **Status:** Proposed +- **Status:** Accepted - **Data:** 2026-07-21 - **Decisores:** Sky-Forge maintainers diff --git a/docs/tasks/CURSOR_IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md b/docs/tasks/CURSOR_IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md index 7a46f6a..d65df27 100644 --- a/docs/tasks/CURSOR_IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md +++ b/docs/tasks/CURSOR_IMPLEMENT_ARCHIFY_VISUALIZATION_ADAPTER.md @@ -238,20 +238,30 @@ Em modo `--json`, retornar resumo estruturado para agentes e CI. A demanda só está concluída quando: -- [ ] o comando Node funciona em Windows, Linux e macOS; -- [ ] o wrapper PowerShell funciona no Windows; -- [ ] a IR é gerada e validada; -- [ ] ao menos uma fixture produz architecture HTML válido; -- [ ] workflow é produzido a partir de evidência real; -- [ ] sequence é produzida ou omitida de maneira explicável; -- [ ] `archify validate`, `render` e `check` são executados; -- [ ] erros retornam código não zero; -- [ ] manifesto registra proveniência e hashes; -- [ ] testes automatizados passam; -- [ ] `check-core-agnostic.ps1` continua passando; -- [ ] documentação inclui bootstrap e troubleshooting; -- [ ] nenhuma dependência obrigatória foi introduzida no core; -- [ ] nenhum artefato confirmado foi criado sem evidência. +- [x] o comando Node funciona em Windows, Linux e macOS; +- [x] o wrapper PowerShell funciona no Windows; +- [x] a IR é gerada e validada; +- [x] ao menos uma fixture produz architecture HTML válido; +- [x] workflow é produzido a partir de evidência real; +- [x] sequence é produzida ou omitida de maneira explicável; +- [x] `archify validate`, `render` e `check` são executados; +- [x] erros retornam código não zero; +- [x] manifesto registra proveniência e hashes; +- [x] testes automatizados passam; +- [x] `check-core-agnostic.ps1` continua passando; +- [x] documentação inclui bootstrap e troubleshooting; +- [x] nenhuma dependência obrigatória foi introduzida no core; +- [x] nenhum artefato confirmado foi criado sem evidência. + +### Evidência desta implementação (2026-07-21) + +- `npm test` em `plugins/examples/archify`: 17/17 passando +- `./scripts/sky/check-core-agnostic.ps1`: OK +- `./scripts/sky/validate-profile.ps1 -Profile consulting-handoff -PackagePath examples/sky-forge-packages/surya-workspace-mvp -FixtureMode`: OK +- Diagrama real: `.tmp/archify/surya-workspace-mvp/archify/system.architecture.html` (SVG presente; validate/render/check passed) +- Workflow real a partir de gates/milestones do pacote surya; sequence omitida com warning (sem `sequences.yaml`) +- Pin Archify: `2.11.0` / `ed0efcc763d358b78df845182b5ed24a9d165a1c` em `ARCHIFY.lock.json` +- Nota: macOS/Linux não foram executados nesta máquina Windows; o CLI Node é multiplataforma por construção (`node:test`, paths via `node:path`) ## Entrega esperada do Cursor diff --git a/plugins/README.md b/plugins/README.md index 41f3af8..3370cb7 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -13,5 +13,6 @@ Plugins **não** rodam dentro da geração do core. Consumidores externos (ex.: | Pasta | Descrição | |-------|-----------| | `examples/surya-labs-workspace/` | Adapter opcional — mapeia `consulting-handoff` → entidades de workspace | +| `examples/archify/` | Adapter opcional pós-export — Sky Architecture IR → Archify HTML | Remova `plugins/examples/` para validar que o core permanece agnóstico. diff --git a/plugins/examples/archify/.gitignore b/plugins/examples/archify/.gitignore new file mode 100644 index 0000000..fb6c4aa --- /dev/null +++ b/plugins/examples/archify/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.vendor/ +.tmp/ +*.log diff --git a/plugins/examples/archify/ARCHIFY.lock.json b/plugins/examples/archify/ARCHIFY.lock.json new file mode 100644 index 0000000..795d742 --- /dev/null +++ b/plugins/examples/archify/ARCHIFY.lock.json @@ -0,0 +1,10 @@ +{ + "package": "tt-a1i/archify", + "version": "2.11.0", + "tag": "v2.11.0", + "commit": "ed0efcc763d358b78df845182b5ed24a9d165a1c", + "repository": "https://github.com/tt-a1i/archify.git", + "cli_relative_path": "archify/bin/archify.mjs", + "skill_relative_path": "archify", + "notes": "Pinned for reproducible offline renders after explicit bootstrap. Do not float to main." +} diff --git a/plugins/examples/archify/README.md b/plugins/examples/archify/README.md index 2cf692e..c4c5934 100644 --- a/plugins/examples/archify/README.md +++ b/plugins/examples/archify/README.md @@ -10,17 +10,28 @@ Sky package -> Sky Architecture IR -> Archify JSON -> validate -> render -> chec O adapter não decide arquitetura, não altera o pacote de origem e não adiciona hooks ao core. -## Dependência - -Fixar uma versão explícita do Archify. Versão inicial recomendada para a implementação: +## Dependência (versão fixada) ```text tt-a1i/archify@2.11.0 +commit: ed0efcc763d358b78df845182b5ed24a9d165a1c +``` + +Pin em `ARCHIFY.lock.json`. O runtime fica em `.vendor/` (gitignored) após bootstrap explícito. + +```powershell +cd plugins/examples/archify +npm install +node scripts/bootstrap-archify.mjs ``` -A instalação pode ser local ao adapter, via submodule, pacote empacotado ou cache de CI. A escolha deve preservar builds reproduzíveis e permitir execução offline após bootstrap. +Resolução em runtime: + +1. `ARCHIFY_HOME` (diretório skill com `bin/archify.mjs`) +2. `plugins/examples/archify/.vendor/archify/archify` +3. erro acionável pedindo bootstrap — sem download silencioso -## Interface de linha de comando esperada +## Interface ```powershell ./plugins/examples/archify/scripts/visualize.ps1 ` @@ -30,8 +41,6 @@ A instalação pode ser local ao adapter, via submodule, pacote empacotado ou ca -Quality standard ``` -Interface Node equivalente: - ```bash node plugins/examples/archify/scripts/visualize.mjs \ --package \ @@ -40,16 +49,27 @@ node plugins/examples/archify/scripts/visualize.mjs \ --quality standard ``` +Integração opcional Sky CLI: + +```powershell +./scripts/sky/sky.ps1 visualize ` + -PackagePath examples/sky-forge-packages/surya-workspace-mvp ` + -Renderer archify ` + -Views architecture,workflow,sequence +``` + +Se o plugin estiver ausente, `sky visualize` orienta a instalação e **não** quebra o core. + ## Etapas -1. validar a existência dos artefatos obrigatórios; -2. normalizar o pacote em `intermediate/sky-architecture-ir.yaml`; -3. validar a IR contra `schemas/sky-forge/sky-architecture-ir.schema.yaml`; -4. converter cada view selecionada para o schema correspondente do Archify; -5. executar `archify validate`; -6. executar `archify render`; -7. executar `archify check` no HTML produzido; -8. gerar `archify/manifest.yaml` com hashes, versões, fontes e resultados das validações. +1. validar artefatos obrigatórios (`brief.yaml`, `architecture.yaml`); +2. normalizar `intermediate/sky-architecture-ir.yaml`; +3. validar a IR; +4. mapear views selecionadas para JSON Archify; +5. `archify validate` / `render` / `check`; +6. gerar `archify/manifest.yaml`. + +Nota: Archify 2.11.0 **não** expõe `--quality`. O perfil `standard|showcase` é gravado na IR/manifesto e não é passado à CLI externa. ## Manifesto mínimo @@ -63,6 +83,7 @@ renderer: source: package_slug: example package_hash: sha256:... + public_authorized: false outputs: - view: system type: architecture @@ -74,26 +95,36 @@ outputs: ## Regras de fidelidade - `confirmed` exige `source_refs` não vazio; -- `inferred` deve ser visualmente distinguível e listado no manifesto; -- relações sem origem e destino existentes bloqueiam a geração; -- o adapter não cria tecnologias, protocolos, trust boundaries ou integrações ausentes; -- uma view pode omitir detalhes para legibilidade, mas não pode alterar o significado; -- o HTML é artefato derivado e nunca deve receber edição manual. +- `inferred` é listado no manifesto e marcado no diagrama (`tag: inferred` / edges dashed); +- relações sem endpoints existentes bloqueiam a geração; +- o adapter não inventa tecnologias, protocolos, trust boundaries ou integrações; +- layout Archify (grid `row`/`col`) existe só no JSON derivado, nunca na IR; +- HTML é derivado e não deve ser editado manualmente. ## Saídas iniciais - `system.architecture.html` -- `delivery.workflow.html` -- `critical-path.sequence.html`, somente quando houver evidência suficiente - -## Testes obrigatórios - -- package mínimo válido; -- package completo válido; -- artefato obrigatório ausente; -- endpoint de relacionamento inexistente; -- elemento confirmado sem evidência; -- elemento inferido corretamente sinalizado; -- execução determinística; -- ausência do diretório do plugin não quebra o core; -- falha do Archify retorna código diferente de zero e mensagem acionável. +- `delivery.workflow.html` (quando houver gates/milestones) +- `critical-path.sequence.html` (quando houver `sequences.yaml`) + +## Testes + +```powershell +cd plugins/examples/archify +npm test +``` + +## Troubleshooting + +| Sintoma | Ação | +|---------|------| +| `Archify runtime not found` | Rodar `node scripts/bootstrap-archify.mjs` ou exportar `ARCHIFY_HOME` | +| `Missing required artifacts` | Garantir `brief.yaml` + `architecture.yaml` no pacote | +| `sequence omitted` | Adicionar `sequences.yaml` com participants + steps | +| `workflow omitted` | Incluir `package.yaml` gates e/ou milestones em `handoff-solution.yaml` | +| `archify validate` ≠ 0 | Corrigir o JSON derivado regenerando; não editar HTML | +| Plugin ausente e `sky visualize` | Esperado — mensagem de orientação, exit 0 | + +## Remoção + +Apagar `plugins/examples/archify` não deve quebrar `sky validate`, `sky export` nem `check-core-agnostic.ps1`. diff --git a/plugins/examples/archify/package-lock.json b/plugins/examples/archify/package-lock.json new file mode 100644 index 0000000..12fce35 --- /dev/null +++ b/plugins/examples/archify/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "@sky-forge/archify-visualization-adapter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@sky-forge/archify-visualization-adapter", + "version": "0.1.0", + "dependencies": { + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/plugins/examples/archify/package.json b/plugins/examples/archify/package.json new file mode 100644 index 0000000..f48aa85 --- /dev/null +++ b/plugins/examples/archify/package.json @@ -0,0 +1,18 @@ +{ + "name": "@sky-forge/archify-visualization-adapter", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Optional post-export Archify visualization adapter for Sky-Forge packages", + "engines": { + "node": ">=18" + }, + "scripts": { + "bootstrap": "node scripts/bootstrap-archify.mjs", + "visualize": "node scripts/visualize.mjs", + "test": "node --test tests/*.test.mjs" + }, + "dependencies": { + "yaml": "^2.8.0" + } +} diff --git a/plugins/examples/archify/scripts/bootstrap-archify.mjs b/plugins/examples/archify/scripts/bootstrap-archify.mjs new file mode 100644 index 0000000..4c3e093 --- /dev/null +++ b/plugins/examples/archify/scripts/bootstrap-archify.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Explicit bootstrap for the pinned Archify runtime. + * Never runs implicitly during visualize. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.resolve(__dirname, '..'); +const lock = JSON.parse(fs.readFileSync(path.join(pluginRoot, 'ARCHIFY.lock.json'), 'utf8')); +const vendorRoot = path.join(pluginRoot, '.vendor', 'archify'); + +const force = process.argv.includes('--force'); + +if (fs.existsSync(path.join(vendorRoot, lock.skill_relative_path, 'bin', 'archify.mjs')) && !force) { + console.log(`Archify already bootstrapped at ${vendorRoot}`); + console.log(`Pin: ${lock.package}@${lock.version} (${lock.commit})`); + process.exit(0); +} + +fs.mkdirSync(path.dirname(vendorRoot), { recursive: true }); +if (fs.existsSync(vendorRoot)) { + fs.rmSync(vendorRoot, { recursive: true, force: true }); +} + +console.log(`Cloning ${lock.repository} @ ${lock.tag} (${lock.commit})...`); +const clone = spawnSync( + 'git', + ['clone', '--depth', '1', '--branch', lock.tag, lock.repository, vendorRoot], + { encoding: 'utf8', stdio: 'inherit' }, +); +if ((clone.status ?? 1) !== 0) { + console.error('Bootstrap failed: git clone error'); + process.exit(clone.status ?? 1); +} + +const head = spawnSync('git', ['-C', vendorRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }); +const commit = (head.stdout || '').trim(); +if (commit && commit !== lock.commit) { + console.warn(`Warning: resolved commit ${commit} differs from lock ${lock.commit}`); +} + +const cli = path.join(vendorRoot, lock.cli_relative_path); +if (!fs.existsSync(cli)) { + console.error(`Bootstrap incomplete: missing CLI at ${cli}`); + process.exit(1); +} + +console.log('Bootstrap complete.'); +console.log(`CLI: ${cli}`); +console.log(`Set ARCHIFY_HOME=${path.join(vendorRoot, lock.skill_relative_path)} (optional)`); diff --git a/plugins/examples/archify/scripts/visualize.mjs b/plugins/examples/archify/scripts/visualize.mjs new file mode 100644 index 0000000..50467ae --- /dev/null +++ b/plugins/examples/archify/scripts/visualize.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; + +import { loadPackage } from '../src/load-package.mjs'; +import { normalizeIr, ADAPTER_VERSION } from '../src/normalize-ir.mjs'; +import { validateIr } from '../src/validate-ir.mjs'; +import { mapArchitecture } from '../src/mappers/architecture.mjs'; +import { mapWorkflow } from '../src/mappers/workflow.mjs'; +import { mapSequence } from '../src/mappers/sequence.mjs'; +import { resolveArchifyHome, runArchify } from '../src/resolve-archify.mjs'; +import { buildManifest, stringifyManifest } from '../src/manifest.mjs'; +import { hashPackage, sha256File, sha256Hex } from '../src/hash.mjs'; +import { logStep, logWarn } from '../src/log.mjs'; +import { sanitizeDeep } from '../src/sanitize.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function parseArgs(argv) { + const args = { + packagePath: null, + outputPath: null, + views: ['architecture', 'workflow', 'sequence'], + quality: 'standard', + generatedAt: null, + json: false, + skipRender: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + const next = argv[i + 1]; + switch (token) { + case '--package': + case '-p': + args.packagePath = next; + i += 1; + break; + case '--output': + case '-o': + args.outputPath = next; + i += 1; + break; + case '--views': + args.views = String(next) + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + i += 1; + break; + case '--quality': + args.quality = next; + i += 1; + break; + case '--generated-at': + args.generatedAt = next; + i += 1; + break; + case '--json': + args.json = true; + break; + case '--skip-render': + args.skipRender = true; + break; + case '--help': + case '-h': + args.help = true; + break; + default: + if (token.startsWith('-')) { + throw new Error(`Unknown argument: ${token}`); + } + } + } + return args; +} + +function usage() { + return `Usage: + node plugins/examples/archify/scripts/visualize.mjs \\ + --package \\ + --output \\ + [--views architecture,workflow,sequence] \\ + [--quality standard|showcase] \\ + [--generated-at ISO-8601] \\ + [--json] \\ + [--skip-render] + +Archify must be bootstrapped first: + node plugins/examples/archify/scripts/bootstrap-archify.mjs +`; +} + +function writeJson(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); +} + +function writeText(filePath, text) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, text, 'utf8'); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.packagePath || !args.outputPath) { + console.log(usage()); + process.exit(args.help ? 0 : 2); + } + + const jsonMode = args.json; + const outputRoot = path.resolve(args.outputPath); + const warnings = []; + const outputs = []; + + try { + logStep('load package', { json: jsonMode }); + const pkg = loadPackage(args.packagePath); + + logStep('normalize IR', { json: jsonMode }); + const { ir, inferences, workflowEvidence, sequenceEvidence } = normalizeIr(pkg, { + generatedAt: args.generatedAt, + qualityProfile: args.quality, + }); + + logStep('validate IR', { json: jsonMode }); + const validation = validateIr(ir); + if (!validation.ok) { + const message = `Sky Architecture IR validation failed:\n- ${validation.errors.join('\n- ')}`; + if (jsonMode) { + console.log(JSON.stringify({ ok: false, stage: 'validate-ir', errors: validation.errors }, null, 2)); + } else { + console.error(message); + } + process.exit(1); + } + + const intermediateDir = path.join(outputRoot, 'intermediate'); + const archifyDir = path.join(outputRoot, 'archify'); + fs.mkdirSync(intermediateDir, { recursive: true }); + fs.mkdirSync(archifyDir, { recursive: true }); + + const irPath = path.join(intermediateDir, 'sky-architecture-ir.yaml'); + writeText(irPath, YAML.stringify(ir, { lineWidth: 100 })); + + const requested = new Set(args.views); + const diagrams = []; + + if (requested.has('architecture')) { + logStep('map architecture', { json: jsonMode }); + const jsonDoc = sanitizeDeep(mapArchitecture(ir, { qualityProfile: args.quality })); + const jsonPath = path.join(archifyDir, 'system.architecture.json'); + writeJson(jsonPath, jsonDoc); + diagrams.push({ view: 'system', type: 'architecture', jsonPath, htmlName: 'system.architecture.html' }); + } + + if (requested.has('workflow')) { + if (!workflowEvidence) { + const msg = 'workflow omitted: no gates/milestones evidence in package'; + warnings.push(msg); + logWarn(msg, { json: jsonMode }); + } else { + logStep('map workflow', { json: jsonMode }); + const jsonDoc = sanitizeDeep(mapWorkflow(ir, { qualityProfile: args.quality })); + if (!jsonDoc) { + warnings.push('workflow omitted: mapper produced empty diagram'); + } else { + const jsonPath = path.join(archifyDir, 'delivery.workflow.json'); + writeJson(jsonPath, jsonDoc); + diagrams.push({ view: 'delivery', type: 'workflow', jsonPath, htmlName: 'delivery.workflow.html' }); + } + } + } + + if (requested.has('sequence')) { + if (!sequenceEvidence) { + const msg = 'sequence omitted: sequences.yaml evidence not found'; + warnings.push(msg); + logWarn(msg, { json: jsonMode }); + } else { + logStep('map sequence', { json: jsonMode }); + const jsonDoc = sanitizeDeep(mapSequence(ir, { qualityProfile: args.quality })); + if (!jsonDoc) { + warnings.push('sequence omitted: mapper produced empty diagram'); + } else { + const jsonPath = path.join(archifyDir, 'critical-path.sequence.json'); + writeJson(jsonPath, jsonDoc); + diagrams.push({ + view: 'critical-path', + type: 'sequence', + jsonPath, + htmlPath: path.join(archifyDir, 'critical-path.sequence.html'), + htmlName: 'critical-path.sequence.html', + }); + } + } + } + + let archify = { version: 'unresolved', cli: null }; + if (!args.skipRender) { + archify = resolveArchifyHome(); + } else { + archify = resolveArchifyHome({ allowMissing: true }); + } + + for (const diagram of diagrams) { + const htmlPath = path.join(archifyDir, diagram.htmlName); + let validateStatus = 'skipped'; + let renderStatus = 'skipped'; + let checkStatus = 'skipped'; + + if (!args.skipRender) { + logStep('archify validate', { json: jsonMode }); + // Archify 2.11.0 has no --quality flag; quality is recorded in IR/manifest only. + const validate = runArchify(archify.cli, ['validate', diagram.type, diagram.jsonPath, '--json'], { + json: true, + }); + if (!validate.ok) { + if (jsonMode) { + console.log( + JSON.stringify( + { + ok: false, + stage: 'archify-validate', + type: diagram.type, + stdout: validate.stdout, + stderr: validate.stderr, + }, + null, + 2, + ), + ); + } else { + console.error(validate.stderr || validate.stdout || 'archify validate failed'); + } + process.exit(validate.status || 1); + } + validateStatus = 'passed'; + + logStep('archify render', { json: jsonMode }); + const render = runArchify(archify.cli, ['render', diagram.type, diagram.jsonPath, htmlPath], { + json: true, + }); + if (!render.ok) { + console.error(render.stderr || render.stdout || 'archify render failed'); + process.exit(render.status || 1); + } + renderStatus = 'passed'; + + logStep('archify check', { json: jsonMode }); + const check = runArchify(archify.cli, ['check', htmlPath], { json: true }); + if (!check.ok) { + console.error(check.stderr || check.stdout || 'archify check failed'); + process.exit(check.status || 1); + } + checkStatus = 'passed'; + } + + outputs.push({ + view: diagram.view, + type: diagram.type, + json: path.relative(outputRoot, diagram.jsonPath).split(path.sep).join('/'), + html: args.skipRender ? null : path.relative(outputRoot, htmlPath).split(path.sep).join('/'), + json_hash: `sha256:${sha256File(diagram.jsonPath)}`, + html_hash: args.skipRender || !fs.existsSync(htmlPath) ? null : `sha256:${sha256File(htmlPath)}`, + validation: validateStatus, + render: renderStatus, + check: checkStatus, + }); + } + + logStep('write manifest', { json: jsonMode }); + const manifest = buildManifest({ + packageSlug: pkg.slug, + packageHash: hashPackage(pkg.path), + generatedAt: ir.metadata.generated_at, + archifyVersion: archify.version || 'unresolved', + viewsRequested: args.views, + outputs, + inferences, + missingOptional: pkg.missingOptional, + publicAuthorized: pkg.publicAuthorized, + warnings, + }); + const manifestPath = path.join(archifyDir, 'manifest.yaml'); + writeText(manifestPath, stringifyManifest(manifest)); + + const summary = { + ok: true, + adapter_version: ADAPTER_VERSION, + archify_version: archify.version, + package: pkg.slug, + output: outputRoot, + ir: path.relative(outputRoot, irPath).split(path.sep).join('/'), + manifest: path.relative(outputRoot, manifestPath).split(path.sep).join('/'), + outputs, + warnings, + inferences: inferences.length, + public_authorized: pkg.publicAuthorized, + ir_hash: `sha256:${sha256Hex(fs.readFileSync(irPath))}`, + }; + + if (jsonMode) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(`Visualization complete: ${outputRoot}`); + console.log(`IR: ${irPath}`); + console.log(`Manifest: ${manifestPath}`); + for (const out of outputs) { + console.log(`- ${out.type}: ${out.json}${out.html ? ` → ${out.html}` : ''}`); + } + for (const warning of warnings) { + console.log(`warning: ${warning}`); + } + } + } catch (error) { + if (jsonMode) { + console.log(JSON.stringify({ ok: false, error: error.message }, null, 2)); + } else { + console.error(`[sky-archify] error: ${error.message}`); + } + process.exit(1); + } +} + +main(); diff --git a/plugins/examples/archify/scripts/visualize.ps1 b/plugins/examples/archify/scripts/visualize.ps1 new file mode 100644 index 0000000..c5b4536 --- /dev/null +++ b/plugins/examples/archify/scripts/visualize.ps1 @@ -0,0 +1,58 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Wrapper PowerShell do adapter Archify (pós-export). +.EXAMPLE + ./plugins/examples/archify/scripts/visualize.ps1 ` + -PackagePath examples/sky-forge-packages/surya-workspace-mvp ` + -OutputPath .tmp/archify/surya-workspace-mvp ` + -Views architecture,workflow,sequence ` + -Quality standard +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PackagePath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter()] + [string]$Views = 'architecture,workflow,sequence', + + [Parameter()] + [ValidateSet('standard', 'showcase')] + [string]$Quality = 'standard', + + [Parameter()] + [string]$GeneratedAt, + + [Parameter()] + [switch]$Json, + + [Parameter()] + [switch]$SkipRender +) + +$ErrorActionPreference = 'Stop' +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Visualizer = Join-Path $ScriptDir 'visualize.mjs' + +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + throw 'Node.js >= 18 is required to run the Archify adapter.' +} + +$nodeArgs = @( + $Visualizer, + '--package', (Resolve-Path $PackagePath).Path, + '--output', $OutputPath, + '--views', $Views, + '--quality', $Quality +) + +if ($GeneratedAt) { $nodeArgs += @('--generated-at', $GeneratedAt) } +if ($Json) { $nodeArgs += '--json' } +if ($SkipRender) { $nodeArgs += '--skip-render' } + +& node @nodeArgs +exit $LASTEXITCODE diff --git a/plugins/examples/archify/src/hash.mjs b/plugins/examples/archify/src/hash.mjs new file mode 100644 index 0000000..72390d2 --- /dev/null +++ b/plugins/examples/archify/src/hash.mjs @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +export function sha256Hex(value) { + return createHash('sha256').update(value).digest('hex'); +} + +export function sha256File(filePath) { + return sha256Hex(fs.readFileSync(filePath)); +} + +export function hashPackage(packagePath) { + const files = listPackageFiles(packagePath); + const payload = files + .map((rel) => { + const abs = path.join(packagePath, rel); + return `${rel}\n${sha256File(abs)}`; + }) + .join('\n'); + return `sha256:${sha256Hex(payload)}`; +} + +export function listPackageFiles(packagePath) { + const out = []; + walk(packagePath, packagePath, out); + return out.sort((a, b) => a.localeCompare(b)); +} + +function walk(root, current, out) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + const abs = path.join(current, entry.name); + const rel = path.relative(root, abs).split(path.sep).join('/'); + if (entry.isDirectory()) { + walk(root, abs, out); + } else if (entry.isFile()) { + out.push(rel); + } + } +} diff --git a/plugins/examples/archify/src/load-package.mjs b/plugins/examples/archify/src/load-package.mjs new file mode 100644 index 0000000..cc5f9c7 --- /dev/null +++ b/plugins/examples/archify/src/load-package.mjs @@ -0,0 +1,71 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; + +const REQUIRED = ['brief.yaml', 'architecture.yaml']; +const OPTIONAL = [ + 'package.yaml', + 'sequences.yaml', + 'integrations.yaml', + 'ux-spec.yaml', + 'sky-merits.yaml', + 'agentic-repo-recommendation.yaml', + 'handoff-solution.yaml', + 'delivery-boundaries.yaml', + 'consulting-brief.yaml', +]; + +export function loadPackage(packagePath) { + const abs = path.resolve(packagePath); + if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { + throw new Error(`Package path not found or not a directory: ${abs}`); + } + + const missingRequired = REQUIRED.filter((name) => !fs.existsSync(path.join(abs, name))); + if (missingRequired.length) { + throw new Error( + `Missing required artifacts: ${missingRequired.join(', ')}. ` + + 'Export/validate the Sky package first, then rerun visualize.', + ); + } + + const artifacts = {}; + const missingOptional = []; + + for (const name of REQUIRED) { + artifacts[name] = readYaml(path.join(abs, name)); + } + + for (const name of OPTIONAL) { + const file = path.join(abs, name); + if (fs.existsSync(file)) { + artifacts[name] = readYaml(file); + } else { + missingOptional.push(name); + } + } + + const packageMeta = artifacts['package.yaml'] || {}; + const publicAuthorized = Boolean( + packageMeta.public_showcase_approved === true || + (Array.isArray(packageMeta.gates) && + packageMeta.gates.some((g) => g?.id === 'public_showcase' && g.passed_by)), + ); + + return { + path: abs, + artifacts, + missingOptional, + publicAuthorized, + slug: + packageMeta.package_id || + artifacts['brief.yaml']?.title || + path.basename(abs), + packageVersion: packageMeta.contract_version || packageMeta.profile_version || '0.0.0-fixture', + }; +} + +function readYaml(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + return YAML.parse(raw); +} diff --git a/plugins/examples/archify/src/log.mjs b/plugins/examples/archify/src/log.mjs new file mode 100644 index 0000000..17689ac --- /dev/null +++ b/plugins/examples/archify/src/log.mjs @@ -0,0 +1,9 @@ +export function logStep(step, { json = false } = {}) { + if (json) return; + console.error(`[sky-archify] ${step}`); +} + +export function logWarn(message, { json = false } = {}) { + if (json) return; + console.error(`[sky-archify] warn: ${message}`); +} diff --git a/plugins/examples/archify/src/manifest.mjs b/plugins/examples/archify/src/manifest.mjs new file mode 100644 index 0000000..8e90d72 --- /dev/null +++ b/plugins/examples/archify/src/manifest.mjs @@ -0,0 +1,52 @@ +import YAML from 'yaml'; +import { ADAPTER_VERSION } from './normalize-ir.mjs'; + +export function buildManifest({ + packageSlug, + packageHash, + generatedAt, + archifyVersion, + viewsRequested, + outputs, + inferences, + missingOptional, + publicAuthorized, + warnings = [], +}) { + return { + adapter: { + id: 'archify-visualization', + version: ADAPTER_VERSION, + }, + renderer: { + name: 'archify', + version: archifyVersion, + }, + source: { + package_slug: packageSlug, + package_hash: packageHash, + generated_at: generatedAt, + public_authorized: Boolean(publicAuthorized), + }, + views_requested: viewsRequested, + outputs, + inferences: inferences.map((i) => ({ + id: i.id, + subject: i.subject, + detail: i.detail, + })), + missing_optional_artifacts: missingOptional, + warnings, + privacy: { + default: 'private', + inherited_public_authorization: Boolean(publicAuthorized), + note: publicAuthorized + ? 'Source package marks public showcase approval.' + : 'HTML outputs remain private by default.', + }, + }; +} + +export function stringifyManifest(manifest) { + return YAML.stringify(manifest, { lineWidth: 100 }); +} diff --git a/plugins/examples/archify/src/mappers/architecture.mjs b/plugins/examples/archify/src/mappers/architecture.mjs new file mode 100644 index 0000000..7610b49 --- /dev/null +++ b/plugins/examples/archify/src/mappers/architecture.mjs @@ -0,0 +1,128 @@ +/** Map Sky Architecture IR kinds to Archify componentType values. */ +export function toArchifyType(kind) { + switch (kind) { + case 'person': + case 'external': + return 'external'; + case 'database': + return 'database'; + case 'queue': + return 'messagebus'; + case 'policy': + return 'security'; + case 'agent': + return 'backend'; + case 'container': + return 'frontend'; + case 'artifact': + return 'cloud'; + case 'system': + case 'component': + default: + return 'backend'; + } +} + +export function mapArchitecture(ir, { qualityProfile = 'standard' } = {}) { + const view = ir.views.find((v) => v.type === 'architecture') || ir.views[0]; + const include = new Set(view?.include || ir.elements.map((e) => e.id)); + const elements = ir.elements.filter((e) => include.has(e.id)).slice(0, 12); + + const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(elements.length)))); + const components = elements.map((el, index) => { + const row = Math.floor(index / cols); + const col = index % cols; + const inferred = el.confidence === 'inferred'; + return { + id: el.id, + type: toArchifyType(el.kind), + label: el.name, + sublabel: el.technology || el.role || undefined, + tag: inferred ? 'inferred' : undefined, + row, + col, + }; + }); + + const idSet = new Set(components.map((c) => c.id)); + const byId = new Map(components.map((c) => [c.id, c])); + const connections = (ir.relationships || []) + .filter((rel) => idSet.has(rel.source) && idSet.has(rel.target)) + .filter((rel) => (rel.source_refs || []).some((ref) => String(ref).startsWith('architecture.yaml'))) + .map((rel) => { + const from = byId.get(rel.source); + const to = byId.get(rel.target); + const sameRow = from && to && from.row === to.row; + const sameCol = from && to && from.col === to.col; + const connection = { + from: rel.source, + to: rel.target, + // Keep edge labels short; long labels move to cards to avoid Archify layout collisions. + label: String(rel.label || '').length > 12 ? undefined : rel.label, + variant: rel.confidence === 'inferred' ? 'dashed' : 'default', + labelDy: sameRow ? 54 : sameCol ? 28 : 40, + }; + if (sameCol) { + connection.fromSide = 'bottom'; + connection.toSide = 'top'; + connection.labelDx = 64; + } + return connection; + }); + + const boundaries = (ir.boundaries || []) + .map((b) => ({ + kind: b.kind === 'trust' || b.kind === 'network' ? 'security-group' : 'region', + label: b.name, + wraps: (b.members || []).filter((id) => idSet.has(id)), + })) + .filter((b) => b.wraps.length > 0); + + const inferredCards = ir.elements + .filter((e) => e.confidence === 'inferred') + .slice(0, 4) + .map((e) => `${e.name} (${e.id})`); + + return { + schema_version: 1, + diagram_type: 'architecture', + meta: { + title: view?.name || ir.system.name, + subtitle: `${ir.system.name} · quality=${qualityProfile}`, + output: 'system.architecture.html', + }, + layout: { + mode: 'grid', + origin: [40, 100], + cols, + gapX: 28, + gapY: 48, + cellW: 140, + cellH: 64, + }, + components, + boundaries, + connections, + cards: [ + { + dot: 'cyan', + title: 'Scope', + items: [ir.system.description, view?.question_answered].filter(Boolean).slice(0, 3), + }, + { + dot: 'emerald', + title: 'Primary components', + items: components.slice(0, 4).map((c) => c.label), + }, + ...(inferredCards.length + ? [ + { + dot: 'orange', + title: 'Inferred (explicit)', + items: inferredCards, + }, + ] + : []), + ], + }; +} diff --git a/plugins/examples/archify/src/mappers/sequence.mjs b/plugins/examples/archify/src/mappers/sequence.mjs new file mode 100644 index 0000000..12e70b1 --- /dev/null +++ b/plugins/examples/archify/src/mappers/sequence.mjs @@ -0,0 +1,77 @@ +import { toArchifyType } from './architecture.mjs'; + +export function mapSequence(ir, { qualityProfile = 'standard' } = {}) { + const view = ir.views.find((v) => v.type === 'sequence'); + if (!view) return null; + + const include = new Set(view.include || []); + const participants = ir.elements + .filter((e) => include.has(e.id)) + .map((el) => ({ + id: el.id, + type: toArchifyType(el.kind), + label: el.name, + sublabel: el.description || el.technology || undefined, + })); + + if (participants.length < 2) return null; + + const messagesSource = (ir.relationships || []).filter( + (rel) => include.has(rel.source) && include.has(rel.target), + ); + if (!messagesSource.length) return null; + + const startY = 180; + const gap = 42; + const messages = messagesSource.map((rel, index) => ({ + from: rel.source, + to: rel.target, + y: startY + index * gap, + label: rel.label, + variant: rel.confidence === 'inferred' ? 'dashed' : index === 0 ? 'emphasis' : 'default', + })); + + const lastY = messages[messages.length - 1].y; + const activations = participants.slice(0, 4).map((p, index) => ({ + participant: p.id, + from: startY - 10 + index * 4, + to: lastY + 20, + type: p.type, + })); + + const segments = [ + { from: startY - 30, to: startY + Math.floor(messages.length / 2) * gap, label: 'Request' }, + { + from: startY + Math.floor(messages.length / 2) * gap + 10, + to: lastY + 30, + label: 'Response', + }, + ]; + + return { + schema_version: 1, + diagram_type: 'sequence', + meta: { + title: view.name, + subtitle: `${ir.system.name} · quality=${qualityProfile}`, + output: 'critical-path.sequence.html', + viewBox: [Math.max(720, 120 + participants.length * 110), Math.max(520, lastY + 160)], + }, + participants, + segments, + messages, + activations, + cards: [ + { + dot: 'cyan', + title: 'Question', + items: [view.question_answered], + }, + { + dot: 'emerald', + title: 'Path', + items: messages.slice(0, 4).map((m) => `${m.from} → ${m.to}: ${m.label}`), + }, + ], + }; +} diff --git a/plugins/examples/archify/src/mappers/workflow.mjs b/plugins/examples/archify/src/mappers/workflow.mjs new file mode 100644 index 0000000..095ed05 --- /dev/null +++ b/plugins/examples/archify/src/mappers/workflow.mjs @@ -0,0 +1,136 @@ +import { toArchifyType } from './architecture.mjs'; + +/** Archify workflow columns are limited to 0..5; keep nodes sparse enough for layout checks. */ +const COL_SLOTS = [0, 2, 4, 5]; + +export function mapWorkflow(ir, { qualityProfile = 'standard' } = {}) { + const view = ir.views.find((v) => v.type === 'workflow'); + if (!view) return null; + + const include = new Set(view.include || []); + const nodesSource = ir.elements.filter((e) => include.has(e.id)); + if (!nodesSource.length) return null; + + const gates = nodesSource.filter((n) => (n.tags || []).includes('gate') || n.kind === 'policy'); + const milestones = nodesSource.filter((n) => (n.tags || []).includes('milestone')); + const working = (gates.length ? gates : nodesSource).slice(0, COL_SLOTS.length); + + const lanes = [ + { id: 'gates', label: 'Package gates' }, + { id: 'delivery', label: 'Delivery notes' }, + ]; + + const nodes = working.map((el, index) => ({ + id: el.id, + lane: 'gates', + col: COL_SLOTS[index], + type: toArchifyType(el.kind), + label: shortenLabel(el.name), + sublabel: el.name !== shortenLabel(el.name) ? el.name : el.description || undefined, + tag: el.confidence === 'inferred' ? 'inferred' : undefined, + })); + + // Anchor note for milestones (evidence preserved in cards, not invented edges). + if (milestones.length) { + nodes.push({ + id: milestones[0].id, + lane: 'delivery', + col: COL_SLOTS[Math.min(working.length - 1, COL_SLOTS.length - 1)], + type: toArchifyType(milestones[0].kind), + label: shortenLabel(milestones[0].name), + sublabel: milestones[0].name, + }); + } + + const maxCol = Math.max(...nodes.map((n) => n.col), 0); + const phases = [ + { id: 'start', label: 'Start', fromCol: 0, toCol: Math.min(2, maxCol) }, + { + id: 'middle', + label: 'Progress', + fromCol: Math.min(2, maxCol), + toCol: Math.min(4, maxCol), + variant: 'emphasis', + }, + { + id: 'end', + label: 'Handoff', + fromCol: Math.min(4, maxCol), + toCol: maxCol, + variant: 'dashed', + }, + ]; + + const idSet = new Set(nodes.map((n) => n.id)); + const gateIds = new Set(working.map((n) => n.id)); + const edgeCandidates = (ir.relationships || []).filter( + (rel) => gateIds.has(rel.source) && gateIds.has(rel.target), + ); + + const edges = []; + if (edgeCandidates.length) { + for (const rel of edgeCandidates) { + edges.push({ + from: rel.source, + to: rel.target, + variant: rel.confidence === 'inferred' ? 'dashed' : 'emphasis', + }); + } + } else { + for (let i = 0; i < working.length - 1; i += 1) { + edges.push({ + from: working[i].id, + to: working[i + 1].id, + variant: 'emphasis', + }); + } + } + + const mainPath = (view.primary_path || working.map((n) => n.id)).filter((id) => idSet.has(id)); + + return { + schema_version: 1, + diagram_type: 'workflow', + meta: { + title: view.name, + subtitle: `${ir.system.name} · quality=${qualityProfile}`, + output: 'delivery.workflow.html', + viewBox: [960, 720], + }, + lanes, + phases, + groups: [], + mainPath: mainPath.length ? mainPath : working.map((n) => n.id), + nodes, + edges, + cards: [ + { + dot: 'cyan', + title: 'Question', + items: [view.question_answered], + }, + { + dot: 'emerald', + title: 'Gates', + items: working.map((n) => n.name), + }, + ...(milestones.length + ? [ + { + dot: 'orange', + title: 'Milestones (evidence)', + items: milestones.map((m) => m.name).slice(0, 4), + }, + ] + : []), + ], + }; +} + +function shortenLabel(value) { + const text = String(value || '') + .replace(/^gate-/i, '') + .replace(/_/g, ' '); + if (text.length <= 14) return text; + return `${text.slice(0, 13)}…`; +} diff --git a/plugins/examples/archify/src/normalize-ir.mjs b/plugins/examples/archify/src/normalize-ir.mjs new file mode 100644 index 0000000..f3c0ad7 --- /dev/null +++ b/plugins/examples/archify/src/normalize-ir.mjs @@ -0,0 +1,349 @@ +import { sanitizeDeep, sanitizeText } from './sanitize.mjs'; + +const ADAPTER_VERSION = '0.1.0'; +const KIND_ENUM = new Set([ + 'person', + 'system', + 'container', + 'component', + 'agent', + 'database', + 'queue', + 'external', + 'policy', + 'artifact', +]); + +export function normalizeIr(pkg, { generatedAt, qualityProfile = 'standard', visualPreset = 'classic' } = {}) { + const brief = pkg.artifacts['brief.yaml']; + const architecture = pkg.artifacts['architecture.yaml']; + const inferences = []; + const elements = []; + const relationships = []; + const boundaries = []; + const views = []; + + const systemId = slugify(brief.title || pkg.slug || 'system'); + const components = Array.isArray(architecture.components) ? architecture.components : []; + + for (const component of components) { + if (!component?.id) continue; + const kind = mapKind(component); + if (kind.inferred) { + inferences.push({ + id: `kind:${component.id}`, + subject: component.id, + detail: `Mapped kind "${kind.value}" from tech/role heuristics`, + }); + } + elements.push({ + id: ensureId(component.id), + name: sanitizeText(component.name || component.id), + kind: kind.value, + description: sanitizeText(component.role || ''), + technology: sanitizeText(component.tech || ''), + role: sanitizeText(component.role || ''), + confidence: 'confirmed', + source_refs: [`architecture.yaml#/components/${component.id}`], + tags: kind.inferred ? ['kind_inferred_from_tech'] : [], + }); + } + + // Explicit relationships only — never invent topology from prose. + const explicitRels = architecture.relationships || architecture.connections || []; + for (const rel of explicitRels) { + if (!rel?.id || !rel?.source || !rel?.target) continue; + const confidence = rel.confidence || 'confirmed'; + const sourceRefs = Array.isArray(rel.source_refs) && rel.source_refs.length + ? rel.source_refs + : [`architecture.yaml#/relationships/${rel.id}`]; + if (confidence === 'inferred') { + inferences.push({ + id: `rel:${rel.id}`, + subject: rel.id, + detail: sanitizeText(rel.label || 'inferred relationship'), + }); + } + relationships.push({ + id: ensureId(rel.id), + source: ensureId(rel.source), + target: ensureId(rel.target), + label: sanitizeText(rel.label || 'uses'), + protocol: sanitizeText(rel.protocol || ''), + data_classification: sanitizeText(rel.data_classification || ''), + confidence, + source_refs: sourceRefs, + }); + } + + const explicitBoundaries = architecture.boundaries || []; + for (const boundary of explicitBoundaries) { + if (!boundary?.id) continue; + boundaries.push({ + id: ensureId(boundary.id), + name: sanitizeText(boundary.name || boundary.id), + kind: boundary.kind || 'system', + members: (boundary.members || []).map(ensureId), + source_refs: boundary.source_refs || [`architecture.yaml#/boundaries/${boundary.id}`], + }); + } + + const primaryIds = components.slice(0, 12).map((c) => ensureId(c.id)); + views.push({ + id: 'system-architecture', + name: 'System architecture', + type: 'architecture', + question_answered: sanitizeText( + architecture.approach || + brief.desired_outcome || + 'What are the primary runtime components of the solution?', + ), + include: primaryIds, + primary_path: primaryIds.slice(0, Math.min(primaryIds.length, 6)), + quality_profile: qualityProfile === 'showcase' ? 'showcase' : 'standard', + visual_preset: ['classic', 'signal-flow', 'blueprint'].includes(visualPreset) + ? visualPreset + : 'classic', + }); + + const workflowEvidence = collectWorkflowEvidence(pkg); + if (workflowEvidence) { + for (const node of workflowEvidence.nodes) { + if (elements.some((e) => e.id === node.id)) continue; + elements.push(node); + if (node.confidence === 'inferred') { + inferences.push({ + id: `workflow-node:${node.id}`, + subject: node.id, + detail: node.description || node.name, + }); + } + } + for (const rel of workflowEvidence.relationships) { + relationships.push(rel); + if (rel.confidence === 'inferred') { + inferences.push({ + id: `workflow-rel:${rel.id}`, + subject: rel.id, + detail: rel.label, + }); + } + } + views.push({ + id: 'delivery-workflow', + name: 'Delivery workflow', + type: 'workflow', + question_answered: 'How does the package move through human gates to handoff?', + include: workflowEvidence.nodes.map((n) => n.id), + primary_path: workflowEvidence.mainPath, + quality_profile: qualityProfile === 'showcase' ? 'showcase' : 'standard', + visual_preset: 'classic', + }); + } + + const sequenceEvidence = collectSequenceEvidence(pkg); + if (sequenceEvidence) { + for (const node of sequenceEvidence.participants) { + if (elements.some((e) => e.id === node.id)) continue; + elements.push(node); + } + for (const rel of sequenceEvidence.relationships) { + relationships.push(rel); + } + views.push({ + id: 'critical-path-sequence', + name: 'Critical path sequence', + type: 'sequence', + question_answered: sequenceEvidence.question, + include: sequenceEvidence.participants.map((p) => p.id), + primary_path: sequenceEvidence.mainPath, + quality_profile: qualityProfile === 'showcase' ? 'showcase' : 'standard', + visual_preset: 'classic', + }); + } + + const ir = { + metadata: { + schema_version: '0.1.0', + package_slug: String(pkg.slug), + generated_at: generatedAt || new Date().toISOString(), + source_package_version: String(pkg.packageVersion), + generator_version: ADAPTER_VERSION, + }, + system: { + id: systemId, + name: sanitizeText(brief.title || pkg.slug), + description: sanitizeText(brief.intent || brief.desired_outcome || ''), + scope: sanitizeText(architecture.approach || ''), + }, + elements, + relationships, + boundaries, + views, + }; + + return { + ir: sanitizeDeep(ir), + inferences, + workflowEvidence: Boolean(workflowEvidence), + sequenceEvidence: Boolean(sequenceEvidence), + }; +} + +function collectWorkflowEvidence(pkg) { + const packageMeta = pkg.artifacts['package.yaml']; + const handoff = pkg.artifacts['handoff-solution.yaml']; + const gates = Array.isArray(packageMeta?.gates) ? packageMeta.gates : []; + const milestones = Array.isArray(handoff?.implementation_plan?.milestones) + ? handoff.implementation_plan.milestones + : []; + + if (!gates.length && !milestones.length) return null; + + const nodes = []; + const relationships = []; + const mainPath = []; + + if (gates.length) { + for (const gate of gates) { + const id = ensureId(`gate-${gate.id}`); + nodes.push({ + id, + name: sanitizeText(gate.id), + kind: 'policy', + description: sanitizeText(gate.notes || `Gate passed by ${gate.passed_by || 'unknown'}`), + confidence: 'confirmed', + source_refs: [`package.yaml#/gates/${gate.id}`], + tags: ['workflow', 'gate'], + }); + mainPath.push(id); + } + for (let i = 0; i < gates.length - 1; i += 1) { + const from = ensureId(`gate-${gates[i].id}`); + const to = ensureId(`gate-${gates[i + 1].id}`); + relationships.push({ + id: ensureId(`flow-${gates[i].id}-to-${gates[i + 1].id}`), + source: from, + target: to, + label: 'then', + confidence: 'confirmed', + source_refs: [`package.yaml#/gates/${gates[i].id}`, `package.yaml#/gates/${gates[i + 1].id}`], + }); + } + } + + if (milestones.length) { + for (const milestone of milestones) { + const id = ensureId(`ms-${milestone.id}`); + nodes.push({ + id, + name: sanitizeText(milestone.name || milestone.id), + kind: 'artifact', + description: sanitizeText(milestone.outcome || ''), + confidence: 'confirmed', + source_refs: [`handoff-solution.yaml#/implementation_plan/milestones/${milestone.id}`], + tags: ['workflow', 'milestone'], + }); + } + } + + return { nodes, relationships, mainPath: mainPath.length ? mainPath : nodes.map((n) => n.id) }; +} + +function collectSequenceEvidence(pkg) { + const sequences = pkg.artifacts['sequences.yaml']; + if (!sequences) return null; + + const list = Array.isArray(sequences.sequences) + ? sequences.sequences + : Array.isArray(sequences) + ? sequences + : sequences.critical_path + ? [sequences.critical_path] + : []; + + const chosen = list[0]; + if (!chosen) return null; + + const participantsRaw = chosen.participants || chosen.actors || []; + const steps = chosen.steps || chosen.messages || []; + if (!participantsRaw.length || !steps.length) return null; + + const participants = participantsRaw.map((p) => { + const id = ensureId(typeof p === 'string' ? p : p.id); + return { + id, + name: sanitizeText(typeof p === 'string' ? p : p.name || p.id), + kind: mapParticipantKind(p), + description: sanitizeText(typeof p === 'string' ? '' : p.role || ''), + confidence: 'confirmed', + source_refs: [`sequences.yaml#/participants/${id}`], + tags: ['sequence'], + }; + }); + + const relationships = []; + steps.forEach((step, index) => { + const from = ensureId(step.from || step.source); + const to = ensureId(step.to || step.target); + if (!from || !to) return; + relationships.push({ + id: ensureId(step.id || `msg-${index + 1}`), + source: from, + target: to, + label: sanitizeText(step.label || step.message || `step ${index + 1}`), + confidence: 'confirmed', + source_refs: [`sequences.yaml#/steps/${index}`], + }); + }); + + return { + participants, + relationships, + mainPath: participants.map((p) => p.id), + question: sanitizeText(chosen.question || chosen.name || 'What is the critical interaction path?'), + title: sanitizeText(chosen.name || 'Critical path'), + raw: chosen, + }; +} + +function mapKind(component) { + const tech = `${component.tech || ''} ${component.role || ''} ${component.name || ''}`.toLowerCase(); + if (/\b(person|human|user|steward)\b/.test(tech)) return { value: 'person', inferred: true }; + if (/\b(postgres|sqlite|redis|database|db|ledger|append-only)\b/.test(tech)) { + return { value: 'database', inferred: true }; + } + if (/\b(queue|sqs|kafka|bus)\b/.test(tech)) return { value: 'queue', inferred: true }; + if (/\b(agent|harness)\b/.test(tech)) return { value: 'agent', inferred: true }; + if (/\b(policy|gate|approval|auth)\b/.test(tech)) return { value: 'policy', inferred: true }; + if (/\b(git|repo|arquivo|file|yaml|schema)\b/.test(tech)) return { value: 'artifact', inferred: true }; + if (/\b(next\.js|ui|frontend|react)\b/.test(tech)) return { value: 'container', inferred: true }; + if (/\b(external|third.party|provider)\b/.test(tech)) return { value: 'external', inferred: true }; + if (KIND_ENUM.has(component.kind)) return { value: component.kind, inferred: false }; + return { value: 'component', inferred: true }; +} + +function mapParticipantKind(p) { + if (typeof p !== 'string' && KIND_ENUM.has(p.kind)) return p.kind; + const text = `${typeof p === 'string' ? p : `${p.name || ''} ${p.role || ''}`}`.toLowerCase(); + if (/\b(user|person|human)\b/.test(text)) return 'person'; + if (/\b(db|database|redis|postgres)\b/.test(text)) return 'database'; + if (/\b(api|service|backend)\b/.test(text)) return 'container'; + return 'component'; +} + +function ensureId(value) { + const raw = String(value || '') + .trim() + .replace(/[^a-zA-Z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!raw) return 'item'; + if (/^[0-9]/.test(raw)) return `n-${raw}`; + return raw; +} + +function slugify(value) { + return ensureId(String(value || 'system').toLowerCase()); +} + +export { ADAPTER_VERSION }; diff --git a/plugins/examples/archify/src/resolve-archify.mjs b/plugins/examples/archify/src/resolve-archify.mjs new file mode 100644 index 0000000..671da8c --- /dev/null +++ b/plugins/examples/archify/src/resolve-archify.mjs @@ -0,0 +1,90 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.resolve(__dirname, '..'); +const lockPath = path.join(pluginRoot, 'ARCHIFY.lock.json'); + +export function loadArchifyLock() { + return JSON.parse(fs.readFileSync(lockPath, 'utf8')); +} + +/** + * Resolution order: + * 1. ARCHIFY_HOME + * 2. plugin .vendor/archify (bootstrapped) + * 3. actionable bootstrap error + */ +export function resolveArchifyHome({ allowMissing = false } = {}) { + const lock = loadArchifyLock(); + const envHome = process.env.ARCHIFY_HOME; + const candidates = []; + + if (envHome) { + candidates.push(path.resolve(envHome)); + } + + candidates.push(path.join(pluginRoot, '.vendor', 'archify', lock.skill_relative_path)); + candidates.push(path.join(pluginRoot, '.vendor', 'archify')); + + for (const candidate of candidates) { + const cli = path.join(candidate, 'bin', 'archify.mjs'); + if (fs.existsSync(cli)) { + return { + home: candidate, + cli, + version: readArchifyVersion(candidate) || lock.version, + lock, + }; + } + } + + if (allowMissing) { + return { home: null, cli: null, version: lock.version, lock }; + } + + throw new Error( + [ + 'Archify runtime not found.', + 'Set ARCHIFY_HOME to a pinned Archify skill directory, or bootstrap explicitly:', + ' node plugins/examples/archify/scripts/bootstrap-archify.mjs', + `Expected pin: ${lock.package}@${lock.version} (${lock.commit})`, + ].join('\n'), + ); +} + +export function readArchifyVersion(home) { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(home, 'package.json'), 'utf8')); + return pkg.version; + } catch { + return null; + } +} + +export function runArchify(cli, args, { json = false } = {}) { + const result = spawnSync(process.execPath, [cli, ...args], { + encoding: 'utf8', + cwd: path.dirname(cli), + }); + + if (result.error) { + throw new Error(`Failed to spawn Archify: ${result.error.message}`); + } + + const stdout = result.stdout || ''; + const stderr = result.stderr || ''; + if (!json) { + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + } + + return { + status: result.status ?? 1, + stdout, + stderr, + ok: (result.status ?? 1) === 0, + }; +} diff --git a/plugins/examples/archify/src/sanitize.mjs b/plugins/examples/archify/src/sanitize.mjs new file mode 100644 index 0000000..02f94c4 --- /dev/null +++ b/plugins/examples/archify/src/sanitize.mjs @@ -0,0 +1,37 @@ +const SECRET_PATTERNS = [ + /(api[_-]?key|token|secret|password|passwd|authorization)\s*[:=]\s*['"]?([^\s'"]+)/gi, + /(Bearer)\s+[A-Za-z0-9\-._~+/]+=*/gi, + /(postgres|mysql|mongodb|redis):\/\/[^\s'"]+/gi, + /([?&](?:access_token|refresh_token|id_token|sig|signature|key)=)[^&\s'"]+/gi, +]; + +export function sanitizeText(value) { + if (value == null) return value; + let text = String(value); + for (const pattern of SECRET_PATTERNS) { + text = text.replace(pattern, (match, p1) => { + if (/^Bearer$/i.test(p1)) return 'Bearer [REDACTED]'; + if (/:\/\//.test(match)) { + return match.replace(/:\/\/.+$/, '://[REDACTED]'); + } + if (typeof p1 === 'string' && p1.startsWith('?') || (typeof p1 === 'string' && p1.startsWith('&'))) { + return `${p1}[REDACTED]`; + } + return `${p1}=[REDACTED]`; + }); + } + return text; +} + +export function sanitizeDeep(value) { + if (Array.isArray(value)) return value.map(sanitizeDeep); + if (value && typeof value === 'object') { + const out = {}; + for (const [key, nested] of Object.entries(value)) { + out[key] = sanitizeDeep(nested); + } + return out; + } + if (typeof value === 'string') return sanitizeText(value); + return value; +} diff --git a/plugins/examples/archify/src/validate-ir.mjs b/plugins/examples/archify/src/validate-ir.mjs new file mode 100644 index 0000000..5cee908 --- /dev/null +++ b/plugins/examples/archify/src/validate-ir.mjs @@ -0,0 +1,144 @@ +const RENDERER_FIELDS = new Set([ + 'pos', + 'size', + 'row', + 'col', + 'x', + 'y', + 'color', + 'colour', + 'fill', + 'stroke', + 'svg', + 'labelAt', + 'labelDx', + 'labelDy', + 'fromSide', + 'toSide', + 'route', +]); + +const ELEMENT_KINDS = new Set([ + 'person', + 'system', + 'container', + 'component', + 'agent', + 'database', + 'queue', + 'external', + 'policy', + 'artifact', +]); + +const CONFIDENCE = new Set(['confirmed', 'inferred', 'unknown']); +const VIEW_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']); + +export function validateIr(ir) { + const errors = []; + const warnings = []; + + if (!ir || typeof ir !== 'object') { + return { ok: false, errors: ['IR must be an object'], warnings }; + } + + if (!ir.metadata?.schema_version) errors.push('metadata.schema_version is required'); + if (!ir.metadata?.package_slug) errors.push('metadata.package_slug is required'); + if (!ir.metadata?.generated_at) errors.push('metadata.generated_at is required'); + if (!ir.metadata?.source_package_version) errors.push('metadata.source_package_version is required'); + if (!ir.metadata?.generator_version) errors.push('metadata.generator_version is required'); + + if (!ir.system?.id || !ir.system?.name || !ir.system?.description) { + errors.push('system.id, system.name and system.description are required'); + } + + if (!Array.isArray(ir.elements)) errors.push('elements must be a list'); + if (!Array.isArray(ir.relationships)) errors.push('relationships must be a list'); + if (!Array.isArray(ir.views)) errors.push('views must be a list'); + + const elementIds = new Set(); + for (const el of ir.elements || []) { + if (!el?.id) { + errors.push('element missing id'); + continue; + } + if (elementIds.has(el.id)) errors.push(`duplicate element id: ${el.id}`); + elementIds.add(el.id); + if (!el.name) errors.push(`element ${el.id} missing name`); + if (!ELEMENT_KINDS.has(el.kind)) errors.push(`element ${el.id} has invalid kind: ${el.kind}`); + if (!CONFIDENCE.has(el.confidence)) errors.push(`element ${el.id} has invalid confidence`); + if (!Array.isArray(el.source_refs)) errors.push(`element ${el.id} source_refs must be a list`); + if (el.confidence === 'confirmed' && (!el.source_refs || el.source_refs.length === 0)) { + errors.push(`confirmed element ${el.id} requires at least one source_ref`); + } + collectRendererFields(el, `element ${el.id}`, errors); + } + + const relationshipIds = new Set(); + for (const rel of ir.relationships || []) { + if (!rel?.id) { + errors.push('relationship missing id'); + continue; + } + if (relationshipIds.has(rel.id)) errors.push(`duplicate relationship id: ${rel.id}`); + relationshipIds.add(rel.id); + if (!elementIds.has(rel.source)) errors.push(`relationship ${rel.id} source missing: ${rel.source}`); + if (!elementIds.has(rel.target)) errors.push(`relationship ${rel.id} target missing: ${rel.target}`); + if (!rel.label) errors.push(`relationship ${rel.id} missing label`); + if (!CONFIDENCE.has(rel.confidence)) errors.push(`relationship ${rel.id} has invalid confidence`); + if (!Array.isArray(rel.source_refs)) errors.push(`relationship ${rel.id} source_refs must be a list`); + if (rel.confidence === 'confirmed' && (!rel.source_refs || rel.source_refs.length === 0)) { + errors.push(`confirmed relationship ${rel.id} requires at least one source_ref`); + } + collectRendererFields(rel, `relationship ${rel.id}`, errors); + } + + const boundaryIds = new Set(); + for (const boundary of ir.boundaries || []) { + if (!boundary?.id) { + errors.push('boundary missing id'); + continue; + } + if (boundaryIds.has(boundary.id)) errors.push(`duplicate boundary id: ${boundary.id}`); + boundaryIds.add(boundary.id); + for (const member of boundary.members || []) { + if (!elementIds.has(member)) errors.push(`boundary ${boundary.id} unknown member: ${member}`); + } + collectRendererFields(boundary, `boundary ${boundary.id}`, errors); + } + + const viewIds = new Set(); + for (const view of ir.views || []) { + if (!view?.id) { + errors.push('view missing id'); + continue; + } + if (viewIds.has(view.id)) errors.push(`duplicate view id: ${view.id}`); + viewIds.add(view.id); + if (!VIEW_TYPES.has(view.type)) errors.push(`view ${view.id} has invalid type: ${view.type}`); + if (!view.name) errors.push(`view ${view.id} missing name`); + if (!view.question_answered || !String(view.question_answered).trim()) { + errors.push(`view ${view.id} missing question_answered`); + } + if (!Array.isArray(view.include) || view.include.length === 0) { + errors.push(`view ${view.id} include list is empty`); + } + for (const id of view.include || []) { + if (!elementIds.has(id)) errors.push(`view ${view.id} include unknown id: ${id}`); + } + for (const id of view.primary_path || []) { + if (!elementIds.has(id)) errors.push(`view ${view.id} primary_path unknown id: ${id}`); + } + collectRendererFields(view, `view ${view.id}`, errors); + } + + return { ok: errors.length === 0, errors, warnings }; +} + +function collectRendererFields(obj, label, errors) { + for (const key of Object.keys(obj || {})) { + if (RENDERER_FIELDS.has(key)) { + errors.push(`${label} contains renderer-specific field: ${key}`); + } + } +} diff --git a/plugins/examples/archify/tests/contract.test.mjs b/plugins/examples/archify/tests/contract.test.mjs new file mode 100644 index 0000000..0b4eee0 --- /dev/null +++ b/plugins/examples/archify/tests/contract.test.mjs @@ -0,0 +1,213 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import YAML from 'yaml'; + +import { loadPackage } from '../src/load-package.mjs'; +import { normalizeIr } from '../src/normalize-ir.mjs'; +import { validateIr } from '../src/validate-ir.mjs'; +import { mapArchitecture } from '../src/mappers/architecture.mjs'; +import { mapWorkflow } from '../src/mappers/workflow.mjs'; +import { mapSequence } from '../src/mappers/sequence.mjs'; +import { resolveArchifyHome } from '../src/resolve-archify.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtures = path.join(__dirname, 'fixtures'); +const visualize = path.join(__dirname, '..', 'scripts', 'visualize.mjs'); +const FIXED_AT = '2026-07-21T12:00:00.000Z'; + +function runVisualize(packagePath, outputPath, extraArgs = []) { + return spawnSync( + process.execPath, + [ + visualize, + '--package', + packagePath, + '--output', + outputPath, + '--generated-at', + FIXED_AT, + '--json', + ...extraArgs, + ], + { encoding: 'utf8' }, + ); +} + +test('minimal valid package normalizes and validates', () => { + const pkg = loadPackage(path.join(fixtures, 'minimal-valid')); + const { ir } = normalizeIr(pkg, { generatedAt: FIXED_AT }); + const result = validateIr(ir); + assert.equal(result.ok, true, result.errors.join('; ')); + assert.ok(ir.elements.length >= 3); + assert.ok(ir.relationships.length >= 2); + const arch = mapArchitecture(ir); + assert.equal(arch.diagram_type, 'architecture'); + assert.ok(arch.components.length >= 3); +}); + +test('complete valid package produces workflow and sequence evidence', () => { + const pkg = loadPackage(path.join(fixtures, 'complete-valid')); + const { ir, workflowEvidence, sequenceEvidence } = normalizeIr(pkg, { generatedAt: FIXED_AT }); + assert.equal(validateIr(ir).ok, true); + assert.equal(workflowEvidence, true); + assert.equal(sequenceEvidence, true); + assert.ok(mapWorkflow(ir)); + assert.ok(mapSequence(ir)); +}); + +test('missing architecture.yaml fails with actionable error', () => { + assert.throws( + () => loadPackage(path.join(fixtures, 'missing-architecture')), + /Missing required artifacts: architecture\.yaml/, + ); +}); + +test('relationship with missing endpoint fails IR validation', () => { + const pkg = loadPackage(path.join(fixtures, 'bad-relationship')); + const { ir } = normalizeIr(pkg, { generatedAt: FIXED_AT }); + const result = validateIr(ir); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /missing-api/.test(e))); +}); + +test('confirmed element without source_refs fails validation', () => { + const ir = { + metadata: { + schema_version: '0.1.0', + package_slug: 'x', + generated_at: FIXED_AT, + source_package_version: '0.1.0', + generator_version: '0.1.0', + }, + system: { id: 'sys', name: 'Sys', description: 'desc' }, + elements: [ + { + id: 'web', + name: 'Web', + kind: 'container', + confidence: 'confirmed', + source_refs: [], + }, + ], + relationships: [], + boundaries: [], + views: [ + { + id: 'v1', + name: 'V', + type: 'architecture', + question_answered: 'q', + include: ['web'], + quality_profile: 'standard', + visual_preset: 'classic', + }, + ], + }; + const result = validateIr(ir); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /source_ref/.test(e))); +}); + +test('inferred relationship is preserved and listed', () => { + const pkg = loadPackage(path.join(fixtures, 'with-inferred')); + const { ir, inferences } = normalizeIr(pkg, { generatedAt: FIXED_AT }); + assert.equal(validateIr(ir).ok, true); + const rel = ir.relationships.find((r) => r.id === 'likely-call'); + assert.equal(rel.confidence, 'inferred'); + assert.ok(inferences.some((i) => i.subject === 'likely-call')); +}); + +test('sequence is produced when sequences.yaml exists', () => { + const pkg = loadPackage(path.join(fixtures, 'with-sequence')); + const { ir, sequenceEvidence } = normalizeIr(pkg, { generatedAt: FIXED_AT }); + assert.equal(sequenceEvidence, true); + assert.ok(mapSequence(ir)); +}); + +test('sequence is omitted with warning when no evidence', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sky-archify-')); + const result = runVisualize(path.join(fixtures, 'minimal-valid'), tmp, [ + '--views', + 'architecture,sequence', + '--skip-render', + ]); + assert.equal(result.status, 0, result.stderr || result.stdout); + const summary = JSON.parse(result.stdout); + assert.ok(summary.warnings.some((w) => /sequence omitted/.test(w))); + assert.ok(!fs.existsSync(path.join(tmp, 'archify', 'critical-path.sequence.json'))); +}); + +test('output is deterministic for fixed generated-at', () => { + const a = fs.mkdtempSync(path.join(os.tmpdir(), 'sky-archify-a-')); + const b = fs.mkdtempSync(path.join(os.tmpdir(), 'sky-archify-b-')); + const ra = runVisualize(path.join(fixtures, 'minimal-valid'), a, ['--views', 'architecture', '--skip-render']); + const rb = runVisualize(path.join(fixtures, 'minimal-valid'), b, ['--views', 'architecture', '--skip-render']); + assert.equal(ra.status, 0, ra.stderr || ra.stdout); + assert.equal(rb.status, 0, rb.stderr || rb.stdout); + const irA = fs.readFileSync(path.join(a, 'intermediate', 'sky-architecture-ir.yaml'), 'utf8'); + const irB = fs.readFileSync(path.join(b, 'intermediate', 'sky-architecture-ir.yaml'), 'utf8'); + assert.equal(irA, irB); + const jsonA = fs.readFileSync(path.join(a, 'archify', 'system.architecture.json'), 'utf8'); + const jsonB = fs.readFileSync(path.join(b, 'archify', 'system.architecture.json'), 'utf8'); + assert.equal(jsonA, jsonB); +}); + +test('visualize fails non-zero on IR validation errors', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sky-archify-')); + const result = runVisualize(path.join(fixtures, 'bad-relationship'), tmp, ['--skip-render']); + assert.notEqual(result.status, 0); +}); + +test('archify validate/render/check produce HTML when runtime is available', { skip: !resolveArchifyHome({ allowMissing: true }).cli }, () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sky-archify-render-')); + const result = runVisualize(path.join(fixtures, 'minimal-valid'), tmp, ['--views', 'architecture']); + assert.equal(result.status, 0, result.stderr || result.stdout); + const summary = JSON.parse(result.stdout); + assert.equal(summary.ok, true); + const htmlPath = path.join(tmp, 'archify', 'system.architecture.html'); + assert.ok(fs.existsSync(htmlPath)); + const html = fs.readFileSync(htmlPath, 'utf8'); + assert.ok(/]/i.test(html)); + const manifest = YAML.parse(fs.readFileSync(path.join(tmp, 'archify', 'manifest.yaml'), 'utf8')); + assert.equal(manifest.adapter.id, 'archify-visualization'); + assert.ok(manifest.renderer.version); + assert.ok(manifest.outputs[0].html_hash); + assert.equal(manifest.outputs[0].validation, 'passed'); +}); + +test('complete fixture renders workflow HTML when runtime is available', { skip: !resolveArchifyHome({ allowMissing: true }).cli }, () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sky-archify-wf-')); + const result = runVisualize(path.join(fixtures, 'complete-valid'), tmp, [ + '--views', + 'architecture,workflow,sequence', + ]); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(path.join(tmp, 'archify', 'delivery.workflow.html'))); + assert.ok(fs.existsSync(path.join(tmp, 'archify', 'critical-path.sequence.html'))); +}); + +test('archify validation failure propagates non-zero exit', { skip: !resolveArchifyHome({ allowMissing: true }).cli }, () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sky-archify-fail-')); + fs.mkdirSync(path.join(tmp, 'archify'), { recursive: true }); + const badJson = path.join(tmp, 'archify', 'system.architecture.json'); + fs.writeFileSync( + badJson, + JSON.stringify({ + schema_version: 1, + diagram_type: 'architecture', + meta: { title: 'Broken' }, + components: [{ id: 'x', type: 'not-a-real-type', label: 'X', pos: [0, 0] }], + }), + ); + + const archify = resolveArchifyHome(); + const validate = spawnSync(process.execPath, [archify.cli, 'validate', 'architecture', badJson, '--json'], { + encoding: 'utf8', + }); + assert.notEqual(validate.status, 0); +}); diff --git a/plugins/examples/archify/tests/core-agnostic.test.mjs b/plugins/examples/archify/tests/core-agnostic.test.mjs new file mode 100644 index 0000000..c653fac --- /dev/null +++ b/plugins/examples/archify/tests/core-agnostic.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..', '..', '..', '..'); + +test('removing plugins/examples/archify does not break check-core-agnostic', () => { + const script = path.join(repoRoot, 'scripts', 'sky', 'check-core-agnostic.ps1'); + assert.ok(fs.existsSync(script)); + const result = spawnSync('powershell', ['-NoProfile', '-File', script], { + encoding: 'utf8', + cwd: repoRoot, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /check-core-agnostic OK/); +}); + +test('sky visualize wrapper exits gracefully guidance path exists', () => { + const wrapper = path.join(repoRoot, 'scripts', 'sky', 'visualize.ps1'); + assert.ok(fs.existsSync(wrapper)); +}); diff --git a/plugins/examples/archify/tests/fixtures/bad-relationship/architecture.yaml b/plugins/examples/archify/tests/fixtures/bad-relationship/architecture.yaml new file mode 100644 index 0000000..ba0222e --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/bad-relationship/architecture.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-bad-rel +components: + - {id: web, name: "Web", tech: "Next.js", role: "ui"} +relationships: + - {id: web-to-missing, source: web, target: missing-api, label: "calls", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/web-to-missing"]} diff --git a/plugins/examples/archify/tests/fixtures/bad-relationship/brief.yaml b/plugins/examples/archify/tests/fixtures/bad-relationship/brief.yaml new file mode 100644 index 0000000..6d6d5d6 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/bad-relationship/brief.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-bad-rel +title: Bad Relationship +intent: Catch dangling relationship endpoints. +desired_outcome: IR validation failure. +language: pt-BR diff --git a/plugins/examples/archify/tests/fixtures/complete-valid/architecture.yaml b/plugins/examples/archify/tests/fixtures/complete-valid/architecture.yaml new file mode 100644 index 0000000..d2a99af --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/complete-valid/architecture.yaml @@ -0,0 +1,20 @@ +demand_ref: fix-complete-001 +approach: "Repo-backed workspace with explicit topology." +components: + - {id: content-repo, name: "Content repo", tech: "git", role: "source of truth"} + - {id: ui, name: "Workspace UI", tech: "Next.js", role: "operator window"} + - {id: git-layer, name: "Git layer", tech: "TypeScript lib", role: "commits and locks"} + - {id: adapter, name: "Sky adapter", tech: "TS CLI", role: "import Sky packages"} + - {id: state-machine, name: "State machine", tech: "TS pure", role: "valid transitions"} + - {id: ledger, name: "Ledger", tech: "append-only files", role: "audit history"} + - {id: harness-probe, name: "Harness probe", tech: "TS", role: "read harness status"} + - {id: operator, name: "Operator", tech: "human steward", role: "approve gates"} +relationships: + - {id: ui-git, source: ui, target: git-layer, label: "read/write", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/ui-git"]} + - {id: git-repo, source: git-layer, target: content-repo, label: "commits", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/git-repo"]} + - {id: adapter-repo, source: adapter, target: content-repo, label: "import files", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/adapter-repo"]} + - {id: ui-sm, source: ui, target: state-machine, label: "transition request", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/ui-sm"]} + - {id: sm-ledger, source: state-machine, target: ledger, label: "append event", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/sm-ledger"]} + - {id: operator-ui, source: operator, target: ui, label: "approve", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/operator-ui"]} +boundaries: + - {id: workspace, name: "Workspace system", kind: system, members: [ui, git-layer, adapter, state-machine, ledger, harness-probe], source_refs: ["architecture.yaml#/boundaries/workspace"]} diff --git a/plugins/examples/archify/tests/fixtures/complete-valid/brief.yaml b/plugins/examples/archify/tests/fixtures/complete-valid/brief.yaml new file mode 100644 index 0000000..1391705 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/complete-valid/brief.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-complete-001 +title: Complete Valid Package +intent: Cover architecture, workflow gates, and optional sequence. +desired_outcome: All three Archify views when evidence exists. +language: pt-BR diff --git a/plugins/examples/archify/tests/fixtures/complete-valid/handoff-solution.yaml b/plugins/examples/archify/tests/fixtures/complete-valid/handoff-solution.yaml new file mode 100644 index 0000000..10de5ad --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/complete-valid/handoff-solution.yaml @@ -0,0 +1,7 @@ +demand_ref: fix-complete-001 +implementation_plan: + milestones: + - {id: m0, name: "Scaffold", outcome: "contracts green"} + - {id: m1, name: "Domain core", outcome: "state machine + ledger"} + - {id: m2, name: "Views", outcome: "MVP screens"} + - {id: m3, name: "Gates", outcome: "human approvals enforced"} diff --git a/plugins/examples/archify/tests/fixtures/complete-valid/package.yaml b/plugins/examples/archify/tests/fixtures/complete-valid/package.yaml new file mode 100644 index 0000000..d7c4466 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/complete-valid/package.yaml @@ -0,0 +1,8 @@ +package_id: pkg-complete-valid +contract_version: 0.1.0 +profile: consulting-handoff +gates: + - {id: intent_confirmed, passed_by: tester, at: 2026-07-01T10:00:00Z} + - {id: scope_reviewed, passed_by: tester, at: 2026-07-02T10:00:00Z} + - {id: proposal_ready, passed_by: tester, at: 2026-07-03T10:00:00Z} + - {id: handoff_approved, passed_by: tester, at: 2026-07-04T10:00:00Z} diff --git a/plugins/examples/archify/tests/fixtures/complete-valid/sequences.yaml b/plugins/examples/archify/tests/fixtures/complete-valid/sequences.yaml new file mode 100644 index 0000000..66b63cd --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/complete-valid/sequences.yaml @@ -0,0 +1,14 @@ +sequences: + - name: Import package path + question: How does an operator import a Sky package? + participants: + - {id: operator, name: Operator, role: human steward} + - {id: ui, name: Workspace UI, role: import wizard} + - {id: adapter, name: Sky adapter, role: validate and map} + - {id: content-repo, name: Content repo, role: store files} + steps: + - {id: s1, from: operator, to: ui, label: "choose folder"} + - {id: s2, from: ui, to: adapter, label: "validate package"} + - {id: s3, from: adapter, to: content-repo, label: "write artifacts"} + - {id: s4, from: adapter, to: ui, label: "validation report"} + - {id: s5, from: ui, to: operator, label: "confirm import"} diff --git a/plugins/examples/archify/tests/fixtures/confirmed-no-refs/architecture.yaml b/plugins/examples/archify/tests/fixtures/confirmed-no-refs/architecture.yaml new file mode 100644 index 0000000..5dc6156 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/confirmed-no-refs/architecture.yaml @@ -0,0 +1,3 @@ +demand_ref: fix-no-refs +components: + - {id: web, name: "Web", tech: "Next.js", role: "ui"} diff --git a/plugins/examples/archify/tests/fixtures/confirmed-no-refs/brief.yaml b/plugins/examples/archify/tests/fixtures/confirmed-no-refs/brief.yaml new file mode 100644 index 0000000..8340466 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/confirmed-no-refs/brief.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-no-refs +title: Confirmed Without Refs +intent: Block confirmed elements lacking evidence. +desired_outcome: IR validation failure. +language: pt-BR diff --git a/plugins/examples/archify/tests/fixtures/minimal-valid/architecture.yaml b/plugins/examples/archify/tests/fixtures/minimal-valid/architecture.yaml new file mode 100644 index 0000000..b78bf04 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/minimal-valid/architecture.yaml @@ -0,0 +1,11 @@ +demand_ref: fix-min-001 +approach: "Two containers and one store with an explicit relationship." +components: + - {id: web, name: "Web UI", tech: "Next.js", role: "user interface"} + - {id: api, name: "API", tech: "Node service", role: "application API"} + - {id: db, name: "Database", tech: "postgres", role: "system of record"} +relationships: + - {id: web-to-api, source: web, target: api, label: "HTTPS", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/web-to-api"]} + - {id: api-to-db, source: api, target: db, label: "SQL", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/api-to-db"]} +boundaries: + - {id: system-boundary, name: "Application boundary", kind: system, members: [web, api, db], source_refs: ["architecture.yaml#/boundaries/system-boundary"]} diff --git a/plugins/examples/archify/tests/fixtures/minimal-valid/brief.yaml b/plugins/examples/archify/tests/fixtures/minimal-valid/brief.yaml new file mode 100644 index 0000000..cf9df4f --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/minimal-valid/brief.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-min-001 +title: Minimal Valid Package +intent: Prove architecture visualization from a tiny confirmed package. +desired_outcome: Architecture IR and Archify architecture diagram. +language: pt-BR diff --git a/plugins/examples/archify/tests/fixtures/missing-architecture/brief.yaml b/plugins/examples/archify/tests/fixtures/missing-architecture/brief.yaml new file mode 100644 index 0000000..e86e16a --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/missing-architecture/brief.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-missing-arch +title: Missing Architecture +intent: Fail closed when architecture.yaml is absent. +desired_outcome: Actionable error. +language: pt-BR diff --git a/plugins/examples/archify/tests/fixtures/with-inferred/architecture.yaml b/plugins/examples/archify/tests/fixtures/with-inferred/architecture.yaml new file mode 100644 index 0000000..1cf48ed --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/with-inferred/architecture.yaml @@ -0,0 +1,6 @@ +demand_ref: fix-inferred +components: + - {id: web, name: "Web", tech: "Next.js", role: "ui"} + - {id: api, name: "API", tech: "Node", role: "backend"} +relationships: + - {id: likely-call, source: web, target: api, label: "likely HTTPS", confidence: inferred, source_refs: ["architecture.yaml#/relationships/likely-call"]} diff --git a/plugins/examples/archify/tests/fixtures/with-inferred/brief.yaml b/plugins/examples/archify/tests/fixtures/with-inferred/brief.yaml new file mode 100644 index 0000000..640e415 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/with-inferred/brief.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-inferred +title: With Inferred Relationship +intent: Preserve inferred relationships and list them in the manifest. +desired_outcome: Manifest inferences populated. +language: pt-BR diff --git a/plugins/examples/archify/tests/fixtures/with-secrets/architecture.yaml b/plugins/examples/archify/tests/fixtures/with-secrets/architecture.yaml new file mode 100644 index 0000000..689ac6e --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/with-secrets/architecture.yaml @@ -0,0 +1,6 @@ +demand_ref: fix-secrets +components: + - {id: api, name: "API", tech: "Node", role: "token=super-secret-value Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc"} + - {id: db, name: "DB", tech: "postgres://user:pass@host/db", role: "primary store"} +relationships: + - {id: api-db, source: api, target: db, label: "sql?access_token=abc123", confidence: confirmed, source_refs: ["architecture.yaml#/relationships/api-db"]} diff --git a/plugins/examples/archify/tests/fixtures/with-secrets/brief.yaml b/plugins/examples/archify/tests/fixtures/with-secrets/brief.yaml new file mode 100644 index 0000000..ae458f1 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/with-secrets/brief.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-secrets +title: Sanitization Package +intent: Ensure secrets never reach diagram labels. +desired_outcome: Redacted strings in IR and Archify JSON. +language: pt-BR diff --git a/plugins/examples/archify/tests/fixtures/with-sequence/architecture.yaml b/plugins/examples/archify/tests/fixtures/with-sequence/architecture.yaml new file mode 100644 index 0000000..bef76af --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/with-sequence/architecture.yaml @@ -0,0 +1,4 @@ +demand_ref: fix-seq +components: + - {id: client, name: "Client", tech: "browser", role: "caller"} + - {id: api, name: "API", tech: "service", role: "callee"} diff --git a/plugins/examples/archify/tests/fixtures/with-sequence/brief.yaml b/plugins/examples/archify/tests/fixtures/with-sequence/brief.yaml new file mode 100644 index 0000000..0632e94 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/with-sequence/brief.yaml @@ -0,0 +1,5 @@ +demand_ref: fix-seq +title: Sequence Only Evidence +intent: Produce sequence when sequences.yaml exists. +desired_outcome: critical-path sequence artifacts. +language: pt-BR diff --git a/plugins/examples/archify/tests/fixtures/with-sequence/sequences.yaml b/plugins/examples/archify/tests/fixtures/with-sequence/sequences.yaml new file mode 100644 index 0000000..9c48719 --- /dev/null +++ b/plugins/examples/archify/tests/fixtures/with-sequence/sequences.yaml @@ -0,0 +1,9 @@ +sequences: + - name: Simple request + question: What is the request path? + participants: + - {id: client, name: Client} + - {id: api, name: API} + steps: + - {id: m1, from: client, to: api, label: "GET /health"} + - {id: m2, from: api, to: client, label: "200 OK"} diff --git a/plugins/examples/archify/tests/sanitize.test.mjs b/plugins/examples/archify/tests/sanitize.test.mjs new file mode 100644 index 0000000..7d5e7cc --- /dev/null +++ b/plugins/examples/archify/tests/sanitize.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { loadPackage } from '../src/load-package.mjs'; +import { normalizeIr } from '../src/normalize-ir.mjs'; +import { mapArchitecture } from '../src/mappers/architecture.mjs'; +import { sanitizeText } from '../src/sanitize.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtures = path.join(__dirname, 'fixtures'); + +test('sanitizeText redacts tokens and connection strings', () => { + assert.match(sanitizeText('token=super-secret-value'), /\[REDACTED\]/); + assert.match(sanitizeText('Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc'), /Bearer \[REDACTED\]/); + assert.match(sanitizeText('postgres://user:pass@host/db'), /postgres:\/\/\[REDACTED\]/); + assert.match(sanitizeText('sql?access_token=abc123'), /\[REDACTED\]/); +}); + +test('normalize + map architecture never leaks raw secrets', () => { + const pkg = loadPackage(path.join(fixtures, 'with-secrets')); + const { ir } = normalizeIr(pkg, { generatedAt: '2026-07-21T12:00:00.000Z' }); + const serialized = JSON.stringify(ir); + assert.doesNotMatch(serialized, /super-secret-value/); + assert.doesNotMatch(serialized, /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9/); + assert.doesNotMatch(serialized, /user:pass@host/); + assert.doesNotMatch(serialized, /access_token=abc123/); + + const arch = mapArchitecture(ir); + const archText = JSON.stringify(arch); + assert.doesNotMatch(archText, /super-secret-value/); + assert.doesNotMatch(archText, /user:pass@host/); +}); diff --git a/scripts/sky/sky.ps1 b/scripts/sky/sky.ps1 index 4c7be45..aab8475 100644 --- a/scripts/sky/sky.ps1 +++ b/scripts/sky/sky.ps1 @@ -9,7 +9,7 @@ [CmdletBinding()] param( [Parameter(Position = 0, Mandatory = $true)] - [ValidateSet('intake', 'status', 'approve', 'run', 'validate', 'export', 'elevate', 'benchmark', 'publish', 'sync', 'showcase', 'agents', 'audit', 'choreograph', 'architect', 'link', 'link-sync', 'pull-spec', 'integrate-dc')] + [ValidateSet('intake', 'status', 'approve', 'run', 'validate', 'export', 'elevate', 'benchmark', 'publish', 'sync', 'showcase', 'agents', 'audit', 'choreograph', 'architect', 'link', 'link-sync', 'pull-spec', 'integrate-dc', 'visualize')] [string]$Command, [Parameter()] @@ -63,7 +63,24 @@ param( [string[]]$ChangedFiles, [Parameter()] - [int]$Last = 20 + [int]$Last = 20, + + [Parameter()] + [string]$PackagePath, + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateSet('archify')] + [string]$Renderer = 'archify', + + [Parameter()] + [string]$Views = 'architecture,workflow,sequence', + + [Parameter()] + [ValidateSet('standard', 'showcase')] + [string]$Quality = 'standard' ) $ErrorActionPreference = 'Stop' @@ -301,4 +318,19 @@ switch ($Command) { & (Join-Path $PSScriptRoot 'integrate-dc.ps1') @iArgs Invoke-AgentAudit $Slug 'showcase-curator' 'design.integrate_dc' 'side_effect' 'ok' "$Folder/$Screen" } + 'visualize' { + # Optional post-export renderer. Missing plugin exits 0 with guidance (core stays agnostic). + $vArgs = @{ + Renderer = $Renderer + Views = $Views + Quality = $Quality + } + if ($Slug) { $vArgs.Slug = $Slug } + if ($PackagePath) { $vArgs.PackagePath = $PackagePath } + if ($OutputPath) { $vArgs.OutputPath = $OutputPath } + & (Join-Path $PSScriptRoot 'visualize.ps1') @vArgs + if ($Slug) { + Invoke-AgentAudit $Slug 'delivery-steward' 'visualize.archify' 'invoke_skill' 'ok' "renderer=$Renderer" + } + } } diff --git a/scripts/sky/visualize.ps1 b/scripts/sky/visualize.ps1 new file mode 100644 index 0000000..ba0dac0 --- /dev/null +++ b/scripts/sky/visualize.ps1 @@ -0,0 +1,81 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Integração opcional e fina: delega visualização pós-export ao plugin Archify. + Não falha o core se o plugin estiver ausente — orienta o uso opcional. +.EXAMPLE + ./scripts/sky/visualize.ps1 -PackagePath examples/sky-forge-packages/surya-workspace-mvp -Renderer archify + ./scripts/sky/visualize.ps1 -Slug minha-sessao -Renderer archify +#> +[CmdletBinding()] +param( + [Parameter()] + [string]$Slug, + + [Parameter()] + [string]$PackagePath, + + [Parameter()] + [ValidateSet('archify')] + [string]$Renderer = 'archify', + + [Parameter()] + [string]$Views = 'architecture,workflow,sequence', + + [Parameter()] + [ValidateSet('standard', 'showcase')] + [string]$Quality = 'standard', + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [switch]$Json +) + +$ErrorActionPreference = 'Stop' +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') + +if ($Renderer -ne 'archify') { + throw "Unsupported renderer: $Renderer" +} + +$pluginScript = Join-Path $RepoRoot 'plugins\examples\archify\scripts\visualize.ps1' +if (-not (Test-Path $pluginScript)) { + Write-Host 'Visualize renderer "archify" is optional and not installed in this checkout.' -ForegroundColor Yellow + Write-Host 'Expected plugin path:' + Write-Host " $pluginScript" + Write-Host 'Install/restore plugins/examples/archify and bootstrap Archify, then retry.' + exit 0 +} + +if (-not $PackagePath) { + if (-not $Slug) { + throw 'Provide -PackagePath or -Slug' + } + $candidates = @( + (Join-Path $RepoRoot ".sky\sessions\$Slug\export"), + (Join-Path $RepoRoot ".sky\sessions\$Slug\package"), + (Join-Path $RepoRoot "examples\sky-forge-packages\$Slug") + ) + $PackagePath = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $PackagePath) { + throw "Could not resolve package for slug '$Slug'. Pass -PackagePath explicitly." + } +} + +if (-not $OutputPath) { + $name = if ($Slug) { $Slug } else { Split-Path -Leaf $PackagePath } + $OutputPath = Join-Path $RepoRoot ".tmp\archify\$name" +} + +$invokeArgs = @{ + PackagePath = $PackagePath + OutputPath = $OutputPath + Views = $Views + Quality = $Quality +} +if ($Json) { $invokeArgs.Json = $true } + +& $pluginScript @invokeArgs +exit $LASTEXITCODE From 86bd09c95203662c81789dde08c115b84074c6b3 Mon Sep 17 00:00:00 2001 From: Raphael Date: Thu, 23 Jul 2026 12:13:23 -0300 Subject: [PATCH 7/7] fix: address Codex review on Archify visualize adapter Filter sequence messages by sequences.yaml provenance so architecture topology edges are not rendered as sequence steps, and resolve -Slug packages from SKY_OUTPUTS_DIR before local fallbacks. Co-authored-by: Cursor --- .../examples/archify/src/mappers/sequence.mjs | 7 ++++++- .../examples/archify/tests/contract.test.mjs | 20 +++++++++++++++++++ scripts/sky/visualize.ps1 | 5 ++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/plugins/examples/archify/src/mappers/sequence.mjs b/plugins/examples/archify/src/mappers/sequence.mjs index 12e70b1..47a259c 100644 --- a/plugins/examples/archify/src/mappers/sequence.mjs +++ b/plugins/examples/archify/src/mappers/sequence.mjs @@ -16,8 +16,13 @@ export function mapSequence(ir, { qualityProfile = 'standard' } = {}) { if (participants.length < 2) return null; + // Only sequence evidence — do not pull architecture/workflow topology edges + // that happen to share participant IDs (see architecture.mjs source_refs filter). const messagesSource = (ir.relationships || []).filter( - (rel) => include.has(rel.source) && include.has(rel.target), + (rel) => + include.has(rel.source) && + include.has(rel.target) && + (rel.source_refs || []).some((ref) => String(ref).startsWith('sequences.yaml')), ); if (!messagesSource.length) return null; diff --git a/plugins/examples/archify/tests/contract.test.mjs b/plugins/examples/archify/tests/contract.test.mjs index 0b4eee0..12608b3 100644 --- a/plugins/examples/archify/tests/contract.test.mjs +++ b/plugins/examples/archify/tests/contract.test.mjs @@ -60,6 +60,26 @@ test('complete valid package produces workflow and sequence evidence', () => { assert.ok(mapSequence(ir)); }); +test('sequence messages exclude architecture topology edges', () => { + const pkg = loadPackage(path.join(fixtures, 'complete-valid')); + const { ir } = normalizeIr(pkg, { generatedAt: FIXED_AT }); + const seq = mapSequence(ir); + assert.ok(seq); + // sequences.yaml has 5 steps; architecture also has operator-ui / adapter-repo + // between the same participant IDs — those must not appear as sequence messages. + assert.equal(seq.messages.length, 5); + const labels = seq.messages.map((m) => m.label); + assert.deepEqual(labels, [ + 'choose folder', + 'validate package', + 'write artifacts', + 'validation report', + 'confirm import', + ]); + assert.ok(!labels.includes('approve')); + assert.ok(!labels.includes('import files')); +}); + test('missing architecture.yaml fails with actionable error', () => { assert.throws( () => loadPackage(path.join(fixtures, 'missing-architecture')), diff --git a/scripts/sky/visualize.ps1 b/scripts/sky/visualize.ps1 index ba0dac0..ae266f0 100644 --- a/scripts/sky/visualize.ps1 +++ b/scripts/sky/visualize.ps1 @@ -34,7 +34,8 @@ param( ) $ErrorActionPreference = 'Stop' -$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') +. (Join-Path $PSScriptRoot 'get-sky-config.ps1') +$RepoRoot = Get-SkyRepoRoot if ($Renderer -ne 'archify') { throw "Unsupported renderer: $Renderer" @@ -53,7 +54,9 @@ if (-not $PackagePath) { if (-not $Slug) { throw 'Provide -PackagePath or -Slug' } + # Prefer configured export dir (SKY_OUTPUTS_DIR / sky.config) — same as export-package.ps1 $candidates = @( + (Get-SkyOutputDirForSlug $Slug), (Join-Path $RepoRoot ".sky\sessions\$Slug\export"), (Join-Path $RepoRoot ".sky\sessions\$Slug\package"), (Join-Path $RepoRoot "examples\sky-forge-packages\$Slug")