wizz-method 2.23.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills-registry.yaml +7 -1
- package/src/bmm-skills/1-analysis/wizz-document-project/references/static-dependencies.md +124 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/scripts/dependencies.py +374 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/scripts/test_dependencies.py +148 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/scripts/test_dependencies_unit.py +183 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/templates/deep-dive-template.md +9 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/templates/index-template.md +9 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/workflows/deep-dive-instructions.md +4 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/workflows/full-scan-instructions.md +7 -1
- package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +19 -0
- package/src/bmm-skills/4-implementation/wizz-code-review/references/review-economy.md +5 -1
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-01-gather-context.md +27 -2
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-02-review.md +2 -0
- package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +19 -0
- package/src/bmm-skills/4-implementation/wizz-quick-dev/references/review-economy.md +5 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +1 -1
- package/src/modules/wizz/_shared/approval-gate.md +45 -0
- package/src/modules/wizz/_shared/approval-gate.mjs +184 -0
- package/src/modules/wizz/_shared/communication-rules.md +2 -0
- package/src/modules/wizz/_shared/model-ladder.md +12 -0
- package/src/modules/wizz/agents/wizz-ads/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-growth/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-maestro/SKILL.md +3 -1
- package/src/modules/wizz/agents/wizz-seo/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-social/SKILL.md +7 -1
- package/src/modules/wizz/agents/wizz-social/customize.toml +4 -3
- package/src/modules/wizz/subagents/codex/wizz-exec-review.toml +2 -1
- package/src/modules/wizz/subagents/gemini/wizz-exec-review.md +2 -1
- package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +2 -1
- package/src/modules/wizz/subagents/spec.yaml +4 -3
- package/src/modules/wizz/subagents/wizz-exec-review.md +2 -1
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -1
- package/src/skills-lib/wizz-security-audit-pentest/SKILL.md +50 -26
- package/src/skills-lib/wizz-security-audit-pentest/references/stage-5-segredos-historico.md +74 -0
- package/src/skills-lib/wizz-security-audit-pentest/references/stage-6-advogado-do-diabo.md +33 -0
- package/src/skills-lib/wizz-security-audit-pentest/references/stage-7-fusao-plano.md +50 -0
- package/tools/installer/README.md +5 -0
- package/tools/installer/commands/templates.js +65 -0
- package/tools/installer/core/resolved-schema.js +5 -3
- package/tools/installer/modules/templates.js +229 -0
- package/tools/installer/templates.md +156 -0
- package/src/skills-lib/wizz-security-audit-pentest/references/stage-5-fusao-plano.md +0 -27
package/package.json
CHANGED
package/skills-registry.yaml
CHANGED
|
@@ -207,8 +207,9 @@ areas:
|
|
|
207
207
|
# estilo copiar antes de voz, timing, efeitos e montagem.
|
|
208
208
|
- id: wizz-video-styles
|
|
209
209
|
door: motion
|
|
210
|
-
when: "Decidir QUAL estilo de vídeo curto copiar a partir do inventário do material (pessoa real, só voz, b-roll, telas, produto) e entregar a receita medida em 110 vídeos reais (ritmo de corte, legenda, áudio, zoom, gancho), para qualquer nicho, inclusive
|
|
210
|
+
when: "Decidir QUAL estilo de vídeo curto copiar a partir do inventário do material (pessoa real, só voz, b-roll, telas, produto) e entregar a receita medida em 110 vídeos reais (ritmo de corte, legenda, áudio, zoom, gancho), para qualquer nicho, inclusive reel de música / vídeo de música / lyric video / vídeo com a letra / edição no ritmo da música, no estilo tipografia-cinetica (texto como imagem, cortes na batida, sem rosto nem narração) e vídeo do app só com a interface (UI morph: uma forma vira cada tela, cursor conduz, estilo Dribbble). Gatilhos: 'estilo do vídeo', 'editar no estilo de', 'copiar a edição de', 'que estilo usar no reel', 'o vídeo ficou amador', 'clipe com a letra', 'tipografia cinética', 'reel de música', 'vídeo de música', 'lyric video', 'vídeo com a letra', 'edição no ritmo da música', 'vídeo do app mexendo', 'anima o app num vídeo', 'animação da interface tipo Dribbble'."
|
|
211
211
|
not_when:
|
|
212
|
+
- "Só roteirizar/montar Reel narrado com b-roll ou captar imagens de apoio, sem pedir escolha de estilo: música em alta como trilha não implica tipografia-cinetica. Roteiro é wizz-social-content, captação é get-brolls e montagem é wizz-remotion-best-practices. Só escolher a música em alta, sem pedir estilo ou vídeo musical, não aciona esta entrada."
|
|
212
213
|
- "Decidir efeitos e SFX por evento, volume, LUFS e portões de zona segura: é wizz-video-effects; esta skill escolhe o estilo que ele depois refina."
|
|
213
214
|
- "Montar o filme em React (cenas, legenda, <Audio>): é wizz-remotion-best-practices."
|
|
214
215
|
- "Animar componente dentro do código do app (hover, transição de página, modal): é wizz-animate; aqui é vídeo para divulgar."
|
|
@@ -678,6 +679,7 @@ areas:
|
|
|
678
679
|
- id: wizz-social-content
|
|
679
680
|
when: "Criar/agendar conteúdo de social (LinkedIn, X, TikTok, IG...), o que inclui ESCREVER o texto: post, story, carrossel, legenda, thread. Gatilhos: 'escreve copy pra story', 'post pro LinkedIn', 'legenda do carrossel', 'thread pro X'."
|
|
680
681
|
not_when:
|
|
682
|
+
- "Reel de música / lyric video só com música e tipografia, sem rosto nem narração: escolher a receita é wizz-video-styles, estilo tipografia-cinetica (designer), antes de escrever roteiro; nunca music-to-video externa. Música em alta como trilha de Reel narrado com b-roll mantém o roteiro aqui."
|
|
681
683
|
- "Editar/revisar texto de social já escrito: aí é wizz-copy-editing."
|
|
682
684
|
- "Pedir SÓ o asset de um Reel/vídeo — b-roll, 'imagens de apoio', 'cenas de apoio', imagem de corte, footage — sem que o texto ou o roteiro façam parte do pedido: a captação é get-brolls, na área designer. Esta entrada entra quando o TEXTO ou o ROTEIRO é parte do que se pede; se o pedido junta roteiro E captação, são 2 áreas."
|
|
683
685
|
rel:
|
|
@@ -1219,3 +1221,7 @@ squads:
|
|
|
1219
1221
|
domain: "Claude Code, tooling de agentes, automação de dev."
|
|
1220
1222
|
advises: [dev, architect]
|
|
1221
1223
|
when: "Dominar Claude Code, hooks, skills, workflows de agente."
|
|
1224
|
+
|
|
1225
|
+
# Pacotes inertes opt-in, separados de skills/MCPs/CLIs: wizz templates --list.
|
|
1226
|
+
# Contrato e exemplos: tools/installer/templates.md. Nunca instalados por área.
|
|
1227
|
+
templates: []
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Mapa estático de dependências
|
|
2
|
+
|
|
3
|
+
Gere o mapa em scans deep/exhaustive de `initial_scan`/`full_rescan`, ou após a
|
|
4
|
+
confirmação do alvo em `deep_dive`. Quick Scan não lê fontes nem gera o mapa.
|
|
5
|
+
O script não chama modelos, executa código do projeto ou envia fontes a serviços.
|
|
6
|
+
O cache de imports não substitui a leitura literal exigida pelo deep-dive.
|
|
7
|
+
|
|
8
|
+
## Ferramenta e instalação
|
|
9
|
+
|
|
10
|
+
JS/TS: `dependency-cruiser` 17.4.3 com TypeScript 5.9.3. Requer Python 3.10+
|
|
11
|
+
e Node compatível com as versões fixadas. Python orquestra Git, cache, impacto e
|
|
12
|
+
exportação; não analisa imports Python. Repositórios sem commits são aceitos
|
|
13
|
+
(`commit: null`). Confirme os requisitos no ambiente usado.
|
|
14
|
+
|
|
15
|
+
Se a ferramenta não estiver instalada, obtenha autorização antes de baixar
|
|
16
|
+
pacotes. Instale em uma pasta de ferramentas confiável fora do projeto:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install --prefix /caminho/ferramentas-wizz --no-audit --no-fund dependency-cruiser@17.4.3 typescript@5.9.3
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Passe um executável absoluto confiável por `--cruiser` ou `WIZZ_DEPCRUISE`.
|
|
23
|
+
O script resolve links e recusa executáveis dentro do checkout. Nunca use o
|
|
24
|
+
caminho lido de um cache como autorização para executá-lo. `refresh` exige a mesma
|
|
25
|
+
entrada explícita e não recupera ferramenta/configuração executável do cache.
|
|
26
|
+
Repita também o `--tsconfig` explicitamente, se necessário para aliases.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
python3 "{skill-root}/scripts/dependencies.py" --root /caminho/projeto scan --cruiser /caminho/ferramentas-wizz/node_modules/.bin/depcruise --tsconfig tsconfig.json
|
|
30
|
+
python3 "{skill-root}/scripts/dependencies.py" --root /caminho/projeto export
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Cache canônico e base estável
|
|
34
|
+
|
|
35
|
+
Sem `--output`, todos os comandos usam
|
|
36
|
+
`~/.cache/wizz/dependencies/<primeiros-24-hex-do-SHA256-do-root-resolvido>/`.
|
|
37
|
+
Esse cache externo ao checkout não entra em Git. `--output` pode indicar outra
|
|
38
|
+
pasta externa exclusiva; saídas dentro do repositório são recusadas. Documentos
|
|
39
|
+
versionados citam a convenção/cobertura, sem copiar caminhos absolutos locais.
|
|
40
|
+
`graph.json` e `obsidian/` ficam apenas nesse cache externo.
|
|
41
|
+
|
|
42
|
+
O cache compara tamanho, mtime, ctime e inode; só relê fontes com metadados
|
|
43
|
+
alterados e só reanalisa aquelas cujo hash mudou. JSONs de configuração
|
|
44
|
+
(inclusive arquivos estendidos pelo tsconfig dentro do inventário), manifests e
|
|
45
|
+
lockfiles são relidos para invalidar resolução. Inclusões/exclusões de qualquer
|
|
46
|
+
arquivo inventariado, inclusive assets JSON/CSS, invalidam todos os importadores.
|
|
47
|
+
Mudanças na ferramenta/capacidades/configuração também forçam análise completa.
|
|
48
|
+
Arquivos ignorados não rastreados ficam fora do inventário; estado externo não
|
|
49
|
+
inventariado exige regenerar o cache após alteração.
|
|
50
|
+
|
|
51
|
+
`baseline_commit` fica ancorado na primeira varredura válida. A união conservadora
|
|
52
|
+
acumula todas as arestas observadas desde essa base, incluindo as removidas; não
|
|
53
|
+
é substituída a cada scan ou commit. Vários refreshes com mudanças não perdem
|
|
54
|
+
impacto histórico. Pode haver falsos positivos. Para iniciar outra base, use uma
|
|
55
|
+
nova pasta de saída externa, preferencialmente a partir do checkout limpo da base
|
|
56
|
+
desejada; um scan inicial já com mudanças não recupera arestas apagadas antes dele.
|
|
57
|
+
|
|
58
|
+
Cache ausente, corrompido/incompatível ou commit anterior inexistente causa scan
|
|
59
|
+
completo com nova base, não exige apagar o arquivo manualmente. Compare a base
|
|
60
|
+
retornada antes de inferir cobertura histórica. `impact`/`export` com cache inválido
|
|
61
|
+
falham com diagnóstico; não retornam um impacto vazio fictício. Subprocessos têm
|
|
62
|
+
timeout de 120 segundos. Não rode scans concorrentes na mesma pasta de saída.
|
|
63
|
+
|
|
64
|
+
No-op reporta `source_hash_reads=0` e `modules_parsed=0`. Isso não significa zero
|
|
65
|
+
I/O: Git, stat, cache, configurações e capacidades da ferramenta são lidos.
|
|
66
|
+
|
|
67
|
+
## Impacto no review
|
|
68
|
+
|
|
69
|
+
Somente após o checkpoint confirmado do review, atualize o cache e use o diff
|
|
70
|
+
completo selecionado, incluindo manifests e lockfiles:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
python3 "{skill-root}/scripts/dependencies.py" --root /caminho/projeto refresh --cruiser /caminho/ferramentas-wizz/node_modules/.bin/depcruise --tsconfig tsconfig.json
|
|
74
|
+
python3 "{skill-root}/scripts/dependencies.py" --root /caminho/projeto impact --diff /caminho/review.diff
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Alternativas: `--base <ref>` compara com a árvore de trabalho; `--files <json>`
|
|
78
|
+
recebe array de strings de caminhos relativos ou absolutos dentro do root.
|
|
79
|
+
Normaliza `./`; recusa traversal, tipos inválidos e paths externos. Diff aceita
|
|
80
|
+
prefixos Git padrão, `--no-prefix`, renames e conteúdo não UTF-8. Se gerado com
|
|
81
|
+
`--relative=<pasta>`, forneça a mesma pasta via `--diff-relative <pasta>`; não
|
|
82
|
+
adivinhe a base de um diff fornecido. Diffs vazios/malformados falham.
|
|
83
|
+
|
|
84
|
+
A saída distingue `touched`, dependentes `direct` e dependentes `transitive`
|
|
85
|
+
(além dos diretos), com proteção contra ciclos. Informe `commit`,
|
|
86
|
+
`baseline_commit` e limites de cobertura. A base do diff pode anteceder a base
|
|
87
|
+
observada: nesse caso, as arestas históricas anteriores não são conhecidas.
|
|
88
|
+
Ausência de impacto estático nunca comprova ausência de impacto em runtime.
|
|
89
|
+
|
|
90
|
+
## Cobertura e Obsidian
|
|
91
|
+
|
|
92
|
+
Fontes JS/TS rastreadas e não rastreadas permitidas pelo Git, sem symlinks.
|
|
93
|
+
Imports de tipo, dinâmicos literais, aliases e ciclos são cobertos pelo scanner.
|
|
94
|
+
Vue/Svelte/Python/Swift, imports calculados e resolução específica de bundler não
|
|
95
|
+
têm garantia. Bibliotecas externas são arestas, não módulos percorridos.
|
|
96
|
+
|
|
97
|
+
`export` gera notas e wikilinks com nomes únicos; imports externos/não resolvidos
|
|
98
|
+
apontam para nós identificados sem nota de código. Só notas `module-` geradas
|
|
99
|
+
são removidas quando obsoletas. Abra a pasta externa `obsidian/` como vault.
|
|
100
|
+
**Aceite visual manual no Obsidian permanece pendente.** Validar Markdown não
|
|
101
|
+
conclui esse aceite.
|
|
102
|
+
|
|
103
|
+
## Comparação e referências
|
|
104
|
+
|
|
105
|
+
- [dependency-cruiser CLI](https://github.com/sverweij/dependency-cruiser/blob/main/doc/cli.md):
|
|
106
|
+
JSON direto, imports TS e resolução via tsconfig, sem seguir dependências.
|
|
107
|
+
- [pydeps](https://github.com/thebjorn/pydeps): grafo por modulefinder/bytecode,
|
|
108
|
+
outro contrato de resolução. Suporte Python não implementado nem testado.
|
|
109
|
+
- [Swift dependency scanner](https://github.com/swiftlang/swift/blob/main/docs/Lexicon.md):
|
|
110
|
+
grafo de módulos/compilação, com SDK e paths; não equivale a imports por arquivo.
|
|
111
|
+
Suporte Swift não implementado nem testado.
|
|
112
|
+
|
|
113
|
+
Opções do dependency-cruiser conferidas via Context7. Não instalar pydeps ou
|
|
114
|
+
anunciar fallback Swift por regex.
|
|
115
|
+
|
|
116
|
+
## Verificação
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
python3 -m unittest discover -s "{skill-root}/scripts" -p 'test_*.py' -v
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Os testes unitários de cache/impacto/paths sempre rodam sem scanner. O runner
|
|
123
|
+
recusa uma suíte inteira pulada. `WIZZ_DEPCRUISE` habilita adicionalmente os testes
|
|
124
|
+
de integração com o executável real; ausência dele pula apenas essa camada.
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic JS/TS dependency cache, reverse impact and Obsidian export."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import sys
|
|
13
|
+
import posixpath
|
|
14
|
+
|
|
15
|
+
SCHEMA = 2
|
|
16
|
+
EXTENSIONS = {'.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts'}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(root, *args):
|
|
20
|
+
return subprocess.check_output(args, cwd=root, timeout=120, stderr=subprocess.PIPE).decode('utf-8', 'surrogateescape')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def git(root, *args):
|
|
24
|
+
return run(root, 'git', *args)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def digest(path):
|
|
28
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save(path, value):
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
with tempfile.NamedTemporaryFile(mode='w', dir=path.parent, delete=False) as stream:
|
|
34
|
+
json.dump(value, stream, indent=2, sort_keys=True)
|
|
35
|
+
stream.write('\n')
|
|
36
|
+
temporary = stream.name
|
|
37
|
+
os.replace(temporary, path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def inventory(root):
|
|
41
|
+
names = git(root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z')
|
|
42
|
+
return sorted({name for name in names.split('\0') if name and
|
|
43
|
+
(root / name).is_file() and not (root / name).is_symlink()})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def canonical_output(root):
|
|
47
|
+
# Outside the checkout: neither PR content nor absolute local paths enter Git.
|
|
48
|
+
key = hashlib.sha256(os.fsencode(root)).hexdigest()[:24]
|
|
49
|
+
return Path.home() / '.cache' / 'wizz' / 'dependencies' / key
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_graph(output, root, recover=False):
|
|
53
|
+
try:
|
|
54
|
+
graph = json.loads((output / 'graph.json').read_text())
|
|
55
|
+
if (not isinstance(graph, dict) or graph.get('schema') != SCHEMA or
|
|
56
|
+
graph.get('root') != str(root) or
|
|
57
|
+
not all(isinstance(graph.get(key), dict) for key in
|
|
58
|
+
('modules', 'previous_modules', 'hashes', 'stats', 'config')) or
|
|
59
|
+
not isinstance(graph.get('inventory'), list) or
|
|
60
|
+
not (graph.get('commit') is None or isinstance(graph.get('commit'), str)) or
|
|
61
|
+
not all(isinstance(name, str) for name in graph['inventory']) or
|
|
62
|
+
not all(isinstance(value, str) for value in graph['hashes'].values()) or
|
|
63
|
+
not set(graph['modules']).issubset(graph['hashes'])):
|
|
64
|
+
raise ValueError('Invalid or incompatible dependency cache')
|
|
65
|
+
for modules in (graph['modules'], graph['previous_modules']):
|
|
66
|
+
if any(not isinstance(name, str) or not isinstance(deps, list) or
|
|
67
|
+
any(not isinstance(dep, dict) for dep in deps)
|
|
68
|
+
for name, deps in modules.items()):
|
|
69
|
+
raise ValueError('Invalid dependency edges')
|
|
70
|
+
return graph
|
|
71
|
+
except (OSError, ValueError, TypeError):
|
|
72
|
+
if recover:
|
|
73
|
+
return {}
|
|
74
|
+
raise ValueError('Dependency cache unavailable or invalid; run scan with a trusted --cruiser') from None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def current_commit(root):
|
|
78
|
+
try:
|
|
79
|
+
return git(root, 'rev-parse', '--verify', 'HEAD').strip()
|
|
80
|
+
except subprocess.CalledProcessError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def union_modules(*graphs):
|
|
85
|
+
result = {}
|
|
86
|
+
for modules in graphs:
|
|
87
|
+
for source, dependencies in modules.items():
|
|
88
|
+
edges = result.setdefault(source, [])
|
|
89
|
+
for dependency in dependencies:
|
|
90
|
+
if dependency not in edges:
|
|
91
|
+
edges.append(dependency)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def trusted_tool(root, value):
|
|
96
|
+
if not value or not Path(value).is_absolute():
|
|
97
|
+
raise ValueError('Pass a trusted absolute --cruiser or WIZZ_DEPCRUISE outside the repository')
|
|
98
|
+
tool = Path(value).resolve()
|
|
99
|
+
if tool.is_relative_to(root.resolve()) or not tool.is_file() or not os.access(tool, os.X_OK):
|
|
100
|
+
raise ValueError('Trusted cruiser must be an executable outside the repository')
|
|
101
|
+
return tool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scan(args, root, output):
|
|
105
|
+
names = inventory(root)
|
|
106
|
+
sources = [name for name in names if Path(name).suffix in EXTENSIONS]
|
|
107
|
+
configs = [name for name in names if Path(name).suffix == '.json' or Path(name).name in
|
|
108
|
+
{'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'} or
|
|
109
|
+
Path(name).name.startswith(('tsconfig', 'jsconfig'))]
|
|
110
|
+
# Hash configuration every time; zero rereads applies to source modules.
|
|
111
|
+
tool = trusted_tool(root, args.cruiser)
|
|
112
|
+
config = {name: digest(root / name) for name in configs}
|
|
113
|
+
config['cruiser'] = str(tool)
|
|
114
|
+
config['cruiser_version'] = run(root, str(tool), '--version').strip()
|
|
115
|
+
capabilities = run(root, str(tool), '--info')
|
|
116
|
+
config['capabilities'] = hashlib.sha256(capabilities.encode()).hexdigest()
|
|
117
|
+
if any(Path(name).suffix in {'.ts', '.tsx', '.mts', '.cts'} for name in sources):
|
|
118
|
+
if re.search(r'^\s*x \.ts\s*$', capabilities, re.MULTILINE):
|
|
119
|
+
raise ValueError('TypeScript transpiler unavailable; install a compatible TypeScript beside dependency-cruiser')
|
|
120
|
+
config['tsconfig'] = args.tsconfig
|
|
121
|
+
cache_path = output / 'graph.json'
|
|
122
|
+
old = load_graph(output, root, recover=True)
|
|
123
|
+
commit = current_commit(root)
|
|
124
|
+
if old.get('commit'):
|
|
125
|
+
try:
|
|
126
|
+
git(root, 'cat-file', '-e', old['commit'] + '^{commit}')
|
|
127
|
+
except subprocess.CalledProcessError:
|
|
128
|
+
old = {} # Rebase/gc recovery: full scan, explicitly new baseline.
|
|
129
|
+
compatible = (old.get('schema') == SCHEMA and old.get('root') == str(root)
|
|
130
|
+
and old.get('config') == config)
|
|
131
|
+
previous = old.get('modules', {}) if compatible else {}
|
|
132
|
+
stats, hashes, changed = {}, {}, []
|
|
133
|
+
reads = 0
|
|
134
|
+
for name in sources:
|
|
135
|
+
stat = (root / name).stat()
|
|
136
|
+
stamp = [stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_ino]
|
|
137
|
+
stats[name] = stamp
|
|
138
|
+
if compatible and name in old['hashes'] and old.get('stats', {}).get(name) == stamp:
|
|
139
|
+
hashes[name] = old['hashes'][name]
|
|
140
|
+
else:
|
|
141
|
+
hashes[name] = digest(root / name)
|
|
142
|
+
reads += 1
|
|
143
|
+
if not compatible or hashes[name] != old.get('hashes', {}).get(name):
|
|
144
|
+
changed.append(name)
|
|
145
|
+
# New/deleted modules can change resolution of previously unresolved imports.
|
|
146
|
+
if set(names) != set(old.get('inventory', [])):
|
|
147
|
+
changed = sources[:]
|
|
148
|
+
modules = {name: previous[name] for name in sources if name in previous}
|
|
149
|
+
for offset in range(0, len(changed), 80):
|
|
150
|
+
batch = changed[offset:offset + 80]
|
|
151
|
+
command = [str(tool), '--no-config', '--do-not-follow', '.',
|
|
152
|
+
'--ts-pre-compilation-deps', '--output-type', 'json']
|
|
153
|
+
if args.tsconfig:
|
|
154
|
+
command += ['--ts-config', args.tsconfig]
|
|
155
|
+
result = json.loads(run(root, *command, '--', *batch))
|
|
156
|
+
by_name = {module['source']: module for module in result['modules']}
|
|
157
|
+
for name in batch:
|
|
158
|
+
if name not in by_name:
|
|
159
|
+
raise ValueError('Scanner omitted input: ' + name)
|
|
160
|
+
modules[name] = by_name[name].get('dependencies', [])
|
|
161
|
+
commit_changes = []
|
|
162
|
+
if old.get('commit') and commit and old['commit'] != commit:
|
|
163
|
+
commit_changes = git(root, 'diff', '--name-only', '--no-renames', '-z',
|
|
164
|
+
old['commit'] + '..HEAD', '--').split('\0')
|
|
165
|
+
# Monotonic union since a stable baseline; unrelated scans never discard
|
|
166
|
+
# removed edges. May over-report. A fresh output starts a new baseline.
|
|
167
|
+
historical = union_modules(old.get('previous_modules', {}), old.get('modules', {}))
|
|
168
|
+
graph = dict(schema=SCHEMA, root=str(root), commit=commit, config=config,
|
|
169
|
+
baseline_commit=old.get('baseline_commit', commit),
|
|
170
|
+
modules=modules, hashes=hashes, stats=stats, inventory=names,
|
|
171
|
+
previous_modules=historical,
|
|
172
|
+
metrics=dict(source_hash_reads=reads, modules_parsed=len(changed),
|
|
173
|
+
source_count=len(sources)),
|
|
174
|
+
commit_changes=[name for name in commit_changes if name])
|
|
175
|
+
save(cache_path, graph)
|
|
176
|
+
print(json.dumps(graph['metrics'], sort_keys=True))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def normalize_files(values, root, relative='.'):
|
|
180
|
+
if not isinstance(values, list) or any(not isinstance(name, str) or not name or
|
|
181
|
+
'\0' in name or '\\' in name for name in values):
|
|
182
|
+
raise ValueError('Files must be a JSON array of nonempty path strings')
|
|
183
|
+
root = root.resolve()
|
|
184
|
+
prefix = Path(relative)
|
|
185
|
+
if prefix.is_absolute() or '..' in prefix.parts:
|
|
186
|
+
raise ValueError('Diff relative directory must be inside the repository')
|
|
187
|
+
result = []
|
|
188
|
+
for name in values:
|
|
189
|
+
candidate = Path(name)
|
|
190
|
+
if candidate.is_absolute():
|
|
191
|
+
try:
|
|
192
|
+
name = str(candidate.resolve().relative_to(root))
|
|
193
|
+
except ValueError:
|
|
194
|
+
raise ValueError('Path outside repository: ' + name) from None
|
|
195
|
+
else:
|
|
196
|
+
if '..' in candidate.parts:
|
|
197
|
+
raise ValueError('Traversal path rejected: ' + name)
|
|
198
|
+
name = str(prefix / candidate)
|
|
199
|
+
name = posixpath.normpath(name)
|
|
200
|
+
if name == '.' or name.startswith('../'):
|
|
201
|
+
raise ValueError('Invalid file path: ' + name)
|
|
202
|
+
result.append(name)
|
|
203
|
+
return sorted(set(result))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def diff_files(root, diff, relative='.'):
|
|
207
|
+
raw = Path(diff).read_bytes()
|
|
208
|
+
if not raw.strip():
|
|
209
|
+
raise ValueError('Empty diff; impact unavailable')
|
|
210
|
+
sections = re.split(rb'(?m)(?=^diff --git )', raw)
|
|
211
|
+
names = []
|
|
212
|
+
for section in sections:
|
|
213
|
+
if section.strip():
|
|
214
|
+
names.extend(diff_section_files(root, section))
|
|
215
|
+
if not names:
|
|
216
|
+
raise ValueError('Diff contains no parseable file changes; impact unavailable')
|
|
217
|
+
return normalize_files(names, root, relative)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def diff_section_files(root, raw):
|
|
221
|
+
# Git parses binary/non-UTF8 content and C-quoted filenames. Parse sections
|
|
222
|
+
# separately because selected diffs can mix Git/no-index prefix styles.
|
|
223
|
+
data = subprocess.check_output(['git', 'apply', '--numstat', '-z', '-p0'],
|
|
224
|
+
input=raw, cwd=root, timeout=120,
|
|
225
|
+
stderr=subprocess.PIPE)
|
|
226
|
+
records = iter(data.decode('utf-8', 'surrogateescape').split('\0'))
|
|
227
|
+
names = []
|
|
228
|
+
try:
|
|
229
|
+
for record in records:
|
|
230
|
+
if not record:
|
|
231
|
+
continue
|
|
232
|
+
fields = record.split('\t', 2)
|
|
233
|
+
if len(fields) != 3:
|
|
234
|
+
raise ValueError('Invalid numstat record')
|
|
235
|
+
name = fields[2]
|
|
236
|
+
if name:
|
|
237
|
+
names.append(name)
|
|
238
|
+
else:
|
|
239
|
+
names.extend([next(records), next(records)])
|
|
240
|
+
except StopIteration:
|
|
241
|
+
raise ValueError('Truncated numstat record') from None
|
|
242
|
+
# Also collect the source of a deletion/rename. --numstat alone only
|
|
243
|
+
# reports the destination of pure renames. Git's raw diff parser emits both
|
|
244
|
+
# via --summary, but rename headers are already unprefixed Git paths.
|
|
245
|
+
def unquote(value):
|
|
246
|
+
if value.startswith(b'"'):
|
|
247
|
+
import codecs
|
|
248
|
+
if not value.endswith(b'"'):
|
|
249
|
+
raise ValueError('Invalid quoted Git path')
|
|
250
|
+
value = codecs.escape_decode(value[1:-1])[0]
|
|
251
|
+
return value.decode('utf-8', 'surrogateescape')
|
|
252
|
+
lines = raw.splitlines()
|
|
253
|
+
standard = any(line.startswith((b'--- a/', b'--- "a/')) for line in lines) or any(
|
|
254
|
+
line.startswith((b'+++ b/', b'+++ "b/')) for line in lines)
|
|
255
|
+
# A no-prefix path may itself start with a/ or b/: establish standard
|
|
256
|
+
# prefixes from paired diff --git headers, including add/delete sections.
|
|
257
|
+
headers = [line for line in lines if line.startswith(b'diff --git ')]
|
|
258
|
+
if headers:
|
|
259
|
+
standard = all(re.match(rb'diff --git (?:"a/|a/).+ (?:"b/|b/)', line) for line in headers)
|
|
260
|
+
rename_targets = [unquote(line[len(b'rename to '):]) for line in lines if line.startswith(b'rename to ')]
|
|
261
|
+
if rename_targets and any(name in rename_targets for name in names):
|
|
262
|
+
standard = False
|
|
263
|
+
if standard:
|
|
264
|
+
names = [name[2:] if name.startswith(('a/', 'b/')) else name for name in names]
|
|
265
|
+
for line in lines:
|
|
266
|
+
for marker in (b'rename from ', b'rename to ', b'copy from ', b'copy to '):
|
|
267
|
+
if line.startswith(marker):
|
|
268
|
+
names.append(unquote(line[len(marker):]))
|
|
269
|
+
if not names:
|
|
270
|
+
raise ValueError('Diff contains no parseable file changes; impact unavailable')
|
|
271
|
+
return names
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def impact(args, root, output):
|
|
275
|
+
graph = load_graph(output, root)
|
|
276
|
+
if args.base:
|
|
277
|
+
touched = normalize_files([name for name in git(
|
|
278
|
+
root, 'diff', '--name-only', '--no-renames', '-z',
|
|
279
|
+
args.base, '--').split('\0') if name], root)
|
|
280
|
+
elif args.files:
|
|
281
|
+
touched = normalize_files(json.loads(Path(args.files).read_text()), root)
|
|
282
|
+
else:
|
|
283
|
+
touched = diff_files(root, args.diff, getattr(args, 'diff_relative', '.'))
|
|
284
|
+
reverse = {}
|
|
285
|
+
for modules in [graph['previous_modules'], graph['modules']]:
|
|
286
|
+
for source, dependencies in modules.items():
|
|
287
|
+
for dependency in dependencies:
|
|
288
|
+
target = dependency.get('resolved')
|
|
289
|
+
if target and not dependency.get('couldNotResolve'):
|
|
290
|
+
reverse.setdefault(target, set()).add(source)
|
|
291
|
+
direct = set().union(*(reverse.get(name, set()) for name in touched)) - set(touched)
|
|
292
|
+
visited, queue = set(touched), touched[:]
|
|
293
|
+
while queue:
|
|
294
|
+
for dependent in reverse.get(queue.pop(), set()):
|
|
295
|
+
if dependent not in visited:
|
|
296
|
+
visited.add(dependent)
|
|
297
|
+
queue.append(dependent)
|
|
298
|
+
result = dict(commit=graph['commit'], baseline_commit=graph.get('baseline_commit'),
|
|
299
|
+
touched=touched, direct=sorted(direct),
|
|
300
|
+
transitive=sorted(visited - set(touched) - direct),
|
|
301
|
+
coverage='Static JS/TS imports; conservative edge union since baseline; historical edges before baseline unavailable; not a completeness guarantee')
|
|
302
|
+
print(json.dumps(result, indent=2))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def export(root, output):
|
|
306
|
+
graph = load_graph(output, root)
|
|
307
|
+
if graph['root'] != str(root):
|
|
308
|
+
raise ValueError('Graph belongs to another checkout')
|
|
309
|
+
# Encoded path plus digest avoids duplicate basenames and wiki metacharacters.
|
|
310
|
+
notes = output / 'obsidian'
|
|
311
|
+
notes.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
modules = graph['modules']
|
|
313
|
+
identifiers = {name: 'module-' + quote(name, safe='')[:140] + '-' + hashlib.sha256(name.encode()).hexdigest()
|
|
314
|
+
for name in modules}
|
|
315
|
+
expected = {identifier + '.md' for identifier in identifiers.values()}
|
|
316
|
+
for stale in notes.glob('module-*.md'):
|
|
317
|
+
if stale.name not in expected:
|
|
318
|
+
stale.unlink()
|
|
319
|
+
for name, dependencies in sorted(modules.items()):
|
|
320
|
+
lines = ['# ' + name, '', 'Source: `' + name + '`', '', '## Imports', '']
|
|
321
|
+
for dependency in dependencies:
|
|
322
|
+
target = dependency.get('resolved')
|
|
323
|
+
if target in identifiers:
|
|
324
|
+
lines.append('- [[' + identifiers[target] + ']]')
|
|
325
|
+
else:
|
|
326
|
+
external = (name + ':' + dependency['module'] if dependency.get('couldNotResolve')
|
|
327
|
+
else target or dependency['module'])
|
|
328
|
+
identifier = ('external-' + quote(external, safe='')[:140] + '-' +
|
|
329
|
+
hashlib.sha256(external.encode()).hexdigest())
|
|
330
|
+
lines.append('- [[' + identifier + ']] (external or unresolved: `' +
|
|
331
|
+
dependency['module'] + '`)')
|
|
332
|
+
(notes / (identifiers[name] + '.md')).write_text('\n'.join(lines) + '\n')
|
|
333
|
+
print(json.dumps({'notes': len(modules), 'directory': str(notes)}))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main():
|
|
337
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
338
|
+
parser.add_argument('--root', default='.')
|
|
339
|
+
parser.add_argument('--output', help='External artifact directory (default: canonical user cache)')
|
|
340
|
+
commands = parser.add_subparsers(dest='command', required=True)
|
|
341
|
+
for name in ('scan', 'refresh'):
|
|
342
|
+
scanner = commands.add_parser(name)
|
|
343
|
+
scanner.add_argument('--cruiser', default=os.environ.get('WIZZ_DEPCRUISE'),
|
|
344
|
+
help='Trusted absolute external executable; never taken from cache')
|
|
345
|
+
scanner.add_argument('--tsconfig', help='Explicit project-relative tsconfig for aliases')
|
|
346
|
+
reviewer = commands.add_parser('impact')
|
|
347
|
+
selection = reviewer.add_mutually_exclusive_group(required=True)
|
|
348
|
+
selection.add_argument('--base', help='Git ref compared with working tree, including index')
|
|
349
|
+
selection.add_argument('--files', help='JSON array of repository-relative paths')
|
|
350
|
+
selection.add_argument('--diff', help='Unified diff selected by code-review')
|
|
351
|
+
commands.add_parser('export')
|
|
352
|
+
reviewer.add_argument('--diff-relative', default='.', help='Explicit Git --relative directory, relative to root')
|
|
353
|
+
args = parser.parse_args()
|
|
354
|
+
root = Path(args.root).resolve()
|
|
355
|
+
root = Path(git(root, 'rev-parse', '--show-toplevel').strip()).resolve()
|
|
356
|
+
output = (Path(args.output) if args.output else canonical_output(root)).resolve()
|
|
357
|
+
if output.is_relative_to(root):
|
|
358
|
+
raise ValueError('Dependency output must be outside the repository; omit --output for canonical cache')
|
|
359
|
+
if args.command in ('scan', 'refresh'):
|
|
360
|
+
if args.tsconfig:
|
|
361
|
+
args.tsconfig = normalize_files([args.tsconfig], root)[0]
|
|
362
|
+
scan(args, root, output)
|
|
363
|
+
elif args.command == 'impact':
|
|
364
|
+
impact(args, root, output)
|
|
365
|
+
else:
|
|
366
|
+
export(root, output)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == '__main__':
|
|
370
|
+
try:
|
|
371
|
+
main()
|
|
372
|
+
except (ValueError, OSError, KeyError, TypeError, subprocess.SubprocessError) as error:
|
|
373
|
+
print('Dependency error: ' + str(error), file=sys.stderr)
|
|
374
|
+
sys.exit(1)
|