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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/skills-registry.yaml +7 -1
  3. package/src/bmm-skills/1-analysis/wizz-document-project/references/static-dependencies.md +124 -0
  4. package/src/bmm-skills/1-analysis/wizz-document-project/scripts/dependencies.py +374 -0
  5. package/src/bmm-skills/1-analysis/wizz-document-project/scripts/test_dependencies.py +148 -0
  6. package/src/bmm-skills/1-analysis/wizz-document-project/scripts/test_dependencies_unit.py +183 -0
  7. package/src/bmm-skills/1-analysis/wizz-document-project/templates/deep-dive-template.md +9 -0
  8. package/src/bmm-skills/1-analysis/wizz-document-project/templates/index-template.md +9 -0
  9. package/src/bmm-skills/1-analysis/wizz-document-project/workflows/deep-dive-instructions.md +4 -0
  10. package/src/bmm-skills/1-analysis/wizz-document-project/workflows/full-scan-instructions.md +7 -1
  11. package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +19 -0
  12. package/src/bmm-skills/4-implementation/wizz-code-review/references/review-economy.md +5 -1
  13. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-01-gather-context.md +27 -2
  14. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-02-review.md +2 -0
  15. package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +1 -1
  16. package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +19 -0
  17. package/src/bmm-skills/4-implementation/wizz-quick-dev/references/review-economy.md +5 -1
  18. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +1 -1
  19. package/src/modules/wizz/_shared/approval-gate.md +45 -0
  20. package/src/modules/wizz/_shared/approval-gate.mjs +184 -0
  21. package/src/modules/wizz/_shared/communication-rules.md +2 -0
  22. package/src/modules/wizz/_shared/model-ladder.md +12 -0
  23. package/src/modules/wizz/agents/wizz-ads/SKILL.md +1 -0
  24. package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
  25. package/src/modules/wizz/agents/wizz-growth/SKILL.md +1 -0
  26. package/src/modules/wizz/agents/wizz-maestro/SKILL.md +3 -1
  27. package/src/modules/wizz/agents/wizz-seo/SKILL.md +1 -0
  28. package/src/modules/wizz/agents/wizz-social/SKILL.md +7 -1
  29. package/src/modules/wizz/agents/wizz-social/customize.toml +4 -3
  30. package/src/modules/wizz/subagents/codex/wizz-exec-review.toml +2 -1
  31. package/src/modules/wizz/subagents/gemini/wizz-exec-review.md +2 -1
  32. package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +2 -1
  33. package/src/modules/wizz/subagents/spec.yaml +4 -3
  34. package/src/modules/wizz/subagents/wizz-exec-review.md +2 -1
  35. package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -1
  36. package/src/skills-lib/wizz-security-audit-pentest/SKILL.md +50 -26
  37. package/src/skills-lib/wizz-security-audit-pentest/references/stage-5-segredos-historico.md +74 -0
  38. package/src/skills-lib/wizz-security-audit-pentest/references/stage-6-advogado-do-diabo.md +33 -0
  39. package/src/skills-lib/wizz-security-audit-pentest/references/stage-7-fusao-plano.md +50 -0
  40. package/tools/installer/README.md +5 -0
  41. package/tools/installer/commands/templates.js +65 -0
  42. package/tools/installer/core/resolved-schema.js +5 -3
  43. package/tools/installer/modules/templates.js +229 -0
  44. package/tools/installer/templates.md +156 -0
  45. package/src/skills-lib/wizz-security-audit-pentest/references/stage-5-fusao-plano.md +0 -27
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "wizz-method",
4
- "version": "2.23.0",
4
+ "version": "2.25.0",
5
5
  "description": "Wizz Method: método de agência orientado por IA em PT-BR",
6
6
  "keywords": [
7
7
  "agile",
@@ -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 clipe com a letra da música, tipografia cinética (texto como imagem, cortes na batida) 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', 'vídeo do app mexendo', 'anima o app num vídeo', 'animação da interface tipo Dribbble'."
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)