@softize/opus 11.1.1 → 12.0.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 (41) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +31 -1
  3. package/bin/cli.mjs +97 -27
  4. package/bin/lib/check.mjs +55 -43
  5. package/bin/lib/copy.mjs +2202 -0
  6. package/bin/lib/create.mjs +227 -39
  7. package/bin/lib/db-migrate-runner.mjs +9 -6
  8. package/bin/lib/db-project-path.mjs +20 -0
  9. package/bin/lib/db-scaffold-runner.mjs +23 -8
  10. package/bin/lib/db.mjs +6 -4
  11. package/bin/lib/gen.mjs +60 -29
  12. package/bin/lib/init.mjs +212 -56
  13. package/bin/lib/introspect.mjs +3 -2
  14. package/bin/lib/materialize.mjs +623 -97
  15. package/bin/lib/postinstall.mjs +6 -5
  16. package/bin/lib/validate-skill.mjs +502 -30
  17. package/docs/code-style.md +142 -7
  18. package/docs/consumer-upgrade-propagation.md +4 -3
  19. package/docs/releasing.md +28 -17
  20. package/package.json +6 -1
  21. package/registry/git/pre-push.d/00-opus-copy +14 -0
  22. package/registry/git/pre-push.d/opus +7 -21
  23. package/registry/git/run-opus-pre-push.mjs +141 -0
  24. package/registry/hooks/opus-check-on-stop.mjs +13 -31
  25. package/registry/instructions/opus.md +11 -5
  26. package/registry/skills/build-opus-ui/SKILL.md +5 -4
  27. package/registry/skills/create-opus-action/SKILL.md +4 -4
  28. package/registry/skills/implement-opus-change/SKILL.md +7 -5
  29. package/registry/skills/upgrade-opus/SKILL.md +8 -4
  30. package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
  31. package/registry/templates/app/package.json +4 -0
  32. package/registry/templates/app/pnpm-workspace.yaml +3 -2
  33. package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
  34. package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
  35. package/src/ui/docs/content/cli.md +8 -7
  36. package/src/ui/docs/content/communication.md +79 -126
  37. package/src/ui/docs/content/getting-started.md +29 -16
  38. package/registry/skills/write-product-communication/SKILL.md +0 -28
  39. package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
  40. package/registry/templates/app/_npmrc +0 -1
  41. package/registry/templates/monorepo/_npmrc +0 -1
package/CHANGELOG.md CHANGED
@@ -4,7 +4,57 @@ O que muda em cada versão — e, quando quebra, **o que fazer**. Regras de leit
4
4
  minor = novidade compatível; major = breaking (a seção **Breaking** diz a migração).
5
5
  Este arquivo viaja no pacote: num projeto, leia `node_modules/@softize/opus/CHANGELOG.md`.
6
6
  Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
- `manifest:check`) — eles apontam o que a mudança cobra do seu código.
7
+ `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
+ mudança cobra do seu código.
9
+
10
+ ## 12.0.0 — 2026-08-21
11
+
12
+ **Copy declarativa passa a ter inventário semântico e gate reproduzível.** `opus copy`
13
+ projeta texto estático dos contratos e de uma allowlist explícita de componentes
14
+ `@softize/opus/ui` e subpaths para o protocolo v2 da `@softize/base`; `--check` confere
15
+ ausência e drift sem escrever. Factories e componentes são reconhecidos pela origem do
16
+ import, inclusive alias e namespace, sem confundir homônimo local. Spreads, duplicatas,
17
+ chaves computadas literais e escopo léxico seguem a semântica real quando estáticos; uma
18
+ incerteza relevante falha em vez de sumir do inventário. `aria-label` não mascara texto
19
+ visual opaco, e `Select`/`ActionTrigger` têm suas superfícies estáveis classificadas.
20
+ Fontes recebem SHA-256, a geração troca o arquivo atomicamente e não atravessa links simbólicos
21
+ no caminho do inventário. O manifesto `source-manifest-v1` inclui todo JS/TS efetivamente
22
+ analisado, mesmo sem entradas, e deriva as contagens de arquivos, fontes cobertas e textos;
23
+ zero arquivo falha em vez de simular uma execução vazia. Transformações e exceções usam
24
+ seletores explícitos em `base.json`; exceções v2 exigem `ruleId`, `kind`, `reason` e
25
+ `reference` auditável.
26
+
27
+ A Base 2.0 passa a ser a única dona da skill geral `write-product-communication`. O Opus não
28
+ mantém um segundo registry com o mesmo slug; sua página `communication.md` registra somente
29
+ vocabulário, papéis semânticos, cobertura e convenções específicas de componentes.
30
+
31
+ `opus setup` configura e gera `.base/copy-inventory.json`. O fragmento
32
+ `00-opus-copy` roda antes de `base` no dispatcher compartilhado, e projetos novos recebem
33
+ scripts `setup`/`copy`/`copy:check` e `@softize/base ^2.0.0`. `pnpm run setup`
34
+ materializa os dois fragmentos do pre-push, gerando o inventário Opus antes de a Base validar
35
+ freshness. O próprio repositório e a release do Opus materializam e validam a Base 2.0.
36
+
37
+ **Breaking — materialização e análise mais conservadoras.** A resolução dos hooks usa o workspace
38
+ declarado em `base.json`, e o
39
+ extrator não aceita como estáticas referências mutadas, escapadas nem condições JSX de tipo
40
+ incerto. Superfícies visuais consideram `style.textTransform`, herança, slots e conteúdo ARIA
41
+ opaco. Quando o texto não pode ser provado estaticamente, o diagnóstico exige copy estática ou
42
+ uma exceção auditável em vez de omitir a superfície.
43
+
44
+ As operações sobre o projeto passam a falhar de forma fechada quando encontram links
45
+ simbólicos, hardlinks mutáveis ou caminhos que mudam durante a leitura/escrita. Isso vale para
46
+ setup, inventário, scaffold, `add`, geração, comandos de banco e scans estáticos; assets internos
47
+ do pacote continuam sendo lidos da instalação. `opus gen --config/--output` e `opus db --config`
48
+ agora aceitam somente caminhos contidos no diretório do projeto em que o comando foi iniciado.
49
+ Um source ilegível ou inseguro deixa de ser pulado silenciosamente e reprova o comando.
50
+
51
+ **Migração:** instale `@softize/base 2.0.0`, rode `pnpm run setup`, revise e versione o
52
+ inventário gerado. Corrija os achados de `base copy check`; para conteúdo dinâmico, forneça
53
+ fallback estático ou documente a superfície fora da cobertura do extrator. O setup remove do
54
+ projeto a configuração legada de `registry.softize.com.br`; remova também a configuração
55
+ global antiga com `pnpm config delete @softize:registry --global`. Para gerar ou migrar conteúdo
56
+ fora do projeto, execute o comando a partir dessa outra raiz ou mova a saída explicitamente
57
+ depois que o gate terminar.
8
58
 
9
59
  ## 11.1.1 — 2026-08-21
10
60
 
package/README.md CHANGED
@@ -82,7 +82,7 @@ src/
82
82
  core/ schema/ server/ client/ ui/
83
83
  data/ auth/ audit/ log/ queue/ events/ scheduler/ dsl/
84
84
  registry/ # scaffolds + skills Opus — geração e conhecimento do SDK, não runtime
85
- bin/ # CLI (opus setup/gen/check/introspect/mcp) + libs
85
+ bin/ # CLI (opus setup/gen/copy/check/introspect/mcp) + libs
86
86
  docs/
87
87
  protocol.md # contrato completo (16 seções)
88
88
  data-layer.md · releasing.md · code-style.md
@@ -102,6 +102,36 @@ pnpm test # vitest run
102
102
  pnpm test:cov # com coverage report
103
103
  ```
104
104
 
105
+ ## Qualidade de copy
106
+
107
+ O Opus extrai o papel semântico dos textos declarados nos contratos; a política universal e
108
+ seus fundamentos ficam na `@softize/base`. Uma allowlist pequena também cobre texto estático
109
+ de componentes importados diretamente de `@softize/opus/ui` ou seus subpaths. O inventário
110
+ derivado usa o protocolo v2, é versionado e conferido sem escrita nos gates:
111
+
112
+ ```bash
113
+ # No projeto criado por `opus create` (o scaffold declara este script):
114
+ pnpm run setup
115
+ pnpm exec opus copy
116
+ pnpm exec opus copy --check
117
+ pnpm exec base copy check
118
+ ```
119
+
120
+ Neste repositório-fonte, que não declara um script `setup` na raiz, a materialização
121
+ equivalente usa os comandos reais e os cwd exigidos por cada protocolo:
122
+
123
+ ```bash
124
+ node packages/opus/bin/cli.mjs setup
125
+ pnpm --filter @softize/opus exec base setup
126
+ ```
127
+
128
+ O manifesto lista com hash todo JS/TS submetido à análise, inclusive arquivos com zero entradas,
129
+ e reporta as contagens observáveis; ele não afirma cobertura total do repositório. O gate verde
130
+ não cobre JSX nativo nem wrappers locais; essas superfícies continuam na revisão editorial.
131
+ Texto de runtime que ocupa uma superfície Opus mapeada falha visivelmente, e `aria-label` não
132
+ mascara copy visual opaca. Detalhes e mapeamentos estão em
133
+ [docs/code-style.md](docs/code-style.md).
134
+
105
135
  ## Publicar
106
136
 
107
137
  Publicado no npm público (`registry.npmjs.org`). `pnpm release
package/bin/cli.mjs CHANGED
@@ -15,10 +15,18 @@
15
15
  import { promises as fs } from 'node:fs'
16
16
  import path from 'node:path'
17
17
  import { fileURLToPath } from 'node:url'
18
+ import {
19
+ canonicalProjectDirectory,
20
+ ensureProjectDirectory,
21
+ readProjectFile,
22
+ safeProjectPath,
23
+ writeProjectFileAtomically,
24
+ } from '@softize/base/project-path'
18
25
 
19
26
  import { cmdGen, helpGen } from './lib/gen.mjs'
20
27
  import { cmdDb } from './lib/db.mjs'
21
28
  import { scanDir, emptyScanVerdict } from './lib/check.mjs'
29
+ import { checkCopyInventory, writeCopyInventory } from './lib/copy.mjs'
22
30
  import { createMonorepo, createProject } from './lib/create.mjs'
23
31
  import { initProject, repoRootOf, setupUiFoundation } from './lib/init.mjs'
24
32
  import { introspect } from './lib/introspect.mjs'
@@ -54,13 +62,12 @@ async function fileExists(p) {
54
62
  }
55
63
 
56
64
  async function loadComponentsJson(cwd) {
57
- const candidate = path.join(cwd, 'components.json')
58
- if (!(await fileExists(candidate))) {
65
+ const candidate = readProjectFile(cwd, 'components.json', { allowMissing: true })
66
+ if (!candidate.exists) {
59
67
  return null
60
68
  }
61
69
  try {
62
- const raw = await fs.readFile(candidate, 'utf-8')
63
- return JSON.parse(raw)
70
+ return JSON.parse(candidate.content)
64
71
  } catch (err) {
65
72
  log('warn', `components.json existe mas não é JSON válido: ${err.message}`)
66
73
  return null
@@ -84,12 +91,12 @@ async function resolveDestination(cwd, name) {
84
91
  'warn',
85
92
  'Sem components.json. Usando default ./src/components/action/. Roda `npx shadcn init` se quiser configurar.',
86
93
  )
87
- return path.join(cwd, 'src/components/action', `${name}.tsx`)
94
+ return path.join('src/components/action', `${name}.tsx`)
88
95
  }
89
96
  const aliasComponents =
90
97
  components.aliases?.components ?? '@/components'
91
98
  const componentsRel = resolveAlias(aliasComponents, 'components')
92
- return path.join(cwd, 'src', componentsRel, 'action', `${name}.tsx`)
99
+ return path.join('src', componentsRel, 'action', `${name}.tsx`)
93
100
  }
94
101
 
95
102
  async function listTemplates() {
@@ -140,26 +147,34 @@ async function cmdAdd(name, options) {
140
147
  process.exit(1)
141
148
  }
142
149
 
143
- const cwd = process.cwd()
150
+ const cwd = canonicalProjectDirectory(process.cwd())
144
151
  const src = path.join(REGISTRY_DIR, `${name}.tsx`)
145
152
  const dest = await resolveDestination(cwd, name)
153
+ safeProjectPath(cwd, dest)
154
+ const current = readProjectFile(cwd, dest, { allowMissing: true })
146
155
 
147
- if (await fileExists(dest)) {
156
+ if (current.exists) {
148
157
  if (options.force !== true) {
149
158
  log(
150
159
  'warn',
151
- `Já existe em ${path.relative(cwd, dest)} — passa --force pra sobrescrever.`,
160
+ `Já existe em ${dest} — passa --force pra sobrescrever.`,
152
161
  )
153
162
  process.exit(1)
154
163
  }
155
- log('warn', `Sobrescrevendo ${path.relative(cwd, dest)}`)
164
+ log('warn', `Sobrescrevendo ${dest}`)
156
165
  }
157
166
 
158
- await fs.mkdir(path.dirname(dest), { recursive: true })
167
+ const parents = []
168
+ let parent = path.dirname(dest)
169
+ while (parent !== '.') {
170
+ parents.unshift(parent)
171
+ parent = path.dirname(parent)
172
+ }
173
+ for (const directory of parents) ensureProjectDirectory(cwd, directory)
159
174
  const content = await fs.readFile(src, 'utf-8')
160
- await fs.writeFile(dest, content, 'utf-8')
175
+ writeProjectFileAtomically(cwd, dest, content, { exists: current.exists, content: current.content })
161
176
 
162
- log('success', `✓ Criado ${path.relative(cwd, dest)}`)
177
+ log('success', `✓ Criado ${dest}`)
163
178
  console.log(
164
179
  '\n Edita à vontade — esse arquivo é seu agora. Próximas runs com --force\n sobrescrevem suas mudanças.\n',
165
180
  )
@@ -219,6 +234,32 @@ async function cmdMaterializationCheck() {
219
234
  log('success', '✓ opus check: artefatos materializados estão atualizados.')
220
235
  }
221
236
 
237
+ async function cmdCopy(dir, flags) {
238
+ const selected = path.resolve(process.cwd(), dir ?? '.')
239
+ const root = (await repoRootOf(selected)) ?? selected
240
+ const result = flags.check ? checkCopyInventory(root) : writeCopyInventory(root)
241
+ for (const diagnostic of result.diagnostics) {
242
+ log('error', `✗ opus copy: ${diagnostic.source}:${diagnostic.line} — ${diagnostic.message}`)
243
+ }
244
+ if (!result.ok) {
245
+ if (result.reason === 'missing') {
246
+ log('error', `✗ opus copy: inventário ausente em ${path.relative(root, result.path)}; rode \`opus copy\` e versione o resultado.`)
247
+ } else if (result.reason === 'stale') {
248
+ log('error', `✗ opus copy: inventário desatualizado em ${path.relative(root, result.path)}; rode \`opus copy\` e revise o diff.`)
249
+ }
250
+ process.exitCode = 1
251
+ return
252
+ }
253
+ const location = path.relative(root, result.path) || '.'
254
+ const coverage = result.inventory.coverage
255
+ const summary =
256
+ `${coverage.analyzedFiles} arquivo(s) analisado(s), ` +
257
+ `${coverage.coveredSources} fonte(s) com copy, ${coverage.extractedEntries} texto(s)`
258
+ if (flags.check) log('success', `✓ opus copy: ${location} está atualizado (${summary}).`)
259
+ else if (result.changed) log('success', `✓ opus copy: ${location} gerado atomicamente (${summary}).`)
260
+ else log('success', `✓ opus copy: ${location} já estava atualizado (${summary}).`)
261
+ }
262
+
222
263
  async function cmdCreate(dir, flags) {
223
264
  if (!dir) {
224
265
  log('error', 'Uso: opus create <dir> — o nome do diretório vira o nome do app (--monorepo: a raiz do workspace).')
@@ -229,8 +270,8 @@ async function cmdCreate(dir, flags) {
229
270
 
230
271
  if (flags.monorepo) {
231
272
  const r = await createMonorepo(REGISTRY_DIR, target, name)
232
- log('success', `✓ opus create: raiz do monorepo "${name}" criada (base v${r.version}).`)
233
- console.log(`\n ${r.files.length} arquivos (workspace apps/* + packages/*, registry, allowBuilds).`)
273
+ log('success', `✓ opus create: raiz do monorepo "${name}" criada (Opus v${r.version}).`)
274
+ console.log(`\n ${r.files.length} arquivos (workspace apps/* + packages/*, allowBuilds e quarentena coordenada).`)
234
275
  console.log(`
235
276
  Próximos passos:
236
277
  cd ${dir}
@@ -245,7 +286,7 @@ async function cmdCreate(dir, flags) {
245
286
  throw new Error(`setup incompleto: ${r.setup.materialization.errors.join(' | ')}`)
246
287
  }
247
288
  const modeLabel = r.mode === 'app' ? 'app no monorepo (workspace detectado)' : 'repo standalone'
248
- log('success', `✓ opus create: app "${name}" criado (base v${r.version}, ${modeLabel}).`)
289
+ log('success', `✓ opus create: app "${name}" criado (Opus v${r.version}, ${modeLabel}).`)
249
290
  console.log(`\n ${r.files.length} arquivos do esqueleto + setup (${r.setup.created.join(', ')}).`)
250
291
  const warnings = [...r.warnings, ...r.setup.warnings]
251
292
  if (warnings.length) {
@@ -256,7 +297,8 @@ async function cmdCreate(dir, flags) {
256
297
  console.log(`
257
298
  Próximos passos (no monorepo):
258
299
  pnpm install
259
- pnpm --filter ${name} test && pnpm --filter ${name} manifest
300
+ pnpm --filter ${name} run setup
301
+ pnpm --filter ${name} test && pnpm --filter ${name} copy:check && pnpm --filter ${name} manifest
260
302
 
261
303
  Opcional: cadastre o app em uma interface de desenvolvimento como o Maestro.
262
304
  `)
@@ -264,9 +306,11 @@ async function cmdCreate(dir, flags) {
264
306
  console.log(`
265
307
  Próximos passos:
266
308
  cd ${dir}
267
- git init && git add -A && git commit -m "chore: esqueleto opus"
309
+ git init
268
310
  pnpm install
269
- pnpm test && pnpm exec opus check src && pnpm manifest
311
+ pnpm run setup
312
+ pnpm test && pnpm exec opus check src && pnpm copy:check && pnpm manifest
313
+ git add -A && git commit -m "chore: esqueleto opus"
270
314
 
271
315
  Opcional: cadastre o repo em uma interface de desenvolvimento como o Maestro.
272
316
  `)
@@ -279,6 +323,7 @@ async function cmdInit() {
279
323
  if (!r.materialization.ok) {
280
324
  for (const error of r.materialization.errors) log('error', `✗ opus setup: ${error}`)
281
325
  process.exitCode = 1
326
+ return
282
327
  }
283
328
  log('success', `✓ opus setup: app v${r.version} ${r.wasInitialized ? 'atualizado' : 'inicializado'}.`)
284
329
  if (r.created.length) {
@@ -311,7 +356,7 @@ async function cmdInit() {
311
356
  }
312
357
 
313
358
  console.log(
314
- '\n Per-app: opus.json. No repo: base.json + skills/hooks/instruções comitados.\n' +
359
+ '\n Por app: opus.json. No repo: base.json + skills/hooks/instruções específicos do Opus.\n' +
315
360
  ' A spec vive nas declarações (description → manifest). Gate: opus check.\n',
316
361
  )
317
362
  }
@@ -402,9 +447,10 @@ Comandos:
402
447
  create <dir> Scaffolda um app opus-based canônico (vite+react+ui+domínio-exemplo);
403
448
  detecta workspace (app em monorepo); --monorepo cria a RAIZ do workspace
404
449
  setup Reconcilia opus.json e os artefatos Opus versionados para Claude/Codex
405
- add <name> Copia template do registry pro consumer
450
+ add <name> Copia template do catálogo empacotado pro projeto
406
451
  list Lista templates disponíveis
407
452
  gen Gera manifest/openapi/docs/stubs a partir do opus.config.ts
453
+ copy [dir] Gera o inventário semântico de copy dos contratos; --check só confere
408
454
  check [dir] Valida as convenções das actions (régua de padrão; exit ≠ 0 se violar)
409
455
  pre-push Gate Git: freshness dos artefatos + convenções das actions
410
456
  db <verbo> Comandos de banco. v1: db check (drift-check entidade ↔ banco)
@@ -414,6 +460,7 @@ Comandos:
414
460
 
415
461
  Flags:
416
462
  --force, -f Sobrescreve arquivo existente
463
+ --check Não escreve; falha se o inventário de copy estiver ausente/desatualizado
417
464
  --config <path> (gen) Caminho do opus.config.ts
418
465
  --output <path> (gen) Pasta de saída
419
466
 
@@ -432,13 +479,13 @@ Exemplos:
432
479
  @softize/opus create <dir>
433
480
 
434
481
  Scaffolda um app opus-based CANÔNICO no diretório <dir> (recusa dir não-vazio).
435
- O esqueleto versiona com a base — nasce com os pré-requisitos plugados:
482
+ O esqueleto versiona com o Opus — nasce com os pré-requisitos plugados:
436
483
  protocolo opus.config.ts + domínio-exemplo + manifest/scripts (+ teste verde)
437
484
  UI vite + react + tailwind v4 CSS-first (tema + @source do Opus)
438
485
  desenvolvimento dev server na porta injetada (PORT) + allowedHosts do preview
439
- dia zero opus.json + skills/hooks/instruções comitados no repo
486
+ automação opus.json + skills/hooks/instruções específicos do Opus
440
487
 
441
- Depois: git init && pnpm install && pnpm test — e cadastre repo/app no admin.
488
+ Depois: git init && pnpm install && pnpm run setup && pnpm test — e cadastre repo/app no admin.
442
489
  `)
443
490
  return
444
491
  }
@@ -453,14 +500,16 @@ Depois: git init && pnpm install && pnpm test — e cadastre repo/app no admin.
453
500
 
454
501
  Setup idempotente de um projeto Opus:
455
502
  opus.json marcador per-app com a versão do SDK
456
- base.json versão aplicada e eventuais skills excluídas
457
- agentes skills Claude/Codex, hook, instruções e pre-push comitados
503
+ base.json configuração compartilhada e versão aplicada do Opus
504
+ agentes skills, hook, instruções e pre-push específicos do SDK
458
505
 
459
506
  Fundação de UI (só apps web — preset Tailwind + tema + flags do Opus):
460
507
  tailwind.config criado se faltar; senão avisa pra estender o preset
461
508
  tema/tsconfig/dep — avisa o que falta plugar (não edita seus arquivos: sem clobber)
462
509
 
463
- O próprio pacote materializa somente seus artefatos. Base e Maestro são opcionais.
510
+ Este comando materializa somente os artefatos Opus. O template encadeia
511
+ \`opus setup && base setup\`: a Base 2.0 é obrigatória quando o gate universal de copy
512
+ está habilitado; Maestro continua opcional.
464
513
  `)
465
514
  return
466
515
  }
@@ -524,6 +573,27 @@ passa verde). Default dir: cwd.
524
573
  return
525
574
  }
526
575
 
576
+ if (command === 'copy') {
577
+ if (flags.help) {
578
+ console.log(`
579
+ @softize/opus copy [dir]
580
+
581
+ Projeta textos de \`defineAction\`/\`defineContract\` no protocolo v2 da
582
+ @softize/base. O caminho vem de \`base.json.copy.inventory\`; o default é
583
+ \`.base/copy-inventory.json\` na raiz Git. A geração é atômica e o arquivo
584
+ derivado deve ser versionado. O manifesto \`source-manifest-v1\` hasheia todo
585
+ JS/TS analisado, mesmo sem entradas; zero arquivo elegível falha porque não
586
+ comprova que o extrator executou sobre uma fonte.
587
+
588
+ Flags:
589
+ --check Compara sem escrever; falha em ausência, drift ou copy dinâmica
590
+ `)
591
+ return
592
+ }
593
+ await cmdCopy(rest[0], flags)
594
+ return
595
+ }
596
+
527
597
  if (command === 'pre-push') {
528
598
  if (rest[0] === 'materialization') await cmdMaterializationCheck()
529
599
  else await cmdCheck(rest[0] ?? '.')
package/bin/lib/check.mjs CHANGED
@@ -21,9 +21,14 @@
21
21
  * É a régua do reviewer e a métrica de padrão.
22
22
  */
23
23
 
24
- import { promises as fs } from 'node:fs'
25
24
  import path from 'node:path'
26
25
  import ts from 'typescript'
26
+ import {
27
+ canonicalProjectDirectory,
28
+ readProjectDirectory,
29
+ readProjectFile,
30
+ safeProjectPath,
31
+ } from '@softize/base/project-path'
27
32
 
28
33
  // Ordem canônica (grupos do ActionBase em src/core/types.ts). Campos kind-específicos
29
34
  // (fields/paginate/filters/expand/background/emits/successStatus…) NÃO entram no mapa —
@@ -480,63 +485,70 @@ const UI_SKIP_DIRS = new Set([...SKIP_DIRS, '__tests__', '__fixtures__', 'fixtur
480
485
  const UI_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html', '.svelte', '.vue'])
481
486
 
482
487
  export async function walkTsFiles(dir, acc = []) {
483
- let entries
484
- try {
485
- entries = await fs.readdir(dir, { withFileTypes: true })
486
- } catch {
487
- return acc
488
- }
489
- for (const e of entries) {
490
- if (e.name.startsWith('.') && e.name !== '.') continue
491
- const full = path.join(dir, e.name)
492
- if (e.isDirectory()) {
493
- if (!SKIP_DIRS.has(e.name)) await walkTsFiles(full, acc)
494
- } else if (
495
- (e.name.endsWith('.ts') || e.name.endsWith('.tsx')) &&
496
- !e.name.endsWith('.d.ts') &&
497
- !e.name.endsWith('.test.ts')
498
- ) {
499
- acc.push(full)
488
+ const root = canonicalProjectDirectory(dir)
489
+ const walk = (local = '.') => {
490
+ const entries = readProjectDirectory(root, local).entries
491
+ for (const e of entries) {
492
+ if (e.name.startsWith('.') && e.name !== '.') continue
493
+ if (SKIP_DIRS.has(e.name)) continue
494
+ const relative = path.join(local, e.name)
495
+ const full = path.join(root, relative)
496
+ if (e.isSymbolicLink()) {
497
+ // Dirent não revela se o link aponta para arquivo ou diretório. Ignorá-lo pela
498
+ // extensão permitiria ocultar uma árvore de sources sob um nome de asset.
499
+ safeProjectPath(root, relative, { mustExist: true })
500
+ }
501
+ if (e.isDirectory()) {
502
+ safeProjectPath(root, relative, { mustExist: true })
503
+ walk(relative)
504
+ } else if (
505
+ (e.name.endsWith('.ts') || e.name.endsWith('.tsx')) &&
506
+ !e.name.endsWith('.d.ts') &&
507
+ !e.name.endsWith('.test.ts')
508
+ ) {
509
+ safeProjectPath(root, relative, { mustExist: true })
510
+ acc.push(full)
511
+ }
500
512
  }
501
513
  }
514
+ walk()
502
515
  return acc
503
516
  }
504
517
 
505
518
  async function walkUiFiles(dir, acc = []) {
506
- let entries
507
- try {
508
- entries = await fs.readdir(dir, { withFileTypes: true })
509
- } catch {
510
- return acc
511
- }
512
- for (const e of entries) {
513
- if (e.name.startsWith('.')) continue
514
- const full = path.join(dir, e.name)
515
- if (e.isDirectory()) {
516
- if (!UI_SKIP_DIRS.has(e.name)) await walkUiFiles(full, acc)
517
- } else if (UI_EXTENSIONS.has(path.extname(e.name)) && !/\.(?:test|spec)\.[^.]+$/.test(e.name)) {
518
- acc.push(full)
519
+ const root = canonicalProjectDirectory(dir)
520
+ const walk = (local = '.') => {
521
+ const entries = readProjectDirectory(root, local).entries
522
+ for (const e of entries) {
523
+ if (e.name.startsWith('.')) continue
524
+ if (UI_SKIP_DIRS.has(e.name)) continue
525
+ const relative = path.join(local, e.name)
526
+ const full = path.join(root, relative)
527
+ if (e.isSymbolicLink()) {
528
+ // Um link com nome de asset ainda pode esconder um diretório com UI analisável.
529
+ safeProjectPath(root, relative, { mustExist: true })
530
+ }
531
+ if (e.isDirectory()) {
532
+ safeProjectPath(root, relative, { mustExist: true })
533
+ walk(relative)
534
+ } else if (UI_EXTENSIONS.has(path.extname(e.name)) && !/\.(?:test|spec)\.[^.]+$/.test(e.name)) {
535
+ safeProjectPath(root, relative, { mustExist: true })
536
+ acc.push(full)
537
+ }
519
538
  }
520
539
  }
540
+ walk()
521
541
  return acc
522
542
  }
523
543
 
524
- async function exists(p) {
525
- try {
526
- await fs.access(p)
527
- return true
528
- } catch {
529
- return false
530
- }
531
- }
532
-
533
544
  /** Escaneia um diretório → { files, actions, contracts, findings, hasMarker }.
534
545
  * `hasMarker` = o diretório é um projeto opus (tem opus.json na raiz). */
535
546
  export async function scanDir(rootDir) {
547
+ rootDir = canonicalProjectDirectory(rootDir)
536
548
  const files = await walkTsFiles(rootDir)
537
549
  const sources = []
538
550
  for (const file of files) {
539
- const text = await fs.readFile(file, 'utf-8')
551
+ const text = readProjectFile(rootDir, path.relative(rootDir, file)).content
540
552
  if (!ACTION_MARKERS.some((m) => text.includes(m))) continue
541
553
  sources.push({ file: path.relative(rootDir, file), text })
542
554
  }
@@ -545,10 +557,10 @@ export async function scanDir(rootDir) {
545
557
  // UI pode morar em src/, app/, pages/ ou na raiz. O walk cobre o projeto inteiro e
546
558
  // exclui dependências, builds, testes e fixtures para não interpretar exemplos como uso.
547
559
  for (const file of await walkUiFiles(rootDir)) {
548
- const text = await fs.readFile(file, 'utf-8')
560
+ const text = readProjectFile(rootDir, path.relative(rootDir, file)).content
549
561
  uiFindings.push(...checkUiMigrations(path.relative(rootDir, file), text))
550
562
  }
551
- const hasMarker = await exists(path.join(rootDir, 'opus.json'))
563
+ const hasMarker = safeProjectPath(rootDir, 'opus.json').exists
552
564
  return {
553
565
  files: files.length,
554
566
  actions: checked.actions,